diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 832f0319..d2a8b14c 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -4842,6 +4842,7 @@ pub async fn list_providers(State(state): State>) -> impl IntoResp "model_count": p.model_count, "key_required": p.key_required, "api_key_env": p.api_key_env, + "base_url": p.base_url, }); // For local providers, add reachability info via health probe @@ -5899,6 +5900,122 @@ pub async fn test_provider( } } +/// PUT /api/providers/{name}/url — Set a custom base URL for a provider. +pub async fn set_provider_url( + State(state): State>, + Path(name): Path, + Json(body): Json, +) -> impl IntoResponse { + // Validate provider exists + let provider_exists = { + let catalog = state + .kernel + .model_catalog + .read() + .unwrap_or_else(|e| e.into_inner()); + catalog.get_provider(&name).is_some() + }; + if !provider_exists { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({"error": format!("Unknown provider '{}'", name)})), + ); + } + + let base_url = match body["base_url"].as_str() { + Some(u) if !u.trim().is_empty() => u.trim().to_string(), + _ => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "Missing or empty 'base_url' field"})), + ); + } + }; + + // Validate URL scheme + if !base_url.starts_with("http://") && !base_url.starts_with("https://") { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "base_url must start with http:// or https://"})), + ); + } + + // Update catalog in memory + { + let mut catalog = state + .kernel + .model_catalog + .write() + .unwrap_or_else(|e| e.into_inner()); + catalog.set_provider_url(&name, &base_url); + } + + // Persist to config.toml [provider_urls] section + let config_path = state.kernel.config.home_dir.join("config.toml"); + if let Err(e) = upsert_provider_url(&config_path, &name, &base_url) { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": format!("Failed to save config: {e}")})), + ); + } + + // Probe reachability at the new URL + let probe = + openfang_runtime::provider_health::probe_provider(&name, &base_url).await; + + ( + StatusCode::OK, + Json(serde_json::json!({ + "status": "saved", + "provider": name, + "base_url": base_url, + "reachable": probe.reachable, + "latency_ms": probe.latency_ms, + })), + ) +} + +/// Upsert a provider URL in the `[provider_urls]` section of config.toml. +fn upsert_provider_url( + config_path: &std::path::Path, + provider: &str, + url: &str, +) -> Result<(), Box> { + let content = if config_path.exists() { + std::fs::read_to_string(config_path)? + } else { + String::new() + }; + + let mut doc: toml::Value = if content.trim().is_empty() { + toml::Value::Table(toml::map::Map::new()) + } else { + toml::from_str(&content)? + }; + + let root = doc.as_table_mut().ok_or("Config is not a TOML table")?; + + if !root.contains_key("provider_urls") { + root.insert( + "provider_urls".to_string(), + toml::Value::Table(toml::map::Map::new()), + ); + } + let urls_table = root + .get_mut("provider_urls") + .and_then(|v| v.as_table_mut()) + .ok_or("provider_urls is not a table")?; + + urls_table.insert(provider.to_string(), toml::Value::String(url.to_string())); + + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent)?; + } + + std::fs::write(config_path, toml::to_string_pretty(&doc)?)?; + Ok(()) +} + /// POST /api/skills/create — Create a local prompt-only skill. pub async fn create_skill( State(state): State>, diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index 0e289352..51948bc7 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -460,6 +460,10 @@ pub async fn build_router( "/api/providers/{name}/test", axum::routing::post(routes::test_provider), ) + .route( + "/api/providers/{name}/url", + axum::routing::put(routes::set_provider_url), + ) .route( "/api/skills/create", axum::routing::post(routes::create_skill), diff --git a/crates/openfang-api/static/index_body.html b/crates/openfang-api/static/index_body.html index f78e3afa..7d2a2a83 100644 --- a/crates/openfang-api/static/index_body.html +++ b/crates/openfang-api/static/index_body.html @@ -2886,6 +2886,19 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/path"] + + diff --git a/crates/openfang-api/static/js/pages/settings.js b/crates/openfang-api/static/js/pages/settings.js index d20b1b11..8052fc09 100644 --- a/crates/openfang-api/static/js/pages/settings.js +++ b/crates/openfang-api/static/js/pages/settings.js @@ -15,6 +15,8 @@ function settingsPage() { modelProviderFilter: '', modelTierFilter: '', providerKeyInputs: {}, + providerUrlInputs: {}, + providerUrlSaving: {}, providerTesting: {}, providerTestResults: {}, loading: true, @@ -208,6 +210,12 @@ function settingsPage() { try { var data = await OpenFangAPI.get('/api/providers'); this.providers = data.providers || []; + for (var i = 0; i < this.providers.length; i++) { + var p = this.providers[i]; + if (p.is_local && p.base_url && !this.providerUrlInputs[p.id]) { + this.providerUrlInputs[p.id] = p.base_url; + } + } } catch(e) { this.providers = []; } }, @@ -378,6 +386,28 @@ function settingsPage() { this.providerTesting[provider.id] = false; }, + async saveProviderUrl(provider) { + var url = this.providerUrlInputs[provider.id]; + if (!url || !url.trim()) { OpenFangToast.error('Please enter a base URL'); return; } + url = url.trim(); + if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) { + OpenFangToast.error('URL must start with http:// or https://'); return; + } + this.providerUrlSaving[provider.id] = true; + try { + var result = await OpenFangAPI.put('/api/providers/' + encodeURIComponent(provider.id) + '/url', { base_url: url }); + if (result.reachable) { + OpenFangToast.success(provider.display_name + ' URL saved — reachable (' + (result.latency_ms || '?') + 'ms)'); + } else { + OpenFangToast.warning(provider.display_name + ' URL saved but not reachable'); + } + await this.loadProviders(); + } catch(e) { + OpenFangToast.error('Failed to save URL: ' + e.message); + } + this.providerUrlSaving[provider.id] = false; + }, + // -- Security methods -- async loadSecurity() { this.secLoading = true; diff --git a/crates/openfang-kernel/src/config_reload.rs b/crates/openfang-kernel/src/config_reload.rs index 1540fff4..6948e5c6 100644 --- a/crates/openfang-kernel/src/config_reload.rs +++ b/crates/openfang-kernel/src/config_reload.rs @@ -41,6 +41,8 @@ pub enum HotAction { ReloadA2aConfig, /// Fallback provider chain changed. ReloadFallbackProviders, + /// Provider base URL overrides changed. + ReloadProviderUrls, } // --------------------------------------------------------------------------- @@ -235,6 +237,10 @@ pub fn build_reload_plan(old: &KernelConfig, new: &KernelConfig) -> ReloadPlan { plan.hot_actions.push(HotAction::ReloadFallbackProviders); } + if field_changed(&old.provider_urls, &new.provider_urls) { + plan.hot_actions.push(HotAction::ReloadProviderUrls); + } + // ----- No-op fields ----- if old.log_level != new.log_level { @@ -461,6 +467,17 @@ mod tests { assert!(plan.hot_actions.contains(&HotAction::ReloadExtensions)); } + #[test] + fn test_provider_urls_hot_reload() { + let a = default_cfg(); + let mut b = default_cfg(); + b.provider_urls + .insert("ollama".to_string(), "http://10.0.0.5:11434/v1".to_string()); + let plan = build_reload_plan(&a, &b); + assert!(!plan.restart_required); + assert!(plan.hot_actions.contains(&HotAction::ReloadProviderUrls)); + } + // ----------------------------------------------------------------------- // Mixed changes // ----------------------------------------------------------------------- diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 9997292a..7a9c442e 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -590,9 +590,16 @@ impl OpenFangKernel { info!("RBAC enabled with {} users", auth.user_count()); } - // Initialize model catalog and detect provider auth + // Initialize model catalog, detect provider auth, and apply URL overrides let mut model_catalog = openfang_runtime::model_catalog::ModelCatalog::new(); model_catalog.detect_auth(); + if !config.provider_urls.is_empty() { + model_catalog.apply_url_overrides(&config.provider_urls); + info!( + "applied {} provider URL override(s)", + config.provider_urls.len() + ); + } let available_count = model_catalog.available_models().len(); let total_count = model_catalog.list_models().len(); let local_count = model_catalog @@ -2762,6 +2769,14 @@ impl OpenFangKernel { self.cron_scheduler .set_max_total_jobs(new_config.max_cron_jobs); } + HotAction::ReloadProviderUrls => { + info!("Hot-reload: applying provider URL overrides"); + let mut catalog = self + .model_catalog + .write() + .unwrap_or_else(|e| e.into_inner()); + catalog.apply_url_overrides(&new_config.provider_urls); + } _ => { // Other hot actions (channels, web, browser, extensions, etc.) // are logged but not applied here — they require subsystem-specific diff --git a/crates/openfang-kernel/src/metering.rs b/crates/openfang-kernel/src/metering.rs index f12b7261..eddf778d 100644 --- a/crates/openfang-kernel/src/metering.rs +++ b/crates/openfang-kernel/src/metering.rs @@ -148,20 +148,33 @@ impl MeteringEngine { /// | Model Family | Input $/M | Output $/M | /// |-----------------------|-----------|------------| /// | claude-haiku | 0.25 | 1.25 | - /// | claude-sonnet | 3.00 | 15.00 | - /// | claude-opus | 15.00 | 75.00 | + /// | claude-sonnet-4-6 | 3.00 | 15.00 | + /// | claude-opus-4-6 | 5.00 | 25.00 | + /// | claude-opus (legacy) | 15.00 | 75.00 | + /// | gpt-5.2(-pro) | 1.75 | 14.00 | + /// | gpt-5(.1) | 1.25 | 10.00 | + /// | gpt-5-mini | 0.25 | 2.00 | + /// | gpt-5-nano | 0.05 | 0.40 | /// | gpt-4o | 2.50 | 10.00 | /// | gpt-4o-mini | 0.15 | 0.60 | /// | gpt-4.1 | 2.00 | 8.00 | /// | gpt-4.1-mini | 0.40 | 1.60 | /// | gpt-4.1-nano | 0.10 | 0.40 | /// | o3-mini | 1.10 | 4.40 | - /// | gemini-2.0-flash | 0.10 | 0.40 | + /// | gemini-3.1 | 2.50 | 15.00 | + /// | gemini-3 | 0.50 | 3.00 | + /// | gemini-2.5-flash-lite | 0.04 | 0.15 | /// | gemini-2.5-pro | 1.25 | 10.00 | /// | gemini-2.5-flash | 0.15 | 0.60 | + /// | gemini-2.0-flash | 0.10 | 0.40 | /// | deepseek-chat/v3 | 0.27 | 1.10 | /// | deepseek-reasoner/r1 | 0.55 | 2.19 | + /// | llama-4-maverick | 0.50 | 0.77 | + /// | llama-4-scout | 0.11 | 0.34 | /// | llama/mixtral (groq) | 0.05 | 0.10 | + /// | grok-4.1 | 0.20 | 0.50 | + /// | grok-4 | 3.00 | 15.00 | + /// | grok-3 | 3.00 | 15.00 | /// | qwen | 0.20 | 0.60 | /// | mistral-large | 2.00 | 6.00 | /// | mistral-small | 0.10 | 0.30 | @@ -222,14 +235,38 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) { if model.contains("haiku") { return (0.25, 1.25); } + if model.contains("opus-4-6") || model.contains("claude-opus-4-6") { + return (5.0, 25.0); + } if model.contains("opus") { return (15.0, 75.0); } + if model.contains("sonnet-4-6") || model.contains("claude-sonnet-4-6") { + return (3.0, 15.0); + } if model.contains("sonnet") { return (3.0, 15.0); } // ── OpenAI ───────────────────────────────────────────────── + if model.contains("gpt-5.2-pro") { + return (1.75, 14.0); + } + if model.contains("gpt-5.2") { + return (1.75, 14.0); + } + if model.contains("gpt-5.1") { + return (1.25, 10.0); + } + if model.contains("gpt-5-nano") { + return (0.05, 0.40); + } + if model.contains("gpt-5-mini") { + return (0.25, 2.0); + } + if model.contains("gpt-5") { + return (1.25, 10.0); + } if model.contains("gpt-4o-mini") { return (0.15, 0.60); } @@ -260,6 +297,15 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) { } // ── Google Gemini ────────────────────────────────────────── + if model.contains("gemini-3.1") { + return (2.50, 15.0); + } + if model.contains("gemini-3") { + return (0.50, 3.0); + } + if model.contains("gemini-2.5-flash-lite") { + return (0.04, 0.15); + } if model.contains("gemini-2.5-pro") { return (1.25, 10.0); } @@ -298,6 +344,12 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) { } // ── Open-source (Groq, Together, etc.) ───────────────────── + if model.contains("llama-4-maverick") { + return (0.50, 0.77); + } + if model.contains("llama-4-scout") { + return (0.11, 0.34); + } if model.contains("llama") || model.contains("mixtral") { return (0.05, 0.10); } @@ -374,6 +426,12 @@ fn estimate_cost_rates(model: &str) -> (f64, f64) { } // ── xAI / Grok ────────────────────────────────────────────── + if model.contains("grok-4.1") { + return (0.20, 0.50); + } + if model.contains("grok-4") { + return (3.0, 15.0); + } if model.contains("grok-3-mini") || model.contains("grok-2-mini") || model.contains("grok-mini") { return (0.30, 0.50); diff --git a/crates/openfang-runtime/src/model_catalog.rs b/crates/openfang-runtime/src/model_catalog.rs index 65bff00a..b62a65a8 100644 --- a/crates/openfang-runtime/src/model_catalog.rs +++ b/crates/openfang-runtime/src/model_catalog.rs @@ -128,6 +128,28 @@ impl ModelCatalog { &self.aliases } + /// Set a custom base URL for a provider, overriding the default. + /// + /// Returns `true` if the provider was found and updated. + pub fn set_provider_url(&mut self, provider: &str, url: &str) -> bool { + if let Some(p) = self.providers.iter_mut().find(|p| p.id == provider) { + p.base_url = url.to_string(); + true + } else { + false + } + } + + /// Apply a batch of provider URL overrides from config. + /// + /// Each entry maps a provider ID to a custom base URL. + /// Unknown providers are silently skipped. + pub fn apply_url_overrides(&mut self, overrides: &HashMap) { + for (provider, url) in overrides { + self.set_provider_url(provider, url); + } + } + /// List models filtered by tier. pub fn models_by_tier(&self, tier: ModelTier) -> Vec<&ModelCatalogEntry> { self.models.iter().filter(|m| m.tier == tier).collect() @@ -446,18 +468,20 @@ fn builtin_providers() -> Vec { fn builtin_aliases() -> HashMap { let pairs = [ - ("sonnet", "claude-sonnet-4-20250514"), - ("claude-sonnet", "claude-sonnet-4-20250514"), + ("sonnet", "claude-sonnet-4-6"), + ("claude-sonnet", "claude-sonnet-4-6"), ("haiku", "claude-haiku-4-5-20251001"), ("claude-haiku", "claude-haiku-4-5-20251001"), - ("opus", "claude-opus-4-20250514"), - ("claude-opus", "claude-opus-4-20250514"), + ("opus", "claude-opus-4-6"), + ("claude-opus", "claude-opus-4-6"), ("gpt4", "gpt-4o"), ("gpt4o", "gpt-4o"), ("gpt4-mini", "gpt-4o-mini"), - ("flash", "gemini-2.5-flash"), - ("gemini-flash", "gemini-2.5-flash"), - ("gemini-pro", "gemini-2.5-pro"), + ("gpt5", "gpt-5.2"), + ("gpt5-mini", "gpt-5-mini"), + ("flash", "gemini-3-flash"), + ("gemini-flash", "gemini-3-flash"), + ("gemini-pro", "gemini-3.1-pro"), ("deepseek", "deepseek-chat"), ("llama", "llama-3.3-70b-versatile"), ("llama-70b", "llama-3.3-70b-versatile"), @@ -471,9 +495,10 @@ fn builtin_aliases() -> HashMap { ("mistral-nemo", "open-mistral-nemo"), ("pixtral", "pixtral-large-latest"), // xAI aliases - ("grok", "grok-2"), + ("grok", "grok-4"), ("grok-mini", "grok-2-mini"), ("grok3", "grok-3"), + ("grok-fast", "grok-4.1-fast"), // Perplexity alias ("sonar", "sonar-pro"), // AI21 aliases @@ -503,8 +528,36 @@ fn builtin_aliases() -> HashMap { fn builtin_models() -> Vec { vec![ // ══════════════════════════════════════════════════════════════ - // Anthropic (5) + // Anthropic (7) // ══════════════════════════════════════════════════════════════ + ModelCatalogEntry { + id: "claude-opus-4-6".into(), + display_name: "Claude Opus 4.6".into(), + provider: "anthropic".into(), + tier: ModelTier::Frontier, + context_window: 200_000, + max_output_tokens: 128_000, + input_cost_per_m: 5.0, + output_cost_per_m: 25.0, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec!["opus".into(), "claude-opus".into()], + }, + ModelCatalogEntry { + id: "claude-sonnet-4-6".into(), + display_name: "Claude Sonnet 4.6".into(), + provider: "anthropic".into(), + tier: ModelTier::Smart, + context_window: 200_000, + max_output_tokens: 64_000, + input_cost_per_m: 3.0, + output_cost_per_m: 15.0, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec!["sonnet".into(), "claude-sonnet".into()], + }, ModelCatalogEntry { id: "claude-opus-4-20250514".into(), display_name: "Claude Opus 4".into(), @@ -517,7 +570,7 @@ fn builtin_models() -> Vec { supports_tools: true, supports_vision: true, supports_streaming: true, - aliases: vec!["opus".into(), "claude-opus".into()], + aliases: vec![], }, ModelCatalogEntry { id: "claude-sonnet-4-20250514".into(), @@ -531,7 +584,7 @@ fn builtin_models() -> Vec { supports_tools: true, supports_vision: true, supports_streaming: true, - aliases: vec!["sonnet".into(), "claude-sonnet".into()], + aliases: vec![], }, ModelCatalogEntry { id: "claude-haiku-4-5-20251001".into(), @@ -576,7 +629,7 @@ fn builtin_models() -> Vec { aliases: vec![], }, // ══════════════════════════════════════════════════════════════ - // OpenAI (10) + // OpenAI (16) // ══════════════════════════════════════════════════════════════ ModelCatalogEntry { id: "gpt-4o".into(), @@ -718,9 +771,149 @@ fn builtin_models() -> Vec { supports_streaming: true, aliases: vec![], }, + ModelCatalogEntry { + id: "gpt-5".into(), + display_name: "GPT-5".into(), + provider: "openai".into(), + tier: ModelTier::Frontier, + context_window: 400_000, + max_output_tokens: 128_000, + input_cost_per_m: 1.25, + output_cost_per_m: 10.0, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "gpt-5-mini".into(), + display_name: "GPT-5 Mini".into(), + provider: "openai".into(), + tier: ModelTier::Balanced, + context_window: 400_000, + max_output_tokens: 128_000, + input_cost_per_m: 0.25, + output_cost_per_m: 2.0, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec!["gpt5-mini".into()], + }, + ModelCatalogEntry { + id: "gpt-5-nano".into(), + display_name: "GPT-5 Nano".into(), + provider: "openai".into(), + tier: ModelTier::Fast, + context_window: 400_000, + max_output_tokens: 128_000, + input_cost_per_m: 0.05, + output_cost_per_m: 0.40, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "gpt-5.1".into(), + display_name: "GPT-5.1".into(), + provider: "openai".into(), + tier: ModelTier::Frontier, + context_window: 400_000, + max_output_tokens: 128_000, + input_cost_per_m: 1.25, + output_cost_per_m: 10.0, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "gpt-5.2".into(), + display_name: "GPT-5.2".into(), + provider: "openai".into(), + tier: ModelTier::Frontier, + context_window: 400_000, + max_output_tokens: 128_000, + input_cost_per_m: 1.75, + output_cost_per_m: 14.0, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec!["gpt5".into()], + }, + ModelCatalogEntry { + id: "gpt-5.2-pro".into(), + display_name: "GPT-5.2 Pro".into(), + provider: "openai".into(), + tier: ModelTier::Frontier, + context_window: 400_000, + max_output_tokens: 128_000, + input_cost_per_m: 1.75, + output_cost_per_m: 14.0, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec![], + }, // ══════════════════════════════════════════════════════════════ - // Google Gemini (6) + // Google Gemini (10) // ══════════════════════════════════════════════════════════════ + ModelCatalogEntry { + id: "gemini-3.1-pro".into(), + display_name: "Gemini 3.1 Pro".into(), + provider: "gemini".into(), + tier: ModelTier::Frontier, + context_window: 1_048_576, + max_output_tokens: 65_536, + input_cost_per_m: 2.50, + output_cost_per_m: 15.0, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec!["gemini-pro".into()], + }, + ModelCatalogEntry { + id: "gemini-3-flash".into(), + display_name: "Gemini 3 Flash".into(), + provider: "gemini".into(), + tier: ModelTier::Smart, + context_window: 1_048_576, + max_output_tokens: 65_536, + input_cost_per_m: 0.50, + output_cost_per_m: 3.0, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec!["flash".into(), "gemini-flash".into()], + }, + ModelCatalogEntry { + id: "gemini-3-deep-think".into(), + display_name: "Gemini 3 Deep Think".into(), + provider: "gemini".into(), + tier: ModelTier::Frontier, + context_window: 1_048_576, + max_output_tokens: 65_536, + input_cost_per_m: 2.50, + output_cost_per_m: 15.0, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "gemini-2.5-flash-lite".into(), + display_name: "Gemini 2.5 Flash Lite".into(), + provider: "gemini".into(), + tier: ModelTier::Fast, + context_window: 1_048_576, + max_output_tokens: 8_192, + input_cost_per_m: 0.04, + output_cost_per_m: 0.15, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec![], + }, ModelCatalogEntry { id: "gemini-2.5-pro".into(), display_name: "Gemini 2.5 Pro".into(), @@ -733,7 +926,7 @@ fn builtin_models() -> Vec { supports_tools: true, supports_vision: true, supports_streaming: true, - aliases: vec!["gemini-pro".into()], + aliases: vec![], }, ModelCatalogEntry { id: "gemini-2.5-flash".into(), @@ -747,7 +940,7 @@ fn builtin_models() -> Vec { supports_tools: true, supports_vision: true, supports_streaming: true, - aliases: vec!["flash".into(), "gemini-flash".into()], + aliases: vec![], }, ModelCatalogEntry { id: "gemini-2.0-flash".into(), @@ -865,7 +1058,7 @@ fn builtin_models() -> Vec { aliases: vec![], }, // ══════════════════════════════════════════════════════════════ - // Groq (10) + // Groq (11) // ══════════════════════════════════════════════════════════════ ModelCatalogEntry { id: "llama-3.3-70b-versatile".into(), @@ -1007,6 +1200,20 @@ fn builtin_models() -> Vec { supports_streaming: true, aliases: vec![], }, + ModelCatalogEntry { + id: "meta-llama/llama-4-scout-17b-16e-instruct".into(), + display_name: "Llama 4 Scout 17B".into(), + provider: "groq".into(), + tier: ModelTier::Balanced, + context_window: 128_000, + max_output_tokens: 8_192, + input_cost_per_m: 0.11, + output_cost_per_m: 0.34, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec![], + }, // ══════════════════════════════════════════════════════════════ // OpenRouter (5) // ══════════════════════════════════════════════════════════════ @@ -1744,8 +1951,36 @@ fn builtin_models() -> Vec { aliases: vec![], }, // ══════════════════════════════════════════════════════════════ - // xAI (4) + // xAI (6) // ══════════════════════════════════════════════════════════════ + ModelCatalogEntry { + id: "grok-4".into(), + display_name: "Grok 4".into(), + provider: "xai".into(), + tier: ModelTier::Frontier, + context_window: 256_000, + max_output_tokens: 32_768, + input_cost_per_m: 3.0, + output_cost_per_m: 15.0, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec!["grok".into()], + }, + ModelCatalogEntry { + id: "grok-4.1-fast".into(), + display_name: "Grok 4.1 Fast".into(), + provider: "xai".into(), + tier: ModelTier::Fast, + context_window: 2_000_000, + max_output_tokens: 32_768, + input_cost_per_m: 0.20, + output_cost_per_m: 0.50, + supports_tools: true, + supports_vision: false, + supports_streaming: true, + aliases: vec!["grok-fast".into()], + }, ModelCatalogEntry { id: "grok-3".into(), display_name: "Grok 3".into(), @@ -1758,7 +1993,7 @@ fn builtin_models() -> Vec { supports_tools: true, supports_vision: true, supports_streaming: true, - aliases: vec![], + aliases: vec!["grok3".into()], }, ModelCatalogEntry { id: "grok-3-mini".into(), @@ -1786,7 +2021,7 @@ fn builtin_models() -> Vec { supports_tools: true, supports_vision: true, supports_streaming: true, - aliases: vec!["grok".into()], + aliases: vec![], }, ModelCatalogEntry { id: "grok-2-mini".into(), @@ -2205,8 +2440,36 @@ fn builtin_models() -> Vec { aliases: vec![], }, // ══════════════════════════════════════════════════════════════ - // AWS Bedrock (6) + // AWS Bedrock (8) // ══════════════════════════════════════════════════════════════ + ModelCatalogEntry { + id: "bedrock/anthropic.claude-opus-4-6".into(), + display_name: "Claude Opus 4.6 (Bedrock)".into(), + provider: "bedrock".into(), + tier: ModelTier::Frontier, + context_window: 200_000, + max_output_tokens: 128_000, + input_cost_per_m: 5.00, + output_cost_per_m: 25.00, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec![], + }, + ModelCatalogEntry { + id: "bedrock/anthropic.claude-sonnet-4-6".into(), + display_name: "Claude Sonnet 4.6 (Bedrock)".into(), + provider: "bedrock".into(), + tier: ModelTier::Smart, + context_window: 200_000, + max_output_tokens: 64_000, + input_cost_per_m: 3.00, + output_cost_per_m: 15.00, + supports_tools: true, + supports_vision: true, + supports_streaming: true, + aliases: vec![], + }, ModelCatalogEntry { id: "bedrock/anthropic.claude-opus-4-20250514".into(), display_name: "Claude Opus 4 (Bedrock)".into(), @@ -2323,7 +2586,7 @@ mod tests { fn test_find_model_by_alias() { let catalog = ModelCatalog::new(); let entry = catalog.find_model("sonnet").unwrap(); - assert_eq!(entry.id, "claude-sonnet-4-20250514"); + assert_eq!(entry.id, "claude-sonnet-4-6"); } #[test] @@ -2344,7 +2607,7 @@ mod tests { let catalog = ModelCatalog::new(); assert_eq!( catalog.resolve_alias("sonnet"), - Some("claude-sonnet-4-20250514") + Some("claude-sonnet-4-6") ); assert_eq!( catalog.resolve_alias("haiku"), @@ -2357,7 +2620,7 @@ mod tests { fn test_models_by_provider() { let catalog = ModelCatalog::new(); let anthropic = catalog.models_by_provider("anthropic"); - assert_eq!(anthropic.len(), 5); + assert_eq!(anthropic.len(), 7); assert!(anthropic.iter().all(|m| m.provider == "anthropic")); } @@ -2415,9 +2678,9 @@ mod tests { fn test_provider_model_counts() { let catalog = ModelCatalog::new(); let anthropic = catalog.get_provider("anthropic").unwrap(); - assert_eq!(anthropic.model_count, 5); + assert_eq!(anthropic.model_count, 7); let groq = catalog.get_provider("groq").unwrap(); - assert_eq!(groq.model_count, 10); + assert_eq!(groq.model_count, 11); } #[test] @@ -2425,9 +2688,9 @@ mod tests { let catalog = ModelCatalog::new(); let aliases = catalog.list_aliases(); assert!(aliases.len() >= 20); - assert_eq!(aliases.get("sonnet").unwrap(), "claude-sonnet-4-20250514"); + assert_eq!(aliases.get("sonnet").unwrap(), "claude-sonnet-4-6"); // New aliases - assert_eq!(aliases.get("grok").unwrap(), "grok-2"); + assert_eq!(aliases.get("grok").unwrap(), "grok-4"); assert_eq!(aliases.get("jamba").unwrap(), "jamba-1.5-large"); } @@ -2435,7 +2698,7 @@ mod tests { fn test_find_grok_by_alias() { let catalog = ModelCatalog::new(); let entry = catalog.find_model("grok").unwrap(); - assert_eq!(entry.id, "grok-2"); + assert_eq!(entry.id, "grok-4"); assert_eq!(entry.provider, "xai"); } @@ -2456,11 +2719,13 @@ mod tests { fn test_xai_models() { let catalog = ModelCatalog::new(); let xai = catalog.models_by_provider("xai"); - assert_eq!(xai.len(), 4); - assert!(xai.iter().any(|m| m.id == "grok-2")); - assert!(xai.iter().any(|m| m.id == "grok-2-mini")); + assert_eq!(xai.len(), 6); + assert!(xai.iter().any(|m| m.id == "grok-4")); + assert!(xai.iter().any(|m| m.id == "grok-4.1-fast")); assert!(xai.iter().any(|m| m.id == "grok-3")); assert!(xai.iter().any(|m| m.id == "grok-3-mini")); + assert!(xai.iter().any(|m| m.id == "grok-2")); + assert!(xai.iter().any(|m| m.id == "grok-2-mini")); } #[test] @@ -2543,6 +2808,52 @@ mod tests { fn test_bedrock_models() { let catalog = ModelCatalog::new(); let bedrock = catalog.models_by_provider("bedrock"); - assert_eq!(bedrock.len(), 6); + assert_eq!(bedrock.len(), 8); + } + + #[test] + fn test_set_provider_url() { + let mut catalog = ModelCatalog::new(); + let old_url = catalog.get_provider("ollama").unwrap().base_url.clone(); + assert_eq!(old_url, OLLAMA_BASE_URL); + + let updated = catalog.set_provider_url("ollama", "http://192.168.1.100:11434/v1"); + assert!(updated); + assert_eq!( + catalog.get_provider("ollama").unwrap().base_url, + "http://192.168.1.100:11434/v1" + ); + } + + #[test] + fn test_set_provider_url_unknown() { + let mut catalog = ModelCatalog::new(); + let updated = catalog.set_provider_url("nonexistent", "http://localhost:9999"); + assert!(!updated); + } + + #[test] + fn test_apply_url_overrides() { + let mut catalog = ModelCatalog::new(); + let mut overrides = HashMap::new(); + overrides.insert("ollama".to_string(), "http://10.0.0.5:11434/v1".to_string()); + overrides.insert("vllm".to_string(), "http://10.0.0.6:8000/v1".to_string()); + overrides.insert("nonexistent".to_string(), "http://nowhere".to_string()); + + catalog.apply_url_overrides(&overrides); + + assert_eq!( + catalog.get_provider("ollama").unwrap().base_url, + "http://10.0.0.5:11434/v1" + ); + assert_eq!( + catalog.get_provider("vllm").unwrap().base_url, + "http://10.0.0.6:8000/v1" + ); + // lmstudio should be unchanged + assert_eq!( + catalog.get_provider("lmstudio").unwrap().base_url, + LMSTUDIO_BASE_URL + ); } } diff --git a/crates/openfang-runtime/src/routing.rs b/crates/openfang-runtime/src/routing.rs index 0a7c80b9..a076c260 100644 --- a/crates/openfang-runtime/src/routing.rs +++ b/crates/openfang-runtime/src/routing.rs @@ -149,7 +149,7 @@ impl ModelRouter { /// Resolve aliases in the routing config using the catalog. /// - /// For example, if "sonnet" is configured, resolves to "claude-sonnet-4-20250514". + /// For example, if "sonnet" is configured, resolves to "claude-sonnet-4-6". pub fn resolve_aliases(&mut self, catalog: &crate::model_catalog::ModelCatalog) { if let Some(resolved) = catalog.resolve_alias(&self.config.simple_model) { self.config.simple_model = resolved.to_string(); @@ -172,8 +172,8 @@ mod tests { fn default_config() -> ModelRoutingConfig { ModelRoutingConfig { simple_model: "llama-3.3-70b-versatile".to_string(), - medium_model: "claude-sonnet-4-20250514".to_string(), - complex_model: "claude-opus-4-20250514".to_string(), + medium_model: "claude-sonnet-4-6".to_string(), + complex_model: "claude-opus-4-6".to_string(), simple_threshold: 200, complex_threshold: 800, } @@ -274,11 +274,11 @@ mod tests { ); assert_eq!( router.model_for_complexity(TaskComplexity::Medium), - "claude-sonnet-4-20250514" + "claude-sonnet-4-6" ); assert_eq!( router.model_for_complexity(TaskComplexity::Complex), - "claude-opus-4-20250514" + "claude-opus-4-6" ); } @@ -294,8 +294,8 @@ mod tests { let catalog = crate::model_catalog::ModelCatalog::new(); let config = ModelRoutingConfig { simple_model: "llama-3.3-70b-versatile".to_string(), - medium_model: "claude-sonnet-4-20250514".to_string(), - complex_model: "claude-opus-4-20250514".to_string(), + medium_model: "claude-sonnet-4-6".to_string(), + complex_model: "claude-opus-4-6".to_string(), simple_threshold: 200, complex_threshold: 800, }; @@ -309,8 +309,8 @@ mod tests { let catalog = crate::model_catalog::ModelCatalog::new(); let config = ModelRoutingConfig { simple_model: "unknown-model".to_string(), - medium_model: "claude-sonnet-4-20250514".to_string(), - complex_model: "claude-opus-4-20250514".to_string(), + medium_model: "claude-sonnet-4-6".to_string(), + complex_model: "claude-opus-4-6".to_string(), simple_threshold: 200, complex_threshold: 800, }; @@ -338,11 +338,11 @@ mod tests { ); assert_eq!( router.model_for_complexity(TaskComplexity::Medium), - "claude-sonnet-4-20250514" + "claude-sonnet-4-6" ); assert_eq!( router.model_for_complexity(TaskComplexity::Complex), - "claude-opus-4-20250514" + "claude-opus-4-6" ); } diff --git a/crates/openfang-types/src/config.rs b/crates/openfang-types/src/config.rs index 3ded5f1e..b6a46fb5 100644 --- a/crates/openfang-types/src/config.rs +++ b/crates/openfang-types/src/config.rs @@ -1039,6 +1039,10 @@ pub struct KernelConfig { /// Global spending budget configuration. #[serde(default)] pub budget: BudgetConfig, + /// Provider base URL overrides (provider ID → custom base URL). + /// e.g. `ollama = "http://192.168.1.100:11434/v1"` + #[serde(default)] + pub provider_urls: HashMap, } /// Global spending budget configuration. @@ -1183,6 +1187,7 @@ impl Default for KernelConfig { auth_profiles: HashMap::new(), thinking: None, budget: BudgetConfig::default(), + provider_urls: HashMap::new(), } } }