diff --git a/crates/openfang-kernel/src/kernel.rs b/crates/openfang-kernel/src/kernel.rs index 8f59414c97..e6d8ed0b2e 100644 --- a/crates/openfang-kernel/src/kernel.rs +++ b/crates/openfang-kernel/src/kernel.rs @@ -15,7 +15,7 @@ use crate::workflow::{StepAgent, Workflow, WorkflowEngine, WorkflowId, WorkflowR use openfang_memory::MemorySubstrate; use openfang_runtime::agent_loop::{ - run_agent_loop, run_agent_loop_streaming, strip_provider_prefix, AgentLoopResult, + api_model_name, run_agent_loop, run_agent_loop_streaming, AgentLoopResult, }; use openfang_runtime::audit::AuditLog; use openfang_runtime::drivers; @@ -762,13 +762,14 @@ impl OpenFangKernel { .resolve(&env_var) .map(|z: zeroize::Zeroizing| z.to_string()) }; + let fb_base_url = fb + .base_url + .clone() + .or_else(|| config.provider_urls.get(&fb.provider).cloned()); let fb_config = DriverConfig { provider: fb.provider.clone(), api_key: fb_api_key, - base_url: fb - .base_url - .clone() - .or_else(|| config.provider_urls.get(&fb.provider).cloned()), + base_url: fb_base_url.clone(), skip_permissions: true, subprocess_timeout_secs: fb.subprocess_timeout_secs, }; @@ -780,7 +781,10 @@ impl OpenFangKernel { "Fallback provider configured" ); driver_chain.push(d.clone()); - model_chain.push((d, strip_provider_prefix(&fb.model, &fb.provider))); + model_chain.push(( + d, + api_model_name(&fb.model, &fb.provider, fb_base_url.as_deref()), + )); } Err(e) => { warn!( @@ -1658,6 +1662,12 @@ impl OpenFangKernel { } } + if manifest.model.base_url.is_none() { + if let Some(url) = self.config.provider_urls.get(&manifest.model.provider) { + manifest.model.base_url = Some(url.clone()); + } + } + // Normalize catalog-backed model labels/aliases into canonical IDs and // fill provider/auth hints when the manifest did not fully specify them. if let Ok(catalog) = self.model_catalog.read() { @@ -1666,7 +1676,11 @@ impl OpenFangKernel { manifest.model.provider.is_empty() || manifest.model.provider == "default"; if provider_is_default || manifest.model.provider == entry.provider { manifest.model.provider = entry.provider.clone(); - manifest.model.model = strip_provider_prefix(&entry.id, &entry.provider); + manifest.model.model = api_model_name( + &entry.id, + &entry.provider, + manifest.model.base_url.as_deref(), + ); if manifest.model.api_key_env.is_none() { manifest.model.api_key_env = Some(self.config.resolve_api_key_env(&entry.provider)); @@ -1683,7 +1697,11 @@ impl OpenFangKernel { } // Normalize: strip provider prefix from model name if present - let normalized = strip_provider_prefix(&manifest.model.model, &manifest.model.provider); + let normalized = api_model_name( + &manifest.model.model, + &manifest.model.provider, + manifest.model.base_url.as_deref(), + ); if normalized != manifest.model.model { manifest.model.model = normalized; } @@ -2892,7 +2910,11 @@ impl OpenFangKernel { router.resolve_aliases(&self.model_catalog.read().unwrap_or_else(|e| e.into_inner())); // Build a probe request to score complexity let probe = CompletionRequest { - model: strip_provider_prefix(&manifest.model.model, &manifest.model.provider), + model: api_model_name( + &manifest.model.model, + &manifest.model.provider, + manifest.model.base_url.as_deref(), + ), messages: vec![openfang_types::message::Message::user(message)], tools: tools.clone(), max_tokens: manifest.model.max_tokens, @@ -3337,6 +3359,10 @@ impl OpenFangKernel { catalog.find_model(model).cloned() } }); + let existing_base_url = self + .registry + .get(agent_id) + .and_then(|e| e.manifest.model.base_url.clone()); let provider = if let Some(ep) = explicit_provider { // User explicitly set the provider — use it as-is Some(ep.to_string()) @@ -3344,11 +3370,7 @@ impl OpenFangKernel { // Check whether the agent has a custom base_url, which indicates // a user-configured provider endpoint. In that case, preserve the // current provider name instead of overriding it with auto-detection. - let has_custom_url = self - .registry - .get(agent_id) - .map(|e| e.manifest.model.base_url.is_some()) - .unwrap_or(false); + let has_custom_url = existing_base_url.is_some(); if has_custom_url { // Keep the current provider — don't let auto-detection override // a deliberately configured custom endpoint. @@ -3364,12 +3386,12 @@ impl OpenFangKernel { let normalized_model = if let (Some(entry), Some(prov)) = (catalog_entry.as_ref(), provider.as_ref()) { if entry.provider == *prov { - strip_provider_prefix(&entry.id, prov) + api_model_name(&entry.id, prov, existing_base_url.as_deref()) } else { - strip_provider_prefix(model, prov) + api_model_name(model, prov, existing_base_url.as_deref()) } } else if let Some(ref prov) = provider { - strip_provider_prefix(model, prov) + api_model_name(model, prov, existing_base_url.as_deref()) } else { model.to_string() }; @@ -5724,14 +5746,18 @@ impl OpenFangKernel { // model to dm; inherit dm's timeout in that case. Custom-provider // manifest fallbacks have no per-provider config, so leave unset. let resolved_to_default = fb.provider.is_empty() || fb.provider == "default"; + let fb_custom_base_url = fb + .base_url + .clone() + .or_else(|| dm.base_url.clone()) + .or_else(|| self.config.provider_urls.get(&fb_provider).cloned()); + let fb_base_url = fb_custom_base_url + .clone() + .or_else(|| self.lookup_provider_url(&fb_provider)); let config = DriverConfig { provider: fb_provider.clone(), api_key: fb_api_key, - base_url: fb - .base_url - .clone() - .or_else(|| dm.base_url.clone()) - .or_else(|| self.lookup_provider_url(&fb_provider)), + base_url: fb_base_url.clone(), skip_permissions: true, subprocess_timeout_secs: if resolved_to_default { dm.subprocess_timeout_secs @@ -5740,7 +5766,10 @@ impl OpenFangKernel { }, }; match drivers::create_driver(&config) { - Ok(d) => chain.push((d, strip_provider_prefix(&fb_model_name, &fb_provider))), + Ok(d) => chain.push(( + d, + api_model_name(&fb_model_name, &fb_provider, fb_custom_base_url.as_deref()), + )), Err(e) => { warn!("Fallback driver '{}' failed to init: {e}", fb_provider); } @@ -5764,19 +5793,26 @@ impl OpenFangKernel { }; self.resolve_credential(&env_var) }; + let fb_custom_base_url = fb + .base_url + .clone() + .or_else(|| self.config.provider_urls.get(&fb.provider).cloned()); + let fb_base_url = fb_custom_base_url + .clone() + .or_else(|| self.lookup_provider_url(&fb.provider)); let fb_config = DriverConfig { provider: fb.provider.clone(), api_key: fb_api_key, - base_url: fb - .base_url - .clone() - .or_else(|| self.lookup_provider_url(&fb.provider)), + base_url: fb_base_url.clone(), skip_permissions: true, subprocess_timeout_secs: fb.subprocess_timeout_secs, }; match drivers::create_driver(&fb_config) { Ok(d) => { - chain.push((d, strip_provider_prefix(&fb.model, &fb.provider))); + chain.push(( + d, + api_model_name(&fb.model, &fb.provider, fb_custom_base_url.as_deref()), + )); } Err(e) => { warn!( @@ -7980,6 +8016,53 @@ mod tests { assert_eq!(caps.len(), 3); // 2 tools + agent_spawn } + #[test] + fn test_spawn_preserves_custom_openai_compatible_model_id() { + use openfang_types::config::DefaultModelConfig; + + let tmp = tempfile::tempdir().unwrap(); + let home_dir = tmp.path().join("openfang-1195-custom-openai-model"); + std::fs::create_dir_all(&home_dir).unwrap(); + + let config = KernelConfig { + home_dir: home_dir.clone(), + data_dir: home_dir.join("data"), + default_model: DefaultModelConfig { + provider: "ollama".to_string(), + model: "test".to_string(), + api_key_env: String::new(), + base_url: None, + subprocess_timeout_secs: None, + }, + ..KernelConfig::default() + }; + let kernel = OpenFangKernel::boot_with_config(config).expect("kernel boots"); + + let manifest = AgentManifest { + name: "featherless".to_string(), + module: "builtin:chat".to_string(), + model: ModelConfig { + provider: "openai".to_string(), + model: "openai/gpt-oss-120b".to_string(), + api_key_env: Some("FEATHERLESS_API_KEY".to_string()), + base_url: Some("https://api.featherless.ai/v1".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + let agent_id = kernel.spawn_agent(manifest).expect("agent spawns"); + let entry = kernel.registry.get(agent_id).expect("agent registered"); + assert_eq!(entry.manifest.model.provider, "openai"); + assert_eq!(entry.manifest.model.model, "openai/gpt-oss-120b"); + assert_eq!( + entry.manifest.model.base_url.as_deref(), + Some("https://api.featherless.ai/v1") + ); + + kernel.shutdown(); + } + /// Regression for #1087: when the user edits any field in agent.toml /// (e.g. description) and the TOML doesn't carry `workspace`, the merge /// must preserve the kernel-assigned workspace path that lives in the DB. diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index 615fbfdb73..2262d4fcec 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -221,6 +221,20 @@ pub fn strip_provider_prefix(model: &str, provider: &str) -> String { } } +/// Return the model ID that should be sent to the provider API. +/// +/// OpenFang catalog IDs may include a provider prefix for routing. OpenAI- +/// compatible gateways behind `provider = "openai"` often use namespaced model +/// IDs such as `openai/gpt-oss-120b`, so a custom endpoint must receive the +/// configured model string exactly. +pub fn api_model_name(model: &str, provider: &str, base_url: Option<&str>) -> String { + if provider == "openai" && base_url.is_some_and(|url| !url.trim().is_empty()) { + model.to_string() + } else { + strip_provider_prefix(model, provider) + } +} + /// Default context window size (tokens) for token-based trimming. const DEFAULT_CONTEXT_WINDOW: usize = 200_000; @@ -524,8 +538,13 @@ pub async fn run_agent_loop( // Context guard: compact oversized tool results before LLM call apply_context_guard(&mut messages, &context_budget, available_tools); - // Strip provider prefix: "openrouter/google/gemini-2.5-flash" → "google/gemini-2.5-flash" - let api_model = strip_provider_prefix(&manifest.model.model, &manifest.model.provider); + // Strip catalog provider prefixes unless the configured endpoint treats + // model names as opaque OpenAI-compatible IDs (#1195). + let api_model = api_model_name( + &manifest.model.model, + &manifest.model.provider, + manifest.model.base_url.as_deref(), + ); let request = CompletionRequest { model: api_model, @@ -1761,8 +1780,13 @@ pub async fn run_agent_loop_streaming( // Context guard: compact oversized tool results before LLM call apply_context_guard(&mut messages, &context_budget, available_tools); - // Strip provider prefix: "openrouter/google/gemini-2.5-flash" → "google/gemini-2.5-flash" - let api_model = strip_provider_prefix(&manifest.model.model, &manifest.model.provider); + // Strip catalog provider prefixes unless the configured endpoint treats + // model names as opaque OpenAI-compatible IDs (#1195). + let api_model = api_model_name( + &manifest.model.model, + &manifest.model.provider, + manifest.model.base_url.as_deref(), + ); let request = CompletionRequest { model: api_model, @@ -3232,13 +3256,42 @@ mod tests { use crate::llm_driver::{CompletionResponse, LlmError}; use async_trait::async_trait; use openfang_types::tool::ToolCall; - use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::{ + atomic::{AtomicU32, Ordering}, + Mutex, + }; #[test] fn test_max_iterations_constant() { assert_eq!(MAX_ITERATIONS, 50); } + #[test] + fn test_api_model_name_preserves_openai_compatible_custom_ids() { + assert_eq!( + api_model_name( + "openai/gpt-oss-120b", + "openai", + Some("https://api.featherless.ai/v1") + ), + "openai/gpt-oss-120b" + ); + assert_eq!( + api_model_name("openai/gpt-4o", "openai", None), + "gpt-4o", + "official OpenAI path keeps legacy prefix normalization" + ); + assert_eq!( + api_model_name( + "openrouter/openai/gpt-oss-120b:free", + "openrouter", + Some("https://openrouter.ai/api/v1") + ), + "openai/gpt-oss-120b:free", + "non-openai catalog prefixes are still internal routing labels" + ); + } + /// Issue #1098: when a response carries Thinking blocks, the persisted /// assistant turn must keep them so the next turn round-trips reasoning /// state to the model. @@ -3785,6 +3838,85 @@ mod tests { } } + struct CapturingModelDriver { + seen_model: Arc>>, + } + + #[async_trait] + impl LlmDriver for CapturingModelDriver { + async fn complete( + &self, + request: CompletionRequest, + ) -> Result { + *self.seen_model.lock().unwrap() = Some(request.model); + Ok(CompletionResponse { + content: vec![ContentBlock::Text { + text: "ok".to_string(), + provider_metadata: None, + }], + stop_reason: StopReason::EndTurn, + tool_calls: vec![], + usage: TokenUsage { + input_tokens: 1, + output_tokens: 1, + }, + }) + } + } + + #[tokio::test] + async fn test_custom_openai_base_url_preserves_request_model_id() { + let memory = openfang_memory::MemorySubstrate::open_in_memory(0.01).unwrap(); + let agent_id = openfang_types::agent::AgentId::new(); + let mut session = openfang_memory::session::Session { + id: openfang_types::agent::SessionId::new(), + agent_id, + messages: Vec::new(), + context_window_tokens: 0, + label: None, + }; + let mut manifest = test_manifest(); + manifest.model.provider = "openai".to_string(); + manifest.model.model = "openai/gpt-oss-120b".to_string(); + manifest.model.base_url = Some("https://api.featherless.ai/v1".to_string()); + + let seen_model = Arc::new(Mutex::new(None)); + let driver: Arc = Arc::new(CapturingModelDriver { + seen_model: seen_model.clone(), + }); + + run_agent_loop( + &manifest, + "hello", + &mut session, + &memory, + driver, + &[], + None, + None, + None, + None, + None, + None, + None, + None, // on_phase + None, // media_engine + None, // tts_engine + None, // docker_config + None, // hooks + None, // context_window_tokens + None, // process_manager + None, // user_content_blocks + ) + .await + .expect("agent loop should complete"); + + assert_eq!( + seen_model.lock().unwrap().as_deref(), + Some("openai/gpt-oss-120b") + ); + } + #[tokio::test] async fn test_empty_response_after_tool_use_returns_fallback() { let memory = openfang_memory::MemorySubstrate::open_in_memory(0.01).unwrap();