From 8b65c2d4ad59beaca0c933a457882aebac6ffc63 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 8 Jul 2026 16:31:15 -0700 Subject: [PATCH 1/3] fix(fleet): route AgentProfile pins through custom providers Allow AgentProfile/Fleet provider pins to preserve user-named OpenAI-compatible custom providers (e.g. provider = "lm-studio") and thread that raw id through interactive agent(profile: ...), Fleet worker --provider launch, and codewhale exec --provider validation. Unknown or unconfigured provider ids fail closed instead of silently inheriting or falling back to DeepSeek. AgentProfile remains the canonical routing surface; do not introduce [subagents.routes]. Fixes #3965 Refs #3969 (stale alternate surface held; not merged as-is) --- crates/tui/src/fleet/executor.rs | 42 ++++++++ crates/tui/src/fleet/profile.rs | 51 +++++++--- crates/tui/src/fleet/worker_runtime.rs | 130 +++++++++++++++++++------ crates/tui/src/main.rs | 78 +++++++++++++-- crates/tui/src/tools/subagent/mod.rs | 61 +++++++++--- crates/tui/src/tools/subagent/tests.rs | 36 +++++++ docs/CONFIGURATION.md | 37 +++++-- docs/FLEET.md | 4 + docs/PROVIDERS.md | 4 + docs/SUBAGENTS.md | 43 ++++++++ 10 files changed, 414 insertions(+), 72 deletions(-) diff --git a/crates/tui/src/fleet/executor.rs b/crates/tui/src/fleet/executor.rs index aefb8c6ea..22be4086f 100644 --- a/crates/tui/src/fleet/executor.rs +++ b/crates/tui/src/fleet/executor.rs @@ -614,6 +614,48 @@ mod tests { ); } + #[test] + fn worker_command_threads_custom_profile_provider_name() { + let mut task = task("format"); + task.worker.as_mut().unwrap().agent_profile = Some("local".to_string()); + + let mut profile = agent_profile("local", "formatter", "Keep edits tight."); + profile.profile.provider = Some("lm-studio".to_string()); + profile.profile.model = Some("qwen-2.5-7b".to_string()); + + let cmd = build_worker_exec_command_with_profiles( + "codewhale", + &task, + &FleetExecConfig::default(), + Some("deepseek-v4-pro"), + &[profile], + ) + .unwrap(); + + let provider_idx = cmd + .args + .iter() + .position(|a| a == "--provider") + .expect("--provider must be threaded for a custom provider pin"); + assert_eq!( + cmd.args.get(provider_idx + 1).map(String::as_str), + Some("lm-studio"), + "{:?}", + cmd.args + ); + let model_idx = cmd + .args + .iter() + .position(|a| a == "--model") + .expect("--model must be present"); + assert_eq!( + cmd.args.get(model_idx + 1).map(String::as_str), + Some("qwen-2.5-7b"), + "{:?}", + cmd.args + ); + } + /// A worker with no profile-bound provider preserves today's behavior: the /// run-level model on `--model`, and NO `--provider` (the worker keeps its /// own session default). Guards against regressing profile-less workers. diff --git a/crates/tui/src/fleet/profile.rs b/crates/tui/src/fleet/profile.rs index 5e7b5dc81..5ac40e062 100644 --- a/crates/tui/src/fleet/profile.rs +++ b/crates/tui/src/fleet/profile.rs @@ -283,15 +283,21 @@ fn validate_agent_profile_model_hint(path: &Path, value: Option<&str>) -> Result Ok(()) } -/// Validate an explicit `provider` field against the known `ApiProvider` -/// vocabulary (#4093). This is the ONLY place a profile's provider is -/// established — a name that doesn't parse is rejected outright rather than -/// silently ignored or guessed from `model` (EPIC #2608: explicit config -/// only, never a model-id prefix/substring sniff). +/// Validate an explicit `provider` field as a safe provider id (#4093). +/// +/// Built-in providers are accepted by the runtime vocabulary, and user-named +/// OpenAI-compatible custom providers are accepted as simple tokens so the +/// launch path can resolve `[providers.]` from the session config (#3965). +/// This field remains the ONLY place a profile's provider is established: +/// callers never infer it from `model` (EPIC #2608). fn validate_agent_profile_provider(path: &Path, value: &str) -> Result<()> { - if crate::config::ApiProvider::parse(value).is_none() { + let trimmed = value.trim(); + if trimmed.is_empty() { + bail!("agent profile {} provider cannot be empty", path.display()); + } + if trimmed != value || !trimmed.chars().all(is_agent_profile_token_char) { bail!( - "agent profile {} provider {value:?} is not a recognized provider id", + "agent profile {} provider must be a simple provider id", path.display() ); } @@ -1015,17 +1021,36 @@ model = "deepseek/deepseek-v4-pro" } #[test] - fn agent_profile_loader_rejects_unrecognized_provider_name() { - // EPIC #2608 explicit-config-only mandate: an unrecognized provider - // name is rejected outright at load time — never silently ignored, - // and never guessed from `model`. + fn agent_profile_loader_accepts_custom_provider_name() { + // #3965: LM Studio and other user-named OpenAI-compatible providers + // are resolved from `[providers.]` at launch time, so the profile + // loader must preserve the safe id instead of requiring a built-in. + let tmp = TempDir::new().unwrap(); + write_profile( + tmp.path(), + "reviewer.toml", + r#" +name = "reviewer" +provider = "lm-studio" +model = "qwen-2.5-7b" +"#, + ); + + let profiles = load_agent_profiles_from_dir(tmp.path()).expect("profile loads"); + + assert_eq!(profiles[0].profile.provider.as_deref(), Some("lm-studio")); + assert_eq!(profiles[0].profile.model.as_deref(), Some("qwen-2.5-7b")); + } + + #[test] + fn agent_profile_loader_rejects_malformed_provider_name() { let tmp = TempDir::new().unwrap(); write_profile( tmp.path(), "reviewer.toml", r#" name = "reviewer" -provider = "not-a-real-provider" +provider = "lm studio" model = "some-model" "#, ); @@ -1035,7 +1060,7 @@ model = "some-model" .to_string(); assert!( - err.contains("not a recognized provider"), + err.contains("provider must be a simple provider id"), "unexpected error: {err}" ); } diff --git a/crates/tui/src/fleet/worker_runtime.rs b/crates/tui/src/fleet/worker_runtime.rs index 602d649dc..00052928a 100644 --- a/crates/tui/src/fleet/worker_runtime.rs +++ b/crates/tui/src/fleet/worker_runtime.rs @@ -75,8 +75,7 @@ pub fn fleet_task_to_worker_spec_with_profiles( &loadout, model_source, ); - requested_runtime.provider = - explicit_fleet_provider(agent_profile).map(|provider| provider.as_str().to_string()); + requested_runtime.provider = explicit_fleet_provider_id(agent_profile); requested_runtime.reasoning_effort = effective_fleet_reasoning_effort(agent_profile); if let Some(agent_profile) = agent_profile && let Some(profile_depth) = agent_profile.profile.delegation.max_spawn_depth @@ -159,8 +158,13 @@ pub(crate) fn resolve_fleet_route( // Resolve within the profile's own explicit provider scope when it has // one (#4093); otherwise fall back to the existing default scope (mirrors // `ProviderKind::default()`). The resolver is fully offline/hermetic and - // never reads secrets, env, or config. - let provider = effective_fleet_provider(agent_profile); + // never reads secrets, env, or config. User-named custom providers need the + // session Config to resolve their table, so this receipt path omits the + // route instead of fabricating DeepSeek details for them (#3965). + let provider = match explicit_fleet_provider_id(agent_profile).as_deref() { + Some(provider_id) => ApiProvider::parse(provider_id)?, + None => ApiProvider::Deepseek, + }; let candidate = resolve_route_candidate(provider, model_selector, None, None, None).ok()?; Some(FleetResolvedRoute { @@ -427,33 +431,42 @@ fn effective_fleet_model_with_source( (run_model.to_string(), "run.model") } -/// The provider this task's resolved route should use (#4093). +/// The provider id a resolved agent profile EXPLICITLY pins, if any (#4093). +/// +/// This preserves user-named OpenAI-compatible custom providers such as +/// `lm-studio` instead of collapsing them through [`ApiProvider`]. Runtime +/// launch paths can set `Config.provider` to this exact id so the normal config +/// resolver finds `[providers.]` (#3965). /// -/// Only an explicit `provider` field on the resolved agent profile ever -/// selects a provider here — EPIC #2608 forbids inferring one by sniffing a -/// substring/prefix out of `model`. Absent an explicit pin (no profile, or a -/// profile that never named a provider), the worker profile carries no -/// provider authority and resolution falls back to the existing default -/// scope, unchanged from prior behavior. -fn effective_fleet_provider(agent_profile: Option<&AgentProfile>) -> ApiProvider { - explicit_fleet_provider(agent_profile).unwrap_or(ApiProvider::Deepseek) +/// Returns `None` when no profile names a provider — never invents a DeepSeek +/// default — so launch paths can omit `--provider` and leave profile-less +/// workers on their own session default. EPIC #2608: never inferred from +/// `model`. +pub(crate) fn explicit_fleet_provider_id(agent_profile: Option<&AgentProfile>) -> Option { + agent_profile + .and_then(|profile| profile.profile.provider.as_deref()) + .map(str::trim) + .filter(|provider| !provider.is_empty()) + .map(str::to_string) } -/// The provider a resolved agent profile EXPLICITLY pins, if any (#4093). +/// The built-in provider a resolved agent profile EXPLICITLY pins, if any (#4093). /// -/// Unlike [`effective_fleet_provider`], this returns `None` (never the DeepSeek -/// default) when no profile names a provider, so the launch path can leave -/// `--provider` off the worker argv and preserve today's behavior for -/// profile-less / provider-less workers (they resolve their provider from -/// their own session default). EPIC #2608: never inferred from `model`. +/// This returns `None` (never the DeepSeek default) when no profile names a +/// provider, so call sites can leave `--provider` off the worker argv and +/// preserve today's behavior for profile-less / provider-less workers (they +/// resolve their provider from their own session default). EPIC #2608: never +/// inferred from `model`. /// /// `pub(crate)` so the interactive-TUI in-process spawn path /// (`tools::subagent`) resolves the pinned provider from the SAME /// explicit-only source as the headless `codewhale exec` launch route (#4193), -/// instead of re-deriving it and risking a second, divergent policy. +/// instead of re-deriving it and risking a second, divergent policy. User-named +/// custom providers intentionally return `None` here; launch paths that can +/// carry strings should use [`explicit_fleet_provider_id`]. pub(crate) fn explicit_fleet_provider(agent_profile: Option<&AgentProfile>) -> Option { - agent_profile - .and_then(|profile| profile.profile.provider.as_deref()) + explicit_fleet_provider_id(agent_profile) + .as_deref() .and_then(ApiProvider::parse) } @@ -488,18 +501,18 @@ pub(crate) fn fleet_worker_launch_reasoning_effort( /// This is the launch-side twin of [`resolve_fleet_route`] (the receipt): both /// read the worker's model from the same task/profile/run precedence /// ([`effective_fleet_model`]) and the provider from the same explicit-only -/// source ([`explicit_fleet_provider`]), so a worker whose profile is pinned to -/// provider B launches on provider B even when the parent session is on +/// source ([`explicit_fleet_provider_id`]), so a worker whose profile is pinned +/// to provider B launches on provider B even when the parent session is on /// provider A. /// /// - `model`: never empty in practice — falls back to `run_model` when neither /// the task nor the profile pins a model, matching pre-#4093 dispatch. -/// - `provider`: `Some(canonical_id)` ONLY when the resolved agent profile +/// - `provider`: `Some(provider_id)` ONLY when the resolved agent profile /// explicitly pins a provider. `None` means "no provider authority" — the /// caller omits `--provider` and the worker keeps its own session default, -/// preserving today's behavior for profile-less workers. The id is -/// [`ApiProvider::as_str`], which round-trips through `ApiProvider::parse` on -/// the worker's `--provider` flag. +/// preserving today's behavior for profile-less workers. Built-ins use their +/// canonical ids; user-named custom providers preserve the profile's id so +/// `codewhale exec --provider ` can resolve `[providers.]`. pub(crate) fn fleet_worker_launch_route( task_spec: &FleetTaskSpec, agent_profiles: &[AgentProfile], @@ -510,8 +523,7 @@ pub(crate) fn fleet_worker_launch_route( .flatten(); let worker_profile = task_spec.worker.as_ref(); let model = effective_fleet_model(run_model, worker_profile, agent_profile); - let provider = - explicit_fleet_provider(agent_profile).map(|provider| provider.as_str().to_string()); + let provider = explicit_fleet_provider_id(agent_profile); (model, provider) } @@ -1428,6 +1440,36 @@ mod tests { assert_ne!(route.provider_id, "deepseek"); } + #[test] + fn resolve_fleet_route_omits_custom_provider_without_config_snapshot() { + let mut profile = agent_profile( + "local", + "scout", + None, + codewhale_config::FleetLoadout::Inherit, + ); + profile.profile.model = Some("qwen-2.5-7b".to_string()); + profile.profile.provider = Some("lm-studio".to_string()); + let task = fleet_task( + "custom-receipt", + Some(worker_profile( + Some("local"), + None, + None, + None, + None, + vec![], + )), + ); + + let route = resolve_fleet_route(&task, &[profile], Some("deepseek-v4-pro")); + + assert!( + route.is_none(), + "custom providers need the session Config snapshot; do not fabricate a DeepSeek receipt" + ); + } + #[test] fn fleet_worker_launch_route_is_explicit_provider_only() { // The LAUNCH resolver (twin of the receipt) must emit a provider ONLY @@ -1466,6 +1508,34 @@ mod tests { Some("high") ); + // 1b) User-named OpenAI-compatible providers are launchable too: keep + // the exact provider id so `codewhale exec --provider lm-studio` + // can resolve `[providers.lm-studio]` from config (#3965). + let mut custom = agent_profile( + "local", + "scout", + None, + codewhale_config::FleetLoadout::Inherit, + ); + custom.profile.model = Some("qwen-2.5-7b".to_string()); + custom.profile.provider = Some("lm-studio".to_string()); + let custom_task = fleet_task( + "launch-custom", + Some(worker_profile( + Some("local"), + None, + None, + None, + None, + vec![], + )), + ); + let custom_profiles = vec![custom]; + let (model, provider) = + fleet_worker_launch_route(&custom_task, &custom_profiles, "deepseek-v4-pro"); + assert_eq!(model, "qwen-2.5-7b"); + assert_eq!(provider.as_deref(), Some("lm-studio")); + // 2) A DeepSeek-shaped model with NO explicit provider must NOT infer a // provider — provider stays None so the worker keeps its own session // default, and no `--provider` is emitted. diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 5b7312bee..bb63f1988 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -653,6 +653,31 @@ fn resolve_exec_model(config: &Config, explicit_model: Option<&str>) -> String { .unwrap_or_else(|| config.default_model()) } +fn apply_exec_provider_override(config: &mut Config, provider_arg: &str) -> Result<()> { + let provider_arg = provider_arg.trim(); + if provider_arg.is_empty() { + return Ok(()); + } + if let Some(provider) = crate::config::ApiProvider::parse(provider_arg) { + config.provider = Some(provider.as_str().to_string()); + return Ok(()); + } + if config + .providers + .as_ref() + .and_then(|providers| providers.custom_provider_config(provider_arg)) + .is_some() + { + config.provider = Some(provider_arg.to_string()); + return Ok(()); + } + bail!( + "Unrecognized --provider {provider_arg:?}. Known providers: {} \ + or a configured [providers.] custom provider", + crate::config::ApiProvider::names_hint() + ); +} + fn exec_model_env_override() -> Option { ["CODEWHALE_MODEL", "DEEPSEEK_MODEL"] .into_iter() @@ -1283,13 +1308,7 @@ async fn main() -> Result<()> { .map(str::trim) .filter(|p| !p.is_empty()) { - let Some(provider) = crate::config::ApiProvider::parse(provider_arg) else { - bail!( - "Unrecognized --provider {provider_arg:?}. Known providers: {}", - crate::config::ApiProvider::names_hint() - ); - }; - config.provider = Some(provider.as_str().to_string()); + apply_exec_provider_override(&mut config, provider_arg)?; } if let Some(reasoning_arg) = args .reasoning_effort @@ -9470,6 +9489,51 @@ mod terminal_mode_tests { ); } + #[test] + fn exec_provider_override_accepts_configured_custom_provider() { + let mut custom = std::collections::HashMap::new(); + custom.insert( + "lm-studio".to_string(), + crate::config::ProviderConfig { + kind: Some("openai-compatible".to_string()), + base_url: Some("http://127.0.0.1:1234/v1".to_string()), + model: Some("qwen-2.5-7b".to_string()), + api_key: Some("lm-studio".to_string()), + ..Default::default() + }, + ); + let mut config = Config { + provider: Some("deepseek".to_string()), + providers: Some(crate::config::ProvidersConfig { + custom, + ..Default::default() + }), + ..Default::default() + }; + + apply_exec_provider_override(&mut config, "lm-studio") + .expect("configured custom provider should be accepted"); + + assert_eq!(config.provider.as_deref(), Some("lm-studio")); + assert_eq!(config.api_provider(), crate::config::ApiProvider::Custom); + } + + #[test] + fn exec_provider_override_rejects_unknown_provider() { + let mut config = Config { + provider: Some("deepseek".to_string()), + ..Default::default() + }; + + let err = apply_exec_provider_override(&mut config, "lm-studio") + .expect_err("unconfigured custom provider should fail closed"); + let message = err.to_string(); + + assert!(message.contains("Unrecognized --provider")); + assert!(message.contains("[providers.] custom provider")); + assert_eq!(config.provider.as_deref(), Some("deepseek")); + } + #[test] fn exec_parses_reasoning_effort_flag_alongside_provider() { let cli = parse_cli(&[ diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 54539d6df..7dbec2bc6 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -1598,21 +1598,20 @@ impl SubAgentRuntime { self } - /// Build an LLM client bound to `provider` from the threaded session + /// Build an LLM client bound to `provider_id` from the threaded session /// `Config` (#4193). Mirrors the proven per-provider client factory used by /// per-turn auto-routing (`model_routing`) and the engine's provider switch: /// clone the session config, override only its `provider`, and let /// [`DeepSeekClient::new`] re-resolve that provider's base URL + credentials - /// from config/env. + /// from config/env. `provider_id` may be a built-in provider id or a + /// user-named `[providers.] kind="openai-compatible"` custom provider + /// such as `lm-studio` (#3965). /// /// Returns `Err` when no config was threaded in, or when the provider's /// credentials/base URL cannot be resolved. Callers MUST surface that error /// rather than fall back to the session client: a silent fallback would send /// the pinned model id to the session provider's endpoint (#4093). - fn client_for_provider( - &self, - provider: crate::config::ApiProvider, - ) -> Result { + fn client_for_provider_id(&self, provider_id: &str) -> Result { let Some(api_config) = self.api_config.as_ref() else { return Err( "session Config was not threaded into this runtime; cannot build a \ @@ -1620,13 +1619,34 @@ impl SubAgentRuntime { .to_string(), ); }; + let provider_id = provider_id.trim(); + if provider_id.is_empty() { + return Err("provider pin was blank".to_string()); + } + let built_in = crate::config::ApiProvider::parse(provider_id); + let custom = built_in.is_none() + && api_config + .providers + .as_ref() + .and_then(|providers| providers.custom_provider_config(provider_id)) + .is_some(); + if built_in.is_none() && !custom { + return Err(format!( + "provider '{provider_id}' is neither a built-in provider nor a configured \ + [providers.{provider_id}] custom provider" + )); + } let mut provider_config = (**api_config).clone(); // EPIC #2608: the provider is taken verbatim from the profile pin - // (already parsed to a canonical `ApiProvider`), never inferred from the - // model id. Overriding only `provider` makes `Config::api_provider`, + // (built-in id or configured custom id), never inferred from the model + // id. Overriding only `provider` makes `Config::api_provider`, // `deepseek_base_url`, and `deepseek_api_key` all re-resolve for the // pinned provider. - provider_config.provider = Some(provider.as_str().to_string()); + provider_config.provider = Some( + built_in + .map(|provider| provider.as_str().to_string()) + .unwrap_or_else(|| provider_id.to_string()), + ); DeepSeekClient::new(&provider_config).map_err(|err| err.to_string()) } @@ -4137,6 +4157,21 @@ async fn wait_result_payload( Ok(tool_result) } +fn provider_pin_matches_session(runtime: &SubAgentRuntime, provider_id: &str) -> bool { + let provider_id = provider_id.trim(); + let session_provider = runtime.client.api_provider(); + if let Some(provider) = crate::config::ApiProvider::parse(provider_id) { + return provider == session_provider; + } + session_provider == crate::config::ApiProvider::Custom + && runtime + .api_config + .as_ref() + .and_then(|config| config.provider.as_deref()) + .map(str::trim) + .is_some_and(|active| active == provider_id) +} + /// Resolve the LLM client a freshly spawned in-process child should run on, /// honoring a fleet roster member's explicit provider pin (#4193). /// @@ -4158,14 +4193,14 @@ fn child_client_for_member( member: Option<&crate::fleet::profile::AgentProfile>, ) -> Result { let session_provider = runtime.client.api_provider(); - match crate::fleet::worker_runtime::explicit_fleet_provider(member) { - Some(pinned) if pinned != session_provider => { - runtime.client_for_provider(pinned).map_err(|err| { + match crate::fleet::worker_runtime::explicit_fleet_provider_id(member) { + Some(pinned_id) if !provider_pin_matches_session(runtime, &pinned_id) => { + runtime.client_for_provider_id(&pinned_id).map_err(|err| { ToolError::execution_failed(format!( "fleet profile pins provider '{}' but its client could not be built \ ({err}). Configure that provider's credentials/base URL, or drop the \ provider pin to inherit the session provider '{}'.", - pinned.as_str(), + pinned_id, session_provider.as_str() )) }) diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index d3a96db90..a5a1f5074 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -4791,6 +4791,17 @@ fn stub_client() -> DeepSeekClient { /// resolve each provider independently. fn cross_provider_config() -> crate::config::Config { let _ = rustls::crypto::ring::default_provider().install_default(); + let mut custom = std::collections::HashMap::new(); + custom.insert( + "lm-studio".to_string(), + crate::config::ProviderConfig { + kind: Some("openai-compatible".to_string()), + api_key: Some("lm-studio-key".to_string()), + base_url: Some("http://127.0.0.1:1234/v1".to_string()), + model: Some("qwen-2.5-7b".to_string()), + ..Default::default() + }, + ); let providers = crate::config::ProvidersConfig { deepseek: crate::config::ProviderConfig { api_key: Some("session-key".to_string()), @@ -4802,6 +4813,7 @@ fn cross_provider_config() -> crate::config::Config { base_url: Some("https://pinned-provider.example.com/v1".to_string()), ..Default::default() }, + custom, ..crate::config::ProvidersConfig::default() }; crate::config::Config { @@ -4873,6 +4885,30 @@ fn spawn_child_client_targets_profile_pinned_provider() { ); } +#[test] +fn spawn_child_client_targets_custom_profile_provider() { + // #3965: LM Studio and other user-named OpenAI-compatible providers live in + // `[providers.]` tables. A profile pin must preserve that name so the + // child client resolves the custom table instead of rejecting it or + // silently inheriting the DeepSeek session client. + let runtime = cross_provider_runtime(); + assert_eq!( + runtime.client.api_provider(), + crate::config::ApiProvider::Deepseek, + "precondition: session is on DeepSeek" + ); + + let member = member_pinning_provider("lm-studio", "qwen-2.5-7b"); + let child_client = child_client_for_member(&runtime, Some(&member)) + .expect("custom provider client builds from the named provider table"); + + assert_eq!( + child_client.api_provider(), + crate::config::ApiProvider::Custom + ); + assert_eq!(child_client.base_url(), "http://127.0.0.1:1234/v1"); +} + #[test] fn spawn_child_client_inherits_session_provider_without_pin() { // Regression: profile-less members and members that pin no provider (or the diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c8ca3d436..e7d0a4ab0 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -355,8 +355,9 @@ is reasoning-capable, while Preview is not marked as a thinking model. ### Custom OpenAI-Compatible Gateways -For a third-party service that implements the OpenAI Chat Completions API, use -the built-in `openai` provider name and point its provider table at the gateway: +For a single third-party service that implements the OpenAI Chat Completions +API, the simplest setup is the built-in `openai` provider name pointed at the +gateway: ```toml provider = "openai" @@ -367,12 +368,27 @@ api_key = "YOUR_OPENAI_COMPATIBLE_API_KEY" base_url = "https://your-gateway.example/v1" ``` -Do not invent a custom provider name; `provider` must be one of the known -providers listed above. Put the endpoint under `[providers.openai]`, not the -legacy top-level `base_url`, so the OpenAI-compatible provider receives it. -`default_text_model` is the model ID sent to the gateway; if you keep several -provider tables in one config, `[providers.openai].model` can be used as the -OpenAI-provider-specific override. +Put the endpoint under `[providers.openai]`, not the legacy top-level +`base_url`, so the OpenAI-compatible provider receives it. `default_text_model` +is the model ID sent to the gateway; `[providers.openai].model` can be used as +the OpenAI-provider-specific override. + +If you keep several OpenAI-compatible gateways, or need a stable name for an +AgentProfile provider pin, define a user-named custom provider table: + +```toml +provider = "lm-studio" + +[providers.lm-studio] +kind = "openai-compatible" +base_url = "http://127.0.0.1:1234/v1" +api_key = "lm-studio" +model = "qwen-2.5-7b" +``` + +Custom provider names may be selected with `provider = ""`, +`--provider `, or an AgentProfile `provider = ""` when the matching +`[providers.]` table exists. StepFun has a first-class provider entry, so keep Coding Plan credentials and base URL scoped to `[providers.stepfun]`: @@ -1362,7 +1378,10 @@ If you are upgrading from older releases: `explorer`, `general`, `explore`, `plan`, and `review`. Values are validated against the active provider at spawn time; direct DeepSeek requires DeepSeek IDs, while OpenAI-compatible/custom provider routes pass explicit model IDs - through to that provider. + through to that provider. To route a child to a different provider than the + parent session, save a Fleet/AgentProfile with explicit `provider` and + `model` fields (including user-named custom providers such as `lm-studio`) + and call `agent(profile: "...")`; see [SUBAGENTS.md](SUBAGENTS.md). - `skills_dir` (string, optional): defaults to `~/.codewhale/skills` (each skill is a directory containing `SKILL.md`). Workspace-local `.agents/skills` or `./skills` are preferred when present; the runtime also discovers global diff --git a/docs/FLEET.md b/docs/FLEET.md index 715428d00..c63b73680 100644 --- a/docs/FLEET.md +++ b/docs/FLEET.md @@ -58,6 +58,10 @@ concrete model pins its provider explicitly: the saved profile records both whichever provider happens to be active when the profile is later loaded. Pressing **Enter** ("start") on the review step previews the exact starter profile TOML inline on that same screen; nothing is written until you ratify it. +The `provider` field may be a built-in provider id such as `openrouter` or a +user-named OpenAI-compatible provider configured under `[providers.]` +such as `lm-studio`; the launch path preserves that id and fails closed if the +provider is not configured. When a provider is configured, the review step also offers model-assisted drafting behind a ratify gate: diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 7ab8227c4..7325c8439 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -144,6 +144,10 @@ Instead, choose the closest shipped route and override its endpoint/model: - Generic OpenAI-compatible gateway: use `provider = "openai"` with `[providers.openai].base_url` plus `[providers.openai].model`, or launch with `OPENAI_BASE_URL` and `OPENAI_MODEL`. +- Multiple named OpenAI-compatible gateways, or local routes you want to pin + from an AgentProfile, can use a custom table such as + `[providers.lm-studio] kind = "openai-compatible"` and select it with + `provider = "lm-studio"` or a profile `provider = "lm-studio"`. - Local OpenAI-compatible runtimes: use `provider = "vllm"`, `"sglang"`, or `"ollama"` with the matching provider-specific base URL/model values. diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 2c256b4e6..641d284d8 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -331,6 +331,49 @@ OpenRouter, Novita, SiliconFlow, SGLang, vLLM) route between that pair; providers without a known cheap tier (e.g. Ollama, Moonshot) skip the network router and keep children on the session model. +## Per-profile provider routes (#3965) + +`[subagents.models]` changes the child model within the active provider. To pin +a child to a different provider, use a Fleet/AgentProfile and pass it to the +model-facing `agent` tool with `profile`. The profile's explicit `provider` + +`model` fields win over the parent session route; omitting `provider` preserves +the existing inherit behavior. + +Example: keep the parent session on DeepSeek, but run a formatter child on a +local LM Studio OpenAI-compatible endpoint: + +```toml +# ~/.codewhale/config.toml or workspace config +provider = "deepseek" + +[providers.deepseek] +api_key = "YOUR_DEEPSEEK_KEY" + +[providers.lm-studio] +kind = "openai-compatible" +base_url = "http://127.0.0.1:1234/v1" +api_key = "lm-studio" +model = "qwen-2.5-7b" +``` + +```toml +# .codewhale/agents/local-formatter.toml +id = "local-formatter" +role_hint = "formatter" +provider = "lm-studio" +model = "qwen-2.5-7b" +reasoning_effort = "off" + +[instructions] +text = "Use small, local edits. Keep formatting changes mechanical." +``` + +Then call `agent(profile: "local-formatter", prompt: "...")`. In-process +children build a client for `lm-studio`; Fleet workers forward +`--provider lm-studio` to `codewhale exec`, which resolves the same +`[providers.lm-studio]` table. Unknown or unconfigured provider ids fail the +spawn rather than silently falling back to the parent provider. + ## Per-step API timeout (#1806, #1808) Each sub-agent step wraps its DeepSeek `create_message` call in a From 5dbe6d0dde199a55275e165b2aa97d5f5682e551 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 8 Jul 2026 16:41:29 -0700 Subject: [PATCH 2/3] test(fleet): update roster degradation fixture for custom provider ids broken_workspace_dir_degrades_to_built_ins_and_config used provider = "not-a-real-provider", which is now a valid simple custom provider token under #3965. Switch the fixture to a malformed id so workspace-dir load still fails closed and degrades to built-ins + config. --- crates/tui/src/fleet/roster.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/fleet/roster.rs b/crates/tui/src/fleet/roster.rs index a65d89e8e..0356478cf 100644 --- a/crates/tui/src/fleet/roster.rs +++ b/crates/tui/src/fleet/roster.rs @@ -412,13 +412,14 @@ mod tests { #[test] fn broken_workspace_dir_degrades_to_built_ins_and_config() { let tmp = TempDir::new().unwrap(); - // An unrecognized provider id is still a load failure (#4093): - // `provider` is now a first-class field, but it's validated against - // the known `ApiProvider` vocabulary, not accepted as any string. + // A malformed provider token is still a load failure (#4093 / #3965): + // profile pins may name built-ins or simple custom ids like + // `lm-studio`, but whitespace/punctuation is rejected so a broken + // workspace dir still degrades to built-ins + config. write_workspace_profile( tmp.path(), "broken.toml", - "provider = \"not-a-real-provider\"\n", + "provider = \"not a real provider\"\n", ); let config = config_with_profiles(BTreeMap::from([( "extra".to_string(), From e6c5587ef0642b5b065159e9e464f00be16c96e5 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 8 Jul 2026 16:52:04 -0700 Subject: [PATCH 3/3] fix(hooks): flush JsonlHookSink writes before drop Ensure each emit is durable before the next sequential emit or test read observes the file. Unflushed tokio File buffers can leave only one line visible under CI load (jsonl_sink_creates_parent_dir_and_appends_events). --- crates/hooks/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/hooks/src/lib.rs b/crates/hooks/src/lib.rs index bea1b620a..8c125bbe7 100644 --- a/crates/hooks/src/lib.rs +++ b/crates/hooks/src/lib.rs @@ -168,6 +168,9 @@ impl HookSink for JsonlHookSink { file.write_all(b"\n") .await .context("failed to write hook event newline")?; + // Flush before drop so sequential emits (and tests that read the + // file immediately after) observe every completed line. + file.flush().await.context("failed to flush hook event")?; Ok(()) } }