F5: web search/fetch on the Kimi services (kimi-cli parity)

web_search now speaks the Kimi search service (kimi-cli tools/web/search.py,
wire-verified against api.kimi.com):
- POST {coding_base}/search with {text_query, limit 1-20 (default 5),
  enable_page_crawling, timeout_seconds: 30}, OAuth bearer +
  X-Msh-Tool-Call-Id; results render in kimi-cli's Title/Date/URL/Summary
  schema with result URLs as citations.
- The old implementation called the xAI Responses API with a search model;
  that client is fully replaced and the entire model-based config surface
  is excised root-and-branch: web_search_model config keys/env/CLI plumbing,
  resolve_web_search_sampling_config, toolset web_search SamplerConfig,
  RemoteSettings.web_search_model, default_web_search_model.
- Enablement is now purely structural: the service exists only on the Kimi
  Code subscription channel, so OAuth sessions get Enabled and API-key-only
  sessions get Disabled (tool absent) — per PRD F5.

web_fetch gains the Kimi fetch service as its primary path (kimi-cli
tools/web/fetch.py): POST {coding_base}/fetch with {url}, Accept:
text/markdown, OAuth bearer + X-Msh-Tool-Call-Id; the 200 body is the
extracted markdown (still overflow-budgeted). Any service failure falls
back to the existing local pipeline (SSRF guards, cache, extraction).
The tool gate defaults ON now (kimi-cli always offers FetchURL) and the
egress User-Agent no longer claims grok-agent/x.ai.

