Merge branch 'main' into micn/embedded-llama-server

This commit is contained in:
Michael Neale
2025-10-16 07:24:23 +11:00
committed by GitHub
43 changed files with 1643 additions and 866 deletions
+1 -1
View File
@@ -116,7 +116,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
artifacts: |
goose-*.tar.bz2
goose-*.zip
goose*.zip
*.deb
*.rpm
download_cli.sh
+1 -1
View File
@@ -118,7 +118,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
artifacts: |
goose-*.tar.bz2
goose-*.zip
goose*.zip
*.deb
*.rpm
download_cli.sh
+2 -2
View File
@@ -105,7 +105,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
artifacts: |
goose-*.tar.bz2
goose-*.zip
goose*.zip
*.deb
*.rpm
download_cli.sh
@@ -122,7 +122,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
artifacts: |
goose-*.tar.bz2
goose-*.zip
goose*.zip
*.deb
*.rpm
download_cli.sh
+12 -9
View File
@@ -8,7 +8,7 @@ use goose::agents::platform_tools::{
};
use goose::agents::Agent;
use goose::agents::{extension::Envs, ExtensionConfig};
use goose::config::custom_providers::CustomProviderConfig;
use goose::config::declarative_providers::{create_custom_provider, remove_custom_provider};
use goose::config::extensions::{
get_all_extension_names, get_all_extensions, get_enabled_extensions, get_extension_by_name,
name_to_key, remove_extension, set_extension, set_extension_enabled,
@@ -420,12 +420,15 @@ pub async fn configure_provider_dialog() -> Result<bool, Box<dyn Error>> {
let config = Config::global();
// Get all available providers and their metadata
let available_providers = providers().await;
let mut available_providers = providers().await;
// Sort providers alphabetically by display name
available_providers.sort_by(|a, b| a.0.display_name.cmp(&b.0.display_name));
// Create selection items from provider metadata
let provider_items: Vec<(&String, &str, &str)> = available_providers
.iter()
.map(|p| (&p.name, p.display_name.as_str(), p.description.as_str()))
.map(|(p, _)| (&p.name, p.display_name.as_str(), p.description.as_str()))
.collect();
// Get current default provider if it exists
@@ -439,9 +442,9 @@ pub async fn configure_provider_dialog() -> Result<bool, Box<dyn Error>> {
.interact()?;
// Get the selected provider's metadata
let provider_meta = available_providers
let (provider_meta, _) = available_providers
.iter()
.find(|p| &p.name == provider_name)
.find(|(p, _)| &p.name == provider_name)
.expect("Selected provider must exist in metadata");
// Configure required provider keys
@@ -1915,7 +1918,7 @@ fn add_provider() -> Result<(), Box<dyn Error>> {
.initial_value(true)
.interact()?;
CustomProviderConfig::create_and_save(
create_custom_provider(
provider_type,
display_name.clone(),
api_url,
@@ -1929,9 +1932,9 @@ fn add_provider() -> Result<(), Box<dyn Error>> {
}
fn remove_provider() -> Result<(), Box<dyn Error>> {
let custom_providers_dir = goose::config::custom_providers::custom_providers_dir();
let custom_providers_dir = goose::config::declarative_providers::custom_providers_dir();
let custom_providers = if custom_providers_dir.exists() {
goose::config::custom_providers::load_custom_providers(&custom_providers_dir)?
goose::config::declarative_providers::load_custom_providers(&custom_providers_dir)?
} else {
Vec::new()
};
@@ -1950,7 +1953,7 @@ fn remove_provider() -> Result<(), Box<dyn Error>> {
.items(&provider_items)
.interact()?;
CustomProviderConfig::remove(selected_id)?;
remove_custom_provider(selected_id)?;
cliclack::outro(format!("Removed custom provider: {}", selected_id))?;
Ok(())
}
+3
View File
@@ -10,6 +10,9 @@ pub async fn check_token(
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
if request.uri().path() == "/status" {
return Ok(next.run(request).await);
}
let secret_key = request
.headers()
.get("X-Secret-Key")
+11 -2
View File
@@ -5,7 +5,7 @@ use goose::config::permission::PermissionLevel;
use goose::config::ExtensionEntry;
use goose::conversation::Conversation;
use goose::permission::permission_confirmation::PrincipalType;
use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata};
use goose::providers::base::{ConfigKey, ModelInfo, ProviderMetadata, ProviderType};
use goose::session::{Session, SessionInsights};
use rmcp::model::{
Annotations, Content, EmbeddedResource, Icon, ImageContent, JsonObject, RawAudioContent,
@@ -14,6 +14,9 @@ use rmcp::model::{
};
use utoipa::{OpenApi, ToSchema};
use goose::config::declarative_providers::{
DeclarativeProviderConfig, LoadedProvider, ProviderEngine,
};
use goose::conversation::message::{
ContextLengthExceeded, FrontendToolRequest, Message, MessageContent, MessageMetadata,
RedactedThinkingContent, SummarizationRequested, ThinkingContent, ToolConfirmationRequest,
@@ -335,6 +338,8 @@ derive_utoipa!(Icon as IconSchema);
super::routes::config_management::get_provider_models,
super::routes::config_management::upsert_permissions,
super::routes::config_management::create_custom_provider,
super::routes::config_management::get_custom_provider,
super::routes::config_management::update_custom_provider,
super::routes::config_management::remove_custom_provider,
super::routes::agent::start_agent,
super::routes::agent::resume_agent,
@@ -386,7 +391,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::config_management::ExtensionQuery,
super::routes::config_management::ToolPermission,
super::routes::config_management::UpsertPermissionsQuery,
super::routes::config_management::CreateCustomProviderRequest,
super::routes::config_management::UpdateCustomProviderRequest,
super::routes::reply::PermissionConfirmationRequest,
super::routes::reply::ChatRequest,
super::routes::context::ContextManageRequest,
@@ -420,6 +425,10 @@ derive_utoipa!(Icon as IconSchema);
JsonObjectSchema,
RoleSchema,
ProviderMetadata,
ProviderType,
LoadedProvider,
ProviderEngine,
DeclarativeProviderConfig,
ExtensionEntry,
ExtensionConfig,
ConfigKey,
@@ -1,15 +1,17 @@
use crate::routes::utils::check_provider_configured;
use crate::state::AppState;
use axum::routing::put;
use axum::{
extract::Path,
routing::{delete, get, post},
Json, Router,
};
use goose::config::declarative_providers::LoadedProvider;
use goose::config::paths::Paths;
use goose::config::ExtensionEntry;
use goose::config::{Config, ConfigError};
use goose::model::ModelConfig;
use goose::providers::base::ProviderMetadata;
use goose::providers::base::{ProviderMetadata, ProviderType};
use goose::providers::pricing::{
get_all_pricing, get_model_pricing, parse_model_id, refresh_pricing,
};
@@ -57,6 +59,7 @@ pub struct ProviderDetails {
pub name: String,
pub metadata: ProviderMetadata,
pub is_configured: bool,
pub provider_type: ProviderType,
}
#[derive(Serialize, ToSchema)]
@@ -76,8 +79,8 @@ pub struct UpsertPermissionsQuery {
}
#[derive(Deserialize, ToSchema)]
pub struct CreateCustomProviderRequest {
pub provider_type: String,
pub struct UpdateCustomProviderRequest {
pub engine: String,
pub display_name: String,
pub api_url: String,
pub api_key: String,
@@ -225,9 +228,7 @@ pub async fn add_extension(
(status = 500, description = "Internal server error")
)
)]
pub async fn remove_extension(
axum::extract::Path(name): axum::extract::Path<String>,
) -> Result<Json<String>, StatusCode> {
pub async fn remove_extension(Path(name): Path<String>) -> Result<Json<String>, StatusCode> {
let key = goose::config::extensions::name_to_key(&name);
goose::config::remove_extension(&key);
Ok(Json(format!("Removed extension {}", name)))
@@ -258,72 +259,17 @@ pub async fn read_all_config() -> Result<Json<ConfigResponse>, StatusCode> {
)
)]
pub async fn providers() -> Result<Json<Vec<ProviderDetails>>, StatusCode> {
let mut providers_metadata = get_providers().await;
let custom_providers_dir = goose::config::custom_providers::custom_providers_dir();
if custom_providers_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&custom_providers_dir) {
for entry in entries.flatten() {
if let Some(extension) = entry.path().extension() {
if extension == "json" {
if let Ok(content) = std::fs::read_to_string(entry.path()) {
if let Ok(custom_provider) = serde_json::from_str::<
goose::config::custom_providers::CustomProviderConfig,
>(&content)
{
// CustomProviderConfig => ProviderMetadata
let default_model = custom_provider
.models
.first()
.map(|m| m.name.clone())
.unwrap_or_default();
let metadata = goose::providers::base::ProviderMetadata {
name: custom_provider.name.clone(),
display_name: custom_provider.display_name.clone(),
description: custom_provider
.description
.clone()
.unwrap_or_else(|| {
format!("{} (custom)", custom_provider.display_name)
}),
default_model,
known_models: custom_provider.models.clone(),
model_doc_link: "Custom provider".to_string(),
config_keys: vec![
goose::providers::base::ConfigKey::new(
&custom_provider.api_key_env,
true,
true,
None,
),
goose::providers::base::ConfigKey::new(
"CUSTOM_PROVIDER_BASE_URL",
true,
false,
Some(&custom_provider.base_url),
),
],
};
providers_metadata.push(metadata);
}
}
}
}
}
}
}
let providers_response: Vec<ProviderDetails> = providers_metadata
let providers = get_providers().await;
let providers_response: Vec<ProviderDetails> = providers
.into_iter()
.map(|metadata| {
let is_configured = check_provider_configured(&metadata);
.map(|(metadata, provider_type)| {
let is_configured = check_provider_configured(&metadata, provider_type);
ProviderDetails {
name: metadata.name.clone(),
metadata,
is_configured,
provider_type,
}
})
.collect();
@@ -347,11 +293,28 @@ pub async fn providers() -> Result<Json<Vec<ProviderDetails>>, StatusCode> {
pub async fn get_provider_models(
Path(name): Path<String>,
) -> Result<Json<Vec<String>>, StatusCode> {
let all = get_providers().await;
let Some(metadata) = all.into_iter().find(|m| m.name == name) else {
let loaded_provider = goose::config::declarative_providers::load_provider(name.as_str()).ok();
// TODO(Douwe): support a get models url for custom providers
if let Some(loaded_provider) = loaded_provider {
return Ok(Json(
loaded_provider
.config
.models
.into_iter()
.map(|m| m.name)
.collect::<Vec<_>>(),
));
}
let all = get_providers()
.await
.into_iter()
//.map(|(m, p)| m)
.collect::<Vec<_>>();
let Some((metadata, provider_type)) = all.into_iter().find(|(m, _)| m.name == name) else {
return Err(StatusCode::BAD_REQUEST);
};
if !check_provider_configured(&metadata) {
if !check_provider_configured(&metadata, provider_type) {
return Err(StatusCode::BAD_REQUEST);
}
@@ -449,12 +412,9 @@ pub async fn get_pricing(
}
}
} else {
// Get only configured providers' pricing
let providers_metadata = get_providers().await;
for metadata in providers_metadata {
for (metadata, provider_type) in get_providers().await {
// Skip unconfigured providers if filtering
if !check_provider_configured(&metadata) {
if !check_provider_configured(&metadata, provider_type) {
continue;
}
@@ -647,25 +607,10 @@ pub async fn validate_config() -> Result<Json<String>, StatusCode> {
}
}
#[utoipa::path(
get,
path = "/config/current-model",
responses(
(status = 200, description = "Current model retrieved successfully", body = String),
)
)]
pub async fn get_current_model() -> Result<Json<Value>, StatusCode> {
let current_model = goose::providers::base::get_current_model();
Ok(Json(serde_json::json!({
"model": current_model
})))
}
#[utoipa::path(
post,
path = "/config/custom-providers",
request_body = CreateCustomProviderRequest,
request_body = UpdateCustomProviderRequest,
responses(
(status = 200, description = "Custom provider created successfully", body = String),
(status = 400, description = "Invalid request"),
@@ -673,10 +618,10 @@ pub async fn get_current_model() -> Result<Json<Value>, StatusCode> {
)
)]
pub async fn create_custom_provider(
Json(request): Json<CreateCustomProviderRequest>,
Json(request): Json<UpdateCustomProviderRequest>,
) -> Result<Json<String>, StatusCode> {
let config = goose::config::custom_providers::CustomProviderConfig::create_and_save(
&request.provider_type,
let config = goose::config::declarative_providers::create_custom_provider(
&request.engine,
request.display_name,
request.api_url,
request.api_key,
@@ -692,6 +637,24 @@ pub async fn create_custom_provider(
Ok(Json(format!("Custom provider added - ID: {}", config.id())))
}
#[utoipa::path(
get,
path = "/config/custom-providers/{id}",
responses(
(status = 200, description = "Custom provider retrieved successfully", body = LoadedProvider),
(status = 404, description = "Provider not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn get_custom_provider(
Path(id): Path<String>,
) -> Result<Json<LoadedProvider>, StatusCode> {
let loaded_provider = goose::config::declarative_providers::load_provider(id.as_str())
.map_err(|_| StatusCode::NOT_FOUND)?;
Ok(Json(loaded_provider))
}
#[utoipa::path(
delete,
path = "/config/custom-providers/{id}",
@@ -701,10 +664,8 @@ pub async fn create_custom_provider(
(status = 500, description = "Internal server error")
)
)]
pub async fn remove_custom_provider(
axum::extract::Path(id): axum::extract::Path<String>,
) -> Result<Json<String>, StatusCode> {
goose::config::custom_providers::CustomProviderConfig::remove(&id)
pub async fn remove_custom_provider(Path(id): Path<String>) -> Result<Json<String>, StatusCode> {
goose::config::declarative_providers::remove_custom_provider(&id)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if let Err(e) = goose::providers::refresh_custom_providers().await {
@@ -714,6 +675,38 @@ pub async fn remove_custom_provider(
Ok(Json(format!("Removed custom provider: {}", id)))
}
#[utoipa::path(
put,
path = "/config/custom-providers/{id}",
request_body = UpdateCustomProviderRequest,
responses(
(status = 200, description = "Custom provider updated successfully", body = String),
(status = 404, description = "Provider not found"),
(status = 500, description = "Internal server error")
)
)]
pub async fn update_custom_provider(
Path(id): Path<String>,
Json(request): Json<UpdateCustomProviderRequest>,
) -> Result<Json<String>, StatusCode> {
goose::config::declarative_providers::update_custom_provider(
&id,
&request.engine,
request.display_name,
request.api_url,
request.api_key,
request.models,
request.supports_streaming,
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if let Err(e) = goose::providers::refresh_custom_providers().await {
tracing::warn!("Failed to refresh custom providers after update: {}", e);
}
Ok(Json(format!("Updated custom provider: {}", id)))
}
pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/config", get(read_all_config))
@@ -731,12 +724,13 @@ pub fn routes(state: Arc<AppState>) -> Router {
.route("/config/recover", post(recover_config))
.route("/config/validate", get(validate_config))
.route("/config/permissions", post(upsert_permissions))
.route("/config/current-model", get(get_current_model))
.route("/config/custom-providers", post(create_custom_provider))
.route(
"/config/custom-providers/{id}",
delete(remove_custom_provider),
)
.route("/config/custom-providers/{id}", put(update_custom_provider))
.route("/config/custom-providers/{id}", get(get_custom_provider))
.with_state(state)
}
@@ -768,39 +762,4 @@ mod tests {
assert!(gpt4_limit.is_some());
assert_eq!(gpt4_limit.unwrap().context_limit, 128_000);
}
#[tokio::test]
async fn test_get_provider_models_unknown_provider() {
let mut headers = HeaderMap::new();
headers.insert("X-Secret-Key", "test".parse().unwrap());
let result = get_provider_models(Path("unknown_provider".to_string())).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_get_provider_models_openai_configured() {
std::env::set_var("OPENAI_API_KEY", "test-key");
let mut headers = HeaderMap::new();
headers.insert("X-Secret-Key", "test".parse().unwrap());
let result = get_provider_models(Path("openai".to_string())).await;
// The response should be BAD_REQUEST since the API key is invalid (authentication error)
assert!(
result.is_err(),
"Expected error response from OpenAI provider with invalid key"
);
let status_code = result.unwrap_err();
assert!(status_code == StatusCode::BAD_REQUEST,
"Expected BAD_REQUEST (authentication error) or INTERNAL_SERVER_ERROR (other errors), got: {}",
status_code
);
std::env::remove_var("OPENAI_API_KEY");
}
}
+13 -3
View File
@@ -1,5 +1,6 @@
use goose::config::declarative_providers::load_provider;
use goose::config::Config;
use goose::providers::base::{ConfigKey, ProviderMetadata};
use goose::providers::base::{ConfigKey, ProviderMetadata, ProviderType};
use serde::{Deserialize, Serialize};
use std::env;
use std::error::Error;
@@ -27,7 +28,7 @@ pub fn inspect_key(key_name: &str, is_secret: bool) -> Result<KeyInfo, Box<dyn E
let config = Config::global();
// Check environment variable first
let env_value = std::env::var(key_name).ok();
let env_value = env::var(key_name).ok();
if let Some(value) = env_value {
return Ok(KeyInfo {
@@ -90,9 +91,18 @@ pub fn inspect_keys(
Ok(results)
}
pub fn check_provider_configured(metadata: &ProviderMetadata) -> bool {
pub fn check_provider_configured(metadata: &ProviderMetadata, provider_type: ProviderType) -> bool {
let config = Config::global();
// TODO(Douwe): if the provider doesn't need an API key, it should be considered configured always
if provider_type == ProviderType::Custom || provider_type == ProviderType::Declarative {
if let Ok(loaded_provider) = load_provider(metadata.name.as_str()) {
return config
.get_secret::<String>(&loaded_provider.config.api_key_env)
.map(|s| !s.is_empty())
.unwrap_or(false);
}
}
// Special case: Zero-config providers (no config keys)
if metadata.config_keys.is_empty() {
// Check if the provider has been explicitly configured via the UI
+6 -2
View File
@@ -113,6 +113,12 @@ impl PromptManager {
Value::String(suggest_disable_extensions_prompt.to_string()),
);
// Add the mode to the context for conditional rendering
let config = Config::global();
let goose_mode = config.get_param("GOOSE_MODE").unwrap_or("auto".to_string());
context.insert("goose_mode", Value::String(goose_mode.clone()));
context.insert("is_autonomous", Value::Bool(goose_mode == "auto"));
// First check the global store, and only if it's not available, fall back to the provided model_name
let model_to_use: Option<String> =
get_current_model().or_else(|| model_name.map(|s| s.to_string()));
@@ -139,8 +145,6 @@ impl PromptManager {
};
let mut system_prompt_extras = self.system_prompt_extras.clone();
let config = Config::global();
let goose_mode = config.get_param("GOOSE_MODE").unwrap_or("auto".to_string());
if goose_mode == "chat" {
system_prompt_extras.push(
"Right now you are in the chat only mode, no access to any tool use and system."
+11
View File
@@ -40,9 +40,20 @@ impl Agent {
// Get tools from extension manager
let mut tools = self.list_tools_for_router().await;
let config = crate::config::Config::global();
let is_autonomous = config.get_param("GOOSE_MODE").unwrap_or("auto".to_string()) == "auto";
// If router is disabled and no tools were returned, fall back to regular tools
if !router_enabled && tools.is_empty() {
// Get all tools but filter out subagent tools if not in autonomous mode
tools = self.list_tools(None).await;
if !is_autonomous {
// Filter out subagent-related tools
tools.retain(|tool| {
tool.name != crate::agents::subagent_execution_tool::subagent_execute_task_tool::SUBAGENT_EXECUTE_TASK_TOOL_NAME
&& tool.name != crate::agents::recipe_tools::dynamic_task_tools::DYNAMIC_TASK_TOOL_NAME_PREFIX
});
}
}
// Add frontend tools
+2 -2
View File
@@ -62,8 +62,8 @@ impl TodoClient {
- [ ] Implement feature X
- [ ] Update API
- [ ] Write tests
- [ ] Run tests (subagent in parallel)
- [ ] Run lint (subagent in parallel)
- [ ] Run tests
- [ ] Run lint
- [ ] Blocked: waiting on credentials
"#}.to_string()),
};
-212
View File
@@ -1,212 +0,0 @@
use crate::config::paths::Paths;
use crate::config::Config;
use crate::model::ModelConfig;
use crate::providers::anthropic::AnthropicProvider;
use crate::providers::base::ModelInfo;
use crate::providers::ollama::OllamaProvider;
use crate::providers::openai::OpenAiProvider;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
pub fn custom_providers_dir() -> std::path::PathBuf {
Paths::config_dir().join("custom_providers")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProviderEngine {
OpenAI,
Ollama,
Anthropic,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomProviderConfig {
pub name: String,
pub engine: ProviderEngine,
pub display_name: String,
pub description: Option<String>,
pub api_key_env: String,
pub base_url: String,
pub models: Vec<ModelInfo>,
pub headers: Option<HashMap<String, String>>,
pub timeout_seconds: Option<u64>,
pub supports_streaming: Option<bool>,
}
impl CustomProviderConfig {
pub fn id(&self) -> &str {
&self.name
}
pub fn display_name(&self) -> &str {
&self.display_name
}
pub fn models(&self) -> &[ModelInfo] {
&self.models
}
pub fn generate_id(display_name: &str) -> String {
format!("custom_{}", display_name.to_lowercase().replace(' ', "_"))
}
pub fn generate_api_key_name(id: &str) -> String {
format!("{}_API_KEY", id.to_uppercase())
}
pub fn create_and_save(
provider_type: &str,
display_name: String,
api_url: String,
api_key: String,
models: Vec<String>,
supports_streaming: Option<bool>,
) -> Result<Self> {
let id = Self::generate_id(&display_name);
let api_key_name = Self::generate_api_key_name(&id);
let config = Config::global();
config.set_secret(&api_key_name, serde_json::Value::String(api_key))?;
let model_infos: Vec<ModelInfo> = models
.into_iter()
.map(|name| ModelInfo::new(name, 128000))
.collect();
let provider_config = CustomProviderConfig {
name: id.clone(),
engine: match provider_type {
"openai_compatible" => ProviderEngine::OpenAI,
"anthropic_compatible" => ProviderEngine::Anthropic,
"ollama_compatible" => ProviderEngine::Ollama,
_ => return Err(anyhow::anyhow!("Invalid provider type: {}", provider_type)),
},
display_name: display_name.clone(),
description: Some(format!("Custom {} provider", display_name)),
api_key_env: api_key_name,
base_url: api_url,
models: model_infos,
headers: None,
timeout_seconds: None,
supports_streaming,
};
// save to JSON file
let custom_providers_dir = custom_providers_dir();
std::fs::create_dir_all(&custom_providers_dir)?;
let json_content = serde_json::to_string_pretty(&provider_config)?;
let file_path = custom_providers_dir.join(format!("{}.json", id));
std::fs::write(file_path, json_content)?;
Ok(provider_config)
}
pub fn remove(id: &str) -> Result<()> {
let config = Config::global();
let api_key_name = Self::generate_api_key_name(id);
let _ = config.delete_secret(&api_key_name);
let custom_providers_dir = custom_providers_dir();
let file_path = custom_providers_dir.join(format!("{}.json", id));
if file_path.exists() {
std::fs::remove_file(file_path)?;
}
Ok(())
}
}
pub fn load_custom_providers(dir: &Path) -> Result<Vec<CustomProviderConfig>> {
if !dir.exists() {
return Ok(Vec::new());
}
std::fs::read_dir(dir)?
.filter_map(|entry| {
let path = entry.ok()?.path();
(path.extension()? == "json").then_some(path)
})
.map(|path| {
let content = std::fs::read_to_string(&path)?;
serde_json::from_str(&content)
.map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", path.display(), e))
})
.collect()
}
pub fn register_custom_providers(
registry: &mut crate::providers::provider_registry::ProviderRegistry,
dir: &Path,
) -> Result<()> {
let configs = load_custom_providers(dir)?;
for config in configs {
let config_clone = config.clone();
let description = config
.description
.clone()
.unwrap_or_else(|| format!("Custom {} provider", config.display_name));
let default_model = config
.models
.first()
.map(|m| m.name.clone())
.unwrap_or_default();
let known_models: Vec<ModelInfo> = config
.models
.iter()
.map(|m| ModelInfo {
name: m.name.clone(),
context_limit: m.context_limit,
input_token_cost: m.input_token_cost,
output_token_cost: m.output_token_cost,
currency: m.currency.clone(),
supports_cache_control: Some(m.supports_cache_control.unwrap_or(false)),
})
.collect();
match config.engine {
ProviderEngine::OpenAI => {
registry.register_with_name::<OpenAiProvider, _>(
config.name.clone(),
config.display_name.clone(),
description,
default_model,
known_models,
move |model: ModelConfig| {
OpenAiProvider::from_custom_config(model, config_clone.clone())
},
);
}
ProviderEngine::Ollama => {
registry.register_with_name::<OllamaProvider, _>(
config.name.clone(),
config.display_name.clone(),
description,
default_model,
known_models,
move |model: ModelConfig| {
OllamaProvider::from_custom_config(model, config_clone.clone())
},
);
}
ProviderEngine::Anthropic => {
registry.register_with_name::<AnthropicProvider, _>(
config.name.clone(),
config.display_name.clone(),
description,
default_model,
known_models,
move |model: ModelConfig| {
AnthropicProvider::from_custom_config(model, config_clone.clone())
},
);
}
}
}
Ok(())
}
@@ -0,0 +1,317 @@
use crate::config::paths::Paths;
use crate::config::Config;
use crate::providers::anthropic::AnthropicProvider;
use crate::providers::base::{ModelInfo, ProviderType};
use crate::providers::ollama::OllamaProvider;
use crate::providers::openai::OpenAiProvider;
use anyhow::Result;
use include_dir::{include_dir, Dir};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
use utoipa::ToSchema;
static FIXED_PROVIDERS: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/providers/declarative");
pub fn custom_providers_dir() -> std::path::PathBuf {
Paths::config_dir().join("custom_providers")
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum ProviderEngine {
OpenAI,
Ollama,
Anthropic,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct DeclarativeProviderConfig {
pub name: String,
pub engine: ProviderEngine,
pub display_name: String,
pub description: Option<String>,
pub api_key_env: String,
pub base_url: String,
pub models: Vec<ModelInfo>,
pub headers: Option<HashMap<String, String>>,
pub timeout_seconds: Option<u64>,
pub supports_streaming: Option<bool>,
}
impl DeclarativeProviderConfig {
pub fn id(&self) -> &str {
&self.name
}
pub fn display_name(&self) -> &str {
&self.display_name
}
pub fn models(&self) -> &[ModelInfo] {
&self.models
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct LoadedProvider {
pub config: DeclarativeProviderConfig,
pub is_editable: bool,
}
static ID_GENERATION_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
pub fn generate_id(display_name: &str) -> String {
let _guard = ID_GENERATION_LOCK.lock().unwrap();
let normalized = display_name.to_lowercase().replace(' ', "_");
let base_id = format!("custom_{}", normalized);
let custom_dir = custom_providers_dir();
let mut candidate_id = base_id.clone();
let mut counter = 1;
while custom_dir.join(format!("{}.json", candidate_id)).exists() {
candidate_id = format!("{}_{}", base_id, counter);
counter += 1;
}
candidate_id
}
pub fn generate_api_key_name(id: &str) -> String {
format!("{}_API_KEY", id.to_uppercase())
}
pub fn create_custom_provider(
engine: &str,
display_name: String,
api_url: String,
api_key: String,
models: Vec<String>,
supports_streaming: Option<bool>,
) -> Result<DeclarativeProviderConfig> {
let id = generate_id(&display_name);
let api_key_name = generate_api_key_name(&id);
let config = Config::global();
config.set_secret(&api_key_name, serde_json::Value::String(api_key))?;
let model_infos: Vec<ModelInfo> = models
.into_iter()
.map(|name| ModelInfo::new(name, 128000))
.collect();
let provider_config = DeclarativeProviderConfig {
name: id.clone(),
engine: match engine {
"openai_compatible" => ProviderEngine::OpenAI,
"anthropic_compatible" => ProviderEngine::Anthropic,
"ollama_compatible" => ProviderEngine::Ollama,
_ => return Err(anyhow::anyhow!("Invalid provider type: {}", engine)),
},
display_name: display_name.clone(),
description: Some(format!("Custom {} provider", display_name)),
api_key_env: api_key_name,
base_url: api_url,
models: model_infos,
headers: None,
timeout_seconds: None,
supports_streaming,
};
let custom_providers_dir = custom_providers_dir();
std::fs::create_dir_all(&custom_providers_dir)?;
let json_content = serde_json::to_string_pretty(&provider_config)?;
let file_path = custom_providers_dir.join(format!("{}.json", id));
std::fs::write(file_path, json_content)?;
Ok(provider_config)
}
pub fn update_custom_provider(
id: &str,
provider_type: &str,
display_name: String,
api_url: String,
api_key: String,
models: Vec<String>,
supports_streaming: Option<bool>,
) -> Result<()> {
let loaded_provider = load_provider(id)?;
let existing_config = loaded_provider.config;
let editable = loaded_provider.is_editable;
let config = Config::global();
if !api_key.is_empty() {
config.set_secret(
&existing_config.api_key_env,
serde_json::Value::String(api_key),
)?;
}
if editable {
let model_infos: Vec<ModelInfo> = models
.into_iter()
.map(|name| ModelInfo::new(name, 128000))
.collect();
let updated_config = DeclarativeProviderConfig {
name: id.to_string(),
engine: match provider_type {
"openai_compatible" => ProviderEngine::OpenAI,
"anthropic_compatible" => ProviderEngine::Anthropic,
"ollama_compatible" => ProviderEngine::Ollama,
_ => return Err(anyhow::anyhow!("Invalid provider type: {}", provider_type)),
},
display_name,
description: existing_config.description,
api_key_env: existing_config.api_key_env,
base_url: api_url,
models: model_infos,
headers: existing_config.headers,
timeout_seconds: existing_config.timeout_seconds,
supports_streaming,
};
let file_path = custom_providers_dir().join(format!("{}.json", id));
let json_content = serde_json::to_string_pretty(&updated_config)?;
std::fs::write(file_path, json_content)?;
}
Ok(())
}
pub fn remove_custom_provider(id: &str) -> Result<()> {
let config = Config::global();
let api_key_name = generate_api_key_name(id);
let _ = config.delete_secret(&api_key_name);
let custom_providers_dir = custom_providers_dir();
let file_path = custom_providers_dir.join(format!("{}.json", id));
if file_path.exists() {
std::fs::remove_file(file_path)?;
}
Ok(())
}
pub fn load_provider(id: &str) -> Result<LoadedProvider> {
let custom_file_path = custom_providers_dir().join(format!("{}.json", id));
if custom_file_path.exists() {
let content = std::fs::read_to_string(&custom_file_path)?;
let config: DeclarativeProviderConfig = serde_json::from_str(&content)?;
return Ok(LoadedProvider {
config,
is_editable: true,
});
}
for file in FIXED_PROVIDERS.files() {
if file.path().extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let content = file
.contents_utf8()
.ok_or_else(|| anyhow::anyhow!("Failed to read file as UTF-8: {:?}", file.path()))?;
let config: DeclarativeProviderConfig = serde_json::from_str(content)?;
if config.name == id {
return Ok(LoadedProvider {
config,
is_editable: false,
});
}
}
Err(anyhow::anyhow!("Provider not found: {}", id))
}
pub fn load_custom_providers(dir: &Path) -> Result<Vec<DeclarativeProviderConfig>> {
if !dir.exists() {
return Ok(Vec::new());
}
std::fs::read_dir(dir)?
.filter_map(|entry| {
let path = entry.ok()?.path();
(path.extension()? == "json").then_some(path)
})
.map(|path| {
let content = std::fs::read_to_string(&path)?;
serde_json::from_str(&content)
.map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", path.display(), e))
})
.collect()
}
fn load_fixed_providers() -> Result<Vec<DeclarativeProviderConfig>> {
let mut res = Vec::new();
for file in FIXED_PROVIDERS.files() {
if file.path().extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let content = file
.contents_utf8()
.ok_or_else(|| anyhow::anyhow!("Failed to read file as UTF-8: {:?}", file.path()))?;
let config: DeclarativeProviderConfig = serde_json::from_str(content)?;
res.push(config)
}
Ok(res)
}
pub fn register_declarative_providers(
registry: &mut crate::providers::provider_registry::ProviderRegistry,
) -> Result<()> {
let dir = custom_providers_dir();
let custom_providers = load_custom_providers(&dir)?;
let fixed_providers = load_fixed_providers()?;
for config in fixed_providers {
register_declarative_provider(registry, config, ProviderType::Declarative);
}
for config in custom_providers {
register_declarative_provider(registry, config, ProviderType::Custom);
}
Ok(())
}
pub fn register_declarative_provider(
registry: &mut crate::providers::provider_registry::ProviderRegistry,
config: DeclarativeProviderConfig,
provider_type: ProviderType,
) {
let config_clone = config.clone();
match config.engine {
ProviderEngine::OpenAI => {
registry.register_with_name::<OpenAiProvider, _>(
&config,
provider_type,
move |model| OpenAiProvider::from_custom_config(model, config_clone.clone()),
);
}
ProviderEngine::Ollama => {
registry.register_with_name::<OllamaProvider, _>(
&config,
provider_type,
move |model| OllamaProvider::from_custom_config(model, config_clone.clone()),
);
}
ProviderEngine::Anthropic => {
registry.register_with_name::<AnthropicProvider, _>(
&config,
provider_type,
move |model| AnthropicProvider::from_custom_config(model, config_clone.clone()),
);
}
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
pub mod base;
pub mod custom_providers;
pub mod declarative_providers;
mod experiments;
pub mod extensions;
pub mod paths;
@@ -9,7 +9,7 @@ pub mod signup_tetrate;
pub use crate::agents::ExtensionConfig;
pub use base::{Config, ConfigError};
pub use custom_providers::CustomProviderConfig;
pub use declarative_providers::DeclarativeProviderConfig;
pub use experiments::ExperimentManager;
pub use extensions::{
get_all_extension_names, get_all_extensions, get_enabled_extensions, get_extension_by_name,
+2 -1
View File
@@ -48,7 +48,7 @@ No extensions are defined. You should let the user know that they should add ext
{% endif %}
{{tool_selection_strategy}}
{% if is_autonomous %}
# sub agents
Execute self contained tasks where step-by-step visibility is not important through subagents.
@@ -60,6 +60,7 @@ Execute self contained tasks where step-by-step visibility is not important thro
- Provide all needed context — subagents cannot see your context
- Use extension filters to limit resource access
- Use return_last_only when only a summary or simple answer is required — inform subagent of this choice.
{% endif %}
# Response Guidelines
+5 -2
View File
@@ -15,7 +15,7 @@ use super::formats::anthropic::{
create_request, get_usage, response_to_message, response_to_streaming_message,
};
use super::utils::{emit_debug_trace, get_model, map_http_error_to_provider_error};
use crate::config::custom_providers::CustomProviderConfig;
use crate::config::declarative_providers::DeclarativeProviderConfig;
use crate::conversation::message::Message;
use crate::model::ModelConfig;
use crate::providers::retry::ProviderRetry;
@@ -69,7 +69,10 @@ impl AnthropicProvider {
})
}
pub fn from_custom_config(model: ModelConfig, config: CustomProviderConfig) -> Result<Self> {
pub fn from_custom_config(
model: ModelConfig,
config: DeclarativeProviderConfig,
) -> Result<Self> {
let global_config = crate::config::Config::global();
let api_key: String = global_config
.get_secret(&config.api_key_env)
+8 -2
View File
@@ -81,6 +81,14 @@ impl ModelInfo {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub enum ProviderType {
Preferred,
Builtin,
Declarative,
Custom,
}
/// Metadata about a provider's configuration requirements and capabilities
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProviderMetadata {
@@ -93,7 +101,6 @@ pub struct ProviderMetadata {
/// The default/recommended model for this provider
pub default_model: String,
/// A list of currently known models with their capabilities
/// TODO: eventually query the apis directly
pub known_models: Vec<ModelInfo>,
/// Link to the docs where models can be found
pub model_doc_link: String,
@@ -132,7 +139,6 @@ impl ProviderMetadata {
}
}
/// Create a new ProviderMetadata with ModelInfo objects that include cost data
pub fn with_models(
name: &str,
display_name: &str,
@@ -0,0 +1,29 @@
{
"name": "custom_deepseek",
"engine": "openai",
"display_name": "DeepSeek",
"description": "Custom DeepSeek provider",
"api_key_env": "DEEPSEEK_API_KEY",
"base_url": "https://api.deepseek.com",
"models": [
{
"name": "deepseek-chat",
"context_limit": 128000,
"input_token_cost": null,
"output_token_cost": null,
"currency": null,
"supports_cache_control": null
},
{
"name": "deepseek-reasoner",
"context_limit": 128000,
"input_token_cost": null,
"output_token_cost": null,
"currency": null,
"supports_cache_control": null
}
],
"headers": null,
"timeout_seconds": null,
"supports_streaming": true
}
@@ -0,0 +1,31 @@
{
"name": "groq",
"engine": "openai",
"display_name": "Groq (d)",
"description": "Fast inference with Groq hardware",
"api_key_env": "GROQ_API_KEY",
"base_url": "https://api.groq.com/openai/v1/chat/completions",
"models": [
{
"name": "openai/gpt-oss-120b",
"context_limit": 131072
},
{
"name": "llama-3.1-8b-instant",
"context_limit": 131072
},
{
"name": "llama-3.3-70b-versatile",
"context_limit": 131072
},
{
"name": "meta-llama/llama-guard-4-12b",
"context_limit": 131072
},
{
"name": "openai/gpt-oss-20b",
"context_limit": 131072
}
],
"supports_streaming": true
}
+46 -27
View File
@@ -13,7 +13,6 @@ use super::{
gemini_cli::GeminiCliProvider,
githubcopilot::GithubCopilotProvider,
google::GoogleProvider,
groq::GroqProvider,
lead_worker::LeadWorkerProvider,
litellm::LiteLLMProvider,
ollama::OllamaProvider,
@@ -26,8 +25,9 @@ use super::{
venice::VeniceProvider,
xai::XaiProvider,
};
use crate::config::custom_providers::{custom_providers_dir, register_custom_providers};
use crate::config::declarative_providers::register_declarative_providers;
use crate::model::ModelConfig;
use crate::providers::base::ProviderType;
use anyhow::Result;
use tokio::sync::OnceCell;
@@ -39,29 +39,45 @@ static REGISTRY: OnceCell<RwLock<ProviderRegistry>> = OnceCell::const_new();
async fn init_registry() -> RwLock<ProviderRegistry> {
let mut registry = ProviderRegistry::new().with_providers(|registry| {
registry.register::<AnthropicProvider, _>(|m| Box::pin(AnthropicProvider::from_env(m)));
registry.register::<AzureProvider, _>(|m| Box::pin(AzureProvider::from_env(m)));
registry.register::<BedrockProvider, _>(|m| Box::pin(BedrockProvider::from_env(m)));
registry.register::<ClaudeCodeProvider, _>(|m| Box::pin(ClaudeCodeProvider::from_env(m)));
registry.register::<CursorAgentProvider, _>(|m| Box::pin(CursorAgentProvider::from_env(m)));
registry.register::<DatabricksProvider, _>(|m| Box::pin(DatabricksProvider::from_env(m)));
registry.register::<EmbeddedProvider, _>(|m| Box::pin(EmbeddedProvider::from_env(m)));
registry.register::<GcpVertexAIProvider, _>(|m| Box::pin(GcpVertexAIProvider::from_env(m)));
registry.register::<GeminiCliProvider, _>(|m| Box::pin(GeminiCliProvider::from_env(m)));
registry
.register::<GithubCopilotProvider, _>(|m| Box::pin(GithubCopilotProvider::from_env(m)));
registry.register::<GoogleProvider, _>(|m| Box::pin(GoogleProvider::from_env(m)));
registry.register::<GroqProvider, _>(|m| Box::pin(GroqProvider::from_env(m)));
registry.register::<LiteLLMProvider, _>(|m| Box::pin(LiteLLMProvider::from_env(m)));
registry.register::<OllamaProvider, _>(|m| Box::pin(OllamaProvider::from_env(m)));
registry.register::<OpenAiProvider, _>(|m| Box::pin(OpenAiProvider::from_env(m)));
registry.register::<OpenRouterProvider, _>(|m| Box::pin(OpenRouterProvider::from_env(m)));
.register::<AnthropicProvider, _>(|m| Box::pin(AnthropicProvider::from_env(m)), true);
registry.register::<AzureProvider, _>(|m| Box::pin(AzureProvider::from_env(m)), false);
registry.register::<BedrockProvider, _>(|m| Box::pin(BedrockProvider::from_env(m)), false);
registry
.register::<SageMakerTgiProvider, _>(|m| Box::pin(SageMakerTgiProvider::from_env(m)));
registry.register::<SnowflakeProvider, _>(|m| Box::pin(SnowflakeProvider::from_env(m)));
registry.register::<TetrateProvider, _>(|m| Box::pin(TetrateProvider::from_env(m)));
registry.register::<VeniceProvider, _>(|m| Box::pin(VeniceProvider::from_env(m)));
registry.register::<XaiProvider, _>(|m| Box::pin(XaiProvider::from_env(m)));
.register::<ClaudeCodeProvider, _>(|m| Box::pin(ClaudeCodeProvider::from_env(m)), true);
registry.register::<CursorAgentProvider, _>(
|m| Box::pin(CursorAgentProvider::from_env(m)),
false,
);
registry
.register::<DatabricksProvider, _>(|m| Box::pin(DatabricksProvider::from_env(m)), true);
registry.register::<GcpVertexAIProvider, _>(
|m| Box::pin(GcpVertexAIProvider::from_env(m)),
false,
);
registry
.register::<EmbeddedProvider, _>(|m| Box::pin(EmbeddedProvider::from_env(m)));
registry
.register::<GeminiCliProvider, _>(|m| Box::pin(GeminiCliProvider::from_env(m)), false);
registry.register::<GithubCopilotProvider, _>(
|m| Box::pin(GithubCopilotProvider::from_env(m)),
false,
);
registry.register::<GoogleProvider, _>(|m| Box::pin(GoogleProvider::from_env(m)), true);
registry.register::<LiteLLMProvider, _>(|m| Box::pin(LiteLLMProvider::from_env(m)), false);
registry.register::<OllamaProvider, _>(|m| Box::pin(OllamaProvider::from_env(m)), true);
registry.register::<OpenAiProvider, _>(|m| Box::pin(OpenAiProvider::from_env(m)), true);
registry
.register::<OpenRouterProvider, _>(|m| Box::pin(OpenRouterProvider::from_env(m)), true);
registry.register::<SageMakerTgiProvider, _>(
|m| Box::pin(SageMakerTgiProvider::from_env(m)),
false,
);
registry
.register::<SnowflakeProvider, _>(|m| Box::pin(SnowflakeProvider::from_env(m)), false);
registry.register::<TetrateProvider, _>(|m| Box::pin(TetrateProvider::from_env(m)), true);
registry.register::<VeniceProvider, _>(|m| Box::pin(VeniceProvider::from_env(m)), false);
registry.register::<XaiProvider, _>(|m| Box::pin(XaiProvider::from_env(m)), false);
});
if let Err(e) = load_custom_providers_into_registry(&mut registry) {
tracing::warn!("Failed to load custom providers: {}", e);
@@ -70,16 +86,19 @@ async fn init_registry() -> RwLock<ProviderRegistry> {
}
fn load_custom_providers_into_registry(registry: &mut ProviderRegistry) -> Result<()> {
let config_dir = custom_providers_dir();
register_custom_providers(registry, &config_dir)
register_declarative_providers(registry)
}
async fn get_registry() -> &'static RwLock<ProviderRegistry> {
REGISTRY.get_or_init(init_registry).await
}
pub async fn providers() -> Vec<ProviderMetadata> {
get_registry().await.read().unwrap().all_metadata()
pub async fn providers() -> Vec<(ProviderMetadata, ProviderType)> {
get_registry()
.await
.read()
.unwrap()
.all_metadata_with_types()
}
pub async fn refresh_custom_providers() -> Result<()> {
-131
View File
@@ -1,131 +0,0 @@
use super::api_client::{ApiClient, AuthMethod};
use super::errors::ProviderError;
use super::retry::ProviderRetry;
use super::utils::{get_model, handle_response_openai_compat};
use crate::conversation::message::Message;
use crate::model::ModelConfig;
use crate::providers::base::{ConfigKey, Provider, ProviderMetadata, ProviderUsage, Usage};
use crate::providers::formats::openai::{create_request, get_usage, response_to_message};
use anyhow::Result;
use async_trait::async_trait;
use rmcp::model::Tool;
use serde_json::Value;
pub const GROQ_API_HOST: &str = "https://api.groq.com";
pub const GROQ_DEFAULT_MODEL: &str = "moonshotai/kimi-k2-instruct";
pub const GROQ_KNOWN_MODELS: &[&str] = &[
"gemma2-9b-it",
"llama-3.3-70b-versatile",
"moonshotai/kimi-k2-instruct",
"qwen/qwen3-32b",
];
pub const GROQ_DOC_URL: &str = "https://console.groq.com/docs/models";
#[derive(serde::Serialize)]
pub struct GroqProvider {
#[serde(skip)]
api_client: ApiClient,
model: ModelConfig,
}
impl GroqProvider {
pub async fn from_env(model: ModelConfig) -> Result<Self> {
let config = crate::config::Config::global();
let api_key: String = config.get_secret("GROQ_API_KEY")?;
let host: String = config
.get_param("GROQ_HOST")
.unwrap_or_else(|_| GROQ_API_HOST.to_string());
let auth = AuthMethod::BearerToken(api_key);
let api_client = ApiClient::new(host, auth)?;
Ok(Self { api_client, model })
}
async fn post(&self, payload: Value) -> Result<Value, ProviderError> {
let response = self
.api_client
.response_post("openai/v1/chat/completions", &payload)
.await?;
handle_response_openai_compat(response).await
}
}
#[async_trait]
impl Provider for GroqProvider {
fn metadata() -> ProviderMetadata {
ProviderMetadata::new(
"groq",
"Groq",
"Fast inference with Groq hardware",
GROQ_DEFAULT_MODEL,
GROQ_KNOWN_MODELS.to_vec(),
GROQ_DOC_URL,
vec![
ConfigKey::new("GROQ_API_KEY", true, true, None),
ConfigKey::new("GROQ_HOST", false, false, Some(GROQ_API_HOST)),
],
)
}
fn get_model_config(&self) -> ModelConfig {
self.model.clone()
}
#[tracing::instrument(
skip(self, model_config, system, messages, tools),
fields(model_config, input, output, input_tokens, output_tokens, total_tokens)
)]
async fn complete_with_model(
&self,
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<(Message, ProviderUsage), ProviderError> {
let payload = create_request(
model_config,
system,
messages,
tools,
&super::utils::ImageFormat::OpenAi,
)?;
let response = self.with_retry(|| self.post(payload.clone())).await?;
let message = response_to_message(&response)?;
let usage = response.get("usage").map(get_usage).unwrap_or_else(|| {
tracing::debug!("Failed to get usage data");
Usage::default()
});
let response_model = get_model(&response);
super::utils::emit_debug_trace(model_config, &payload, &response, &usage);
Ok((message, ProviderUsage::new(response_model, usage)))
}
/// Fetch supported models from Groq; returns Err on failure, Ok(None) if no models found
async fn fetch_supported_models(&self) -> Result<Option<Vec<String>>, ProviderError> {
let response = self
.api_client
.request("openai/v1/models")
.header("Content-Type", "application/json")?
.response_get()
.await?;
let response = handle_response_openai_compat(response).await?;
let data = response
.get("data")
.and_then(|v| v.as_array())
.ok_or_else(|| {
ProviderError::UsageError("Missing or invalid `data` field in response".into())
})?;
let mut model_names: Vec<String> = data
.iter()
.filter_map(|m| m.get("id").and_then(|v| v.as_str()).map(String::from))
.collect();
model_names.sort();
Ok(Some(model_names))
}
}
-1
View File
@@ -17,7 +17,6 @@ pub mod gcpvertexai;
pub mod gemini_cli;
pub mod githubcopilot;
pub mod google;
pub mod groq;
pub mod lead_worker;
pub mod litellm;
pub mod oauth;
+11 -4
View File
@@ -3,7 +3,7 @@ use super::base::{ConfigKey, MessageStream, Provider, ProviderMetadata, Provider
use super::errors::ProviderError;
use super::retry::ProviderRetry;
use super::utils::{get_model, handle_response_openai_compat, handle_status_openai_compat};
use crate::config::custom_providers::CustomProviderConfig;
use crate::config::declarative_providers::DeclarativeProviderConfig;
use crate::conversation::message::Message;
use crate::conversation::Conversation;
@@ -30,9 +30,13 @@ use url::Url;
pub const OLLAMA_HOST: &str = "localhost";
pub const OLLAMA_TIMEOUT: u64 = 600; // seconds
pub const OLLAMA_DEFAULT_PORT: u16 = 11434;
pub const OLLAMA_DEFAULT_MODEL: &str = "qwen2.5";
pub const OLLAMA_DEFAULT_MODEL: &str = "qwen3";
// Ollama can run many models, we only provide the default
pub const OLLAMA_KNOWN_MODELS: &[&str] = &[OLLAMA_DEFAULT_MODEL];
pub const OLLAMA_KNOWN_MODELS: &[&str] = &[
OLLAMA_DEFAULT_MODEL,
"qwen3-coder:30b",
"qwen3-coder:480b-cloud",
];
pub const OLLAMA_DOC_URL: &str = "https://ollama.com/library";
#[derive(serde::Serialize)]
@@ -89,7 +93,10 @@ impl OllamaProvider {
})
}
pub fn from_custom_config(model: ModelConfig, config: CustomProviderConfig) -> Result<Self> {
pub fn from_custom_config(
model: ModelConfig,
config: DeclarativeProviderConfig,
) -> Result<Self> {
let timeout = Duration::from_secs(config.timeout_seconds.unwrap_or(OLLAMA_TIMEOUT));
// Parse and normalize the custom URL
+5 -2
View File
@@ -20,7 +20,7 @@ use super::utils::{
emit_debug_trace, get_model, handle_response_openai_compat, handle_status_openai_compat,
ImageFormat,
};
use crate::config::custom_providers::CustomProviderConfig;
use crate::config::declarative_providers::DeclarativeProviderConfig;
use crate::conversation::message::Message;
use crate::model::ModelConfig;
@@ -110,7 +110,10 @@ impl OpenAiProvider {
})
}
pub fn from_custom_config(model: ModelConfig, config: CustomProviderConfig) -> Result<Self> {
pub fn from_custom_config(
model: ModelConfig,
config: DeclarativeProviderConfig,
) -> Result<Self> {
let global_config = crate::config::Config::global();
let api_key: String = global_config
.get_secret(&config.api_key_env)
+42 -12
View File
@@ -1,4 +1,5 @@
use super::base::{Provider, ProviderMetadata};
use super::base::{ModelInfo, Provider, ProviderMetadata, ProviderType};
use crate::config::DeclarativeProviderConfig;
use crate::model::ModelConfig;
use anyhow::Result;
use futures::future::BoxFuture;
@@ -11,6 +12,7 @@ type ProviderConstructor =
pub struct ProviderEntry {
metadata: ProviderMetadata,
pub(crate) constructor: ProviderConstructor,
provider_type: ProviderType,
}
#[derive(Default)]
@@ -25,7 +27,7 @@ impl ProviderRegistry {
}
}
pub fn register<P, F>(&mut self, constructor: F)
pub fn register<P, F>(&mut self, constructor: F, preferred: bool)
where
P: Provider + 'static,
F: Fn(ModelConfig) -> BoxFuture<'static, Result<P>> + Send + Sync + 'static,
@@ -44,26 +46,50 @@ impl ProviderRegistry {
Ok(Arc::new(provider) as Arc<dyn Provider>)
})
}),
provider_type: if preferred {
ProviderType::Preferred
} else {
ProviderType::Builtin
},
},
);
}
pub fn register_with_name<P, F>(
&mut self,
custom_name: String,
display_name: String,
description: String,
default_model: String,
known_models: Vec<super::base::ModelInfo>,
config: &DeclarativeProviderConfig,
provider_type: ProviderType,
constructor: F,
) where
P: Provider + 'static,
F: Fn(ModelConfig) -> Result<P> + Send + Sync + 'static,
{
let base_metadata = P::metadata();
let description = config
.description
.clone()
.unwrap_or_else(|| format!("Custom {} provider", config.display_name));
let default_model = config
.models
.first()
.map(|m| m.name.clone())
.unwrap_or_default();
let known_models: Vec<ModelInfo> = config
.models
.iter()
.map(|m| ModelInfo {
name: m.name.clone(),
context_limit: m.context_limit,
input_token_cost: m.input_token_cost,
output_token_cost: m.output_token_cost,
currency: m.currency.clone(),
supports_cache_control: Some(m.supports_cache_control.unwrap_or(false)),
})
.collect();
let custom_metadata = ProviderMetadata {
name: custom_name.clone(),
display_name,
name: config.name.clone(),
display_name: config.display_name.clone(),
description,
default_model,
known_models,
@@ -72,7 +98,7 @@ impl ProviderRegistry {
};
self.entries.insert(
custom_name,
config.name.clone(),
ProviderEntry {
metadata: custom_metadata,
constructor: Arc::new(move |model| {
@@ -82,6 +108,7 @@ impl ProviderRegistry {
Ok(Arc::new(provider) as Arc<dyn Provider>)
})
}),
provider_type,
},
);
}
@@ -103,8 +130,11 @@ impl ProviderRegistry {
(entry.constructor)(model).await
}
pub fn all_metadata(&self) -> Vec<ProviderMetadata> {
self.entries.values().map(|e| e.metadata.clone()).collect()
pub fn all_metadata_with_types(&self) -> Vec<(ProviderMetadata, ProviderType)> {
self.entries
.values()
.map(|e| (e.metadata.clone(), e.provider_type))
.collect()
}
pub fn remove_custom_providers(&mut self) {
+11 -3
View File
@@ -80,6 +80,14 @@ pub fn map_http_error_to_provider_error(
);
ProviderError::Authentication(message)
}
StatusCode::PAYLOAD_TOO_LARGE => {
let payload_str = if let Some(payload) = &payload {
payload.to_string()
} else {
"Payload is too large.".to_string()
};
ProviderError::ContextLengthExceeded(payload_str)
}
StatusCode::BAD_REQUEST => {
let mut error_msg = "Unknown error".to_string();
if let Some(payload) = &payload {
@@ -929,12 +937,12 @@ mod tests {
"The model 'gpt-5' does not exist (code: model_not_found, type: invalid_request_error) (status 404)".to_string(),
)),
),
// Non-JSON body error (tests parse failure path)
// Non-JSON body error (tests 413 PAYLOAD_TOO_LARGE -> ContextLengthExceeded)
(
413,
Some(Value::String("Payload Too Large".to_string())),
Err(ProviderError::RequestFailed(
"Request failed with status: 413 Payload Too Large".to_string(),
Err(ProviderError::ContextLengthExceeded(
"Payload is too large.".to_string(),
)),
),
];
+2 -15
View File
@@ -10,8 +10,8 @@ use goose::providers::base::Provider;
use goose::providers::{
anthropic::AnthropicProvider, azure::AzureProvider, bedrock::BedrockProvider,
databricks::DatabricksProvider, gcpvertexai::GcpVertexAIProvider, google::GoogleProvider,
groq::GroqProvider, ollama::OllamaProvider, openai::OpenAiProvider,
openrouter::OpenRouterProvider, xai::XaiProvider,
ollama::OllamaProvider, openai::OpenAiProvider, openrouter::OpenRouterProvider,
xai::XaiProvider,
};
#[derive(Debug, PartialEq)]
@@ -24,7 +24,6 @@ enum ProviderType {
Databricks,
GcpVertexAI,
Google,
Groq,
Ollama,
OpenRouter,
Xai,
@@ -43,7 +42,6 @@ impl ProviderType {
ProviderType::Bedrock => &["AWS_PROFILE"],
ProviderType::Databricks => &["DATABRICKS_HOST"],
ProviderType::Google => &["GOOGLE_API_KEY"],
ProviderType::Groq => &["GROQ_API_KEY"],
ProviderType::Ollama => &[],
ProviderType::OpenRouter => &["OPENROUTER_API_KEY"],
ProviderType::GcpVertexAI => &["GCP_PROJECT_ID", "GCP_LOCATION"],
@@ -80,7 +78,6 @@ impl ProviderType {
Arc::new(GcpVertexAIProvider::from_env(model_config).await?)
}
ProviderType::Google => Arc::new(GoogleProvider::from_env(model_config).await?),
ProviderType::Groq => Arc::new(GroqProvider::from_env(model_config).await?),
ProviderType::Ollama => Arc::new(OllamaProvider::from_env(model_config).await?),
ProviderType::OpenRouter => Arc::new(OpenRouterProvider::from_env(model_config).await?),
ProviderType::Xai => Arc::new(XaiProvider::from_env(model_config).await?),
@@ -305,16 +302,6 @@ mod tests {
.await
}
#[tokio::test]
async fn test_agent_with_groq() -> Result<()> {
run_test_with_config(TestConfig {
provider_type: ProviderType::Groq,
model: "gemma2-9b-it",
context_window: 9_000,
})
.await
}
#[tokio::test]
async fn test_agent_with_openrouter() -> Result<()> {
run_test_with_config(TestConfig {
-6
View File
@@ -9,7 +9,6 @@ use goose::providers::create_with_named_model;
use goose::providers::databricks::DATABRICKS_DEFAULT_MODEL;
use goose::providers::errors::ProviderError;
use goose::providers::google::GOOGLE_DEFAULT_MODEL;
use goose::providers::groq::GROQ_DEFAULT_MODEL;
use goose::providers::litellm::LITELLM_DEFAULT_MODEL;
use goose::providers::ollama::OLLAMA_DEFAULT_MODEL;
use goose::providers::openai::OPEN_AI_DEFAULT_MODEL;
@@ -501,11 +500,6 @@ async fn test_ollama_provider() -> Result<()> {
test_provider("Ollama", OLLAMA_DEFAULT_MODEL, &["OLLAMA_HOST"], None).await
}
#[tokio::test]
async fn test_groq_provider() -> Result<()> {
test_provider("Groq", GROQ_DEFAULT_MODEL, &["GROQ_API_KEY"], None).await
}
#[tokio::test]
async fn test_anthropic_provider() -> Result<()> {
test_provider(
+2 -2
View File
@@ -154,10 +154,10 @@ External subagents let you bring in AI agents from other providers and platforms
```yaml
subagent:
args:
- mcp
- mcp-server
bundled: true
cmd: codex
description: OpenAI Codex CLI Sub-agent
description: OpenAI Codex CLI Subagent
enabled: true
env_keys:
- OPENAI_API_KEY
+7 -13
View File
@@ -7,7 +7,7 @@ import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import CLIExtensionInstructions from '@site/src/components/CLIExtensionInstructions';
This tutorial covers how to add the [Cognee MCP Server](https://github.com/topoteretes/cognee) as a Goose extension to enable knowledge graph memory capabilities, connecting to over 30 data sources for enhanced context and retrieval.
This tutorial covers how to add the [Cognee MCP Server](https://github.com/topoteretes/cognee/tree/main/cognee-mcp) as a Goose extension to enable knowledge graph memory capabilities, connecting to over 30 data sources for enhanced context and retrieval.
:::tip TLDR
**Command**
@@ -17,7 +17,6 @@ uv --directory /path/to/cognee-mcp run python src/server.py
**Environment Variables**
```
LLM_API_KEY: <YOUR_OPENAI_API_KEY>
EMBEDDING_API_KEY: <YOUR_OPENAI_API_KEY>
```
:::
@@ -34,7 +33,7 @@ Note that you'll need [uv](https://docs.astral.sh/uv/#installation) installed on
```bash
# Clone and install Cognee
git clone https://github.com/topoteretes/cognee
cd cognee-mcp
cd cognee/cognee-mcp
uv sync --dev --all-extras --reinstall
# On Linux, install additional dependencies
@@ -153,7 +152,7 @@ goose configure
8. Add the required environment variables:
:::info
You'll need OpenAI API keys for both LLM and embedding models. [Get your API keys here](https://platform.openai.com/api-keys).
You'll need an API key for your LLM provider. By default, this is an [OpenAI API key](https://platform.openai.com/api-keys).
:::
```sh
@@ -188,15 +187,6 @@ You'll need OpenAI API keys for both LLM and embedding models. [Get your API key
│ ▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪
│
◇ Add another environment variable?
│ Yes
│
◇ Environment variable name:
│ EMBEDDING_API_KEY
│
◇ Environment variable value:
│ ▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪
│
◇ Add another environment variable?
│ No
// highlight-end
└ Added Cognee extension
@@ -205,6 +195,10 @@ You'll need OpenAI API keys for both LLM and embedding models. [Get your API key
</TabItem>
</Tabs>
:::info
See the [Cognee MCP documentation](https://docs.cognee.ai/how-to-guides/deployment/mcp) for supported configuration options.
:::
## Example Usage
Cognee provides knowledge graph memory capabilities for Goose, allowing it to remember and connect information across conversations and documents.
+1 -1
View File
@@ -9,7 +9,7 @@ import YouTubeShortEmbed from '@site/src/components/YouTubeShortEmbed';
import GooseDesktopInstaller from '@site/src/components/GooseDesktopInstaller';
import CLIExtensionInstructions from '@site/src/components/CLIExtensionInstructions';
<!-- YouTubeShortEmbed videoUrl="https://www.youtube.com/embed/TBA" /-->
<YouTubeShortEmbed videoUrl="https://youtube.com/embed/_rKBFLREVcM" />
This tutorial will get you started with the [Firecrawl MCP Server](https://github.com/firecrawl/firecrawl-mcp-server) as a goose extension to enable powerful web scraping, crawling, and search capabilities for AI agents.
+2 -22
View File
@@ -13,18 +13,12 @@ import GooseDesktopInstaller from '@site/src/components/GooseDesktopInstaller';
This tutorial will get you started with [deemkeen's MQTT MCP server](https://github.com/deemkeen/mbotmcp) for the [MakeBlock mbot2 rover](https://www.makeblock.com/products/buy-mbot2), and outline some code changes we made along the way.
:::tip TLDR
<Tabs groupId="interface">
<TabItem value="ui" label="Goose Desktop" default>
[Launch the installer](goose://extension?cmd=/path/to/java&arg=-jar&arg=/path/to/mbotmcp-0.0.1-SNAPSHOT.jar&name=mbot2&description=mbot2&env=MQTT_SERVER_URI%3Dtcp://1.2.3.4:1883&env=MQTT_USERNAME%3Dyour_username&env=MQTT_PASSWORD%3Dyour_password)
</TabItem>
<TabItem value="cli" label="Goose CLI">
**Command**
```sh
/path/to/java -jar /path/to/mbotmcp-0.0.1-SNAPSHOT.jar
```
</TabItem>
</Tabs>
**Environment Variable**
**Environment Variables**
```
MQTT_SERVER_URI: tcp://1.2.3.4:1883
MQTT_PASSWORD: <string or blank>
@@ -35,20 +29,6 @@ This tutorial will get you started with [deemkeen's MQTT MCP server](https://git
## Configuration
<Tabs groupId="interface">
<TabItem value="ui" label="Goose Desktop" default>
<GooseDesktopInstaller
extensionId="mbot2"
extensionName="mbot2"
description="mbot2"
command="/path/to/java"
args={["-jar", "/path/to/mbotmcp-0.0.1-SNAPSHOT.jar"]}
envVars={[
{ name: "MQTT_SERVER_URI", label: "tcp://1.2.3.4:1883" },
{ name: "MQTT_USERNAME", label: "your_username" },
{ name: "MQTT_PASSWORD", label: "your_password" }
]}
/>
</TabItem>
<TabItem value="cli" label="Goose CLI">
1. Run the `configure` command:
```sh
@@ -30,6 +30,19 @@
"submittedDate": "2025-10-04",
"tags": ["hacktoberfest"],
"hacktoberfest": true
},
{
"title": "Getting started with Goose on Windows",
"author": {
"name": "Halimah",
"handle": "lymah123"
},
"type": "blog",
"url": "https://dev.to/lymah/getting-started-with-goose-on-windows-30bh",
"thumbnail": "https://i.imgur.com/WEZXcBD.png",
"submittedDate": "2025-10-14",
"tags": ["hacktoberfest"],
"hacktoberfest": true
}
]
}
@@ -0,0 +1,99 @@
version: 1.0.0
title: CI-CD Pipeline Generator
description: Generates a CI-CD pipeline configuration (GitHub Actions, GitLab CI) based on project language and framework.
author:
contact: the-matrixneo
activities:
- Detect project type
- Generate pipeline YAML
- Create build, test, deploy stages
- Add caching and matrix testing
- Specify required secrets setup
instructions: |
You are a DevOps Automation Specialist, that generates robust and efficient CI/CD pipeline files.
Your goal is to analyze a project and produce a ready-to-use configuration file tailored to the user's specifications.
Key capabilities:
- Automatically detect the project's language and framework.
- Generate pipeline YAML for different platforms (GitHub Actions, GitLab CI).
- Create logical stages for building, testing, and deploying.
- Implement optimizations like dependency caching.
- Provide clear instructions for setting up required secrets.
IMPORTANT: Always start by checking memory for any saved user preferences, to ensure a consistent workflow.
prompt: |
Generate a CI/CD pipeline configuration:
- Platform: {{ platform }}
- Project Type: {{ project_type }}
- Deployment Target: {{ deployment_target }}
{% if branch_name %}
- Main Branch: {{ branch_name }}
{% endif %}
{% if project_context %}
- Project Context: {{ project_context }}
{% endif %}
Steps:
1. Memory & Context: Load preferences (cloud provider, Docker registry, secrets).
2. Project Analysis: If "auto," scan for key files; derive build/test commands.
3. Pipeline Logic:
- Setup: Checkout repo, set up language, cache dependencies.
- Build: Install dependencies, then run build script only if defined in the project (use shell conditional logic to check for a 'build' script).
- Test: Run tests, optionally lint/code quality.
- Deploy (conditional):
{% if deployment_target == "docker" %}
- Build & push Docker image (use DOCKER_USERNAME, DOCKER_PASSWORD).
{% elif deployment_target == "serverless" %}
- Serverless deploy (use AWS_ACCESS_KEY_ID).
{% endif %}
- Matrix: Include matrix testing when applicable.
- Validate: Ensure generated YAML is valid.
4. Save & Summarize:
- Save: `.github/workflows/main.yml` (GitHub), `.gitlab-ci.yml` (GitLab).
- Output config and list required secrets.
parameters:
- key: platform
input_type: string
requirement: required
description: "CI/CD platform: 'github_actions', 'gitlab_ci'."
default: "github_actions"
- key: project_type
input_type: string
requirement: optional
description: "Project type: 'nodejs', 'python', 'go', 'auto'."
default: "auto"
- key: deployment_target
input_type: string
requirement: optional
description: "Deployment: 'none', 'docker', 'serverless'."
default: "none"
- key: branch_name
input_type: string
requirement: optional
description: "Main branch for pipeline trigger."
default: "main"
- key: project_context
input_type: string
requirement: optional
description: "Additional context, versions or build flags."
default: ""
extensions:
- type: builtin
name: developer
display_name: Developer
timeout: 300
bundled: true
description: For file system scanning, project detection, YAML saving.
- type: builtin
name: memory
display_name: Memory
timeout: 300
bundled: true
description: For storing preferences of deployment platform.
@@ -0,0 +1,223 @@
version: 1.0.0
title: Daily Standup Report Generator
description: Automates daily standup report creation by fetching PR status, issue progress, and commit activity directly from GitHub via OAuth - generates formatted reports stored in ./standup/ folder for team communication and historical tracking
author:
contact: ARYPROGRAMMER
activities:
- Fetch pull requests and their status from GitHub
- Fetch issues assigned to or created by user
- Analyze merged PRs and commits from specified time period
- Identify blockers from PR reviews and issue comments
- Generate formatted standup report with accomplishments
- Store report in ./standup/ folder with dated filename
- Read previous standup reports for progress continuity
instructions: |
You are a Daily Standup Report Generator that helps developers create comprehensive, professional standup reports by fetching all data directly from GitHub.
Key capabilities:
- Fetch PRs directly from GitHub (all branches, no local repo needed)
- Get PR reviews, approvals, and CI/CD status
- Fetch issues and their current state from GitHub
- Extract commits from merged PRs
- Identify blockers from PR reviews and failing checks
- Store standup reports in dated files in ./standup/ folder
- Read previous standup files to show progress continuity
- Generate professional standup reports in multiple formats
- Works without any local git repository
IMPORTANT:
- All data is fetched from GitHub API via OAuth authentication
- Works for any GitHub repository you have access to
- Uses official github-mcp-server with OAuth flow for secure authentication
- Filters all data by the authenticated GitHub user
- Always create a report file in ./standup/ folder at the end
- Previous reports are stored as ./standup/standup-{YYYY-MM-DD}.{format}
parameters:
- key: github_owner
input_type: string
requirement: required
default: ""
description: "GitHub repository owner/organization (e.g., 'ARYPROGRAMMER', 'block')"
- key: github_repo
input_type: string
requirement: required
default: ""
description: "GitHub repository name (e.g., 'goose')"
- key: time_period
input_type: string
requirement: optional
default: "24h"
description: "Time period to analyze: '24h' (last 24 hours), '48h', 'week'"
- key: include_prs
input_type: string
requirement: optional
default: "true"
description: "Include PR status in the report (true/false)"
- key: include_issues
input_type: string
requirement: optional
default: "true"
description: "Include issue progress in the report (true/false)"
- key: output_format
input_type: string
requirement: optional
default: "markdown"
description: "Report format: 'markdown', 'slack', 'text', 'json'"
- key: team_channel
input_type: string
requirement: optional
default: ""
description: "Team channel name to mention in the report (e.g., '#engineering')"
extensions:
- type: builtin
name: developer
display_name: Developer
timeout: 300
bundled: true
description: For writing the standup report files to disk and reading previous reports
- type: stdio
name: github
cmd: uvx
args: ["github-mcp-server"]
display_name: GitHub
timeout: 300
bundled: false
description: For fetching PRs, issues, reviews, commits, and GitHub repository data via OAuth authentication
prompt: |
Generate a daily standup report with the following configuration:
- GitHub Repository: {{ github_owner }}/{{ github_repo }}
- Time Period: {{ time_period }}
- Include PRs: {{ include_prs }}
- Include Issues: {{ include_issues }}
- Output Format: {{ output_format }}
{% if team_channel %}
- Team Channel: {{ team_channel }}
{% endif %}
**CRITICAL REQUIREMENT**: You MUST create a file named `./standup/standup-{date}.{{ output_format }}` containing the complete standup report. This is the PRIMARY GOAL of this recipe.
Execute this standup report generation workflow (fetch everything from GitHub):
## Phase 1: Context Gathering & User Identification
1. **Read previous standup reports for context:**
- Check if ./standup/ directory exists (if not, create it)
- Scan for previous standup files matching pattern: ./standup/standup-*.{{ output_format }}
- Read the most recent 3-5 standup files (by date) to understand:
* What was accomplished in previous standups
* Ongoing tasks and their progress
* Previously mentioned blockers and their status
- Use this context to show progress continuity in today's report
2. **Identify the authenticated GitHub user:**
- The GitHub MCP server authenticates using OAuth
- When you fetch PRs and issues, the API returns data for the authenticated user
- Note: You'll need to filter results to show only items where the authenticated user is the author or assignee
## Phase 2: Fetch PRs from GitHub
{% if include_prs == "true" %}
3. **Fetch PRs from GitHub:**
- Use GitHub extension tool `list_pull_requests` with these parameters:
* owner: {{ github_owner }}
* repo: {{ github_repo }}
* state: all (to get both open and closed PRs)
* per_page: 100 (maximum allowed)
* page: 1
{% if time_period == "24h" %}
- After fetching, filter PRs updated in last 24 hours by checking the `updated_at` timestamp
{% elif time_period == "48h" %}
- After fetching, filter PRs updated in last 48 hours by checking the `updated_at` timestamp
{% elif time_period == "week" %}
- After fetching, filter PRs updated in last week by checking the `updated_at` timestamp
{% endif %}
- **IMPORTANT**: Filter to show only PRs where the authenticated user is the author (check PR.user field)
- Categorize by status:
* ✅ Merged (shows completed work - check if `merged_at` is not null)
* ✅ Ready to merge (check review_decision field for 'APPROVED' and mergeable_state)
* 👀 Awaiting review (no reviews yet or review count is 0)
* 🔄 In review (has reviews, may need changes based on review state)
* 🚧 Blocked (has conflicts or failing checks - check mergeable field)
- For merged PRs, you can use `get_pull_request` tool to get detailed commit information if needed
{% endif %}
## Phase 3: Fetch Issues from GitHub
{% if include_issues == "true" %}
4. **Fetch issues from GitHub:**
- Use GitHub extension tool `list_issues` with these parameters:
* owner: {{ github_owner }}
* repo: {{ github_repo }}
* state: all (to get both open and closed issues)
* per_page: 100 (maximum allowed)
* page: 1
- **IMPORTANT**: Filter to show only issues where the authenticated user is either:
* The assignee (check assignees array)
* The creator (check user field)
- Filter by activity in {{ time_period }} by checking `updated_at` timestamp
- Categorize:
* ✅ Closed (state is 'closed' and closed within the time period)
* 🔄 In progress (state is 'open' and recently updated)
* 🆕 Created (state is 'open' and created within the time period)
{% endif %}
## Phase 4: Identify Blockers
5. **Identify blockers:**
- For open PRs:
* Use `get_pull_request_status` tool to check for failing CI/CD checks
* Check the `mergeable` field - if false, there are merge conflicts
* Check review state - if "CHANGES_REQUESTED", it needs updates
- For open issues:
* Look for "blocked" label in the labels array
* Search comments for keywords like "blocked", "waiting", "dependency"
- Summarize each blocker with:
* What is blocked (PR# or Issue#)
* Why it's blocked
* What's needed to unblock
## Phase 5: Generate Report
6. **Create the standup report:**
{% if output_format == "markdown" %}
Write a Markdown report with:
- **Header**: Date, Repository ({{ github_owner }}/{{ github_repo }})
- **✅ Completed**: Merged PRs and closed issues from {{ time_period }}
{% if include_prs == "true" %}
- **🔄 Open PRs**: Current status of open PRs
{% endif %}
{% if include_issues == "true" %}
- **📋 Active Issues**: Issues you're working on
{% endif %}
- **🎯 Next Steps**: Based on open PRs and issues
- **🚧 Blockers**: List or "None"
{% elif output_format == "slack" %}
Write Slack format with Completed, Today, Blockers sections
{% elif output_format == "json" %}
Write JSON with: date, repo, merged_prs[], closed_issues[], open_prs[], open_issues[], blockers[]
{% else %}
Write plain text with clear sections
{% endif %}
## Phase 6: Save Report
6. **Save the file:**
- Ensure the ./standup/ directory exists (create if needed)
- **MUST DO**: Use the developer extension's file writing capability to create the file `./standup/standup-{date}.{{ output_format }}`
- The file must contain the complete standup report generated above
- Do NOT store summaries in memory - the file system is the source of truth
- Display confirmation message: "✅ Report saved: ./standup/standup-{date}.{{ output_format }}"
- Include file location in the confirmation
+37 -12
View File
@@ -130,13 +130,20 @@
{
"id": "cognee-mcp",
"name": "Cognee",
"description": "Knowledge graph and cognitive AI capabilities",
"command": "uvx cognee-mcp",
"description": "Knowledge graph and cognitive AI capabilities (requires local server setup)",
"command": "uv --directory /path/to/cognee-mcp run python src/server.py",
"link": "https://github.com/topoteretes/cognee",
"installation_notes": "Install using uvx package manager.",
"installation_notes": "This is a local extension. You must first clone and install the Cognee MCP server locally before using this extension. Update the placeholder path before running the 'goose session' command.",
"is_builtin": false,
"endorsed": false,
"environmentVariables": []
"endorsed": true,
"show_install_link": false,
"environmentVariables": [
{
"name": "LLM_API_KEY",
"description": "API key for your LLM provider",
"required": true
}
]
},
{
"id": "computercontroller",
@@ -298,10 +305,11 @@
{
"id": "jetbrains",
"name": "JetBrains",
"description": "Integrate Goose with any JetBrains IDE",
"command": "npx -y @jetbrains/mcp-proxy",
"description": "Integrate with any JetBrains IDE (requires local setup, dependent on your IDE version)",
"url": "http://localhost:<PORT>/sse",
"type": "remote",
"link": "https://github.com/JetBrains/mcp-jetbrains",
"installation_notes": "Install using npx package manager. Requires the MCP Server plugin to be installed in your JetBrains IDE.",
"installation_notes": "IDE v2025.2 and later: Enable SSE configuration from Settings > Tools > MCP Server, then update the placeholder SSE URL in the following command. If installing from the link, you must update the URL from the Extensions page before activating the extension. For earlier IDE versions, you must install the MCP Server plugin and use npx package manager to install the extension.",
"is_builtin": false,
"endorsed": true,
"environmentVariables": []
@@ -320,13 +328,30 @@
{
"id": "mbot-mcp",
"name": "MBot",
"description": "Chatbot and conversational AI integration",
"command": "npx -y mbot-mcp",
"description": "Control a MakeBlock mbot2 rover through MQTT (requires local setup)",
"command": "/path/to/java -jar /path/to/mbotmcp-0.0.1-SNAPSHOT.jar",
"link": "https://github.com/deemkeen/mbotmcp",
"installation_notes": "Install using npx package manager.",
"installation_notes": "This is a local extension. You must first download the JAR file from the repository, install Java, and update the placeholder paths before running the 'goose session' command.",
"is_builtin": false,
"endorsed": false,
"environmentVariables": []
"show_install_link": false,
"environmentVariables": [
{
"name": "MQTT_SERVER_URI",
"description": "MQTT server URI (example: tcp://1.2.3.4:1883)",
"required": true
},
{
"name": "MQTT_USERNAME",
"description": "MQTT username (empty string if no auth required)",
"required": true
},
{
"name": "MQTT_PASSWORD",
"description": "MQTT password (empty string if no auth required)",
"required": true
}
]
},
{
"id": "memory",
+206 -37
View File
@@ -382,7 +382,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateCustomProviderRequest"
"$ref": "#/components/schemas/UpdateCustomProviderRequest"
}
}
},
@@ -409,6 +409,84 @@
}
},
"/config/custom-providers/{id}": {
"get": {
"tags": [
"super::routes::config_management"
],
"operationId": "get_custom_provider",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Custom provider retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LoadedProvider"
}
}
}
},
"404": {
"description": "Provider not found"
},
"500": {
"description": "Internal server error"
}
}
},
"put": {
"tags": [
"super::routes::config_management"
],
"operationId": "update_custom_provider",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateCustomProviderRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Custom provider updated successfully",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"404": {
"description": "Provider not found"
},
"500": {
"description": "Internal server error"
}
}
},
"delete": {
"tags": [
"super::routes::config_management"
@@ -2203,40 +2281,6 @@
"$ref": "#/components/schemas/Message"
}
},
"CreateCustomProviderRequest": {
"type": "object",
"required": [
"provider_type",
"display_name",
"api_url",
"api_key",
"models"
],
"properties": {
"api_key": {
"type": "string"
},
"api_url": {
"type": "string"
},
"display_name": {
"type": "string"
},
"models": {
"type": "array",
"items": {
"type": "string"
}
},
"provider_type": {
"type": "string"
},
"supports_streaming": {
"type": "boolean",
"nullable": true
}
}
},
"CreateRecipeRequest": {
"type": "object",
"required": [
@@ -2296,6 +2340,61 @@
}
}
},
"DeclarativeProviderConfig": {
"type": "object",
"required": [
"name",
"engine",
"display_name",
"api_key_env",
"base_url",
"models"
],
"properties": {
"api_key_env": {
"type": "string"
},
"base_url": {
"type": "string"
},
"description": {
"type": "string",
"nullable": true
},
"display_name": {
"type": "string"
},
"engine": {
"$ref": "#/components/schemas/ProviderEngine"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"nullable": true
},
"models": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ModelInfo"
}
},
"name": {
"type": "string"
},
"supports_streaming": {
"type": "boolean",
"nullable": true
},
"timeout_seconds": {
"type": "integer",
"format": "int64",
"nullable": true,
"minimum": 0
}
}
},
"DecodeRecipeRequest": {
"type": "object",
"required": [
@@ -2979,6 +3078,21 @@
}
}
},
"LoadedProvider": {
"type": "object",
"required": [
"config",
"is_editable"
],
"properties": {
"config": {
"$ref": "#/components/schemas/DeclarativeProviderConfig"
},
"is_editable": {
"type": "boolean"
}
}
},
"Message": {
"type": "object",
"description": "A message to or from an LLM",
@@ -3347,7 +3461,8 @@
"required": [
"name",
"metadata",
"is_configured"
"is_configured",
"provider_type"
],
"properties": {
"is_configured": {
@@ -3358,9 +3473,20 @@
},
"name": {
"type": "string"
},
"provider_type": {
"$ref": "#/components/schemas/ProviderType"
}
}
},
"ProviderEngine": {
"type": "string",
"enum": [
"openai",
"ollama",
"anthropic"
]
},
"ProviderMetadata": {
"type": "object",
"description": "Metadata about a provider's configuration requirements and capabilities",
@@ -3398,7 +3524,7 @@
"items": {
"$ref": "#/components/schemas/ModelInfo"
},
"description": "A list of currently known models with their capabilities\nTODO: eventually query the apis directly"
"description": "A list of currently known models with their capabilities"
},
"model_doc_link": {
"type": "string",
@@ -3410,6 +3536,15 @@
}
}
},
"ProviderType": {
"type": "string",
"enum": [
"Preferred",
"Builtin",
"Declarative",
"Custom"
]
},
"ProvidersResponse": {
"type": "object",
"required": [
@@ -4449,6 +4584,40 @@
}
}
},
"UpdateCustomProviderRequest": {
"type": "object",
"required": [
"engine",
"display_name",
"api_url",
"api_key",
"models"
],
"properties": {
"api_key": {
"type": "string"
},
"api_url": {
"type": "string"
},
"display_name": {
"type": "string"
},
"engine": {
"type": "string"
},
"models": {
"type": "array",
"items": {
"type": "string"
}
},
"supports_streaming": {
"type": "boolean",
"nullable": true
}
}
},
"UpdateProviderRequest": {
"type": "object",
"required": [
+19 -1
View File
@@ -1,7 +1,7 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from './client';
import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, ResumeAgentData, ResumeAgentResponses, ResumeAgentErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, StartAgentData, StartAgentResponses, StartAgentErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ParseRecipeData, ParseRecipeResponses, ParseRecipeErrors, SaveRecipeData, SaveRecipeResponses, SaveRecipeErrors, ScanRecipeData, ScanRecipeResponses, ReplyData, ReplyResponses, ReplyErrors, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, ImportSessionData, ImportSessionResponses, ImportSessionErrors, GetSessionInsightsData, GetSessionInsightsResponses, GetSessionInsightsErrors, DeleteSessionData, DeleteSessionResponses, DeleteSessionErrors, GetSessionData, GetSessionResponses, GetSessionErrors, UpdateSessionDescriptionData, UpdateSessionDescriptionResponses, UpdateSessionDescriptionErrors, ExportSessionData, ExportSessionResponses, ExportSessionErrors, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesResponses, UpdateSessionUserRecipeValuesErrors, StatusData, StatusResponses } from './types.gen';
import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, ResumeAgentData, ResumeAgentResponses, ResumeAgentErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, StartAgentData, StartAgentResponses, StartAgentErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetCustomProviderData, GetCustomProviderResponses, GetCustomProviderErrors, UpdateCustomProviderData, UpdateCustomProviderResponses, UpdateCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ParseRecipeData, ParseRecipeResponses, ParseRecipeErrors, SaveRecipeData, SaveRecipeResponses, SaveRecipeErrors, ScanRecipeData, ScanRecipeResponses, ReplyData, ReplyResponses, ReplyErrors, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, ImportSessionData, ImportSessionResponses, ImportSessionErrors, GetSessionInsightsData, GetSessionInsightsResponses, GetSessionInsightsErrors, DeleteSessionData, DeleteSessionResponses, DeleteSessionErrors, GetSessionData, GetSessionResponses, GetSessionErrors, UpdateSessionDescriptionData, UpdateSessionDescriptionResponses, UpdateSessionDescriptionErrors, ExportSessionData, ExportSessionResponses, ExportSessionErrors, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesResponses, UpdateSessionUserRecipeValuesErrors, StatusData, StatusResponses } from './types.gen';
import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@@ -134,6 +134,24 @@ export const removeCustomProvider = <ThrowOnError extends boolean = false>(optio
});
};
export const getCustomProvider = <ThrowOnError extends boolean = false>(options: Options<GetCustomProviderData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<GetCustomProviderResponses, GetCustomProviderErrors, ThrowOnError>({
url: '/config/custom-providers/{id}',
...options
});
};
export const updateCustomProvider = <ThrowOnError extends boolean = false>(options: Options<UpdateCustomProviderData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).put<UpdateCustomProviderResponses, UpdateCustomProviderErrors, ThrowOnError>({
url: '/config/custom-providers/{id}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const getExtensions = <ThrowOnError extends boolean = false>(options?: Options<GetExtensionsData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetExtensionsResponses, GetExtensionsErrors, ThrowOnError>({
url: '/config/extensions',
+93 -11
View File
@@ -110,15 +110,6 @@ export type ContextManageResponse = {
export type Conversation = Array<Message>;
export type CreateCustomProviderRequest = {
api_key: string;
api_url: string;
display_name: string;
models: Array<string>;
provider_type: string;
supports_streaming?: boolean | null;
};
export type CreateRecipeRequest = {
author?: AuthorRequest | null;
session_id: string;
@@ -136,6 +127,21 @@ export type CreateScheduleRequest = {
recipe_source: string;
};
export type DeclarativeProviderConfig = {
api_key_env: string;
base_url: string;
description?: string | null;
display_name: string;
engine: ProviderEngine;
headers?: {
[key: string]: string;
} | null;
models: Array<ModelInfo>;
name: string;
supports_streaming?: boolean | null;
timeout_seconds?: number | null;
};
export type DecodeRecipeRequest = {
deeplink: string;
};
@@ -367,6 +373,11 @@ export type ListSchedulesResponse = {
jobs: Array<ScheduledJob>;
};
export type LoadedProvider = {
config: DeclarativeProviderConfig;
is_editable: boolean;
};
/**
* A message to or from an LLM
*/
@@ -473,8 +484,11 @@ export type ProviderDetails = {
is_configured: boolean;
metadata: ProviderMetadata;
name: string;
provider_type: ProviderType;
};
export type ProviderEngine = 'openai' | 'ollama' | 'anthropic';
/**
* Metadata about a provider's configuration requirements and capabilities
*/
@@ -497,7 +511,6 @@ export type ProviderMetadata = {
display_name: string;
/**
* A list of currently known models with their capabilities
* TODO: eventually query the apis directly
*/
known_models: Array<ModelInfo>;
/**
@@ -510,6 +523,8 @@ export type ProviderMetadata = {
name: string;
};
export type ProviderType = 'Preferred' | 'Builtin' | 'Declarative' | 'Custom';
export type ProvidersResponse = {
providers: Array<ProviderDetails>;
};
@@ -852,6 +867,15 @@ export type ToolResponse = {
};
};
export type UpdateCustomProviderRequest = {
api_key: string;
api_url: string;
display_name: string;
engine: string;
models: Array<string>;
supports_streaming?: boolean | null;
};
export type UpdateProviderRequest = {
model?: string | null;
provider: string;
@@ -1183,7 +1207,7 @@ export type BackupConfigResponses = {
export type BackupConfigResponse = BackupConfigResponses[keyof BackupConfigResponses];
export type CreateCustomProviderData = {
body: CreateCustomProviderRequest;
body: UpdateCustomProviderRequest;
path?: never;
query?: never;
url: '/config/custom-providers';
@@ -1238,6 +1262,64 @@ export type RemoveCustomProviderResponses = {
export type RemoveCustomProviderResponse = RemoveCustomProviderResponses[keyof RemoveCustomProviderResponses];
export type GetCustomProviderData = {
body?: never;
path: {
id: string;
};
query?: never;
url: '/config/custom-providers/{id}';
};
export type GetCustomProviderErrors = {
/**
* Provider not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type GetCustomProviderResponses = {
/**
* Custom provider retrieved successfully
*/
200: LoadedProvider;
};
export type GetCustomProviderResponse = GetCustomProviderResponses[keyof GetCustomProviderResponses];
export type UpdateCustomProviderData = {
body: UpdateCustomProviderRequest;
path: {
id: string;
};
query?: never;
url: '/config/custom-providers/{id}';
};
export type UpdateCustomProviderErrors = {
/**
* Provider not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type UpdateCustomProviderResponses = {
/**
* Custom provider updated successfully
*/
200: string;
};
export type UpdateCustomProviderResponse = UpdateCustomProviderResponses[keyof UpdateCustomProviderResponses];
export type GetExtensionsData = {
body?: never;
path?: never;
@@ -18,6 +18,7 @@ import { useModelAndProvider } from '../../../ModelAndProviderContext';
import type { View } from '../../../../utils/navigationUtils';
import Model, { getProviderMetadata } from '../modelInterface';
import { getPredefinedModelsFromEnv, shouldShowPredefinedModels } from '../predefinedModelsUtils';
import { ProviderType } from '../../../../api';
type SwitchModelModalProps = {
sessionId: string | null;
@@ -165,8 +166,9 @@ export const SwitchModelModal = ({ sessionId, onClose, setView }: SwitchModelMod
const results = await Promise.all(modelPromises);
// Process results and build grouped options
const groupedOptions: { options: { value: string; label: string; provider: string }[] }[] =
[];
const groupedOptions: {
options: { value: string; label: string; provider: string; providerType: ProviderType }[];
}[] = [];
const errors: string[] = [];
results.forEach(({ provider: p, models, error }) => {
@@ -178,13 +180,19 @@ export const SwitchModelModal = ({ sessionId, onClose, setView }: SwitchModelMod
options: p.metadata.known_models.map(({ name }) => ({
value: name,
label: name,
providerType: p.provider_type,
provider: p.name,
})),
});
}
} else if (models && models.length > 0) {
groupedOptions.push({
options: models.map((m) => ({ value: m, label: m, provider: p.name })),
options: models.map((m) => ({
value: m,
label: m,
provider: p.name,
providerType: p.provider_type,
})),
});
}
});
@@ -196,12 +204,14 @@ export const SwitchModelModal = ({ sessionId, onClose, setView }: SwitchModelMod
// Add the "Custom model" option to each provider group
groupedOptions.forEach((group) => {
const providerName = group.options[0]?.provider;
if (providerName && !providerName.startsWith('custom_')) {
const option = group.options[0];
const providerName = option?.provider;
if (providerName && option?.providerType !== 'Custom') {
group.options.push({
value: 'custom',
label: 'Use custom model',
provider: providerName,
providerType: option?.providerType,
});
}
});
@@ -3,7 +3,11 @@ import { ProviderCard } from './subcomponents/ProviderCard';
import CardContainer from './subcomponents/CardContainer';
import { ProviderModalProvider, useProviderModal } from './modal/ProviderModalProvider';
import ProviderConfigurationModal from './modal/ProviderConfiguationModal';
import { ProviderDetails, CreateCustomProviderRequest } from '../../../api';
import {
DeclarativeProviderConfig,
ProviderDetails,
UpdateCustomProviderRequest,
} from '../../../api';
import { Plus } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog';
import CustomProviderForm from './modal/subcomponents/forms/CustomProviderForm';
@@ -43,7 +47,6 @@ const CustomProviderCard = memo(function CustomProviderCard({ onClick }: { onCli
);
});
// Memoize the ProviderCards component
const ProviderCards = memo(function ProviderCards({
providers,
isOnboarding,
@@ -57,28 +60,69 @@ const ProviderCards = memo(function ProviderCards({
}) {
const { openModal } = useProviderModal();
const [showCustomProviderModal, setShowCustomProviderModal] = useState(false);
const [editingProvider, setEditingProvider] = useState<{
id: string;
config: DeclarativeProviderConfig;
isEditable: boolean;
} | null>(null);
// Memoize these functions so they don't get recreated on every render
const configureProviderViaModal = useCallback(
(provider: ProviderDetails) => {
openModal(provider, {
onSubmit: () => {
// Only refresh if the function is provided
if (refreshProviders) {
refreshProviders();
}
},
onDelete: (_values: unknown) => {
if (refreshProviders) {
refreshProviders();
}
},
formProps: {},
});
async (provider: ProviderDetails) => {
if (provider.provider_type === 'Custom' || provider.provider_type === 'Declarative') {
const { getCustomProvider } = await import('../../../api');
const result = await getCustomProvider({ path: { id: provider.name }, throwOnError: true });
if (result.data) {
setEditingProvider({
id: provider.name,
config: result.data.config,
isEditable: result.data.is_editable,
});
setShowCustomProviderModal(true);
}
} else {
openModal(provider, {
onSubmit: () => {
if (refreshProviders) {
refreshProviders();
}
},
onDelete: (_values: unknown) => {
if (refreshProviders) {
refreshProviders();
}
},
formProps: {},
});
}
},
[openModal, refreshProviders]
);
const handleUpdateCustomProvider = useCallback(
async (data: UpdateCustomProviderRequest) => {
if (!editingProvider) return;
const { updateCustomProvider } = await import('../../../api');
await updateCustomProvider({
path: { id: editingProvider.id },
body: data,
throwOnError: true,
});
setShowCustomProviderModal(false);
setEditingProvider(null);
if (refreshProviders) {
refreshProviders();
}
},
[editingProvider, refreshProviders]
);
const handleCloseModal = useCallback(() => {
setShowCustomProviderModal(false);
setEditingProvider(null);
}, []);
const deleteProviderConfigViaModal = useCallback(
(provider: ProviderDetails) => {
openModal(provider, {
@@ -95,26 +139,23 @@ const ProviderCards = memo(function ProviderCards({
);
const handleCreateCustomProvider = useCallback(
async (data: CreateCustomProviderRequest) => {
try {
const { createCustomProvider } = await import('../../../api');
await createCustomProvider({ body: data });
setShowCustomProviderModal(false);
if (refreshProviders) {
refreshProviders();
}
} catch (error) {
console.error('Failed to create custom provider:', error);
async (data: UpdateCustomProviderRequest) => {
const { createCustomProvider } = await import('../../../api');
await createCustomProvider({ body: data, throwOnError: true });
setShowCustomProviderModal(false);
if (refreshProviders) {
refreshProviders();
}
},
[refreshProviders]
);
// Use useMemo to memoize the cards array
const providerCards = useMemo(() => {
// providers needs to be an array
const providersArray = Array.isArray(providers) ? providers : [];
const cards = providersArray.map((provider) => (
// Sort providers alphabetically by name
const sortedProviders = [...providersArray].sort((a, b) => a.name.localeCompare(b.name));
const cards = sortedProviders.map((provider) => (
<ProviderCard
key={provider.name}
provider={provider}
@@ -138,21 +179,33 @@ const ProviderCards = memo(function ProviderCards({
onProviderLaunch,
]);
const initialData = editingProvider && {
engine: editingProvider.config.engine.toLowerCase() + '_compatible',
display_name: editingProvider.config.display_name,
api_url: editingProvider.config.base_url,
api_key: '',
models: editingProvider.config.models.map((m) => m.name),
supports_streaming: editingProvider.config.supports_streaming ?? true,
};
const editable = editingProvider ? editingProvider.isEditable : true;
const title = (editingProvider ? (editable ? 'Edit' : 'Configure') : 'Add') + ' Provider';
return (
<>
{providerCards}
<Dialog open={showCustomProviderModal} onOpenChange={setShowCustomProviderModal}>
<Dialog open={showCustomProviderModal} onOpenChange={handleCloseModal}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Add Custom Provider</DialogTitle>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<CustomProviderForm
onSubmit={handleCreateCustomProvider}
onCancel={() => setShowCustomProviderModal(false)}
initialData={initialData}
isEditable={editable}
onSubmit={editingProvider ? handleUpdateCustomProvider : handleCreateCustomProvider}
onCancel={handleCloseModal}
/>
</DialogContent>
</Dialog>
</Dialog>{' '}
</>
);
});
@@ -162,7 +162,7 @@ export default function ProviderConfigurationModal() {
}
try {
const isCustomProvider = currentProvider.name.startsWith('custom_');
const isCustomProvider = currentProvider.provider_type === 'Custom';
if (isCustomProvider) {
await removeCustomProvider({
@@ -1,24 +1,25 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Input } from '../../../../../ui/input';
import { Select } from '../../../../../ui/Select';
import { Button } from '../../../../../ui/button';
import { SecureStorageNotice } from '../SecureStorageNotice';
import { Checkbox } from '@radix-ui/themes';
import { UpdateCustomProviderRequest } from '../../../../../../api';
interface CustomProviderFormProps {
onSubmit: (data: {
provider_type: string;
display_name: string;
api_url: string;
api_key: string;
models: string[];
supports_streaming: boolean;
}) => void;
onSubmit: (data: UpdateCustomProviderRequest) => void;
onCancel: () => void;
initialData: UpdateCustomProviderRequest | null;
isEditable?: boolean;
}
export default function CustomProviderForm({ onSubmit, onCancel }: CustomProviderFormProps) {
const [providerType, setProviderType] = useState('openai_compatible');
export default function CustomProviderForm({
onSubmit,
onCancel,
initialData,
isEditable,
}: CustomProviderFormProps) {
const [engine, setEngine] = useState('openai_compatible');
const [displayName, setDisplayName] = useState('');
const [apiUrl, setApiUrl] = useState('');
const [apiKey, setApiKey] = useState('');
@@ -27,6 +28,22 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
const [supportsStreaming, setSupportsStreaming] = useState(true);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (initialData) {
const engineMap: Record<string, string> = {
openai: 'openai_compatible',
anthropic: 'anthropic_compatible',
ollama: 'ollama_compatible',
};
setEngine(engineMap[initialData.engine.toLowerCase()] || 'openai_compatible');
setDisplayName(initialData.display_name);
setApiUrl(initialData.api_url);
setModels(initialData.models.join(', '));
setSupportsStreaming(initialData.supports_streaming ?? true);
}
}, [initialData]);
const handleLocalModels = (checked: boolean) => {
setIsLocalModel(checked);
if (checked) {
@@ -42,7 +59,7 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
const errors: Record<string, string> = {};
if (!displayName) errors.displayName = 'Display name is required';
if (!apiUrl) errors.apiUrl = 'API URL is required';
if (!isLocalModel && !apiKey) errors.apiKey = 'API key is required';
if (!isLocalModel && !apiKey && !initialData) errors.apiKey = 'API key is required';
if (!models) errors.models = 'At least one model is required';
if (Object.keys(errors).length > 0) {
@@ -56,7 +73,7 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
.filter((m) => m);
onSubmit({
provider_type: providerType,
engine,
display_name: displayName,
api_url: apiUrl,
api_key: apiKey,
@@ -67,92 +84,94 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
return (
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
<div>
<label
htmlFor="provider-select"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Provider Type
<span className="text-red-500 ml-1">*</span>
</label>
<Select
id="provider-select"
aria-invalid={!!validationErrors.providerType}
aria-describedby={validationErrors.providerType ? 'provider-select-error' : undefined}
options={[
{ value: 'openai_compatible', label: 'OpenAI Compatible' },
{ value: 'anthropic_compatible', label: 'Anthropic Compatible' },
{ value: 'ollama_compatible', label: 'Ollama Compatible' },
]}
value={{
value: providerType,
label:
providerType === 'openai_compatible'
? 'OpenAI Compatible'
: providerType === 'anthropic_compatible'
? 'Anthropic Compatible'
: 'Ollama Compatible',
}}
onChange={(option: unknown) => {
const selectedOption = option as { value: string; label: string } | null;
if (selectedOption) setProviderType(selectedOption.value);
}}
isSearchable={false}
/>
{validationErrors.providerType && (
<p id="provider-select-error" className="text-red-500 text-sm mt-1">
{validationErrors.providerType}
</p>
)}
</div>
<div>
<label
htmlFor="display-name"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Display Name
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your Provider Name"
aria-invalid={!!validationErrors.displayName}
aria-describedby={validationErrors.displayName ? 'display-name-error' : undefined}
className={validationErrors.displayName ? 'border-red-500' : ''}
/>
{validationErrors.displayName && (
<p id="display-name-error" className="text-red-500 text-sm mt-1">
{validationErrors.displayName}
</p>
)}
</div>
<div>
<label
htmlFor="api-url"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
API URL
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="api-url"
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
placeholder="https://api.example.com/v1/messages"
aria-invalid={!!validationErrors.apiUrl}
aria-describedby={validationErrors.apiUrl ? 'api-url-error' : undefined}
className={validationErrors.apiUrl ? 'border-red-500' : ''}
/>
{validationErrors.apiUrl && (
<p id="api-url-error" className="text-red-500 text-sm mt-1">
{validationErrors.apiUrl}
</p>
)}
</div>
{isEditable && (
<>
<div>
<label
htmlFor="provider-select"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Provider Type
<span className="text-red-500 ml-1">*</span>
</label>
<Select
id="provider-select"
aria-invalid={!!validationErrors.providerType}
aria-describedby={validationErrors.providerType ? 'provider-select-error' : undefined}
options={[
{ value: 'openai_compatible', label: 'OpenAI Compatible' },
{ value: 'anthropic_compatible', label: 'Anthropic Compatible' },
{ value: 'ollama_compatible', label: 'Ollama Compatible' },
]}
value={{
value: engine,
label:
engine === 'openai_compatible'
? 'OpenAI Compatible'
: engine === 'anthropic_compatible'
? 'Anthropic Compatible'
: 'Ollama Compatible',
}}
onChange={(option: unknown) => {
const selectedOption = option as { value: string; label: string } | null;
if (selectedOption) setEngine(selectedOption.value);
}}
isSearchable={false}
/>
{validationErrors.providerType && (
<p id="provider-select-error" className="text-red-500 text-sm mt-1">
{validationErrors.providerType}
</p>
)}
</div>
<div>
<label
htmlFor="display-name"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Display Name
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Your Provider Name"
aria-invalid={!!validationErrors.displayName}
aria-describedby={validationErrors.displayName ? 'display-name-error' : undefined}
className={validationErrors.displayName ? 'border-red-500' : ''}
/>
{validationErrors.displayName && (
<p id="display-name-error" className="text-red-500 text-sm mt-1">
{validationErrors.displayName}
</p>
)}
</div>
<div>
<label
htmlFor="api-url"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
API URL
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="api-url"
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
placeholder="https://api.example.com/v1/messages"
aria-invalid={!!validationErrors.apiUrl}
aria-describedby={validationErrors.apiUrl ? 'api-url-error' : undefined}
className={validationErrors.apiUrl ? 'border-red-500' : ''}
/>
{validationErrors.apiUrl && (
<p id="api-url-error" className="text-red-500 text-sm mt-1">
{validationErrors.apiUrl}
</p>
)}
</div>
</>
)}
<div>
<label
@@ -160,14 +179,14 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
API Key
{!isLocalModel && <span className="text-red-500 ml-1">*</span>}
{!isLocalModel && !initialData && <span className="text-red-500 ml-1">*</span>}
</label>
<Input
id="api-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Your API key"
placeholder={initialData ? 'Leave blank to keep existing key' : 'Your API key'}
aria-invalid={!!validationErrors.apiKey}
aria-describedby={validationErrors.apiKey ? 'api-key-error' : undefined}
className={validationErrors.apiKey ? 'border-red-500' : ''}
@@ -179,62 +198,64 @@ export default function CustomProviderForm({ onSubmit, onCancel }: CustomProvide
</p>
)}
<div className="flex items-center space-x-2 mt-2">
<Checkbox id="local-model" checked={isLocalModel} onCheckedChange={handleLocalModels} />
<label
htmlFor="local-model"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-textSubtle"
>
This is a local model (no auth required)
</label>
</div>
</div>
<div>
<label
htmlFor="available-models"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Available Models (comma-separated)
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="available-models"
value={models}
onChange={(e) => setModels(e.target.value)}
placeholder="model-a, model-b, model-c"
aria-invalid={!!validationErrors.models}
aria-describedby={validationErrors.models ? 'available-models-error' : undefined}
className={validationErrors.models ? 'border-red-500' : ''}
/>
{validationErrors.models && (
<p id="available-models-error" className="text-red-500 text-sm mt-1">
{validationErrors.models}
</p>
{!initialData && (
<div className="flex items-center space-x-2 mt-2">
<Checkbox id="local-model" checked={isLocalModel} onCheckedChange={handleLocalModels} />
<label
htmlFor="local-model"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-textSubtle"
>
This is a local model (no auth required)
</label>
</div>
)}
</div>
<div className="flex items-center space-x-2 mb-10">
<Checkbox
id="supports-streaming"
checked={supportsStreaming}
onCheckedChange={(checked) => setSupportsStreaming(checked as boolean)}
/>
<label
htmlFor="supports-streaming"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-textSubtle"
>
Provider supports streaming responses
</label>
</div>
{isEditable && (
<>
<div>
<label
htmlFor="available-models"
className="flex items-center text-sm font-medium text-textStandard mb-2"
>
Available Models (comma-separated)
<span className="text-red-500 ml-1">*</span>
</label>
<Input
id="available-models"
value={models}
onChange={(e) => setModels(e.target.value)}
placeholder="model-a, model-b, model-c"
aria-invalid={!!validationErrors.models}
aria-describedby={validationErrors.models ? 'available-models-error' : undefined}
className={validationErrors.models ? 'border-red-500' : ''}
/>
{validationErrors.models && (
<p id="available-models-error" className="text-red-500 text-sm mt-1">
{validationErrors.models}
</p>
)}
</div>
<div className="flex items-center space-x-2 mb-10">
<Checkbox
id="supports-streaming"
checked={supportsStreaming}
onCheckedChange={(checked) => setSupportsStreaming(checked as boolean)}
/>
<label
htmlFor="supports-streaming"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-textSubtle"
>
Provider supports streaming responses
</label>
</div>
</>
)}
<SecureStorageNotice />
<div className="flex justify-end space-x-2 pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit">Create Provider</Button>
<Button type="submit">{initialData ? 'Update Provider' : 'Create Provider'}</Button>
</div>
</form>
);