From 74b210535ecac36f3ba51bbef4deb6eda149d6d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 17 Jul 2026 19:33:19 -0400 Subject: [PATCH] F5: web search/fetch on the Kimi services (kimi-cli parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/codegen/kigi-agent/src/builder.rs | 8 +- crates/codegen/kigi-config-types/src/lib.rs | 2 - crates/codegen/kigi-models/src/lib.rs | 8 - crates/codegen/kigi-shell/README.md | 18 - crates/codegen/kigi-shell/src/agent/config.rs | 158 +--- .../src/agent/mvp_agent/agent_ops.rs | 62 +- .../agent/mvp_agent/subagent_coordinator.rs | 2 +- .../src/agent/subagent/handle_request.rs | 2 +- .../kigi-shell/src/agent/subagent/mod.rs | 2 +- crates/codegen/kigi-shell/src/config/mod.rs | 31 +- crates/codegen/kigi-shell/src/config/tests.rs | 139 +--- .../src/extensions/session_admin.rs | 2 - .../src/session/acp_session_impl/spawn.rs | 22 +- .../acp_session_tests/web_search_e2e_tests.rs | 144 +--- .../src/test_support/lsp_runtime.rs | 2 +- crates/codegen/kigi-shell/src/tools/config.rs | 86 +- .../kigi-shell/src/util/config/persist.rs | 22 +- .../grok_build/web_fetch/client.rs | 196 ++++- .../grok_build/web_fetch/config.rs | 9 +- .../grok_build/web_fetch/error.rs | 3 + .../grok_build/web_fetch/mod.rs | 1 + .../grok_build/web_search/mod.rs | 49 +- .../src/implementations/web_search/client.rs | 747 ++++++------------ .../src/implementations/web_search/types.rs | 75 +- .../codegen/kigi-tools/src/registry/types.rs | 5 +- .../kigi-tools/src/types/api_key_provider.rs | 19 + .../codegen/kigi-tools/src/types/tool_io.rs | 3 +- .../docs/user-guide/05-configuration.md | 1 - .../docs/user-guide/11-custom-models.md | 28 - crates/codegen/kigi-tui/src/acp/mod.rs | 1 - .../src/app/dispatch/settings/setters.rs | 6 +- .../kigi-tui/src/views/settings_modal.rs | 5 +- .../kigi-workspace/src/permission/types.rs | 3 +- .../kigi-workspace/src/session/tool_config.rs | 7 +- 34 files changed, 659 insertions(+), 1209 deletions(-) diff --git a/crates/codegen/kigi-agent/src/builder.rs b/crates/codegen/kigi-agent/src/builder.rs index 97a201f..4e642a5 100644 --- a/crates/codegen/kigi-agent/src/builder.rs +++ b/crates/codegen/kigi-agent/src/builder.rs @@ -2169,11 +2169,9 @@ mod tests { ) .from_definition(definition) .with_web_search_config(WebSearchConfig::Enabled { + search_url: "https://api.kimi.example/coding/v1/search".into(), api_key: "test-key".into(), - base_url: "https://api.x.ai/v1".into(), - model: "test-web-search-model".into(), extra_headers: Default::default(), - alpha_test_key: None, }) .with_web_fetch_config(WebFetchConfig::Enabled { params: Default::default(), @@ -2295,11 +2293,9 @@ mod tests { use kigi_tools::notification::ToolNotificationHandle; let web_search_config = if web_search_enabled { WebSearchConfig::Enabled { + search_url: "https://api.kimi.example/coding/v1/search".into(), api_key: "test-key".into(), - base_url: "https://api.x.ai/v1".into(), - model: "test-web-search-model".into(), extra_headers: Default::default(), - alpha_test_key: None, } } else { WebSearchConfig::Disabled diff --git a/crates/codegen/kigi-config-types/src/lib.rs b/crates/codegen/kigi-config-types/src/lib.rs index c8d8f49..36eba7c 100644 --- a/crates/codegen/kigi-config-types/src/lib.rs +++ b/crates/codegen/kigi-config-types/src/lib.rs @@ -474,8 +474,6 @@ pub struct RemoteSettings { #[serde(default)] pub non_git_warning: Option, #[serde(default)] - pub web_search_model: Option, - #[serde(default)] pub session_summary_model: Option, #[serde(default)] pub image_description_model: Option, diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index 6290697..eeb7fde 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -328,8 +328,6 @@ pub const DEFAULT_MODELS_JSON: &str = include_str!("../default_models.json"); struct DefaultModels { default: String, /// Falls back to `default` if not specified in JSON. - web_search: Option, - /// Falls back to `default` if not specified in JSON. image_description: Option, /// Falls back to `default` if not specified in JSON. session_summary: Option, @@ -361,11 +359,6 @@ pub fn default_model() -> &'static str { &DEFAULTS.default } -/// Model for web search tool synthesis. Falls back to default model. -pub fn default_web_search_model() -> &'static str { - DEFAULTS.web_search.as_deref().unwrap_or(&DEFAULTS.default) -} - /// Model for image describe. Falls back to default model. pub fn default_image_description_model() -> &'static str { DEFAULTS @@ -613,7 +606,6 @@ mod tests { fn bundled_fallback_is_kimi_catalog() { assert_eq!(default_model(), "kimi-for-coding"); // Aux models fall back to the default (no dedicated entries). - assert_eq!(default_web_search_model(), "kimi-for-coding"); assert_eq!(default_image_description_model(), "kimi-for-coding"); assert_eq!(default_session_summary_model(), "kimi-for-coding"); // No grok remnants in the embedded fallback. diff --git a/crates/codegen/kigi-shell/README.md b/crates/codegen/kigi-shell/README.md index 4f97ba3..889f53c 100644 --- a/crates/codegen/kigi-shell/README.md +++ b/crates/codegen/kigi-shell/README.md @@ -1284,7 +1284,6 @@ auto_update = true # check for updates on launch [models] default = "grok-build" # model used for new sessions -web_search = "grok-4.20-multi-agent" # model used by the web_search tool [ui] max_thoughts_width = 120 # max column width for reasoning display @@ -1739,23 +1738,6 @@ api_key = "sk-custom" 2. Prefetched models from remote `/v1/models` 3. Hardcoded defaults — lowest priority -**Web search model:** Set `[models] web_search`, `KIGI_WEB_SEARCH_MODEL`, or `--web-search-model` to point the `web_search` tool at a different model. The target endpoint must support the Responses API and web search. - -> **Overriding with a custom model:** Setting `[models] web_search` alone is not -> enough if the model isn't already in the catalog (built-in defaults or -> `grok models` output). You also need a `[model.*]` entry so Grok knows -> how to reach it. Without both, web search is silently disabled. -> -> ```toml -> [models] -> web_search = "my-custom-model" # 1. tell web search which model to use -> -> [model.my-custom-model] # 2. tell Grok how to reach it -> model = "my-custom-model" -> api_backend = "responses" # required — web search uses the Responses API -> # base_url, api_key, env_key optional — defaults to cli-chat-proxy -> ``` - ### Examples **OpenAI-compatible endpoint:** diff --git a/crates/codegen/kigi-shell/src/agent/config.rs b/crates/codegen/kigi-shell/src/agent/config.rs index 47b05ad..3cfa1ca 100644 --- a/crates/codegen/kigi-shell/src/agent/config.rs +++ b/crates/codegen/kigi-shell/src/agent/config.rs @@ -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, - 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, #[serde(skip_serializing_if = "Option::is_none")] - pub web_search: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub session_summary: Option, /// 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, - /// CLI override for the web search model ID. - #[serde(skip)] - pub web_search_model_override: Option, /// CLI override for the session summary model ID. #[serde(skip)] pub session_summary_model_override: Option, @@ -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 { 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, - 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, - session_key: Option<&str>, - alpha_test_key: Option, - endpoints: &EndpointsConfig, -) -> Option { - 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, ) -> IndexMap { @@ -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() { diff --git a/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs b/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs index e02bd52..365474d 100644 --- a/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs +++ b/crates/codegen/kigi-shell/src/agent/mvp_agent/agent_ops.rs @@ -834,20 +834,29 @@ impl MvpAgent { tier_restricted: false, } } - pub(super) fn prepare_web_search_sampling_config(&self) -> Option { - 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, diff --git a/crates/codegen/kigi-shell/src/agent/mvp_agent/subagent_coordinator.rs b/crates/codegen/kigi-shell/src/agent/mvp_agent/subagent_coordinator.rs index a872b0f..a0a1aa7 100644 --- a/crates/codegen/kigi-shell/src/agent/mvp_agent/subagent_coordinator.rs +++ b/crates/codegen/kigi-shell/src/agent/mvp_agent/subagent_coordinator.rs @@ -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(), diff --git a/crates/codegen/kigi-shell/src/agent/subagent/handle_request.rs b/crates/codegen/kigi-shell/src/agent/subagent/handle_request.rs index f4dd857..ce46d37 100644 --- a/crates/codegen/kigi-shell/src/agent/subagent/handle_request.rs +++ b/crates/codegen/kigi-shell/src/agent/subagent/handle_request.rs @@ -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(), diff --git a/crates/codegen/kigi-shell/src/agent/subagent/mod.rs b/crates/codegen/kigi-shell/src/agent/subagent/mod.rs index 32d58cd..bcc39d2 100644 --- a/crates/codegen/kigi-shell/src/agent/subagent/mod.rs +++ b/crates/codegen/kigi-shell/src/agent/subagent/mod.rs @@ -206,7 +206,7 @@ pub(crate) struct SubagentSpawnContext { /// cross-session memory store. pub memory_config: Option, /// Resolved sampling config for web_search. - pub web_search_sampling_config: Option, + 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). diff --git a/crates/codegen/kigi-shell/src/config/mod.rs b/crates/codegen/kigi-shell/src/config/mod.rs index 5c262f6..38b0014 100644 --- a/crates/codegen/kigi-shell/src/config/mod.rs +++ b/crates/codegen/kigi-shell/src/config/mod.rs @@ -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, /// 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") diff --git a/crates/codegen/kigi-shell/src/config/tests.rs b/crates/codegen/kigi-shell/src/config/tests.rs index 77919e8..5ba499a 100644 --- a/crates/codegen/kigi-shell/src/config/tests.rs +++ b/crates/codegen/kigi-shell/src/config/tests.rs @@ -1350,7 +1350,6 @@ fn managed_mcp_gateway_tools_enabled_with_managed_master() { ); } fn with_model_overrides_env_full( - ws: Option<&str>, ss: Option<&str>, id: Option<&str>, ps: Option<&str>, @@ -1359,95 +1358,35 @@ fn with_model_overrides_env_full( 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( - 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 diff --git a/crates/codegen/kigi-shell/src/extensions/session_admin.rs b/crates/codegen/kigi-shell/src/extensions/session_admin.rs index 333748f..f09b8c3 100644 --- a/crates/codegen/kigi-shell/src/extensions/session_admin.rs +++ b/crates/codegen/kigi-shell/src/extensions/session_admin.rs @@ -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; diff --git a/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs b/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs index 28c5f45..67505e8 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_impl/spawn.rs @@ -148,7 +148,7 @@ pub(crate) async fn spawn_session_actor( session_client_identifier: Option, inference_idle_timeout_secs: u64, max_retries: Option, - web_search_sampling_config: Option, + 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, inference_idle_timeout_secs: u64, max_retries: Option, - web_search_sampling_config: Option, + 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, diff --git a/crates/codegen/kigi-shell/src/session/acp_session_tests/web_search_e2e_tests.rs b/crates/codegen/kigi-shell/src/session/acp_session_tests/web_search_e2e_tests.rs index 0dffe18..4fce030 100644 --- a/crates/codegen/kigi-shell/src/session/acp_session_tests/web_search_e2e_tests.rs +++ b/crates/codegen/kigi-shell/src/session/acp_session_tests/web_search_e2e_tests.rs @@ -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::(); - async fn handle_request( - State(tx): State>, - Json(body): Json, - ) -> Json { - 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 = std::sync::Arc::new(LocalFs); - let terminal: std::sync::Arc = - 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 { diff --git a/crates/codegen/kigi-shell/src/test_support/lsp_runtime.rs b/crates/codegen/kigi-shell/src/test_support/lsp_runtime.rs index 5595b4b..bfe24b0 100644 --- a/crates/codegen/kigi-shell/src/test_support/lsp_runtime.rs +++ b/crates/codegen/kigi-shell/src/test_support/lsp_runtime.rs @@ -81,7 +81,7 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap) -> 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(), diff --git a/crates/codegen/kigi-shell/src/tools/config.rs b/crates/codegen/kigi-shell/src/tools/config.rs index 55eb3db..bc49246 100644 --- a/crates/codegen/kigi-shell/src/tools/config.rs +++ b/crates/codegen/kigi-shell/src/tools/config.rs @@ -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, sampling_config: Option) -> 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( diff --git a/crates/codegen/kigi-shell/src/util/config/persist.rs b/crates/codegen/kigi-shell/src/util/config/persist.rs index 2d92305..95353e6 100644 --- a/crates/codegen/kigi-shell/src/util/config/persist.rs +++ b/crates/codegen/kigi-shell/src/util/config/persist.rs @@ -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, 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()); diff --git a/crates/codegen/kigi-tools/src/implementations/grok_build/web_fetch/client.rs b/crates/codegen/kigi-tools/src/implementations/grok_build/web_fetch/client.rs index 7031cd7..3e52480 100644 --- a/crates/codegen/kigi-tools/src/implementations/grok_build/web_fetch/client.rs +++ b/crates/codegen/kigi-tools/src/implementations/grok_build/web_fetch/client.rs @@ -30,6 +30,8 @@ pub struct WebFetchClient { image_writer: SessionFileWriter, video_writer: SessionFileWriter, overflow: OverflowHandler, + /// Live-token source for the Kimi fetch service (OAuth refresh). + api_key_provider: Option, } struct ProcessedText { @@ -42,7 +44,10 @@ struct ProcessedText { } impl WebFetchClient { - pub fn new(params: &WebFetchParams) -> Result { + pub fn new( + params: &WebFetchParams, + api_key_provider: Option, + ) -> Result { let converter = Arc::new( htmd::HtmlToMarkdown::builder() .skip_tags(vec![ @@ -64,9 +69,67 @@ impl WebFetchClient { image_writer: SessionFileWriter::new("images", "jpg"), video_writer: SessionFileWriter::new("videos", "mp4"), overflow: OverflowHandler::new(), + api_key_provider, }) } + /// Fetch through the Kimi fetch service (kimi-cli `_fetch_with_service`): + /// `POST {service_url}` with `{"url": ...}`, `Accept: text/markdown`, + /// OAuth bearer, and `X-Msh-Tool-Call-Id`. The 200 body IS the extracted + /// markdown; it still runs through the overflow budget so a huge page + /// cannot flood the context. + async fn fetch_via_service( + &self, + service_url: &str, + url_str: &str, + tool_call_id: &str, + session_folder: Option<&Path>, + tools: RecoveryTools<'_>, + ) -> Result { + let Some(bearer) = + crate::types::api_key_provider::resolve_bearer(self.api_key_provider.as_ref()).await + else { + return Err(WebFetchError::ServiceUnavailable( + "no live bearer token for the fetch service".to_string(), + )); + }; + let http = self.http.get_or_rebuild()?; + let response = http + .post(service_url) + .header("Authorization", format!("Bearer {bearer}")) + .header("Accept", "text/markdown") + .header("X-Msh-Tool-Call-Id", tool_call_id) + .json(&serde_json::json!({ "url": url_str })) + .send() + .await + .map_err(|e| WebFetchError::ServiceUnavailable(e.to_string()))?; + let status = response.status(); + if !status.is_success() { + return Err(WebFetchError::ServiceUnavailable(format!( + "fetch service returned {status}" + ))); + } + let body = response + .text() + .await + .map_err(|e| WebFetchError::ServiceUnavailable(e.to_string()))?; + let processed = self + .process_text_content(body.as_bytes(), "text/markdown", session_folder, tools) + .await; + Ok(WebFetchOutput::Content(WebFetchContent { + url: url_str.to_string(), + content: processed.content, + content_type: processed.content_type, + status_code: status.as_u16(), + bytes: processed.bytes, + source_artifact: processed + .artifact_path + .map(|path| WebFetchSourceArtifact { path }), + inline_fallback: processed.inline_fallback, + output_location: None, + })) + } + /// Fetch a URL and return its content as markdown. /// /// Handles: validation, HTTPS upgrade, SSRF check, HTTP fetch with @@ -76,6 +139,7 @@ impl WebFetchClient { pub async fn fetch( &self, raw_url: &str, + tool_call_id: &str, session_folder: Option<&Path>, read_tool_name: Option<&str>, execute_tool_name: Option<&str>, @@ -94,6 +158,33 @@ impl WebFetchClient { } } + // Kimi fetch service first (OAuth sessions); local pipeline is the + // fallback on any service failure (kimi-cli fetch.py `__call__`). + if let Some(service_url) = self.params.service_url.clone() { + match self + .fetch_via_service( + &service_url, + &url_str, + tool_call_id, + session_folder, + RecoveryTools { + read: read_tool_name, + execute: execute_tool_name, + }, + ) + .await + { + Ok(output) => { + let mut cache = self.cache.write(); + cache.insert_text(url_str, output.clone(), false); + return Ok(output); + } + Err(e) => { + tracing::warn!(error = %e, url = %url_str, "Kimi fetch service failed; falling back to local fetch"); + } + } + } + // SSRF check. ssrf::check_ssrf(&url).await?; @@ -817,6 +908,96 @@ fn strip_base64_data_uris(content: String) -> String { mod tests { use super::*; + /// Kimi fetch service happy path (kimi-cli `_fetch_with_service`): + /// the POST carries the OAuth bearer + call id + Accept: text/markdown, + /// and the 200 body IS the page markdown. + #[tokio::test] + async fn service_fetch_posts_kimi_contract_and_returns_markdown() { + use wiremock::matchers::{body_json, header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/fetch")) + .and(header("accept", "text/markdown")) + .and(header("authorization", "Bearer live-token")) + .and(header("x-msh-tool-call-id", "call-7")) + .and(body_json( + serde_json::json!({ "url": "https://docs.rs/serde" }), + )) + .respond_with(ResponseTemplate::new(200).set_body_string("# Serde\n\nExtracted.")) + .expect(1) + .mount(&server) + .await; + + let params = WebFetchParams { + service_url: Some(format!("{}/fetch", server.uri())), + ..WebFetchParams::default() + }; + let provider = crate::types::api_key_provider::test_support::fixed_provider("live-token"); + let client = WebFetchClient::new(¶ms, Some(provider)).unwrap(); + let output = client + .fetch_via_service( + ¶ms.service_url.clone().unwrap(), + "https://docs.rs/serde", + "call-7", + None, + RecoveryTools { + read: None, + execute: None, + }, + ) + .await + .unwrap(); + match output { + WebFetchOutput::Content(content) => { + assert_eq!(content.url, "https://docs.rs/serde"); + assert!(content.content.contains("# Serde")); + assert_eq!(content.status_code, 200); + } + other => panic!("expected Content, got {other:?}"), + } + } + + /// A failing service must yield an error the caller can fall back on — + /// never a fabricated success. + #[tokio::test] + async fn service_fetch_errors_on_non_200_and_missing_token() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/fetch")) + .respond_with(ResponseTemplate::new(503)) + .mount(&server) + .await; + let url = format!("{}/fetch", server.uri()); + let tools = || RecoveryTools { + read: None, + execute: None, + }; + + // 503 from the service → ServiceUnavailable. + let provider = crate::types::api_key_provider::test_support::fixed_provider("t"); + let params = WebFetchParams { + service_url: Some(url.clone()), + ..WebFetchParams::default() + }; + let client = WebFetchClient::new(¶ms, Some(provider)).unwrap(); + let err = client + .fetch_via_service(&url, "https://docs.rs/x", "c", None, tools()) + .await + .unwrap_err(); + assert!(matches!(err, WebFetchError::ServiceUnavailable(_)), "{err}"); + + // No bearer available → ServiceUnavailable without any HTTP call. + let client = WebFetchClient::new(¶ms, None).unwrap(); + let err = client + .fetch_via_service(&url, "https://docs.rs/x", "c", None, tools()) + .await + .unwrap_err(); + assert!(matches!(err, WebFetchError::ServiceUnavailable(_)), "{err}"); + } + fn test_converter() -> htmd::HtmlToMarkdown { htmd::HtmlToMarkdown::builder() .skip_tags(vec![ @@ -828,10 +1009,13 @@ mod tests { #[tokio::test] async fn oversized_html_persists_exact_pre_truncation_markdown() { let tmp = tempfile::tempdir().unwrap(); - let client = WebFetchClient::new(&WebFetchParams { - context_window_tokens: Some(100), - ..WebFetchParams::default() - }) + let client = WebFetchClient::new( + &WebFetchParams { + context_window_tokens: Some(100), + ..WebFetchParams::default() + }, + None, + ) .unwrap(); let tail = "TAIL-MUST-REMAIN-RECOVERABLE"; let html = format!( @@ -1291,7 +1475,7 @@ mod tests { ); // Client builds successfully with the proxy endpoint set. - let client = WebFetchClient::new(¶ms); + let client = WebFetchClient::new(¶ms, None); assert!(client.is_ok()); } diff --git a/crates/codegen/kigi-tools/src/implementations/grok_build/web_fetch/config.rs b/crates/codegen/kigi-tools/src/implementations/grok_build/web_fetch/config.rs index 15b58d1..c62d020 100644 --- a/crates/codegen/kigi-tools/src/implementations/grok_build/web_fetch/config.rs +++ b/crates/codegen/kigi-tools/src/implementations/grok_build/web_fetch/config.rs @@ -9,7 +9,8 @@ use crate::register_resource; // Safety-boundary constants. Not configurable. pub const MAX_URL_LENGTH: usize = 2_000; pub const MAX_REDIRECTS: usize = 10; -pub const USER_AGENT_STRING: &str = "Mozilla/5.0 (compatible; grok-agent/1.0; +https://x.ai)"; +pub const USER_AGENT_STRING: &str = + "Mozilla/5.0 (compatible; kigi-agent/1.0; +https://github.com/ZacharyZhang-NY/Kigi-CLI)"; /// Runtime-configurable parameters for the `web_fetch` tool. /// @@ -40,6 +41,12 @@ pub struct WebFetchParams { /// routed through this URL. #[serde(default)] pub proxy_endpoint: Option, + /// Kimi fetch service endpoint (`POST {coding_base}/fetch`, PRD F5). + /// Set by the shell for Kimi Code OAuth sessions; when present, the + /// tool tries the service first and falls back to the local pipeline + /// on any failure (kimi-cli `tools/web/fetch.py FetchURL.__call__`). + #[serde(default)] + pub service_url: Option, } register_resource!("grok_build", "WebFetch", WebFetchParams); diff --git a/crates/codegen/kigi-tools/src/implementations/grok_build/web_fetch/error.rs b/crates/codegen/kigi-tools/src/implementations/grok_build/web_fetch/error.rs index 3dc8a23..210eb7e 100644 --- a/crates/codegen/kigi-tools/src/implementations/grok_build/web_fetch/error.rs +++ b/crates/codegen/kigi-tools/src/implementations/grok_build/web_fetch/error.rs @@ -18,6 +18,9 @@ pub enum WebFetchError { #[error("invalid URL: {0}")] InvalidUrl(#[from] url::ParseError), + #[error("fetch service unavailable: {0}")] + ServiceUnavailable(String), + #[error("SSRF blocked: {host} resolves to private/internal IP {ip}{}", ssrf_recovery_hint(.host))] SsrfBlocked { host: String, ip: IpAddr }, diff --git a/crates/codegen/kigi-tools/src/implementations/grok_build/web_fetch/mod.rs b/crates/codegen/kigi-tools/src/implementations/grok_build/web_fetch/mod.rs index eea5da3..61a5227 100644 --- a/crates/codegen/kigi-tools/src/implementations/grok_build/web_fetch/mod.rs +++ b/crates/codegen/kigi-tools/src/implementations/grok_build/web_fetch/mod.rs @@ -184,6 +184,7 @@ impl kigi_tool_runtime::Tool for WebFetchTool { let output = client .fetch( &input.url, + ctx.call_id.as_str(), session_folder.as_deref(), read_tool_name.as_deref(), execute_tool_name.as_deref(), diff --git a/crates/codegen/kigi-tools/src/implementations/grok_build/web_search/mod.rs b/crates/codegen/kigi-tools/src/implementations/grok_build/web_search/mod.rs index 99163b8..d151d6f 100644 --- a/crates/codegen/kigi-tools/src/implementations/grok_build/web_search/mod.rs +++ b/crates/codegen/kigi-tools/src/implementations/grok_build/web_search/mod.rs @@ -1,8 +1,9 @@ //! `web_search` tool — new architecture (`Tool` trait). //! -//! Calls the Responses API with web search capability. Reads the -//! pre-constructed `WebSearchClient` from Resources (inserted by -//! `with_backend()` when the config is `Enabled`). +//! Calls the Kimi search service (PRD F5; kimi-cli `tools/web/search.py` +//! parity). Reads the pre-constructed `WebSearchClient` from Resources +//! (inserted by `with_backend()` when the config is `Enabled`, i.e. only +//! on Kimi Code OAuth sessions). use crate::implementations::web_search::client::WebSearchClient; use crate::types::output::WebSearchOutput; @@ -15,12 +16,27 @@ use crate::types::tool::{ToolKind, ToolNamespace}; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] pub struct WebSearchInput { - #[schemars(description = "The search query to perform.")] + #[schemars(description = "The query text to search for.")] pub query: String, - #[schemars(description = "Optional list of domains to restrict search to.")] - pub allowed_domains: Option>, + #[schemars( + description = "The number of results to return (1-20). Typically you do \ + not need to set this value. When the results do not contain \ + what you need, you probably want to give a more concrete \ + query." + )] + pub limit: Option, + #[schemars( + description = "Whether to include the content of the web pages in the \ + results. It can consume a large amount of tokens when set. \ + Avoid enabling this together with a large limit." + )] + pub include_content: Option, } +/// kimi-cli search.py `Params.limit` default / bounds (default=5, ge=1, le=20). +const DEFAULT_LIMIT: u8 = 5; +const MAX_LIMIT: u8 = 20; + // ─────────────────────────────────────────────────────────────────────────── // Tool implementation // ─────────────────────────────────────────────────────────────────────────── @@ -87,21 +103,21 @@ impl kigi_tool_runtime::Tool for WebSearchTool { client = res.require::()?.clone(); } + let limit = input.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT); let (content, citations) = client - .search(&input.query, input.allowed_domains.clone()) - .await - .map_err(|e| { - kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - e.to_string(), - ) - })?; + .search( + &input.query, + limit, + input.include_content.unwrap_or(false), + ctx.call_id.as_str(), + ) + .await?; Ok(WebSearchOutput { query: input.query.clone(), content, citations, - allowed_domains: input.allowed_domains.clone(), + allowed_domains: None, pre_formatted: None, }) } @@ -136,7 +152,8 @@ mod tests { test_ctx_with_call_id(resources.into_shared(), "test-call"), WebSearchInput { query: "test".into(), - allowed_domains: None, + limit: None, + include_content: None, }, ) .await; diff --git a/crates/codegen/kigi-tools/src/implementations/web_search/client.rs b/crates/codegen/kigi-tools/src/implementations/web_search/client.rs index 74700bd..b27b8d4 100644 --- a/crates/codegen/kigi-tools/src/implementations/web_search/client.rs +++ b/crates/codegen/kigi-tools/src/implementations/web_search/client.rs @@ -1,21 +1,70 @@ +//! HTTP client for the Kimi search service (PRD F5). +//! +//! Wire contract ported from kimi-cli `tools/web/search.py` (`SearchWeb`) +//! and verified against the live `api.kimi.com/coding/v1` service: +//! +//! - `POST {search_url}` with JSON `{"text_query", "limit", +//! "enable_page_crawling", "timeout_seconds": 30}` +//! - headers: `Authorization: Bearer ` and +//! `X-Msh-Tool-Call-Id: ` (search.py:82-88) +//! - 200 → `{"search_results": [{site_name, title, url, snippet, +//! content?, date?, icon?, mime?}]}` +//! +//! The server-side timeout is 30s but page crawling can run longer, so the +//! client allows a generous total timeout (search.py:74 uses 180s). + use super::types::WebSearchConfig; use crate::attribution::{SharedAttributionCallback, ToolConsumer}; use crate::types::SharedApiKeyProvider; -use async_openai::types::responses as rs; use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; -/// A minimal, purpose-built HTTP client for calling the Responses API -/// with web search capability. + +/// Total request timeout. Mirrors kimi-cli search.py:74 (`total=180`): +/// the service crawls pages when `include_content` is set. +const SEARCH_TIMEOUT_SECS: u64 = 180; +/// `timeout_seconds` request field — the server-side search budget +/// (search.py:93). +const SERVER_TIMEOUT_SECS: u64 = 30; + +fn tool_error(msg: impl Into) -> kigi_tool_runtime::ToolError { + kigi_tool_runtime::ToolError::execution( + kigi_tool_protocol::ToolId::new("web_search").expect("valid"), + msg.into(), + ) +} + +/// One search hit (kimi-cli search.py `SearchResult`). +#[derive(Debug, Clone, serde::Deserialize)] +pub struct SearchResult { + #[serde(default)] + pub site_name: String, + pub title: String, + pub url: String, + pub snippet: String, + #[serde(default)] + pub content: String, + #[serde(default)] + pub date: String, +} + +/// Response envelope (kimi-cli search.py `Response`). +#[derive(Debug, serde::Deserialize)] +struct SearchResponse { + search_results: Vec, +} + +/// A minimal, purpose-built HTTP client for the Kimi search service. #[derive(Clone)] pub struct WebSearchClient { http: reqwest::Client, - base_url: String, - model: String, + search_url: String, + api_key: String, api_key_provider: Option, - /// Optional 401-attribution hook. Callers can wire this so a 401 - /// from the Responses API emits an `auth_401_attribution` event - /// with `consumer == "WebSearch"`. + /// Optional 401-attribution hook. Callers can wire this so a 401 from + /// the search service emits an `auth_401_attribution` event with + /// `consumer == "WebSearch"`. attribution_callback: Option, } + impl WebSearchClient { /// Create a new web search client from `WebSearchConfig::Enabled`. /// @@ -25,62 +74,38 @@ impl WebSearchClient { api_key_provider: Option, ) -> Result { let WebSearchConfig::Enabled { + search_url, api_key, - base_url, - model, extra_headers, - alpha_test_key, } = config else { - return Err(kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - "Cannot create WebSearchClient from disabled config".to_string(), + return Err(tool_error( + "Cannot create WebSearchClient from disabled config", )); }; let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - headers.insert( - AUTHORIZATION, - HeaderValue::from_str(&format!("Bearer {api_key}")).map_err(|e| { - kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - format!("Invalid API key for header: {e}"), - ) - })?, - ); for (key, value) in extra_headers { - let header_name = HeaderName::from_bytes(key.as_bytes()).map_err(|e| { - kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - format!("Invalid header name '{key}': {e}"), - ) - })?; - let header_value = HeaderValue::from_str(value).map_err(|e| { - kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - format!("Invalid header value for '{key}': {e}"), - ) - })?; + let header_name = HeaderName::from_bytes(key.as_bytes()) + .map_err(|e| tool_error(format!("Invalid header name '{key}': {e}")))?; + let header_value = HeaderValue::from_str(value) + .map_err(|e| tool_error(format!("Invalid header value for '{key}': {e}")))?; headers.insert(header_name, header_value); } - let _ = alpha_test_key; let http = reqwest::Client::builder() .default_headers(headers) + .timeout(std::time::Duration::from_secs(SEARCH_TIMEOUT_SECS)) .build() - .map_err(|e| { - kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - format!("Failed to build HTTP client: {e}"), - ) - })?; + .map_err(|e| tool_error(format!("Failed to build HTTP client: {e}")))?; Ok(Self { http, - base_url: base_url.clone(), - model: model.clone(), + search_url: search_url.clone(), + api_key: api_key.clone(), api_key_provider, attribution_callback: None, }) } + /// Wire a 401-attribution callback into this client. Idempotent; /// safe to call before or after the first request. pub fn with_attribution_callback( @@ -90,501 +115,255 @@ impl WebSearchClient { self.attribution_callback = callback; self } - async fn current_bearer(&self) -> Option { - crate::types::api_key_provider::resolve_bearer(self.api_key_provider.as_ref()).await + + /// Live token from the provider (OAuth refresh) when available, else the + /// config-time key. + async fn current_bearer(&self) -> String { + crate::types::api_key_provider::resolve_bearer(self.api_key_provider.as_ref()) + .await + .unwrap_or_else(|| self.api_key.clone()) } - fn record_401_attribution(&self, sent_bearer: Option<&str>) { + + fn record_401_attribution(&self, sent_bearer: &str) { crate::attribution::emit_401( self.attribution_callback.as_ref(), ToolConsumer::WebSearch, - sent_bearer, + Some(sent_bearer), ); } - /// Perform a web search query using the Responses API. + + /// Search the Kimi service. Returns the rendered result text plus the + /// unique result URLs as citations. /// - /// Returns `(content, citations)` where content is the assistant's text - /// and citations are unique URLs found in the response annotations. + /// `tool_call_id` rides along as `X-Msh-Tool-Call-Id` (search.py:85) so + /// the service can correlate the request with the agent turn. pub async fn search( &self, query: &str, - allowed_domains: Option>, + limit: u8, + include_content: bool, + tool_call_id: &str, ) -> Result<(String, Vec), kigi_tool_runtime::ToolError> { - let web_search = rs::WebSearchToolArgs::default() - .filters(rs::WebSearchToolFilters { allowed_domains }) - .build() + let bearer = self.current_bearer().await; + let response = self + .http + .post(&self.search_url) + .header(AUTHORIZATION, format!("Bearer {bearer}")) + .header("X-Msh-Tool-Call-Id", tool_call_id) + .json(&serde_json::json!({ + "text_query": query, + "limit": limit, + "enable_page_crawling": include_content, + "timeout_seconds": SERVER_TIMEOUT_SECS, + })) + .send() + .await .map_err(|e| { - kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - format!("Failed to build web search tool: {e}"), - ) + tool_error(format!( + "Search request failed: {e}. The search service may be unavailable." + )) })?; - let request = rs::CreateResponseArgs::default() - .model(self.model.clone()) - .input(query.to_string()) - .tools(vec![rs::Tool::WebSearch(web_search)]) - .store(false) - .temperature(0.1_f32) - .top_p(0.95_f32) - .max_output_tokens(8192u32) - .build() - .map_err(|e| { - kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - format!("Failed to build request: {e}"), - ) - })?; - let url = format!("{}/responses", self.base_url.trim_end_matches('/')); - let sent_bearer = self.current_bearer().await; - let mut req = self.http.post(&url).json(&request); - if let Some(ref key) = sent_bearer { - req = req.header(AUTHORIZATION, format!("Bearer {key}")); - } - let response = req.send().await.map_err(|e| { - kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - format!("HTTP request failed: {e}"), - ) - })?; let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED { - self.record_401_attribution(sent_bearer.as_deref()); - let body = response - .text() - .await - .unwrap_or_else(|_| "Failed to read error body".to_string()); - return Err(kigi_tool_runtime::ToolError::unauthorized(format!( - "Responses API returned 401 Unauthorized: {body}" - )) - .with_details(serde_json::json!({ "tool_id" : "web_search", "status" : 401, }))); - } - if !status.is_success() { - let body = response - .text() - .await - .unwrap_or_else(|_| "Failed to read error body".to_string()); - return Err(kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - format!("Responses API returned {status}: {body}"), + self.record_401_attribution(&bearer); + return Err(kigi_tool_runtime::ToolError::unauthorized( + "Search service returned 401 Unauthorized".to_string(), )); } - let bytes = response.bytes().await.map_err(|e| { - kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - format!("Failed to read response body: {e}"), - ) - })?; - let response_obj: rs::Response = serde_json::from_slice(&bytes).map_err(|e| { - kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - format!("Failed to parse response: {e}"), - ) - })?; - let content = response_obj - .output_text() - .unwrap_or_else(|| "No search results found.".to_string()); - let citations = extract_citations(&response_obj); - Ok((content, citations)) - } - /// Same as [`Self::search`] but also extracts per-citation titles when - /// the Responses API surfaces them. Returns `(content, citations_with_titles)` - /// where each citation is `(title, url)`. Empty `title` strings indicate - /// the upstream didn't supply one for that URL. - /// - /// Used by the cursor-compat `WebSearch` adapter to render a - /// `Links:\n1. [title](url)` list instead of the LLM synthesis text. - pub async fn search_with_titles( - &self, - query: &str, - allowed_domains: Option>, - ) -> Result<(String, Vec<(String, String)>), kigi_tool_runtime::ToolError> { - let web_search = rs::WebSearchToolArgs::default() - .filters(rs::WebSearchToolFilters { allowed_domains }) - .build() - .map_err(|e| { - kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - format!("Failed to build web search tool: {e}"), - ) - })?; - let request = rs::CreateResponseArgs::default() - .model(self.model.clone()) - .input(query.to_string()) - .tools(vec![rs::Tool::WebSearch(web_search)]) - .store(false) - .temperature(0.1_f32) - .top_p(0.95_f32) - .max_output_tokens(8192u32) - .build() - .map_err(|e| { - kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - format!("Failed to build request: {e}"), - ) - })?; - let url = format!("{}/responses", self.base_url.trim_end_matches('/')); - let sent_bearer = self.current_bearer().await; - let mut req = self.http.post(&url).json(&request); - if let Some(ref key) = sent_bearer { - req = req.header(AUTHORIZATION, format!("Bearer {key}")); - } - let response = req.send().await.map_err(|e| { - kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - format!("HTTP request failed: {e}"), - ) - })?; - let status = response.status(); - if status == reqwest::StatusCode::UNAUTHORIZED { - self.record_401_attribution(sent_bearer.as_deref()); - let body = response - .text() - .await - .unwrap_or_else(|_| "Failed to read error body".to_string()); - return Err(kigi_tool_runtime::ToolError::unauthorized(format!( - "Responses API returned 401 Unauthorized: {body}" - )) - .with_details(serde_json::json!({ "tool_id" : "web_search", "status" : 401, }))); - } if !status.is_success() { - let body = response - .text() - .await - .unwrap_or_else(|_| "Failed to read error body".to_string()); - return Err(kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - format!("Responses API returned {status}: {body}"), - )); + return Err(tool_error(format!( + "Failed to search. Status: {status}. This may indicate that the \ + search service is currently unavailable." + ))); } - let bytes = response.bytes().await.map_err(|e| { - kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - format!("Failed to read response body: {e}"), - ) - })?; - let response_obj: rs::Response = serde_json::from_slice(&bytes).map_err(|e| { - kigi_tool_runtime::ToolError::execution( - kigi_tool_protocol::ToolId::new("web_search").expect("valid"), - format!("Failed to parse response: {e}"), - ) - })?; - let content = response_obj - .output_text() - .unwrap_or_else(|| "No search results found.".to_string()); - let pairs = extract_citation_pairs(&response_obj); - Ok((content, pairs)) + let results = response + .json::() + .await + .map_err(|e| tool_error(format!("Failed to parse search results: {e}")))? + .search_results; + Ok(render_results(&results)) } } -/// Extract citation URLs from the Response output items. -/// The async-openai crate doesn't provide a helper for this, and the `url` field -/// in `UrlCitationBody` is private, so we serialize to JSON to extract it. -fn extract_citations(response: &rs::Response) -> Vec { - let mut citations = Vec::new(); - for output_item in &response.output { - if let rs::OutputItem::Message(output_message) = output_item { - for message_content in &output_message.content { - if let rs::OutputMessageContent::OutputText(text_content) = message_content { - for annotation in &text_content.annotations { - if let rs::Annotation::UrlCitation(url_citation) = annotation - && let Ok(json) = serde_json::to_value(url_citation) - && let Some(url) = json.get("url").and_then(|v| v.as_str()) - { - citations.push(url.to_string()); - } - } - } - } + +/// Render hits in kimi-cli's result schema (search.py:141-149): +/// `Title/Date/URL/Summary` per hit, page content when crawled, hits +/// separated by `---`. Citations are the unique result URLs in order. +fn render_results(results: &[SearchResult]) -> (String, Vec) { + let mut content = String::new(); + let mut citations: Vec = Vec::new(); + for (i, result) in results.iter().enumerate() { + if i > 0 { + content.push_str("---\n\n"); + } + content.push_str(&format!( + "Title: {}\nDate: {}\nURL: {}\nSummary: {}\n\n", + result.title, result.date, result.url, result.snippet + )); + if !result.content.is_empty() { + content.push_str(&format!("{}\n\n", result.content)); + } + if !citations.contains(&result.url) { + citations.push(result.url.clone()); } } - let mut seen = std::collections::HashSet::new(); - citations.retain(|url| seen.insert(url.clone())); - citations -} -/// Extract `(title, url)` pairs from the Responses API annotations. -/// -/// `title` may be an empty string when upstream doesn't supply one. URLs -/// are deduplicated while preserving the first-seen order so the rendered -/// `Links:` list is stable and free of duplicates. -fn extract_citation_pairs(response: &rs::Response) -> Vec<(String, String)> { - let mut pairs: Vec<(String, String)> = Vec::new(); - for output_item in &response.output { - if let rs::OutputItem::Message(output_message) = output_item { - for message_content in &output_message.content { - if let rs::OutputMessageContent::OutputText(text_content) = message_content { - for annotation in &text_content.annotations { - if let rs::Annotation::UrlCitation(url_citation) = annotation - && let Ok(json) = serde_json::to_value(url_citation) - { - let url = json.get("url").and_then(|v| v.as_str()).unwrap_or(""); - if url.is_empty() { - continue; - } - let title = json - .get("title") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - pairs.push((title, url.to_string())); - } - } - } - } - } - } - let mut seen = std::collections::HashSet::new(); - pairs.retain(|(_t, url)| seen.insert(url.clone())); - pairs + (content, citations) } + #[cfg(test)] mod tests { use super::*; use indexmap::IndexMap; - /// Helper to create a Response from JSON for testing. - fn response_from_json(json: serde_json::Value) -> rs::Response { - serde_json::from_value(json).expect("Failed to parse test Response JSON") - } - #[test] - fn test_new_client_uses_configured_model() { - let config = WebSearchConfig::Enabled { + + fn enabled_config(url: &str) -> WebSearchConfig { + WebSearchConfig::Enabled { + search_url: url.to_string(), api_key: "test-key".to_string(), - base_url: "https://api.x.ai/v1".to_string(), - model: "custom-enterprise-model".to_string(), extra_headers: IndexMap::new(), - alpha_test_key: None, - }; - let client = WebSearchClient::new(&config, None).expect("client should build"); - assert_eq!(client.model, "custom-enterprise-model"); - } - /// Counts attribution callback invocations for the test below. - #[derive(Default, Debug)] - struct CountingCallback { - invocations: std::sync::Mutex)>>, - } - impl crate::attribution::Auth401AttributionCallback for CountingCallback { - fn record_401(&self, consumer: ToolConsumer, sent_bearer_prefix: Option<&str>) { - self.invocations - .lock() - .unwrap() - .push((consumer, sent_bearer_prefix.map(|s| s.to_string()))); } } - /// `record_401_attribution` invokes the wired callback with - /// `ToolConsumer::WebSearch` and the truncated bearer prefix. - /// The full bearer never crosses the trait boundary. + #[test] - fn record_401_attribution_passes_truncated_prefix_to_callback() { - let cb = std::sync::Arc::new(CountingCallback::default()); - let cb_dyn: crate::attribution::SharedAttributionCallback = cb.clone(); + fn new_rejects_disabled_config() { + assert!(WebSearchClient::new(&WebSearchConfig::Disabled, None).is_err()); + } + + #[test] + fn new_rejects_invalid_extra_header() { + let mut headers = IndexMap::new(); + headers.insert("bad header name".to_string(), "v".to_string()); let config = WebSearchConfig::Enabled { - api_key: "ignored".to_string(), - base_url: "https://api.x.ai/v1".to_string(), - model: "test-model".to_string(), - extra_headers: IndexMap::new(), - alpha_test_key: None, + search_url: "https://api.kimi.com/coding/v1/search".to_string(), + api_key: "k".to_string(), + extra_headers: headers, }; - let client = WebSearchClient::new(&config, None) - .expect("client should build") - .with_attribution_callback(Some(cb_dyn)); - client.record_401_attribution(Some("bearer-with-long-tail-aaaaaaaaaa")); - let calls = cb.invocations.lock().unwrap(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].0, ToolConsumer::WebSearch); - assert_eq!(calls[0].1.as_deref(), Some("bearer-with-")); + assert!(WebSearchClient::new(&config, None).is_err()); + } + + #[test] + fn render_results_follows_kimi_cli_schema() { + let results = vec![ + SearchResult { + site_name: "Rust Blog".into(), + title: "Announcing Rust".into(), + url: "https://blog.rust-lang.org/a".into(), + snippet: "The release".into(), + content: String::new(), + date: "2026-01-01".into(), + }, + SearchResult { + site_name: "Docs".into(), + title: "The Book".into(), + url: "https://doc.rust-lang.org/book".into(), + snippet: "Learn Rust".into(), + content: "Full crawled page text".into(), + date: String::new(), + }, + ]; + let (content, citations) = render_results(&results); assert_eq!( - calls[0].1.as_deref().map(str::len), - Some(crate::attribution::SENT_BEARER_PREFIX_LEN), + content, + "Title: Announcing Rust\nDate: 2026-01-01\nURL: https://blog.rust-lang.org/a\n\ + Summary: The release\n\n---\n\nTitle: The Book\nDate: \n\ + URL: https://doc.rust-lang.org/book\nSummary: Learn Rust\n\n\ + Full crawled page text\n\n" + ); + assert_eq!( + citations, + [ + "https://blog.rust-lang.org/a", + "https://doc.rust-lang.org/book" + ] ); } - /// `record_401_attribution` is a no-op when no callback is wired - /// -- the BYOK / standalone case must not panic or allocate. + #[test] - fn record_401_attribution_is_noop_without_callback() { - let config = WebSearchConfig::Enabled { - api_key: "test-key".to_string(), - base_url: "https://api.x.ai/v1".to_string(), - model: "test-model".to_string(), - extra_headers: IndexMap::new(), - alpha_test_key: None, + fn render_results_deduplicates_citations() { + let hit = SearchResult { + site_name: String::new(), + title: "T".into(), + url: "https://same.example".into(), + snippet: "S".into(), + content: String::new(), + date: String::new(), }; - let client = WebSearchClient::new(&config, None).expect("client should build"); - client.record_401_attribution(Some("any-bearer")); - client.record_401_attribution(None); + let (_, citations) = render_results(&[hit.clone(), hit]); + assert_eq!(citations, ["https://same.example"]); } - #[test] - fn test_extract_citations_empty_response() { - let response = response_from_json(serde_json::json!( - { "id" : "resp_test", "object" : "response", "created_at" : 1234567890, - "status" : "completed", "output" : [], "model" : "test-model" } - )); - let citations = extract_citations(&response); - assert!(citations.is_empty()); - } - #[test] - fn test_extract_citations_with_url_citations() { - let response = response_from_json(serde_json::json!( - { "id" : "resp_test", "object" : "response", "created_at" : 1234567890, - "status" : "completed", "model" : "test-model", "output" : [{ "type" : - "message", "id" : "msg_1", "status" : "completed", "role" : "assistant", - "content" : [{ "type" : "output_text", "text" : - "Here is some info about Rust.", "annotations" : [{ "type" : - "url_citation", "url" : "https://www.rust-lang.org/", "title" : - "Rust Programming Language", "start_index" : 0, "end_index" : 10 }, { - "type" : "url_citation", "url" : "https://docs.rs/", "title" : "Docs.rs", - "start_index" : 11, "end_index" : 20 }] }] }] } - )); - let citations = extract_citations(&response); - assert_eq!(citations.len(), 2); - assert_eq!(citations[0], "https://www.rust-lang.org/"); - assert_eq!(citations[1], "https://docs.rs/"); - } - #[test] - fn test_extract_citations_deduplicates() { - let response = response_from_json(serde_json::json!( - { "id" : "resp_test", "object" : "response", "created_at" : 1234567890, - "status" : "completed", "model" : "test-model", "output" : [{ "type" : - "message", "id" : "msg_1", "status" : "completed", "role" : "assistant", - "content" : [{ "type" : "output_text", "text" : - "Info with duplicate citations.", "annotations" : [{ "type" : - "url_citation", "url" : "https://example.com/page1", "title" : "Page 1", - "start_index" : 0, "end_index" : 5 }, { "type" : "url_citation", "url" : - "https://example.com/page2", "title" : "Page 2", "start_index" : 6, - "end_index" : 10 }, { "type" : "url_citation", "url" : - "https://example.com/page1", "title" : "Page 1 Again", "start_index" : - 11, "end_index" : 15 }] }] }] } - )); - let citations = extract_citations(&response); - assert_eq!(citations.len(), 2); - assert_eq!(citations[0], "https://example.com/page1"); - assert_eq!(citations[1], "https://example.com/page2"); - } - #[test] - fn test_extract_citations_multiple_messages() { - let response = response_from_json(serde_json::json!( - { "id" : "resp_test", "object" : "response", "created_at" : 1234567890, - "status" : "completed", "model" : "test-model", "output" : [{ "type" : - "message", "id" : "msg_1", "status" : "completed", "role" : "assistant", - "content" : [{ "type" : "output_text", "text" : "First message", - "annotations" : [{ "type" : "url_citation", "url" : "https://first.com/", - "title" : "First", "start_index" : 0, "end_index" : 5 }] }] }, { "type" : - "message", "id" : "msg_2", "status" : "completed", "role" : "assistant", - "content" : [{ "type" : "output_text", "text" : "Second message", - "annotations" : [{ "type" : "url_citation", "url" : - "https://second.com/", "title" : "Second", "start_index" : 0, "end_index" - : 6 }] }] }] } - )); - let citations = extract_citations(&response); - assert_eq!(citations.len(), 2); - assert_eq!(citations[0], "https://first.com/"); - assert_eq!(citations[1], "https://second.com/"); - } - #[test] - fn test_extract_citations_ignores_non_url_annotations() { - let response = response_from_json(serde_json::json!( - { "id" : "resp_test", "object" : "response", "created_at" : 1234567890, - "status" : "completed", "model" : "test-model", "output" : [{ "type" : - "message", "id" : "msg_1", "status" : "completed", "role" : "assistant", - "content" : [{ "type" : "output_text", "text" : "Some text", - "annotations" : [{ "type" : "url_citation", "url" : "https://valid.com/", - "title" : "Valid", "start_index" : 0, "end_index" : 4 }] }] }] } - )); - let citations = extract_citations(&response); - assert_eq!(citations.len(), 1); - assert_eq!(citations[0], "https://valid.com/"); - } - /// A provider that always returns `None`, simulating an API-key user - /// whose token has aged past the client-side TTL. - struct NoneProvider; - impl crate::types::ApiKeyProvider for NoneProvider { - fn current_api_key(&self) -> Option { - None - } - } - /// When the dynamic provider returns `None`, the static `api_key` - /// from config must still be sent as the Authorization header. - /// This is a regression scenario: API-key users - /// past the 30-day client TTL saw 401 because no auth was sent. + #[tokio::test] - async fn static_api_key_is_fallback_when_provider_returns_none() { - use wiremock::matchers::{header, method, path}; + async fn search_sends_kimi_wire_contract_and_parses_results() { + use wiremock::matchers::{body_json, header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; let server = MockServer::start().await; Mock::given(method("POST")) - .and(path("/responses")) - .and(header("Authorization", "Bearer static-key-from-config")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!( - { "id" : "resp_test", "object" : "response", "created_at" : - 1234567890, "status" : "completed", "model" : "test-model", - "output" : [{ "type" : "message", "id" : "msg_1", "status" : - "completed", "role" : "assistant", "content" : [{ "type" : - "output_text", "text" : "search result", "annotations" : [] - }] }] } - ))) + .and(path("/search")) + .and(header("authorization", "Bearer test-key")) + .and(header("x-msh-tool-call-id", "call-42")) + .and(body_json(serde_json::json!({ + "text_query": "rust ownership", + "limit": 5, + "enable_page_crawling": false, + "timeout_seconds": 30, + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "search_results": [{ + "site_name": "Docs", + "title": "Ownership", + "url": "https://doc.rust-lang.org/ownership", + "snippet": "What is ownership?", + "content": "", + "date": "2026-05-01", + "icon": "", + "mime": "" + }] + }))) + .expect(1) .mount(&server) .await; - let config = WebSearchConfig::Enabled { - api_key: "static-key-from-config".to_string(), - base_url: server.uri(), - model: "test-model".to_string(), - extra_headers: IndexMap::new(), - alpha_test_key: None, - }; - let provider: SharedApiKeyProvider = std::sync::Arc::new(NoneProvider); - let client = WebSearchClient::new(&config, Some(provider)).expect("client should build"); - let (content, _citations) = client - .search("test query", None) + + let client = + WebSearchClient::new(&enabled_config(&format!("{}/search", server.uri())), None) + .unwrap(); + let (content, citations) = client + .search("rust ownership", 5, false, "call-42") .await - .expect("search must succeed with static key fallback"); - assert_eq!(content, "search result"); + .unwrap(); + assert!(content.contains("Title: Ownership")); + assert!(content.contains("URL: https://doc.rust-lang.org/ownership")); + assert_eq!(citations, ["https://doc.rust-lang.org/ownership"]); } - /// When the provider returns a fresh key, it overrides the static one. + #[tokio::test] - async fn provider_key_overrides_static_key() { - use wiremock::matchers::{header, method, path}; + async fn search_maps_401_to_unauthorized() { + use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - struct FreshProvider; - impl crate::types::ApiKeyProvider for FreshProvider { - fn current_api_key(&self) -> Option { - Some("fresh-key-from-provider".to_string()) - } - } let server = MockServer::start().await; Mock::given(method("POST")) - .and(path("/responses")) - .and(header("Authorization", "Bearer fresh-key-from-provider")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!( - { "id" : "resp_test", "object" : "response", "created_at" : - 1234567890, "status" : "completed", "model" : "test-model", - "output" : [{ "type" : "message", "id" : "msg_1", "status" : - "completed", "role" : "assistant", "content" : [{ "type" : - "output_text", "text" : "fresh result", "annotations" : [] }] - }] } - ))) + .and(path("/search")) + .respond_with(ResponseTemplate::new(401)) .mount(&server) .await; - let config = WebSearchConfig::Enabled { - api_key: "stale-static-key".to_string(), - base_url: server.uri(), - model: "test-model".to_string(), - extra_headers: IndexMap::new(), - alpha_test_key: None, - }; - let provider: SharedApiKeyProvider = std::sync::Arc::new(FreshProvider); - let client = WebSearchClient::new(&config, Some(provider)).expect("client should build"); - let (content, _citations) = client - .search("test query", None) - .await - .expect("search must succeed with provider key"); - assert_eq!(content, "fresh result"); + let client = + WebSearchClient::new(&enabled_config(&format!("{}/search", server.uri())), None) + .unwrap(); + let err = client.search("q", 5, false, "c").await.unwrap_err(); + assert!(err.to_string().contains("401"), "{err}"); } - #[test] - fn test_extract_citations_no_annotations() { - let response = response_from_json(serde_json::json!( - { "id" : "resp_test", "object" : "response", "created_at" : 1234567890, - "status" : "completed", "model" : "test-model", "output" : [{ "type" : - "message", "id" : "msg_1", "status" : "completed", "role" : "assistant", - "content" : [{ "type" : "output_text", "text" : - "Plain text with no annotations", "annotations" : [] }] }] } - )); - let citations = extract_citations(&response); - assert!(citations.is_empty()); + + #[tokio::test] + async fn search_surfaces_server_errors() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/search")) + .respond_with(ResponseTemplate::new(503)) + .mount(&server) + .await; + let client = + WebSearchClient::new(&enabled_config(&format!("{}/search", server.uri())), None) + .unwrap(); + let err = client.search("q", 5, false, "c").await.unwrap_err(); + assert!(err.to_string().contains("503"), "{err}"); } } diff --git a/crates/codegen/kigi-tools/src/implementations/web_search/types.rs b/crates/codegen/kigi-tools/src/implementations/web_search/types.rs index 0081724..b63eb24 100644 --- a/crates/codegen/kigi-tools/src/implementations/web_search/types.rs +++ b/crates/codegen/kigi-tools/src/implementations/web_search/types.rs @@ -1,22 +1,25 @@ use indexmap::IndexMap; -/// Configuration for the web search tool. +/// Configuration for the `web_search` tool (PRD F5). /// -/// Use `Disabled` when no API key is available or web search should be turned off. -/// Use `Enabled { … }` to provide credentials and endpoint configuration. +/// The Kimi search service exists only on the Kimi Code subscription channel +/// (`POST {coding_base}/search`, kimi-cli `auth/platforms.py`: +/// `search_url=f"{_kimi_code_base_url()}/search"`), so the shell enables this +/// only for OAuth sessions — API-key-only sessions get `Disabled` and the +/// tool is absent. #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] #[serde(tag = "status", rename_all = "snake_case")] pub enum WebSearchConfig { #[default] Disabled, Enabled { + /// Full POST endpoint, e.g. `https://api.kimi.com/coding/v1/search`. + search_url: String, + /// Initial bearer token; a live token from the api-key provider + /// (OAuth refresh) takes precedence per request. api_key: String, - base_url: String, - model: String, #[serde(default, skip_serializing_if = "IndexMap::is_empty")] extra_headers: IndexMap, - #[serde(skip_serializing_if = "Option::is_none")] - alpha_test_key: Option, }, } @@ -26,24 +29,19 @@ impl WebSearchConfig { matches!(self, Self::Enabled { .. }) } - /// Return a copy safe for returning to clients. - /// - /// The `api_key` is replaced with `"***REDACTED***"` and the optional - /// extra access key field is stripped. + /// Return a copy safe for returning to clients: the `api_key` is + /// replaced with `"***REDACTED***"`. pub fn redacted(&self) -> Self { match self { Self::Disabled => Self::Disabled, Self::Enabled { - base_url, - model, + search_url, extra_headers, .. } => Self::Enabled { + search_url: search_url.clone(), api_key: "***REDACTED***".to_string(), - base_url: base_url.clone(), - model: model.clone(), extra_headers: extra_headers.clone(), - alpha_test_key: None, }, } } @@ -59,71 +57,38 @@ mod tests { assert!(!config.is_enabled()); } - #[test] - fn test_config_enabled() { - let config = WebSearchConfig::Enabled { - api_key: "test-key".to_string(), - base_url: "https://api.x.ai/v1".to_string(), - model: "test-web-search-model".to_string(), - extra_headers: IndexMap::new(), - alpha_test_key: None, - }; - assert!(config.is_enabled()); - } - #[test] fn test_config_redacted() { let mut headers = IndexMap::new(); headers.insert("X-Custom".to_string(), "value".to_string()); let config = WebSearchConfig::Enabled { + search_url: "https://api.kimi.com/coding/v1/search".to_string(), api_key: "secret-key-12345".to_string(), - base_url: "https://api.x.ai/v1".to_string(), - model: "test-web-search-model".to_string(), extra_headers: headers, - alpha_test_key: Some("alpha-secret".to_string()), }; - let redacted = config.redacted(); - match redacted { + match config.redacted() { WebSearchConfig::Enabled { + search_url, api_key, - base_url, - model, extra_headers, - alpha_test_key, } => { assert_eq!(api_key, "***REDACTED***"); - assert_eq!(base_url, "https://api.x.ai/v1"); - assert_eq!(model, "test-web-search-model"); + assert_eq!(search_url, "https://api.kimi.com/coding/v1/search"); assert_eq!(extra_headers.get("X-Custom").unwrap(), "value"); - assert!(alpha_test_key.is_none()); } - _ => panic!("Expected Enabled variant"), + WebSearchConfig::Disabled => panic!("expected Enabled variant"), } } #[test] fn test_config_serde_roundtrip() { let config = WebSearchConfig::Enabled { + search_url: "https://api.kimi.com/coding/v1/search".to_string(), api_key: "key".to_string(), - base_url: "https://api.x.ai/v1".to_string(), - model: "test-web-search-model".to_string(), extra_headers: IndexMap::new(), - alpha_test_key: None, }; let json = serde_json::to_string(&config).unwrap(); let parsed: WebSearchConfig = serde_json::from_str(&json).unwrap(); assert!(parsed.is_enabled()); } - - #[test] - fn test_config_deserialize_from_set_options_payload() { - let json = r#"{ - "status": "enabled", - "api_key": "xai-abc123", - "base_url": "https://api.x.ai/v1", - "model": "test-web-search-model" - }"#; - let config: WebSearchConfig = serde_json::from_str(json).unwrap(); - assert!(config.is_enabled()); - } } diff --git a/crates/codegen/kigi-tools/src/registry/types.rs b/crates/codegen/kigi-tools/src/registry/types.rs index f49e8a1..8029dc3 100644 --- a/crates/codegen/kigi-tools/src/registry/types.rs +++ b/crates/codegen/kigi-tools/src/registry/types.rs @@ -1034,7 +1034,10 @@ impl ToolRegistryBuilder { if let crate::implementations::grok_build::web_fetch::WebFetchConfig::Enabled { params } = &ctx.web_fetch_config { - match crate::implementations::grok_build::web_fetch::WebFetchClient::new(params) { + match crate::implementations::grok_build::web_fetch::WebFetchClient::new( + params, + ctx.api_key_provider.clone(), + ) { Ok(client) => { resources.insert(client); } diff --git a/crates/codegen/kigi-tools/src/types/api_key_provider.rs b/crates/codegen/kigi-tools/src/types/api_key_provider.rs index cbeaf8d..2c6acdf 100644 --- a/crates/codegen/kigi-tools/src/types/api_key_provider.rs +++ b/crates/codegen/kigi-tools/src/types/api_key_provider.rs @@ -24,3 +24,22 @@ pub(crate) async fn resolve_bearer(provider: Option<&SharedApiKeyProvider>) -> O None => None, } } + +/// Test fixtures shared by tool-client tests. +#[cfg(test)] +pub(crate) mod test_support { + use super::*; + + struct FixedProvider(String); + impl ApiKeyProvider for FixedProvider { + fn current_api_key(&self) -> Option { + Some(self.0.clone()) + } + } + + /// A provider that always yields `token` — stands in for the OAuth + /// refresh chain in client tests. + pub(crate) fn fixed_provider(token: &str) -> SharedApiKeyProvider { + Arc::new(FixedProvider(token.to_owned())) + } +} diff --git a/crates/codegen/kigi-tools/src/types/tool_io.rs b/crates/codegen/kigi-tools/src/types/tool_io.rs index ff82502..34105ff 100644 --- a/crates/codegen/kigi-tools/src/types/tool_io.rs +++ b/crates/codegen/kigi-tools/src/types/tool_io.rs @@ -187,7 +187,8 @@ mod tests { assert_eq!(kill.unwrap().task_id, "t1"); let ws: Result = ToolInput::WebSearch(WebSearchInput { query: "q".into(), - allowed_domains: None, + limit: None, + include_content: None, }) .try_into(); assert_eq!(ws.unwrap().query, "q"); diff --git a/crates/codegen/kigi-tui/docs/user-guide/05-configuration.md b/crates/codegen/kigi-tui/docs/user-guide/05-configuration.md index af7b166..b144ff2 100644 --- a/crates/codegen/kigi-tui/docs/user-guide/05-configuration.md +++ b/crates/codegen/kigi-tui/docs/user-guide/05-configuration.md @@ -32,7 +32,6 @@ auto_update = true # check for updates on launch [models] default = "grok-build" # model used for new sessions -web_search = "grok-4.20-multi-agent" # model used by the web_search tool # Defaults applied to every model; a per-model [model.] value always wins. # See "Custom Models" for the per-model overrides and full details. diff --git a/crates/codegen/kigi-tui/docs/user-guide/11-custom-models.md b/crates/codegen/kigi-tui/docs/user-guide/11-custom-models.md index a7fd116..a2ccb1b 100644 --- a/crates/codegen/kigi-tui/docs/user-guide/11-custom-models.md +++ b/crates/codegen/kigi-tui/docs/user-guide/11-custom-models.md @@ -277,34 +277,6 @@ When you set `models_base_url`, Grok uses API key auth (`Authorization: Bearer`) --- -## Web Search Model - -The `web_search` tool uses a separate model. Configure it with: - -```toml -[models] -web_search = "grok-4.20-multi-agent" -``` - -Or via environment variable: - -```bash -export KIGI_WEB_SEARCH_MODEL="grok-4.20-multi-agent" -``` - -If you point web search at a custom model, you also need a `[model.*]` entry so Grok can reach it. Server-side ("backend") web search runs only when the model sets `supports_backend_search = true` (and the build enables backend search); it does not depend on `api_backend`: - -```toml -[models] -web_search = "my-custom-model" - -[model.my-custom-model] -model = "my-custom-model" -supports_backend_search = true -``` - ---- - ## Using Custom Models ```bash diff --git a/crates/codegen/kigi-tui/src/acp/mod.rs b/crates/codegen/kigi-tui/src/acp/mod.rs index 3f19cee..7fe361c 100644 --- a/crates/codegen/kigi-tui/src/acp/mod.rs +++ b/crates/codegen/kigi-tui/src/acp/mod.rs @@ -159,7 +159,6 @@ pub async fn connect(cancel: &CancellationToken, flags: ConnectFlags) -> Result< cwd: None, is_headless: false, cli_subagents: Some(flags.subagents), - cli_web_search_model: None, cli_session_summary_model: None, cli_experimental_memory: flags.experimental_memory, cli_no_memory: flags.no_memory, diff --git a/crates/codegen/kigi-tui/src/app/dispatch/settings/setters.rs b/crates/codegen/kigi-tui/src/app/dispatch/settings/setters.rs index e9d0da3..c9214d4 100644 --- a/crates/codegen/kigi-tui/src/app/dispatch/settings/setters.rs +++ b/crates/codegen/kigi-tui/src/app/dispatch/settings/setters.rs @@ -1730,9 +1730,9 @@ pub(in crate::app::dispatch) fn clear_fork_secondary_model(app: &mut AppView) -> }] } -// `web_search_model`, `session_summary_model`, and -// `default_reasoning_effort` setters were removed alongside their -// registry entries. Mirror fields and TOML schema stay for compat. +// `session_summary_model` and `default_reasoning_effort` setters were +// removed alongside their registry entries. Mirror fields and TOML +// schema stay for compat. // --------------------------------------------------------------------------- // max_thoughts_width — Int-valued setting. Registry surface is `i64`; diff --git a/crates/codegen/kigi-tui/src/views/settings_modal.rs b/crates/codegen/kigi-tui/src/views/settings_modal.rs index ff52d65..35d37d3 100644 --- a/crates/codegen/kigi-tui/src/views/settings_modal.rs +++ b/crates/codegen/kigi-tui/src/views/settings_modal.rs @@ -5668,9 +5668,8 @@ mod tests { "plan_mode", // SHELL-owned default_model (Models category). "default_model", - // Models category. `default_reasoning_effort`, - // `web_search_model`, and `session_summary_model` are - // not exposed in the modal. + // Models category. `default_reasoning_effort` and + // `session_summary_model` are not exposed in the modal. "fork_secondary_model", // `auto_compact_threshold_percent` (Session category) is // not exposed in the modal. diff --git a/crates/codegen/kigi-workspace/src/permission/types.rs b/crates/codegen/kigi-workspace/src/permission/types.rs index a81a086..637e449 100644 --- a/crates/codegen/kigi-workspace/src/permission/types.rs +++ b/crates/codegen/kigi-workspace/src/permission/types.rs @@ -594,7 +594,8 @@ mod tests { use kigi_tools::types::ToolInput; let input = ToolInput::WebSearch(WebSearchInput { query: "rust lang".into(), - allowed_domains: None, + limit: None, + include_content: None, }); let access = AccessKind::from(&input); assert!( diff --git a/crates/codegen/kigi-workspace/src/session/tool_config.rs b/crates/codegen/kigi-workspace/src/session/tool_config.rs index 8ed6013..f645c04 100644 --- a/crates/codegen/kigi-workspace/src/session/tool_config.rs +++ b/crates/codegen/kigi-workspace/src/session/tool_config.rs @@ -428,11 +428,9 @@ impl SessionContextFactory for WorkspaceSessionContextFactory { tier_restricted: false, }, WebSearchConfig::Enabled { + search_url: format!("{}/search", url.trim_end_matches('/')), api_key: token, - base_url: url.clone(), - model: default_web_search_model(), extra_headers: headers, - alpha_test_key: None, }, AppBuilderDeployerConfig::default(), ) @@ -515,9 +513,6 @@ fn build_web_fetch_config() -> kigi_tools::implementations::grok_build::web_fetc } WebFetchConfig::Enabled { params } } -fn default_web_search_model() -> String { - std::env::var("KIGI_WEB_SEARCH_MODEL").unwrap_or_else(|_| "grok-4.20-multi-agent".to_string()) -} #[cfg(any(test, feature = "test-support"))] pub mod test_support { use crate::config::SessionContextFactory;