Verified end-to-end against the scripted mock service: a headless session
drove web_search (limit/crawling/call-id observed on the wire) then
web_fetch (Accept + call-id observed) to completion.
This commit is contained in:
2026-07-17 19:33:19 -04:00
parent 78cd94a751
commit 74b210535e
34 changed files with 659 additions and 1209 deletions
+9 -149
View File
@@ -490,7 +490,6 @@ pub struct RuntimeResolutionContext<'a> {
pub is_headless: bool,
/// `Some(true)` = CLI explicitly enabled, `None` = defer to config/env/remote.
pub cli_subagents: Option<bool>,
pub cli_web_search_model: Option<&'a str>,
pub cli_session_summary_model: Option<&'a str>,
/// CLI `--experimental-memory` flag. Enables cross-session memory.
pub cli_experimental_memory: bool,
@@ -846,8 +845,6 @@ pub struct ModelsConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub default_reasoning_effort: Option<ReasoningEffort>,
#[serde(skip_serializing_if = "Option::is_none")]
pub web_search: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_summary: Option<String>,
/// Vision model used to transcribe user-supplied
/// images via a separate endpoint.
@@ -1310,9 +1307,6 @@ pub struct Config {
/// CLI override for reasoning effort.
#[serde(skip)]
pub reasoning_effort_override: Option<ReasoningEffort>,
/// CLI override for the web search model ID.
#[serde(skip)]
pub web_search_model_override: Option<String>,
/// CLI override for the session summary model ID.
#[serde(skip)]
pub session_summary_model_override: Option<String>,
@@ -1446,9 +1440,6 @@ pub struct Config {
/// Enforced requirement pins from `requirements.toml`.
#[serde(skip)]
pub requirements: Requirements,
/// Model ID for web_search.
#[serde(skip)]
pub web_search_model: String,
/// Session title model. Resolved to the compiled default
/// (`default_session_summary_model`) when unset; see `ModelOverrideConfig::resolve`.
#[serde(skip)]
@@ -1662,7 +1653,6 @@ impl Default for Config {
storage_mode: StorageMode::resolve(None, None),
default_model_override: None,
reasoning_effort_override: None,
web_search_model_override: None,
session_summary_model_override: None,
default_yolo_mode: false,
default_auto_mode: false,
@@ -1693,7 +1683,6 @@ impl Default for Config {
auto_wake_enabled: true,
compat_resolved: CompatConfig::default(),
requirements: Requirements::default(),
web_search_model: crate::models::default_web_search_model().to_owned(),
session_summary_model: None,
image_description_model: None,
prompt_suggest_model_pin: crate::config::PromptSuggestModelPin::Unpinned,
@@ -1780,9 +1769,7 @@ impl Config {
if config.client_version.is_none() {
config.client_version = Self::default().client_version;
}
let model_overrides =
crate::config::ModelOverrideConfig::resolve(None, None, raw_config, None);
config.web_search_model = model_overrides.web_search;
let model_overrides = crate::config::ModelOverrideConfig::resolve(None, raw_config, None);
config.session_summary_model = model_overrides.session_summary;
config.image_description_model = model_overrides.image_description;
config.prompt_suggest_model_pin = model_overrides.prompt_suggestion;
@@ -1813,7 +1800,7 @@ impl Config {
/// - respect_gitignore via `ToolsConfig::resolve`
/// - disable_zdr_incompatible_tools via `ToolsConfig::resolve`
/// - managed_mcps_enabled via `ManagedMcpsConfig::resolve`
/// - web_search_model / session_summary_model / image_description_model /
/// - session_summary_model / image_description_model /
/// prompt_suggest_model_pin via `ModelOverrideConfig::resolve`
/// - memory_config via `MemoryConfig::resolve`
/// - disable_web_search (CLI flag ORed with config.toml)
@@ -1824,7 +1811,6 @@ impl Config {
/// `resolve_worktree_type` since it's an agent-level field, not a Config field.
pub fn resolve_runtime_fields(&mut self, ctx: &RuntimeResolutionContext<'_>) {
self.cli_subagents = ctx.cli_subagents;
self.web_search_model_override = ctx.cli_web_search_model.map(|s| s.to_owned());
self.session_summary_model_override = ctx.cli_session_summary_model.map(|s| s.to_owned());
let cli_flag = ctx.cli_subagents.unwrap_or(false);
self.resolve_subagents(cli_flag, ctx.raw_config, ctx.cwd);
@@ -1843,12 +1829,10 @@ impl Config {
self.managed_mcps_enabled = mcps.enabled;
self.managed_mcp_gateway_tools_enabled = mcps.gateway_tools_enabled;
let models = crate::config::ModelOverrideConfig::resolve(
ctx.cli_web_search_model,
ctx.cli_session_summary_model,
ctx.raw_config,
ctx.remote_settings,
);
self.web_search_model = models.web_search;
self.session_summary_model = models.session_summary;
self.image_description_model = models.image_description;
self.prompt_suggest_model_pin = models.prompt_suggestion;
@@ -1889,7 +1873,6 @@ impl Config {
cwd: Option<&std::path::Path>,
) {
let remote_settings = self.remote_settings.clone();
let cli_web_search_model = self.web_search_model_override.clone();
let cli_session_summary_model = self.session_summary_model_override.clone();
let laziness_debug_log = self.laziness_debug_log.clone();
let ctx = RuntimeResolutionContext {
@@ -1898,7 +1881,6 @@ impl Config {
cwd,
is_headless: self.mode == AgentMode::Headless,
cli_subagents: self.cli_subagents,
cli_web_search_model: cli_web_search_model.as_deref(),
cli_session_summary_model: cli_session_summary_model.as_deref(),
cli_experimental_memory: self.cli_experimental_memory,
cli_no_memory: self.cli_no_memory,
@@ -2021,6 +2003,9 @@ impl Config {
.feature_flag(ff)
.resolve()
}
/// `web_fetch` tool gate; default ON (kimi-cli parity: `FetchURL` is
/// always offered). `KIGI_WEB_FETCH=0` / `[features] web_fetch = false`
/// are the kill switches.
pub(crate) fn resolve_web_fetch(&self) -> Resolved<bool> {
let ff = self
.remote_settings
@@ -2030,6 +2015,7 @@ impl Config {
.requirement(self.requirements.web_fetch.pinned())
.config(self.features.web_fetch)
.feature_flag(ff)
.default(true)
.resolve()
}
/// `ask_user_question` tool gate; default ON. remote settings
@@ -4387,85 +4373,6 @@ pub fn resolve_model_to_sampling_config(
alpha_test_key,
))
}
fn resolve_hidden_default_web_search_sampling_config(
model_id: &str,
session_key: Option<&str>,
alpha_test_key: Option<String>,
endpoints: &EndpointsConfig,
) -> SamplerConfig {
let entry = ModelEntry {
info: ModelInfo {
id: None,
model: model_id.to_owned(),
base_url: endpoints.resolve_inference_base_url(),
name: None,
description: None,
max_completion_tokens: None,
temperature: None,
top_p: None,
api_backend: ApiBackend::Responses,
auth_scheme: Default::default(),
extra_headers: IndexMap::new(),
context_window: NonZeroU64::new(200_000).unwrap(),
auto_compact_threshold_percent: None,
system_prompt_label: None,
use_concise: false,
agent_type: default_agent_type(),
inference_idle_timeout_secs: None,
max_retries: None,
hidden: true,
user_selectable: true,
supported_in_api: true,
reasoning_effort: None,
supports_reasoning_effort: false,
reasoning_efforts: Vec::new(),
capabilities: Vec::new(),
supports_backend_search: false,
compactions_remaining: None,
compaction_at_tokens: None,
show_model_fingerprint: false,
stream_tool_calls: None,
laziness_detector: LazinessDetectorPerModelConfig::default(),
},
api_key: None,
env_key: None,
api_base_url: None,
};
let credentials = resolve_credentials(&entry, session_key);
sampling_config_for_model(&entry, credentials, alpha_test_key)
}
pub fn resolve_web_search_sampling_config(
model_id: &str,
models: &IndexMap<String, ModelEntry>,
session_key: Option<&str>,
alpha_test_key: Option<String>,
endpoints: &EndpointsConfig,
) -> Option<SamplerConfig> {
let resolved = if let Some(entry) = find_model_by_id(models, model_id).cloned() {
let credentials = resolve_credentials(&entry, session_key);
Some(sampling_config_for_model(
&entry,
credentials,
alpha_test_key,
))
} else if model_id == crate::models::default_web_search_model() {
Some(resolve_hidden_default_web_search_sampling_config(
model_id,
session_key,
alpha_test_key,
endpoints,
))
} else {
None
};
if resolved.is_none() {
tracing::warn!(
web_search_model = % model_id,
"configured web_search model not found; disabling web search"
);
}
resolved.map(crate::tools::config::web_search_sampling_config)
}
pub fn to_acp_model_info(
models: &IndexMap<String, ModelEntry>,
) -> IndexMap<acp::ModelId, acp::ModelInfo> {
@@ -4803,7 +4710,6 @@ reasoning_effort = "low"
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: None,
cli_session_summary_model: None,
cli_experimental_memory: false,
cli_no_memory: false,
@@ -4832,7 +4738,6 @@ reasoning_effort = "low"
cwd: None,
is_headless: true,
cli_subagents: None,
cli_web_search_model: None,
cli_session_summary_model: None,
cli_experimental_memory: false,
cli_no_memory: false,
@@ -4855,14 +4760,9 @@ reasoning_effort = "low"
assert!(cfg.disable_web_search);
}
#[test]
fn new_from_toml_cfg_restores_web_search_and_session_summary_models() {
fn new_from_toml_cfg_restores_session_summary_and_image_description_models() {
let empty: toml::Value = toml::Value::Table(toml::map::Map::new());
let cfg = Config::new_from_toml_cfg(&empty).expect("empty config should parse");
assert_eq!(
cfg.web_search_model,
crate::models::default_web_search_model(),
"empty config should produce the compiled-in default web_search model"
);
assert_eq!(
cfg.session_summary_model,
Some(crate::models::default_session_summary_model().to_owned()),
@@ -4876,14 +4776,12 @@ reasoning_effort = "low"
let with_overrides: toml::Value = toml::from_str(
r#"
[models]
web_search = "custom-ws-model"
session_summary = "custom-ss-model"
image_description = "custom-id-model"
"#,
)
.unwrap();
let cfg2 = Config::new_from_toml_cfg(&with_overrides).expect("config should parse");
assert_eq!(cfg2.web_search_model, "custom-ws-model");
assert_eq!(
cfg2.session_summary_model,
Some("custom-ss-model".to_owned())
@@ -4894,26 +4792,6 @@ reasoning_effort = "low"
);
}
#[test]
fn hidden_default_web_search_resolution_is_explicit_and_responses_only() {
let endpoints = EndpointsConfig::default();
let resolved = resolve_web_search_sampling_config(
crate::models::default_web_search_model(),
&IndexMap::new(),
Some("session-token"),
None,
&endpoints,
)
.expect("hidden default web search model should resolve");
assert_eq!(resolved.model, crate::models::default_web_search_model());
assert_eq!(resolved.base_url, endpoints.proxy_url());
assert_eq!(resolved.api_backend, ApiBackend::Responses);
assert_eq!(
resolved.api_key.as_deref(),
Some("session-token"),
"hidden default should still use normal credential resolution"
);
}
#[test]
fn finalize_image_describe_sampler_none_uses_active_session_model_not_forced_helper() {
let active = SamplerConfig {
model: "composer-session-model".into(),
@@ -6671,14 +6549,12 @@ reasoning_effort = "low"
r#"
[models]
default = "my-enterprise-model"
web_search = "enterprise-search"
session_summary = "title-model"
"#,
)
.unwrap();
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
assert_eq!(cfg.models.default.as_deref(), Some("my-enterprise-model"));
assert_eq!(cfg.models.web_search.as_deref(), Some("enterprise-search"));
assert_eq!(cfg.models.session_summary.as_deref(), Some("title-model"));
}
#[test]
@@ -8903,7 +8779,6 @@ agent_type = "cursor"
unsafe {
std::env::remove_var("KIGI_SUBAGENTS");
std::env::remove_var("KIGI_RESPECT_GITIGNORE");
std::env::remove_var("KIGI_WEB_SEARCH_MODEL");
std::env::remove_var("KIGI_SESSION_SUMMARY_MODEL");
std::env::remove_var("KIGI_CURSOR_SKILLS_ENABLED");
std::env::remove_var("KIGI_CURSOR_RULES_ENABLED");
@@ -9189,7 +9064,6 @@ hooks = true
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: None,
cli_session_summary_model: None,
cli_experimental_memory: false,
cli_no_memory: false,
@@ -9215,7 +9089,6 @@ hooks = true
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: None,
cli_session_summary_model: None,
cli_experimental_memory: false,
cli_no_memory: false,
@@ -9228,10 +9101,6 @@ hooks = true
assert!(!cfg.respect_gitignore);
assert!(cfg.managed_mcps_enabled);
assert!(!cfg.managed_mcp_gateway_tools_enabled);
assert_eq!(
cfg.web_search_model,
crate::models::default_web_search_model()
);
assert_eq!(
cfg.session_summary_model,
Some(crate::models::default_session_summary_model().to_owned())
@@ -9251,7 +9120,6 @@ hooks = true
cwd: None,
is_headless: true,
cli_subagents: None,
cli_web_search_model: None,
cli_session_summary_model: None,
cli_experimental_memory: false,
cli_no_memory: false,
@@ -9283,7 +9151,6 @@ hooks = true
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: None,
cli_session_summary_model: None,
cli_experimental_memory: false,
cli_no_memory: false,
@@ -9306,7 +9173,6 @@ hooks = true
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: None,
cli_session_summary_model: None,
cli_experimental_memory: false,
cli_no_memory: false,
@@ -9329,7 +9195,6 @@ hooks = true
cwd: None,
is_headless: false,
cli_subagents: Some(true),
cli_web_search_model: None,
cli_session_summary_model: None,
cli_experimental_memory: false,
cli_no_memory: false,
@@ -9353,7 +9218,6 @@ hooks = true
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: None,
cli_session_summary_model: None,
cli_experimental_memory: false,
cli_no_memory: false,
@@ -9377,7 +9241,6 @@ hooks = true
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: Some("custom-ws"),
cli_session_summary_model: Some("custom-ss"),
cli_experimental_memory: false,
cli_no_memory: false,
@@ -9386,7 +9249,6 @@ hooks = true
laziness_debug_log: None,
storage_mode: None,
});
assert_eq!(cfg.web_search_model, "custom-ws");
assert_eq!(cfg.session_summary_model, Some("custom-ss".to_owned()));
}
#[test]
@@ -9405,7 +9267,6 @@ hooks = true
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: None,
cli_session_summary_model: None,
cli_experimental_memory: false,
cli_no_memory: false,
@@ -9428,7 +9289,6 @@ hooks = true
cwd: None,
is_headless: false,
cli_subagents: None,
cli_web_search_model: None,
cli_session_summary_model: None,
cli_experimental_memory: false,
cli_no_memory: false,
@@ -9441,12 +9301,12 @@ hooks = true
let first_subagents = cfg.subagents_enabled;
let first_gitignore = cfg.respect_gitignore;
let first_mcps = cfg.managed_mcps_enabled;
let first_ws = cfg.web_search_model.clone();
let first_ss = cfg.session_summary_model.clone();
cfg.resolve_runtime_fields(&ctx);
assert_eq!(cfg.subagents_enabled, first_subagents);
assert_eq!(cfg.respect_gitignore, first_gitignore);
assert_eq!(cfg.managed_mcps_enabled, first_mcps);
assert_eq!(cfg.web_search_model, first_ws);
assert_eq!(cfg.session_summary_model, first_ss);
}
#[test]
fn version_overrides_apply_into_typed_config() {
@@ -834,20 +834,29 @@ impl MvpAgent {
tier_restricted: false,
}
}
pub(super) fn prepare_web_search_sampling_config(&self) -> Option<SamplingConfig> {
let model_id = self.cfg.borrow().web_search_model.clone();
let models = self.models_manager.models();
let session = self.current_or_buffered_auth();
let alpha_test_key = self.cfg.borrow().endpoints.alpha_test_key.clone();
let client_version = self.cfg.borrow().client_version.clone();
let mut cfg = config::resolve_web_search_sampling_config(
&model_id,
&models,
session.as_ref().map(|a| a.key.as_str()),
alpha_test_key.clone(),
&self.cfg.borrow().endpoints,
)?;
Some(cfg)
/// Web search config (PRD F5). The Kimi search service exists only on
/// the Kimi Code subscription channel (`POST {coding_base}/search`,
/// kimi-cli `auth/platforms.py`), so this is `Enabled` only for OAuth
/// sessions — API-key-only sessions get `Disabled` and the tool is
/// absent. The live token is refreshed per request via the session
/// api-key provider; the config key is just the bootstrap value.
pub(super) fn prepare_web_search_config(
&self,
) -> kigi_tools::implementations::WebSearchConfig {
use kigi_tools::implementations::WebSearchConfig;
if self.cfg.borrow().disable_web_search {
return WebSearchConfig::Disabled;
}
let Some(auth) = self.current_or_buffered_auth().filter(|a| a.is_session_auth()) else {
tracing::info!("web_search disabled: no Kimi Code OAuth session");
return WebSearchConfig::Disabled;
};
let base = self.cfg.borrow().endpoints.proxy_url();
WebSearchConfig::Enabled {
search_url: format!("{}/search", base.trim_end_matches('/')),
api_key: auth.key,
extra_headers: indexmap::IndexMap::new(),
}
}
/// Returns `Err` with a user-facing message on invalid config; the caller at
/// the process boundary prints it and exits.
@@ -867,11 +876,14 @@ impl MvpAgent {
/// Prepare the web fetch configuration based on feature flags.
///
/// Enabled gate: `disable_web_search` kill-switch > `KIGI_WEB_FETCH` env >
/// remote settings `web_fetch_enabled` > default (false).
/// remote settings `web_fetch_enabled` > default ON (kimi-cli parity:
/// `FetchURL` is always offered).
///
/// Params resolution (TOML > env > remote settings > default):
/// - `proxy_endpoint`: `[toolset.web_fetch] proxy_endpoint` > `KIGI_WEB_FETCH_PROXY` > remote settings > None
/// - `allowed_domains`: `[toolset.web_fetch] allowed_domains` > remote settings > built-in defaults
/// - `service_url`: TOML/env dev override > `{coding_base}/fetch` on
/// OAuth sessions (PRD F5) > None (local pipeline only)
pub(super) fn prepare_web_fetch_config(
&self,
) -> kigi_tools::implementations::grok_build::web_fetch::WebFetchConfig {
@@ -886,7 +898,7 @@ impl MvpAgent {
return WebFetchConfig::Disabled;
}
let context_window = Some(self.sampling_config.borrow().context_window);
let params = cfg
let mut params = cfg
.toolset
.web_fetch
.resolve_params(
@@ -898,6 +910,20 @@ impl MvpAgent {
tracing::info!("web_fetch disabled: allowed_domains is explicitly empty");
return WebFetchConfig::Disabled;
}
// PRD F5: the Kimi fetch service exists only on the OAuth channel.
// TOML/env may pin their own service_url (dev override); otherwise
// OAuth sessions get `{coding_base}/fetch` and API-key sessions
// stay local-only.
if params.service_url.is_none()
&& self
.current_or_buffered_auth()
.is_some_and(|a| a.is_session_auth())
{
params.service_url = Some(format!(
"{}/fetch",
cfg.endpoints.proxy_url().trim_end_matches('/')
));
}
WebFetchConfig::Enabled { params }
}
/// Construct from pre-built components. Use when the caller needs the
@@ -2231,7 +2257,7 @@ impl MvpAgent {
.find(|entry| entry.info.model == sampling_config.model)
.and_then(|entry| entry.info.max_retries);
let origin_client = self.origin_client_info_from_meta(init.meta.as_ref());
let web_search_sampling_config = self.prepare_web_search_sampling_config();
let web_search_config = self.prepare_web_search_config();
let image_gen_config = self.prepare_image_gen_config();
let video_gen_config = self.prepare_video_gen_config();
let app_builder_deployer_config = self.prepare_app_builder_deployer_config();
@@ -2454,7 +2480,7 @@ impl MvpAgent {
origin_client.as_ref().map(|o| o.product.clone()),
inference_idle_timeout_secs,
model_max_retries,
web_search_sampling_config,
web_search_config,
web_fetch_config,
image_gen_config,
video_gen_config,
@@ -427,7 +427,7 @@ impl MvpAgent {
terminal,
session_env,
memory_config: self.memory_config.clone(),
web_search_sampling_config: self.prepare_web_search_sampling_config(),
web_search_config: self.prepare_web_search_config(),
web_fetch_config: self.prepare_web_fetch_config(),
image_gen_config: self.prepare_image_gen_config(),
video_gen_config: self.prepare_video_gen_config(),
@@ -1067,7 +1067,7 @@ pub(crate) async fn handle_subagent_request(
None,
ctx.inference_idle_timeout_secs,
None,
ctx.web_search_sampling_config.clone(),
ctx.web_search_config.clone(),
ctx.web_fetch_config.clone(),
ctx.image_gen_config.clone(),
ctx.video_gen_config.clone(),
@@ -206,7 +206,7 @@ pub(crate) struct SubagentSpawnContext {
/// cross-session memory store.
pub memory_config: Option<crate::config::MemoryConfig>,
/// Resolved sampling config for web_search.
pub web_search_sampling_config: Option<kigi_sampler::SamplerConfig>,
pub web_search_config: kigi_tools::implementations::WebSearchConfig,
/// Resolved config for web fetch.
pub web_fetch_config: kigi_tools::implementations::grok_build::web_fetch::WebFetchConfig,
/// Image generation config (parent-inherited).
+1 -30
View File
@@ -531,10 +531,9 @@ impl ManagedMcpsConfig {
}
}
/// Auxiliary model overrides under `[models]`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct ModelOverrideConfig {
pub web_search: String,
/// `None` = current model.
pub session_summary: Option<String>,
/// Compiled default (`kigi_models::default_model()`) when unset locally, remotely, and via env.
@@ -544,16 +543,6 @@ pub struct ModelOverrideConfig {
#[serde(skip)]
pub prompt_suggestion: PromptSuggestModelPin,
}
impl Default for ModelOverrideConfig {
fn default() -> Self {
Self {
web_search: crate::models::default_web_search_model().to_owned(),
session_summary: None,
image_description: None,
prompt_suggestion: PromptSuggestModelPin::Unpinned,
}
}
}
/// Resolved model pin for the next-prompt suggestion call (tab-autocomplete
/// ghost text), `env > config.toml > remote` — see
/// [`ModelOverrideConfig::resolve`].
@@ -600,7 +589,6 @@ impl ModelOverrideConfig {
/// a model string (no CLI flag; the default and the catalog guard live at
/// the consumer, `handle_suggest_prompt`).
pub fn resolve(
cli_web_search_model: Option<&str>,
cli_session_summary_model: Option<&str>,
config: &toml::Value,
remote: Option<&crate::util::config::RemoteSettings>,
@@ -610,16 +598,12 @@ impl ModelOverrideConfig {
.and_then(|v| v.clone().try_into().ok())
.unwrap_or_default();
let mut result = Self {
web_search: parsed_models
.web_search
.unwrap_or_else(|| crate::models::default_web_search_model().to_owned()),
session_summary: non_empty_model_override(parsed_models.session_summary.as_deref()),
image_description: non_empty_model_override(parsed_models.image_description.as_deref()),
prompt_suggestion: non_empty_model_override(parsed_models.prompt_suggestion.as_deref())
.map(PromptSuggestModelPin::Pinned)
.unwrap_or_default(),
};
let has_local_ws = models_table.and_then(|m| m.get("web_search")).is_some();
let has_local_ss = models_table
.and_then(|m| m.get("session_summary"))
.is_some();
@@ -627,9 +611,6 @@ impl ModelOverrideConfig {
.and_then(|m| m.get("image_description"))
.is_some();
if let Some(remote) = remote {
if !has_local_ws && let Some(ref v) = remote.web_search_model {
result.web_search = v.clone();
}
if !has_local_ss {
result.session_summary =
non_empty_model_override(remote.session_summary_model.as_deref());
@@ -644,12 +625,6 @@ impl ModelOverrideConfig {
result.prompt_suggestion = PromptSuggestModelPin::Pinned(v);
}
}
if let Ok(v) = std::env::var("KIGI_WEB_SEARCH_MODEL") {
let v = v.trim();
if !v.is_empty() {
result.web_search = v.to_owned();
}
}
if let Ok(v) = std::env::var("KIGI_SESSION_SUMMARY_MODEL") {
result.session_summary = non_empty_model_override(Some(v.as_str()));
}
@@ -661,9 +636,6 @@ impl ModelOverrideConfig {
{
result.prompt_suggestion = PromptSuggestModelPin::Env(v);
}
if let Some(v) = cli_web_search_model {
result.web_search = v.to_owned();
}
if let Some(v) = cli_session_summary_model {
result.session_summary = non_empty_model_override(Some(v));
}
@@ -1085,7 +1057,6 @@ fn apply_requirements_inner(
};
}
enforce_str!("models", "default", config.models.default);
enforce_str!("models", "web_search", config.models.web_search);
enforce_str!("cli", "channel", config.cli.channel);
enforce_str!("cli", "minimum_version", config.cli.minimum_version);
if let Some(val) = req_str(req, "endpoints", "xai_api_base_url")
+28 -111
View File
@@ -1350,7 +1350,6 @@ fn managed_mcp_gateway_tools_enabled_with_managed_master() {
);
}
fn with_model_overrides_env_full<T>(
ws: Option<&str>,
ss: Option<&str>,
id: Option<&str>,
ps: Option<&str>,
@@ -1359,95 +1358,35 @@ fn with_model_overrides_env_full<T>(
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
with_env_var_opt(
"KIGI_WEB_SEARCH_MODEL",
ws,
"KIGI_SESSION_SUMMARY_MODEL",
ss,
|| with_env_var_opt(
"KIGI_SESSION_SUMMARY_MODEL",
ss,
|| with_env_var_opt(
"KIGI_IMAGE_DESCRIPTION_MODEL",
id,
|| with_env_var_opt("KIGI_PROMPT_SUGGESTIONS_MODEL", ps, f),
),
"KIGI_IMAGE_DESCRIPTION_MODEL",
id,
|| with_env_var_opt("KIGI_PROMPT_SUGGESTIONS_MODEL", ps, f),
),
)
}
fn with_model_overrides_env<T>(
ws: Option<&str>,
ss: Option<&str>,
id: Option<&str>,
f: impl FnOnce() -> T,
) -> T {
with_model_overrides_env_full(ws, ss, id, None, f)
}
#[test]
fn model_overrides_remote_settings_blocked_by_local_config() {
with_model_overrides_env(
None,
None,
None,
|| {
let config: toml::Value = toml::from_str(
r#"
[models]
web_search = "local-ws"
"#,
)
.unwrap();
let remote = crate::util::config::RemoteSettings {
web_search_model: Some("remote-ws".to_owned()),
session_summary_model: Some("remote-ss".to_owned()),
image_description_model: Some("remote-id".to_owned()),
..Default::default()
};
let cfg = ModelOverrideConfig::resolve(None, None, &config, Some(&remote));
assert_eq!(cfg.web_search, "local-ws");
assert_eq!(cfg.session_summary, Some("remote-ss".to_owned()));
assert_eq!(cfg.image_description, Some("remote-id".to_owned()));
},
);
}
#[test]
fn model_overrides_cli_overrides_everything() {
with_model_overrides_env(
Some("env-ws"),
Some("env-ss"),
None,
|| {
let config: toml::Value = toml::from_str(
r#"
[models]
web_search = "local-ws"
"#,
)
.unwrap();
let cfg = ModelOverrideConfig::resolve(
Some("cli-ws"),
Some("cli-ss"),
&config,
None,
);
assert_eq!(cfg.web_search, "cli-ws");
assert_eq!(cfg.session_summary, Some("cli-ss".to_owned()));
},
);
with_model_overrides_env_full(ss, id, None, f)
}
#[test]
fn model_overrides_remote_settings_applies_without_local_config() {
with_model_overrides_env(
None,
None,
None,
|| {
let empty = toml::Value::Table(toml::map::Map::new());
let remote = crate::util::config::RemoteSettings {
web_search_model: Some("remote-ws".to_owned()),
session_summary_model: Some("remote-ss".to_owned()),
image_description_model: Some("remote-id".to_owned()),
..Default::default()
};
let cfg = ModelOverrideConfig::resolve(None, None, &empty, Some(&remote));
assert_eq!(cfg.web_search, "remote-ws");
let cfg = ModelOverrideConfig::resolve(None, &empty, Some(&remote));
assert_eq!(cfg.session_summary, Some("remote-ss".to_owned()));
assert_eq!(cfg.image_description, Some("remote-id".to_owned()));
},
@@ -1456,7 +1395,6 @@ fn model_overrides_remote_settings_applies_without_local_config() {
#[test]
fn model_overrides_local_image_description_wins_over_remote() {
with_model_overrides_env(
None,
None,
None,
|| {
@@ -1471,7 +1409,7 @@ fn model_overrides_local_image_description_wins_over_remote() {
image_description_model: Some("remote-id".to_owned()),
..Default::default()
};
let cfg = ModelOverrideConfig::resolve(None, None, &config, Some(&remote));
let cfg = ModelOverrideConfig::resolve(None, &config, Some(&remote));
assert_eq!(cfg.image_description, Some("local-id".to_owned()));
},
);
@@ -1479,12 +1417,11 @@ fn model_overrides_local_image_description_wins_over_remote() {
#[test]
fn model_overrides_default_image_description_is_grok_build() {
with_model_overrides_env(
None,
None,
None,
|| {
let empty = toml::Value::Table(toml::map::Map::new());
let cfg = ModelOverrideConfig::resolve(None, None, &empty, None);
let cfg = ModelOverrideConfig::resolve(None, &empty, None);
assert_eq!(
cfg.image_description, Some(crate
::models::default_image_description_model().to_owned())
@@ -1495,12 +1432,11 @@ fn model_overrides_default_image_description_is_grok_build() {
#[test]
fn model_overrides_default_session_summary_is_grok_build() {
with_model_overrides_env(
None,
None,
None,
|| {
let empty = toml::Value::Table(toml::map::Map::new());
let cfg = ModelOverrideConfig::resolve(None, None, &empty, None);
let cfg = ModelOverrideConfig::resolve(None, &empty, None);
assert_eq!(
cfg.session_summary, Some(crate ::models::default_session_summary_model()
.to_owned())
@@ -1511,7 +1447,6 @@ fn model_overrides_default_session_summary_is_grok_build() {
#[test]
fn model_overrides_local_session_summary_wins_over_remote() {
with_model_overrides_env(
None,
None,
None,
|| {
@@ -1526,7 +1461,7 @@ fn model_overrides_local_session_summary_wins_over_remote() {
session_summary_model: Some("remote-ss".to_owned()),
..Default::default()
};
let cfg = ModelOverrideConfig::resolve(None, None, &config, Some(&remote));
let cfg = ModelOverrideConfig::resolve(None, &config, Some(&remote));
assert_eq!(cfg.session_summary, Some("local-ss".to_owned()));
},
);
@@ -1534,7 +1469,6 @@ fn model_overrides_local_session_summary_wins_over_remote() {
#[test]
fn model_overrides_env_session_summary_overrides_remote() {
with_model_overrides_env(
None,
Some("env-ss"),
None,
|| {
@@ -1543,7 +1477,7 @@ fn model_overrides_env_session_summary_overrides_remote() {
session_summary_model: Some("remote-ss".to_owned()),
..Default::default()
};
let cfg = ModelOverrideConfig::resolve(None, None, &empty, Some(&remote));
let cfg = ModelOverrideConfig::resolve(None, &empty, Some(&remote));
assert_eq!(cfg.session_summary, Some("env-ss".to_owned()));
},
);
@@ -1551,7 +1485,6 @@ fn model_overrides_env_session_summary_overrides_remote() {
#[test]
fn model_overrides_env_session_summary_overrides_local() {
with_model_overrides_env(
None,
Some("env-ss"),
None,
|| {
@@ -1562,7 +1495,7 @@ fn model_overrides_env_session_summary_overrides_local() {
"#,
)
.unwrap();
let cfg = ModelOverrideConfig::resolve(None, None, &config, None);
let cfg = ModelOverrideConfig::resolve(None, &config, None);
assert_eq!(cfg.session_summary, Some("env-ss".to_owned()));
},
);
@@ -1570,7 +1503,6 @@ fn model_overrides_env_session_summary_overrides_local() {
#[test]
fn model_overrides_empty_session_summary_toml_uses_default() {
with_model_overrides_env(
None,
None,
None,
|| {
@@ -1581,7 +1513,7 @@ fn model_overrides_empty_session_summary_toml_uses_default() {
"#,
)
.unwrap();
let cfg = ModelOverrideConfig::resolve(None, None, &config, None);
let cfg = ModelOverrideConfig::resolve(None, &config, None);
assert_eq!(
cfg.session_summary, Some(crate ::models::default_session_summary_model()
.to_owned())
@@ -1592,7 +1524,6 @@ fn model_overrides_empty_session_summary_toml_uses_default() {
#[test]
fn model_overrides_empty_session_summary_remote_uses_default() {
with_model_overrides_env(
None,
None,
None,
|| {
@@ -1601,7 +1532,7 @@ fn model_overrides_empty_session_summary_remote_uses_default() {
session_summary_model: Some(" ".to_owned()),
..Default::default()
};
let cfg = ModelOverrideConfig::resolve(None, None, &empty, Some(&remote));
let cfg = ModelOverrideConfig::resolve(None, &empty, Some(&remote));
assert_eq!(
cfg.session_summary, Some(crate ::models::default_session_summary_model()
.to_owned())
@@ -1612,7 +1543,6 @@ fn model_overrides_empty_session_summary_remote_uses_default() {
#[test]
fn model_overrides_cli_session_summary_overrides_everything() {
with_model_overrides_env(
None,
Some("env-ss"),
None,
|| {
@@ -1628,7 +1558,6 @@ fn model_overrides_cli_session_summary_overrides_everything() {
..Default::default()
};
let cfg = ModelOverrideConfig::resolve(
None,
Some("cli-ss"),
&config,
Some(&remote),
@@ -1640,12 +1569,11 @@ fn model_overrides_cli_session_summary_overrides_everything() {
#[test]
fn model_overrides_empty_cli_session_summary_uses_default() {
with_model_overrides_env(
None,
None,
None,
|| {
let empty = toml::Value::Table(toml::map::Map::new());
let cfg = ModelOverrideConfig::resolve(None, Some(""), &empty, None);
let cfg = ModelOverrideConfig::resolve(Some(""), &empty, None);
assert_eq!(
cfg.session_summary, Some(crate ::models::default_session_summary_model()
.to_owned())
@@ -1656,7 +1584,6 @@ fn model_overrides_empty_cli_session_summary_uses_default() {
#[test]
fn model_overrides_env_image_description_overrides_remote() {
with_model_overrides_env(
None,
None,
Some("env-id"),
|| {
@@ -1665,7 +1592,7 @@ fn model_overrides_env_image_description_overrides_remote() {
image_description_model: Some("remote-id".to_owned()),
..Default::default()
};
let cfg = ModelOverrideConfig::resolve(None, None, &empty, Some(&remote));
let cfg = ModelOverrideConfig::resolve(None, &empty, Some(&remote));
assert_eq!(cfg.image_description, Some("env-id".to_owned()));
},
);
@@ -1673,7 +1600,6 @@ fn model_overrides_env_image_description_overrides_remote() {
#[test]
fn model_overrides_env_image_description_overrides_local() {
with_model_overrides_env(
None,
None,
Some("env-id"),
|| {
@@ -1684,7 +1610,7 @@ fn model_overrides_env_image_description_overrides_local() {
"#,
)
.unwrap();
let cfg = ModelOverrideConfig::resolve(None, None, &config, None);
let cfg = ModelOverrideConfig::resolve(None, &config, None);
assert_eq!(cfg.image_description, Some("env-id".to_owned()));
},
);
@@ -1692,7 +1618,6 @@ fn model_overrides_env_image_description_overrides_local() {
#[test]
fn model_overrides_empty_image_description_toml_uses_default() {
with_model_overrides_env(
None,
None,
None,
|| {
@@ -1703,7 +1628,7 @@ fn model_overrides_empty_image_description_toml_uses_default() {
"#,
)
.unwrap();
let cfg = ModelOverrideConfig::resolve(None, None, &config, None);
let cfg = ModelOverrideConfig::resolve(None, &config, None);
assert_eq!(
cfg.image_description, Some(crate
::models::default_image_description_model().to_owned())
@@ -1714,7 +1639,6 @@ fn model_overrides_empty_image_description_toml_uses_default() {
#[test]
fn model_overrides_empty_image_description_remote_uses_default() {
with_model_overrides_env(
None,
None,
None,
|| {
@@ -1723,7 +1647,7 @@ fn model_overrides_empty_image_description_remote_uses_default() {
image_description_model: Some(" ".to_owned()),
..Default::default()
};
let cfg = ModelOverrideConfig::resolve(None, None, &empty, Some(&remote));
let cfg = ModelOverrideConfig::resolve(None, &empty, Some(&remote));
assert_eq!(
cfg.image_description, Some(crate
::models::default_image_description_model().to_owned())
@@ -1734,12 +1658,11 @@ fn model_overrides_empty_image_description_remote_uses_default() {
#[test]
fn model_overrides_prompt_suggestion_unpinned_by_default() {
with_model_overrides_env(
None,
None,
None,
|| {
let empty = toml::Value::Table(toml::map::Map::new());
let cfg = ModelOverrideConfig::resolve(None, None, &empty, None);
let cfg = ModelOverrideConfig::resolve(None, &empty, None);
assert_eq!(cfg.prompt_suggestion, PromptSuggestModelPin::Unpinned);
},
);
@@ -1747,7 +1670,6 @@ fn model_overrides_prompt_suggestion_unpinned_by_default() {
#[test]
fn model_overrides_prompt_suggestion_local_wins_over_remote() {
with_model_overrides_env(
None,
None,
None,
|| {
@@ -1762,7 +1684,7 @@ fn model_overrides_prompt_suggestion_local_wins_over_remote() {
prompt_suggestion_model: Some("remote-ps".to_owned()),
..Default::default()
};
let cfg = ModelOverrideConfig::resolve(None, None, &config, Some(&remote));
let cfg = ModelOverrideConfig::resolve(None, &config, Some(&remote));
assert_eq!(
cfg.prompt_suggestion, PromptSuggestModelPin::Pinned("local-ps"
.to_owned())
@@ -1773,7 +1695,6 @@ fn model_overrides_prompt_suggestion_local_wins_over_remote() {
#[test]
fn model_overrides_prompt_suggestion_remote_applies_without_local() {
with_model_overrides_env(
None,
None,
None,
|| {
@@ -1782,7 +1703,7 @@ fn model_overrides_prompt_suggestion_remote_applies_without_local() {
prompt_suggestion_model: Some("remote-ps".to_owned()),
..Default::default()
};
let cfg = ModelOverrideConfig::resolve(None, None, &empty, Some(&remote));
let cfg = ModelOverrideConfig::resolve(None, &empty, Some(&remote));
assert_eq!(
cfg.prompt_suggestion, PromptSuggestModelPin::Pinned("remote-ps"
.to_owned())
@@ -1793,7 +1714,6 @@ fn model_overrides_prompt_suggestion_remote_applies_without_local() {
#[test]
fn model_overrides_prompt_suggestion_env_wins_over_local_and_remote() {
with_model_overrides_env_full(
None,
None,
None,
Some("env-ps"),
@@ -1809,7 +1729,7 @@ fn model_overrides_prompt_suggestion_env_wins_over_local_and_remote() {
prompt_suggestion_model: Some("remote-ps".to_owned()),
..Default::default()
};
let cfg = ModelOverrideConfig::resolve(None, None, &config, Some(&remote));
let cfg = ModelOverrideConfig::resolve(None, &config, Some(&remote));
assert_eq!(
cfg.prompt_suggestion, PromptSuggestModelPin::Env("env-ps".to_owned())
);
@@ -1819,7 +1739,6 @@ fn model_overrides_prompt_suggestion_env_wins_over_local_and_remote() {
#[test]
fn model_overrides_prompt_suggestion_blank_values_are_unset() {
with_model_overrides_env_full(
None,
None,
None,
Some(" "),
@@ -1831,7 +1750,7 @@ fn model_overrides_prompt_suggestion_blank_values_are_unset() {
"#,
)
.unwrap();
let cfg = ModelOverrideConfig::resolve(None, None, &config, None);
let cfg = ModelOverrideConfig::resolve(None, &config, None);
assert_eq!(
cfg.prompt_suggestion, PromptSuggestModelPin::Pinned("local-ps"
.to_owned())
@@ -1839,7 +1758,6 @@ fn model_overrides_prompt_suggestion_blank_values_are_unset() {
},
);
with_model_overrides_env(
None,
None,
None,
|| {
@@ -1854,7 +1772,7 @@ fn model_overrides_prompt_suggestion_blank_values_are_unset() {
prompt_suggestion_model: Some(" ".to_owned()),
..Default::default()
};
let cfg = ModelOverrideConfig::resolve(None, None, &config, Some(&remote));
let cfg = ModelOverrideConfig::resolve(None, &config, Some(&remote));
assert_eq!(cfg.prompt_suggestion, PromptSuggestModelPin::Unpinned);
},
);
@@ -2668,13 +2586,13 @@ fn config_layers_system_managed_lowest_priority() {
#[test]
fn apply_requirements_value_overrides_user_settings() {
let raw_config: toml::Value = toml::from_str(
"[cli]\nauto_update = true\nchannel = \"beta\"\n\n[features]\nfeedback = true\nlsp_tools = true\nweb_fetch = true\nwrite_file = true\n\n[ui]\nyolo = true\n\n[models]\ndefault = \"user-model\"\nweb_search = \"user-ws-model\"\n\n[endpoints]\ncoding_api_base_url = \"https://user-proxy.example/v1\"\nxai_api_base_url = \"https://user-api.example/v1\"\nmodels_base_url = \"https://user-models.example/v1\"\nmodels_list_url = \"https://user-models.example/v1/models\"\n",
"[cli]\nauto_update = true\nchannel = \"beta\"\n\n[features]\nfeedback = true\nlsp_tools = true\nweb_fetch = true\nwrite_file = true\n\n[ui]\nyolo = true\n\n[models]\ndefault = \"user-model\"\n\n[endpoints]\ncoding_api_base_url = \"https://user-proxy.example/v1\"\nxai_api_base_url = \"https://user-api.example/v1\"\nmodels_base_url = \"https://user-models.example/v1\"\nmodels_list_url = \"https://user-models.example/v1/models\"\n",
)
.unwrap();
let mut cfg = crate::agent::config::Config::new_from_toml_cfg(&raw_config).unwrap();
cfg.default_yolo_mode = true;
let requirements: toml::Value = toml::from_str(
"[cli]\nauto_update = false\nchannel = \"stable\"\n\n[features]\nfeedback = false\nlsp_tools = false\nweb_fetch = false\nwrite_file = false\nremote_fetch = false\n\n[ui]\nyolo = false\n\n[models]\ndefault = \"managed-model\"\nweb_search = \"managed-ws-model\"\n\n[endpoints]\ncoding_api_base_url = \"https://managed-proxy.example/v1\"\nxai_api_base_url = \"https://managed-api.example/v1\"\nmodels_base_url = \"https://managed-models.example/v1\"\nmodels_list_url = \"https://managed-models.example/v1/models\"\ndeployment_key = \"enterprise-deploy-key-should-not-log\"\n",
"[cli]\nauto_update = false\nchannel = \"stable\"\n\n[features]\nfeedback = false\nlsp_tools = false\nweb_fetch = false\nwrite_file = false\nremote_fetch = false\n\n[ui]\nyolo = false\n\n[models]\ndefault = \"managed-model\"\n\n[endpoints]\ncoding_api_base_url = \"https://managed-proxy.example/v1\"\nxai_api_base_url = \"https://managed-api.example/v1\"\nmodels_base_url = \"https://managed-models.example/v1\"\nmodels_list_url = \"https://managed-models.example/v1/models\"\ndeployment_key = \"enterprise-deploy-key-should-not-log\"\n",
)
.unwrap();
let source = RequirementSource::Requirements {
@@ -2694,7 +2612,6 @@ fn apply_requirements_value_overrides_user_settings() {
assert!(! cfg.ui.yolo);
assert!(! cfg.default_yolo_mode);
assert_eq!(Some("managed-model"), cfg.models.default.as_deref());
assert_eq!(Some("managed-ws-model"), cfg.models.web_search.as_deref());
assert_eq!(Some("stable"), cfg.cli.channel.as_deref());
assert_eq!(
Some("https://managed-proxy.example/v1"), cfg.endpoints.coding_api_base_url
@@ -449,7 +449,6 @@ fn handle_reload_models(agent: &MvpAgent) -> ExtResult {
{
let agent_config = agent.cfg.borrow();
let overrides = crate::config::ModelOverrideConfig::resolve(
agent_config.web_search_model_override.as_deref(),
agent_config.session_summary_model_override.as_deref(),
&disk_config,
agent_config.remote_settings.as_ref(),
@@ -458,7 +457,6 @@ fn handle_reload_models(agent: &MvpAgent) -> ExtResult {
let mut agent_config = agent.cfg.borrow_mut();
agent_config.models = toml_config.models.clone();
agent_config.config_models = toml_config.config_models.clone();
agent_config.web_search_model = overrides.web_search;
agent_config.session_summary_model = overrides.session_summary;
agent_config.image_description_model = overrides.image_description;
agent_config.prompt_suggest_model_pin = overrides.prompt_suggestion;
@@ -148,7 +148,7 @@ pub(crate) async fn spawn_session_actor(
session_client_identifier: Option<String>,
inference_idle_timeout_secs: u64,
max_retries: Option<u32>,
web_search_sampling_config: Option<kigi_sampler::SamplerConfig>,
web_search_config: kigi_tools::implementations::WebSearchConfig,
web_fetch_config: kigi_tools::implementations::grok_build::web_fetch::WebFetchConfig,
image_gen_config: kigi_tools::implementations::grok_build::image_gen::ImageGenConfig,
video_gen_config: kigi_tools::implementations::grok_build::video_gen::VideoGenConfig,
@@ -341,22 +341,8 @@ pub(crate) async fn spawn_session_actor(
let primary_model_id = sampling_config.model.clone();
let web_search_config = if disable_web_search {
kigi_tools::implementations::WebSearchConfig::Disabled
} else if let Some(cfg) = web_search_sampling_config {
if let Some(api_key) = cfg.api_key {
kigi_tools::implementations::WebSearchConfig::Enabled {
api_key,
base_url: cfg.base_url,
model: cfg.model,
extra_headers: cfg.extra_headers,
alpha_test_key: credentials.alpha_test_key.clone(),
}
} else {
tracing::warn!("web_search disabled: resolved config has no API key");
kigi_tools::implementations::WebSearchConfig::Disabled
}
} else {
tracing::warn!("web_search disabled: configured model could not be resolved");
kigi_tools::implementations::WebSearchConfig::Disabled
web_search_config
};
let embed_base_url = sampling_config.base_url.clone();
let embed_api_key = sampling_config.api_key.clone();
@@ -1597,7 +1583,7 @@ pub(crate) async fn spawn_session_on_thread(
session_client_identifier: Option<String>,
inference_idle_timeout_secs: u64,
max_retries: Option<u32>,
web_search_sampling_config: Option<kigi_sampler::SamplerConfig>,
web_search_config: kigi_tools::implementations::WebSearchConfig,
web_fetch_config: kigi_tools::implementations::grok_build::web_fetch::WebFetchConfig,
image_gen_config: kigi_tools::implementations::grok_build::image_gen::ImageGenConfig,
video_gen_config: kigi_tools::implementations::grok_build::video_gen::VideoGenConfig,
@@ -1744,7 +1730,7 @@ pub(crate) async fn spawn_session_on_thread(
session_client_identifier,
inference_idle_timeout_secs,
max_retries,
web_search_sampling_config,
web_search_config,
web_fetch_config,
image_gen_config,
video_gen_config,
@@ -1,151 +1,11 @@
use axum::{Json, Router, extract::State, routing::post};
use kigi_tools::computer::local::{LocalFs, LocalTerminalBackend};
use kigi_tools::computer::types::{AsyncFileSystem, TerminalBackend};
use kigi_tools::notification::ToolNotificationHandle;
use kigi_tools::registry::types::{SessionContext, ToolConfig, ToolServerConfig};
use serde_json::{Value, json};
use serde_json::json;
#[tokio::test]
async fn web_search_uses_model_override_from_config_end_to_end() {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
async fn handle_request(
State(tx): State<tokio::sync::mpsc::UnboundedSender<Value>>,
Json(body): Json<Value>,
) -> Json<Value> {
let _ = tx.send(body);
Json(json!({
"id": "resp_test",
"object": "response",
"created_at": 1234567890,
"status": "completed",
"model": "enterprise-search",
"output": [{
"type": "message",
"id": "msg_1",
"status": "completed",
"role": "assistant",
"content": [{
"type": "output_text",
"text": "search result",
"annotations": []
}]
}]
}))
}
let app = Router::new()
.route("/responses", post(handle_request))
.with_state(tx);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let raw_config: toml::Value = toml::from_str(&format!(
r#"
[models]
web_search = "enterprise-search"
[model.enterprise-search]
model = "enterprise-search"
base_url = "http://{addr}"
api_key = "enterprise-key"
context_window = 256000
api_backend = "responses"
"#,
))
.unwrap();
let web_search_model =
crate::config::ModelOverrideConfig::resolve(None, None, &raw_config, None).web_search;
let agent_cfg = crate::agent::config::Config::new_from_toml_cfg(&raw_config).unwrap();
let models = crate::agent::config::resolve_model_list(&agent_cfg, None);
let entry = models.get(web_search_model.as_str()).unwrap();
let resolved = crate::agent::config::sampling_config_for_model(
entry,
crate::agent::config::resolve_credentials(entry, None),
None,
);
let web_search_sampling = crate::tools::config::web_search_sampling_config(resolved);
let builder = crate::tools::bridge::ToolBridge::get_builder();
let config = ToolServerConfig {
tools: vec![ToolConfig {
id: "GrokBuild:web_search".into(),
params: None,
name_override: None,
params_name_overrides: None,
description_override: None,
behavior_version: None,
kind: None,
}],
behavior_preset: None,
};
let fs: std::sync::Arc<dyn AsyncFileSystem> = std::sync::Arc::new(LocalFs);
let terminal: std::sync::Arc<dyn TerminalBackend> =
std::sync::Arc::new(LocalTerminalBackend::new());
let ctx = SessionContext {
backend: terminal,
fs,
cwd: std::env::temp_dir(),
session_folder: std::env::temp_dir().join("grok-web-search-e2e"),
session_env: std::sync::Arc::new(std::collections::HashMap::new()),
notification_handle: ToolNotificationHandle::noop(),
owner_session_id: None,
parent_scheduler_handle: None,
skills: vec![],
state_path: std::env::temp_dir().join("grok-web-search-e2e/state.json"),
memory_backend: None,
web_search_config: kigi_tools::implementations::web_search::WebSearchConfig::Enabled {
api_key: web_search_sampling.api_key.clone().unwrap(),
base_url: web_search_sampling.base_url.clone(),
model: web_search_sampling.model.clone(),
extra_headers: web_search_sampling.extra_headers.clone(),
// The optional extra access key is no longer carried on
// `SamplerConfig`. The shell-level value flows in via
// `Credentials` at session-spawn time; in this self-contained
// test fixture there's no extra access key in scope.
alpha_test_key: None,
},
web_fetch_config: Default::default(),
lsp: None,
image_gen_config: Default::default(),
video_gen_config: Default::default(),
app_builder_deployer_config: Default::default(),
api_key_provider: None,
auth_provider: None,
attribution_callback: None,
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
};
let bridge = crate::tools::bridge::ToolBridge::finalize_builder(builder, config, ctx)
.await
.expect("finalize_builder should succeed");
let result = bridge
.call(
"web_search",
json!({
"query": "test query",
"allowed_domains": ["example.com"]
}),
"web-search-e2e",
)
.await;
assert!(
result.is_ok(),
"web_search should succeed: {:?}",
result.err()
);
let request = rx.recv().await.expect("mock server should receive request");
assert_eq!(
request.get("model").and_then(|v| v.as_str()),
Some(web_search_model.as_str())
);
server.abort();
}
#[tokio::test]
async fn web_search_errors_when_configured_model_cannot_be_resolved() {
async fn web_search_errors_when_disabled() {
let builder = crate::tools::bridge::ToolBridge::get_builder();
let config = ToolServerConfig {
tools: vec![ToolConfig {
@@ -81,7 +81,7 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnCon
)),
session_env: Arc::new(HashMap::new()),
memory_config: None,
web_search_sampling_config: None,
web_search_config: Default::default(),
web_fetch_config: Default::default(),
image_gen_config: Default::default(),
video_gen_config: Default::default(),
+2 -84
View File
@@ -1,5 +1,3 @@
use crate::models;
use kigi_sampler::SamplerConfig;
use kigi_tools::implementations::grok_build;
use kigi_tools::registry::types::ToolConfig;
use serde::{Deserialize, Serialize};
@@ -153,15 +151,13 @@ impl WebFetchToolConfig {
/// Top-level toolset configuration for the shell layer.
///
/// This is the *shell-side* config that holds sampling-level settings
/// (e.g., web search API key from the sampling client). It is distinct
/// This is the *shell-side* config for per-tool settings. It is distinct
/// from `kigi_tools::registry::types::ToolsetConfig` which holds
/// tool-implementation-level config (bash limits, web search mode).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct ShellToolsetConfig {
pub bash: BashToolConfig,
pub web_search: SamplerConfig,
/// Web fetch tool parameters (`[toolset.web_fetch]`).
#[serde(default)]
pub web_fetch: WebFetchToolConfig,
@@ -176,85 +172,7 @@ pub struct ShellToolsetConfig {
pub hashline: HashlineSchemeConfig,
}
impl Default for ShellToolsetConfig {
fn default() -> Self {
Self::new(None, None)
}
}
/// Web-search-specific sampling overrides applied on top of a base `SamplerConfig`.
pub(crate) fn web_search_sampling_config(base: SamplerConfig) -> SamplerConfig {
let model = if base.model.is_empty() {
models::default_web_search_model().to_string()
} else {
base.model.clone()
};
SamplerConfig {
model,
max_completion_tokens: Some(8192),
temperature: Some(0.1),
top_p: Some(0.95),
force_http1: false,
max_retries: None,
..base
}
}
impl ShellToolsetConfig {
/// Optionally layers sampling credentials onto the web search config.
pub fn new(base: Option<Self>, sampling_config: Option<SamplerConfig>) -> Self {
let default_base = SamplerConfig {
api_key: None,
base_url: kigi_env::coding_api_base_url(),
model: String::new(),
max_completion_tokens: None,
temperature: None,
top_p: None,
api_backend: Default::default(),
auth_scheme: Default::default(),
extra_headers: indexmap::IndexMap::new(),
context_window: 256_000,
reasoning_effort: None,
force_http1: false,
max_retries: None,
stream_tool_calls: false,
idle_timeout_secs: None,
origin_client: None,
// Default base for the in-process web-search tool config.
// Real `SamplerConfig`s (e.g. from `sampling_config_for_model`)
// overwrite this entire struct via the `..base` pattern in
// `web_search_sampling_config`, so leaving the callback
// `None` here is fine -- it is only the placeholder for the
// "no base provided" path. The live attribution
// wiring lives at the production SamplerConfig sites in
// agent/config.rs and acp_session.rs.
attribution_callback: None,
bearer_resolver: None,
supports_backend_search: false,
compactions_remaining: None,
compaction_at_tokens: None,
doom_loop_recovery: None,
header_injector: None,
};
let mut toolset = base.unwrap_or_else(|| Self {
bash: BashToolConfig::default(),
web_search: web_search_sampling_config(default_base),
web_fetch: WebFetchToolConfig::default(),
ask_user_question: AskUserQuestionToolConfig::default(),
file_toolset: FileToolset::default(),
hashline: HashlineSchemeConfig::default(),
});
if let Some(sc) = sampling_config {
toolset.web_search = web_search_sampling_config(sc);
}
toolset
}
/// Returns true if web search is enabled based on config.
pub fn web_search_enabled(&self) -> bool {
self.web_search.api_key.is_some()
}
/// Resolve the effective file toolset. Local config takes precedence;
/// remote `/v1/settings` is used as fallback when local is the default.
pub fn resolve_file_toolset(
@@ -153,9 +153,9 @@ pub(crate) fn atomic_write_string(path: &std::path::Path, content: &str) -> std:
}
/// Merge `[toolset.ask_user_question]` into the root table. `[toolset]` is
/// deliberately NOT merged wholesale — it carries runtime-only structs
/// (`web_search` sampler etc.) whose serialized defaults must never land in
/// the user file — so only this settings-writable sub-table round-trips.
/// deliberately NOT merged wholesale — its other sub-tables (`bash` etc.)
/// carry serialized defaults that must never land in the user file — so only
/// this settings-writable sub-table round-trips.
fn merge_ask_user_question_section(
table: &mut TomlMap<String, TomlValue>,
ask: &crate::tools::config::AskUserQuestionToolConfig,
@@ -245,7 +245,7 @@ mod tests {
/// The `[toolset.ask_user_question]` settings write merges only that
/// sub-table: the toggled field lands, hand-written sibling keys survive,
/// and no other `[toolset]` defaults (bash/web_search) are splatted into
/// and no other `[toolset]` defaults (bash etc.) are splatted into
/// the user file. All-None leaves the file untouched.
#[test]
fn ask_user_question_merge_writes_subtable_without_splatting_toolset() {
@@ -830,7 +830,6 @@ auto_update = true
if let TomlValue::Table(t) = v {
assert_eq!(t.len(), 1);
assert!(t.contains_key("default"));
assert!(!t.contains_key("web_search"));
assert!(!t.contains_key("session_summary"));
assert!(!t.contains_key("image_description"));
assert!(!t.contains_key("hidden_models"));
@@ -935,7 +934,10 @@ auto_update = true
fn merge_section_models_only_updates_set_fields_preserves_others() {
let mut table = TomlMap::new();
let mut models = TomlMap::new();
models.insert("web_search".into(), TomlValue::String("old-search".into()));
models.insert(
"session_summary".into(),
TomlValue::String("old-title".into()),
);
models.insert("unmodeled_foo".into(), TomlValue::String("keep-me".into()));
table.insert("models".into(), TomlValue::Table(models));
let cfg = crate::agent::config::ModelsConfig {
@@ -946,19 +948,19 @@ auto_update = true
let m = table.get("models").unwrap().as_table().unwrap();
assert_eq!(m.get("default").and_then(|v| v.as_str()), Some("grok-new"));
assert_eq!(
m.get("web_search").and_then(|v| v.as_str()),
Some("old-search")
m.get("session_summary").and_then(|v| v.as_str()),
Some("old-title")
);
assert_eq!(
m.get("unmodeled_foo").and_then(|v| v.as_str()),
Some("keep-me")
);
assert!(!m.contains_key("session_summary"));
assert!(!m.contains_key("image_description"));
}
#[test]
fn persist_preferred_model_flow_roundtrips_via_load_and_new_from_toml_cfg() {
let original = "[models]\ndefault = \"grok-old\"\nweb_search = \"some-search\"\n";
let original = "[models]\ndefault = \"grok-old\"\n";
let root: TomlValue = toml::from_str(original).unwrap();
let mut cfg = load_config_from_toml(&root);
cfg.models.default = Some("grok-persisted".to_string());