From 00a5e318bb4efb5d96501b32c75c5baa29a91680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 13 Sep 2026 07:13:47 -0400 Subject: [PATCH] fix(config): an explicit [model.X] api_backend survives same-slug propagation The slug-match step treated an entry whose api_backend equals the default as unset and overwrote it with a same-slug sibling's backend, so `api_backend = "chat_completions"` silently became messages/responses. Config keys whose override sets api_backend are recorded and skipped by the donor step; an unset backend still inherits. Ported from upstream 1.0.22 (grok-build agent/config.rs explicit_api_backend_keys). Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4 --- crates/codegen/kigi-shell/src/agent/config.rs | 51 ++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/codegen/kigi-shell/src/agent/config.rs b/crates/codegen/kigi-shell/src/agent/config.rs index 2311f8f..490b282 100644 --- a/crates/codegen/kigi-shell/src/agent/config.rs +++ b/crates/codegen/kigi-shell/src/agent/config.rs @@ -2601,6 +2601,7 @@ pub(crate) fn resolve_model_list( } resolved = prefetched; } + let mut explicit_api_backend_keys = std::collections::HashSet::new(); for (key, model_override) in &cfg.config_models { let had_base = resolved.contains_key(key); let base = resolved.shift_remove(key); @@ -2617,6 +2618,9 @@ pub(crate) fn resolve_model_list( ); } } + if model_override.api_backend.is_some() { + explicit_api_backend_keys.insert(key.as_str()); + } let entry = model_override.apply(key, base, &cfg.endpoints); tracing::debug!( model_key = % key, base_url = % entry.info.base_url, has_api_key = entry @@ -2638,7 +2642,7 @@ pub(crate) fn resolve_model_list( ) }) .collect(); - for entry in resolved.values_mut() { + for (key, entry) in resolved.iter_mut() { if let Some((donor_cw, donor_backend)) = donors.get(&entry.info.model) { if entry.info.context_window.get() == default_cw { tracing::debug!( @@ -2648,7 +2652,9 @@ pub(crate) fn resolve_model_list( ); entry.info.context_window = *donor_cw; } - if entry.info.api_backend == ApiBackend::default() + // An explicit `[model.X] api_backend` wins even when it is the default. + if !explicit_api_backend_keys.contains(key.as_str()) + && entry.info.api_backend == ApiBackend::default() && *donor_backend != ApiBackend::default() { entry.info.api_backend.clone_from(donor_backend); @@ -9067,6 +9073,47 @@ default = "kigi-4.5" sibling 'kigi-build' (same model slug), not stay at {default_cw}" ); } + /// An explicit `[model.X] api_backend` survives a same-slug sibling even when it is the default. + #[test] + fn slug_propagation_keeps_an_explicit_default_api_backend() { + // Only kigi-messages has a non-default context window: the slug's single donor. + let default_cw = DEFAULT_CONTEXT_WINDOW; + let raw: toml::Value = toml::from_str(&format!( + r#" + [model.kigi-messages] + model = "kigi-4.5" + context_window = 500000 + base_url = "https://test.example.com/v1" + api_backend = "messages" + + [model.kigi-chat] + model = "kigi-4.5" + context_window = {default_cw} + base_url = "https://test.example.com/v1" + api_backend = "chat_completions" + + [model.kigi-plain] + model = "kigi-4.5" + context_window = {default_cw} + base_url = "https://test.example.com/v1" + "# + )) + .unwrap(); + let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse"); + let resolved = resolve_model_list(&cfg, None, &Default::default()); + let backend = |key: &str| resolved.get(key).expect(key).info.api_backend.clone(); + assert_eq!( + backend("kigi-chat"), + ApiBackend::ChatCompletions, + "explicit default backend must not be overwritten by the sibling" + ); + assert_eq!( + backend("kigi-plain"), + ApiBackend::Messages, + "unset backend still inherits" + ); + assert_eq!(backend("kigi-messages"), ApiBackend::Messages); + } /// Slug propagation should carry over api_backend but NOT agent_type. #[test] fn slug_propagation_inherits_api_backend_but_not_agent_type() {