F5: web search/fetch on the Kimi services (kimi-cli parity)
web_search now speaks the Kimi search service (kimi-cli tools/web/search.py,
wire-verified against api.kimi.com):
- POST {coding_base}/search with {text_query, limit 1-20 (default 5),
enable_page_crawling, timeout_seconds: 30}, OAuth bearer +
X-Msh-Tool-Call-Id; results render in kimi-cli's Title/Date/URL/Summary
schema with result URLs as citations.
- The old implementation called the xAI Responses API with a search model;
that client is fully replaced and the entire model-based config surface
is excised root-and-branch: web_search_model config keys/env/CLI plumbing,
resolve_web_search_sampling_config, toolset web_search SamplerConfig,
RemoteSettings.web_search_model, default_web_search_model.
- Enablement is now purely structural: the service exists only on the Kimi
Code subscription channel, so OAuth sessions get Enabled and API-key-only
sessions get Disabled (tool absent) — per PRD F5.
web_fetch gains the Kimi fetch service as its primary path (kimi-cli
tools/web/fetch.py): POST {coding_base}/fetch with {url}, Accept:
text/markdown, OAuth bearer + X-Msh-Tool-Call-Id; the 200 body is the
extracted markdown (still overflow-budgeted). Any service failure falls
back to the existing local pipeline (SSRF guards, cache, extraction).
The tool gate defaults ON now (kimi-cli always offers FetchURL) and the
egress User-Agent no longer claims grok-agent/x.ai.
Verified end-to-end against the scripted mock service: a headless session
drove web_search (limit/crawling/call-id observed on the wire) then
web_fetch (Accept + call-id observed) to completion.
This commit is contained in:
@@ -2169,11 +2169,9 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.from_definition(definition)
|
.from_definition(definition)
|
||||||
.with_web_search_config(WebSearchConfig::Enabled {
|
.with_web_search_config(WebSearchConfig::Enabled {
|
||||||
|
search_url: "https://api.kimi.example/coding/v1/search".into(),
|
||||||
api_key: "test-key".into(),
|
api_key: "test-key".into(),
|
||||||
base_url: "https://api.x.ai/v1".into(),
|
|
||||||
model: "test-web-search-model".into(),
|
|
||||||
extra_headers: Default::default(),
|
extra_headers: Default::default(),
|
||||||
alpha_test_key: None,
|
|
||||||
})
|
})
|
||||||
.with_web_fetch_config(WebFetchConfig::Enabled {
|
.with_web_fetch_config(WebFetchConfig::Enabled {
|
||||||
params: Default::default(),
|
params: Default::default(),
|
||||||
@@ -2295,11 +2293,9 @@ mod tests {
|
|||||||
use kigi_tools::notification::ToolNotificationHandle;
|
use kigi_tools::notification::ToolNotificationHandle;
|
||||||
let web_search_config = if web_search_enabled {
|
let web_search_config = if web_search_enabled {
|
||||||
WebSearchConfig::Enabled {
|
WebSearchConfig::Enabled {
|
||||||
|
search_url: "https://api.kimi.example/coding/v1/search".into(),
|
||||||
api_key: "test-key".into(),
|
api_key: "test-key".into(),
|
||||||
base_url: "https://api.x.ai/v1".into(),
|
|
||||||
model: "test-web-search-model".into(),
|
|
||||||
extra_headers: Default::default(),
|
extra_headers: Default::default(),
|
||||||
alpha_test_key: None,
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
WebSearchConfig::Disabled
|
WebSearchConfig::Disabled
|
||||||
|
|||||||
@@ -474,8 +474,6 @@ pub struct RemoteSettings {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub non_git_warning: Option<bool>,
|
pub non_git_warning: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub web_search_model: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub session_summary_model: Option<String>,
|
pub session_summary_model: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub image_description_model: Option<String>,
|
pub image_description_model: Option<String>,
|
||||||
|
|||||||
@@ -328,8 +328,6 @@ pub const DEFAULT_MODELS_JSON: &str = include_str!("../default_models.json");
|
|||||||
struct DefaultModels {
|
struct DefaultModels {
|
||||||
default: String,
|
default: String,
|
||||||
/// Falls back to `default` if not specified in JSON.
|
/// Falls back to `default` if not specified in JSON.
|
||||||
web_search: Option<String>,
|
|
||||||
/// Falls back to `default` if not specified in JSON.
|
|
||||||
image_description: Option<String>,
|
image_description: Option<String>,
|
||||||
/// Falls back to `default` if not specified in JSON.
|
/// Falls back to `default` if not specified in JSON.
|
||||||
session_summary: Option<String>,
|
session_summary: Option<String>,
|
||||||
@@ -361,11 +359,6 @@ pub fn default_model() -> &'static str {
|
|||||||
&DEFAULTS.default
|
&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.
|
/// Model for image describe. Falls back to default model.
|
||||||
pub fn default_image_description_model() -> &'static str {
|
pub fn default_image_description_model() -> &'static str {
|
||||||
DEFAULTS
|
DEFAULTS
|
||||||
@@ -613,7 +606,6 @@ mod tests {
|
|||||||
fn bundled_fallback_is_kimi_catalog() {
|
fn bundled_fallback_is_kimi_catalog() {
|
||||||
assert_eq!(default_model(), "kimi-for-coding");
|
assert_eq!(default_model(), "kimi-for-coding");
|
||||||
// Aux models fall back to the default (no dedicated entries).
|
// 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_image_description_model(), "kimi-for-coding");
|
||||||
assert_eq!(default_session_summary_model(), "kimi-for-coding");
|
assert_eq!(default_session_summary_model(), "kimi-for-coding");
|
||||||
// No grok remnants in the embedded fallback.
|
// No grok remnants in the embedded fallback.
|
||||||
|
|||||||
@@ -1284,7 +1284,6 @@ auto_update = true # check for updates on launch
|
|||||||
|
|
||||||
[models]
|
[models]
|
||||||
default = "grok-build" # model used for new sessions
|
default = "grok-build" # model used for new sessions
|
||||||
web_search = "grok-4.20-multi-agent" # model used by the web_search tool
|
|
||||||
|
|
||||||
[ui]
|
[ui]
|
||||||
max_thoughts_width = 120 # max column width for reasoning display
|
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`
|
2. Prefetched models from remote `/v1/models`
|
||||||
3. Hardcoded defaults — lowest priority
|
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
|
### Examples
|
||||||
|
|
||||||
**OpenAI-compatible endpoint:**
|
**OpenAI-compatible endpoint:**
|
||||||
|
|||||||
@@ -490,7 +490,6 @@ pub struct RuntimeResolutionContext<'a> {
|
|||||||
pub is_headless: bool,
|
pub is_headless: bool,
|
||||||
/// `Some(true)` = CLI explicitly enabled, `None` = defer to config/env/remote.
|
/// `Some(true)` = CLI explicitly enabled, `None` = defer to config/env/remote.
|
||||||
pub cli_subagents: Option<bool>,
|
pub cli_subagents: Option<bool>,
|
||||||
pub cli_web_search_model: Option<&'a str>,
|
|
||||||
pub cli_session_summary_model: Option<&'a str>,
|
pub cli_session_summary_model: Option<&'a str>,
|
||||||
/// CLI `--experimental-memory` flag. Enables cross-session memory.
|
/// CLI `--experimental-memory` flag. Enables cross-session memory.
|
||||||
pub cli_experimental_memory: bool,
|
pub cli_experimental_memory: bool,
|
||||||
@@ -846,8 +845,6 @@ pub struct ModelsConfig {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub default_reasoning_effort: Option<ReasoningEffort>,
|
pub default_reasoning_effort: Option<ReasoningEffort>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub web_search: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
pub session_summary: Option<String>,
|
pub session_summary: Option<String>,
|
||||||
/// Vision model used to transcribe user-supplied
|
/// Vision model used to transcribe user-supplied
|
||||||
/// images via a separate endpoint.
|
/// images via a separate endpoint.
|
||||||
@@ -1310,9 +1307,6 @@ pub struct Config {
|
|||||||
/// CLI override for reasoning effort.
|
/// CLI override for reasoning effort.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub reasoning_effort_override: Option<ReasoningEffort>,
|
pub reasoning_effort_override: Option<ReasoningEffort>,
|
||||||
/// CLI override for the web search model ID.
|
|
||||||
#[serde(skip)]
|
|
||||||
pub web_search_model_override: Option<String>,
|
|
||||||
/// CLI override for the session summary model ID.
|
/// CLI override for the session summary model ID.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub session_summary_model_override: Option<String>,
|
pub session_summary_model_override: Option<String>,
|
||||||
@@ -1446,9 +1440,6 @@ pub struct Config {
|
|||||||
/// Enforced requirement pins from `requirements.toml`.
|
/// Enforced requirement pins from `requirements.toml`.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub requirements: Requirements,
|
pub requirements: Requirements,
|
||||||
/// Model ID for web_search.
|
|
||||||
#[serde(skip)]
|
|
||||||
pub web_search_model: String,
|
|
||||||
/// Session title model. Resolved to the compiled default
|
/// Session title model. Resolved to the compiled default
|
||||||
/// (`default_session_summary_model`) when unset; see `ModelOverrideConfig::resolve`.
|
/// (`default_session_summary_model`) when unset; see `ModelOverrideConfig::resolve`.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
@@ -1662,7 +1653,6 @@ impl Default for Config {
|
|||||||
storage_mode: StorageMode::resolve(None, None),
|
storage_mode: StorageMode::resolve(None, None),
|
||||||
default_model_override: None,
|
default_model_override: None,
|
||||||
reasoning_effort_override: None,
|
reasoning_effort_override: None,
|
||||||
web_search_model_override: None,
|
|
||||||
session_summary_model_override: None,
|
session_summary_model_override: None,
|
||||||
default_yolo_mode: false,
|
default_yolo_mode: false,
|
||||||
default_auto_mode: false,
|
default_auto_mode: false,
|
||||||
@@ -1693,7 +1683,6 @@ impl Default for Config {
|
|||||||
auto_wake_enabled: true,
|
auto_wake_enabled: true,
|
||||||
compat_resolved: CompatConfig::default(),
|
compat_resolved: CompatConfig::default(),
|
||||||
requirements: Requirements::default(),
|
requirements: Requirements::default(),
|
||||||
web_search_model: crate::models::default_web_search_model().to_owned(),
|
|
||||||
session_summary_model: None,
|
session_summary_model: None,
|
||||||
image_description_model: None,
|
image_description_model: None,
|
||||||
prompt_suggest_model_pin: crate::config::PromptSuggestModelPin::Unpinned,
|
prompt_suggest_model_pin: crate::config::PromptSuggestModelPin::Unpinned,
|
||||||
@@ -1780,9 +1769,7 @@ impl Config {
|
|||||||
if config.client_version.is_none() {
|
if config.client_version.is_none() {
|
||||||
config.client_version = Self::default().client_version;
|
config.client_version = Self::default().client_version;
|
||||||
}
|
}
|
||||||
let model_overrides =
|
let model_overrides = crate::config::ModelOverrideConfig::resolve(None, raw_config, None);
|
||||||
crate::config::ModelOverrideConfig::resolve(None, None, raw_config, None);
|
|
||||||
config.web_search_model = model_overrides.web_search;
|
|
||||||
config.session_summary_model = model_overrides.session_summary;
|
config.session_summary_model = model_overrides.session_summary;
|
||||||
config.image_description_model = model_overrides.image_description;
|
config.image_description_model = model_overrides.image_description;
|
||||||
config.prompt_suggest_model_pin = model_overrides.prompt_suggestion;
|
config.prompt_suggest_model_pin = model_overrides.prompt_suggestion;
|
||||||
@@ -1813,7 +1800,7 @@ impl Config {
|
|||||||
/// - respect_gitignore via `ToolsConfig::resolve`
|
/// - respect_gitignore via `ToolsConfig::resolve`
|
||||||
/// - disable_zdr_incompatible_tools via `ToolsConfig::resolve`
|
/// - disable_zdr_incompatible_tools via `ToolsConfig::resolve`
|
||||||
/// - managed_mcps_enabled via `ManagedMcpsConfig::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`
|
/// prompt_suggest_model_pin via `ModelOverrideConfig::resolve`
|
||||||
/// - memory_config via `MemoryConfig::resolve`
|
/// - memory_config via `MemoryConfig::resolve`
|
||||||
/// - disable_web_search (CLI flag ORed with config.toml)
|
/// - 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.
|
/// `resolve_worktree_type` since it's an agent-level field, not a Config field.
|
||||||
pub fn resolve_runtime_fields(&mut self, ctx: &RuntimeResolutionContext<'_>) {
|
pub fn resolve_runtime_fields(&mut self, ctx: &RuntimeResolutionContext<'_>) {
|
||||||
self.cli_subagents = ctx.cli_subagents;
|
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());
|
self.session_summary_model_override = ctx.cli_session_summary_model.map(|s| s.to_owned());
|
||||||
let cli_flag = ctx.cli_subagents.unwrap_or(false);
|
let cli_flag = ctx.cli_subagents.unwrap_or(false);
|
||||||
self.resolve_subagents(cli_flag, ctx.raw_config, ctx.cwd);
|
self.resolve_subagents(cli_flag, ctx.raw_config, ctx.cwd);
|
||||||
@@ -1843,12 +1829,10 @@ impl Config {
|
|||||||
self.managed_mcps_enabled = mcps.enabled;
|
self.managed_mcps_enabled = mcps.enabled;
|
||||||
self.managed_mcp_gateway_tools_enabled = mcps.gateway_tools_enabled;
|
self.managed_mcp_gateway_tools_enabled = mcps.gateway_tools_enabled;
|
||||||
let models = crate::config::ModelOverrideConfig::resolve(
|
let models = crate::config::ModelOverrideConfig::resolve(
|
||||||
ctx.cli_web_search_model,
|
|
||||||
ctx.cli_session_summary_model,
|
ctx.cli_session_summary_model,
|
||||||
ctx.raw_config,
|
ctx.raw_config,
|
||||||
ctx.remote_settings,
|
ctx.remote_settings,
|
||||||
);
|
);
|
||||||
self.web_search_model = models.web_search;
|
|
||||||
self.session_summary_model = models.session_summary;
|
self.session_summary_model = models.session_summary;
|
||||||
self.image_description_model = models.image_description;
|
self.image_description_model = models.image_description;
|
||||||
self.prompt_suggest_model_pin = models.prompt_suggestion;
|
self.prompt_suggest_model_pin = models.prompt_suggestion;
|
||||||
@@ -1889,7 +1873,6 @@ impl Config {
|
|||||||
cwd: Option<&std::path::Path>,
|
cwd: Option<&std::path::Path>,
|
||||||
) {
|
) {
|
||||||
let remote_settings = self.remote_settings.clone();
|
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 cli_session_summary_model = self.session_summary_model_override.clone();
|
||||||
let laziness_debug_log = self.laziness_debug_log.clone();
|
let laziness_debug_log = self.laziness_debug_log.clone();
|
||||||
let ctx = RuntimeResolutionContext {
|
let ctx = RuntimeResolutionContext {
|
||||||
@@ -1898,7 +1881,6 @@ impl Config {
|
|||||||
cwd,
|
cwd,
|
||||||
is_headless: self.mode == AgentMode::Headless,
|
is_headless: self.mode == AgentMode::Headless,
|
||||||
cli_subagents: self.cli_subagents,
|
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_session_summary_model: cli_session_summary_model.as_deref(),
|
||||||
cli_experimental_memory: self.cli_experimental_memory,
|
cli_experimental_memory: self.cli_experimental_memory,
|
||||||
cli_no_memory: self.cli_no_memory,
|
cli_no_memory: self.cli_no_memory,
|
||||||
@@ -2021,6 +2003,9 @@ impl Config {
|
|||||||
.feature_flag(ff)
|
.feature_flag(ff)
|
||||||
.resolve()
|
.resolve()
|
||||||
}
|
}
|
||||||
|
/// `web_fetch` tool gate; default ON (kimi-cli parity: `FetchURL` is
|
||||||
|
/// always offered). `KIGI_WEB_FETCH=0` / `[features] web_fetch = false`
|
||||||
|
/// are the kill switches.
|
||||||
pub(crate) fn resolve_web_fetch(&self) -> Resolved<bool> {
|
pub(crate) fn resolve_web_fetch(&self) -> Resolved<bool> {
|
||||||
let ff = self
|
let ff = self
|
||||||
.remote_settings
|
.remote_settings
|
||||||
@@ -2030,6 +2015,7 @@ impl Config {
|
|||||||
.requirement(self.requirements.web_fetch.pinned())
|
.requirement(self.requirements.web_fetch.pinned())
|
||||||
.config(self.features.web_fetch)
|
.config(self.features.web_fetch)
|
||||||
.feature_flag(ff)
|
.feature_flag(ff)
|
||||||
|
.default(true)
|
||||||
.resolve()
|
.resolve()
|
||||||
}
|
}
|
||||||
/// `ask_user_question` tool gate; default ON. remote settings
|
/// `ask_user_question` tool gate; default ON. remote settings
|
||||||
@@ -4387,85 +4373,6 @@ pub fn resolve_model_to_sampling_config(
|
|||||||
alpha_test_key,
|
alpha_test_key,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
fn resolve_hidden_default_web_search_sampling_config(
|
|
||||||
model_id: &str,
|
|
||||||
session_key: Option<&str>,
|
|
||||||
alpha_test_key: Option<String>,
|
|
||||||
endpoints: &EndpointsConfig,
|
|
||||||
) -> SamplerConfig {
|
|
||||||
let entry = ModelEntry {
|
|
||||||
info: ModelInfo {
|
|
||||||
id: None,
|
|
||||||
model: model_id.to_owned(),
|
|
||||||
base_url: endpoints.resolve_inference_base_url(),
|
|
||||||
name: None,
|
|
||||||
description: None,
|
|
||||||
max_completion_tokens: None,
|
|
||||||
temperature: None,
|
|
||||||
top_p: None,
|
|
||||||
api_backend: ApiBackend::Responses,
|
|
||||||
auth_scheme: Default::default(),
|
|
||||||
extra_headers: IndexMap::new(),
|
|
||||||
context_window: NonZeroU64::new(200_000).unwrap(),
|
|
||||||
auto_compact_threshold_percent: None,
|
|
||||||
system_prompt_label: None,
|
|
||||||
use_concise: false,
|
|
||||||
agent_type: default_agent_type(),
|
|
||||||
inference_idle_timeout_secs: None,
|
|
||||||
max_retries: None,
|
|
||||||
hidden: true,
|
|
||||||
user_selectable: true,
|
|
||||||
supported_in_api: true,
|
|
||||||
reasoning_effort: None,
|
|
||||||
supports_reasoning_effort: false,
|
|
||||||
reasoning_efforts: Vec::new(),
|
|
||||||
capabilities: Vec::new(),
|
|
||||||
supports_backend_search: false,
|
|
||||||
compactions_remaining: None,
|
|
||||||
compaction_at_tokens: None,
|
|
||||||
show_model_fingerprint: false,
|
|
||||||
stream_tool_calls: None,
|
|
||||||
laziness_detector: LazinessDetectorPerModelConfig::default(),
|
|
||||||
},
|
|
||||||
api_key: None,
|
|
||||||
env_key: None,
|
|
||||||
api_base_url: None,
|
|
||||||
};
|
|
||||||
let credentials = resolve_credentials(&entry, session_key);
|
|
||||||
sampling_config_for_model(&entry, credentials, alpha_test_key)
|
|
||||||
}
|
|
||||||
pub fn resolve_web_search_sampling_config(
|
|
||||||
model_id: &str,
|
|
||||||
models: &IndexMap<String, ModelEntry>,
|
|
||||||
session_key: Option<&str>,
|
|
||||||
alpha_test_key: Option<String>,
|
|
||||||
endpoints: &EndpointsConfig,
|
|
||||||
) -> Option<SamplerConfig> {
|
|
||||||
let resolved = if let Some(entry) = find_model_by_id(models, model_id).cloned() {
|
|
||||||
let credentials = resolve_credentials(&entry, session_key);
|
|
||||||
Some(sampling_config_for_model(
|
|
||||||
&entry,
|
|
||||||
credentials,
|
|
||||||
alpha_test_key,
|
|
||||||
))
|
|
||||||
} else if model_id == crate::models::default_web_search_model() {
|
|
||||||
Some(resolve_hidden_default_web_search_sampling_config(
|
|
||||||
model_id,
|
|
||||||
session_key,
|
|
||||||
alpha_test_key,
|
|
||||||
endpoints,
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
if resolved.is_none() {
|
|
||||||
tracing::warn!(
|
|
||||||
web_search_model = % model_id,
|
|
||||||
"configured web_search model not found; disabling web search"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
resolved.map(crate::tools::config::web_search_sampling_config)
|
|
||||||
}
|
|
||||||
pub fn to_acp_model_info(
|
pub fn to_acp_model_info(
|
||||||
models: &IndexMap<String, ModelEntry>,
|
models: &IndexMap<String, ModelEntry>,
|
||||||
) -> IndexMap<acp::ModelId, acp::ModelInfo> {
|
) -> IndexMap<acp::ModelId, acp::ModelInfo> {
|
||||||
@@ -4803,7 +4710,6 @@ reasoning_effort = "low"
|
|||||||
cwd: None,
|
cwd: None,
|
||||||
is_headless: false,
|
is_headless: false,
|
||||||
cli_subagents: None,
|
cli_subagents: None,
|
||||||
cli_web_search_model: None,
|
|
||||||
cli_session_summary_model: None,
|
cli_session_summary_model: None,
|
||||||
cli_experimental_memory: false,
|
cli_experimental_memory: false,
|
||||||
cli_no_memory: false,
|
cli_no_memory: false,
|
||||||
@@ -4832,7 +4738,6 @@ reasoning_effort = "low"
|
|||||||
cwd: None,
|
cwd: None,
|
||||||
is_headless: true,
|
is_headless: true,
|
||||||
cli_subagents: None,
|
cli_subagents: None,
|
||||||
cli_web_search_model: None,
|
|
||||||
cli_session_summary_model: None,
|
cli_session_summary_model: None,
|
||||||
cli_experimental_memory: false,
|
cli_experimental_memory: false,
|
||||||
cli_no_memory: false,
|
cli_no_memory: false,
|
||||||
@@ -4855,14 +4760,9 @@ reasoning_effort = "low"
|
|||||||
assert!(cfg.disable_web_search);
|
assert!(cfg.disable_web_search);
|
||||||
}
|
}
|
||||||
#[test]
|
#[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 empty: toml::Value = toml::Value::Table(toml::map::Map::new());
|
||||||
let cfg = Config::new_from_toml_cfg(&empty).expect("empty config should parse");
|
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!(
|
assert_eq!(
|
||||||
cfg.session_summary_model,
|
cfg.session_summary_model,
|
||||||
Some(crate::models::default_session_summary_model().to_owned()),
|
Some(crate::models::default_session_summary_model().to_owned()),
|
||||||
@@ -4876,14 +4776,12 @@ reasoning_effort = "low"
|
|||||||
let with_overrides: toml::Value = toml::from_str(
|
let with_overrides: toml::Value = toml::from_str(
|
||||||
r#"
|
r#"
|
||||||
[models]
|
[models]
|
||||||
web_search = "custom-ws-model"
|
|
||||||
session_summary = "custom-ss-model"
|
session_summary = "custom-ss-model"
|
||||||
image_description = "custom-id-model"
|
image_description = "custom-id-model"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg2 = Config::new_from_toml_cfg(&with_overrides).expect("config should parse");
|
let cfg2 = Config::new_from_toml_cfg(&with_overrides).expect("config should parse");
|
||||||
assert_eq!(cfg2.web_search_model, "custom-ws-model");
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg2.session_summary_model,
|
cfg2.session_summary_model,
|
||||||
Some("custom-ss-model".to_owned())
|
Some("custom-ss-model".to_owned())
|
||||||
@@ -4894,26 +4792,6 @@ reasoning_effort = "low"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
#[test]
|
#[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() {
|
fn finalize_image_describe_sampler_none_uses_active_session_model_not_forced_helper() {
|
||||||
let active = SamplerConfig {
|
let active = SamplerConfig {
|
||||||
model: "composer-session-model".into(),
|
model: "composer-session-model".into(),
|
||||||
@@ -6671,14 +6549,12 @@ reasoning_effort = "low"
|
|||||||
r#"
|
r#"
|
||||||
[models]
|
[models]
|
||||||
default = "my-enterprise-model"
|
default = "my-enterprise-model"
|
||||||
web_search = "enterprise-search"
|
|
||||||
session_summary = "title-model"
|
session_summary = "title-model"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
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.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"));
|
assert_eq!(cfg.models.session_summary.as_deref(), Some("title-model"));
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
@@ -8903,7 +8779,6 @@ agent_type = "cursor"
|
|||||||
unsafe {
|
unsafe {
|
||||||
std::env::remove_var("KIGI_SUBAGENTS");
|
std::env::remove_var("KIGI_SUBAGENTS");
|
||||||
std::env::remove_var("KIGI_RESPECT_GITIGNORE");
|
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_SESSION_SUMMARY_MODEL");
|
||||||
std::env::remove_var("KIGI_CURSOR_SKILLS_ENABLED");
|
std::env::remove_var("KIGI_CURSOR_SKILLS_ENABLED");
|
||||||
std::env::remove_var("KIGI_CURSOR_RULES_ENABLED");
|
std::env::remove_var("KIGI_CURSOR_RULES_ENABLED");
|
||||||
@@ -9189,7 +9064,6 @@ hooks = true
|
|||||||
cwd: None,
|
cwd: None,
|
||||||
is_headless: false,
|
is_headless: false,
|
||||||
cli_subagents: None,
|
cli_subagents: None,
|
||||||
cli_web_search_model: None,
|
|
||||||
cli_session_summary_model: None,
|
cli_session_summary_model: None,
|
||||||
cli_experimental_memory: false,
|
cli_experimental_memory: false,
|
||||||
cli_no_memory: false,
|
cli_no_memory: false,
|
||||||
@@ -9215,7 +9089,6 @@ hooks = true
|
|||||||
cwd: None,
|
cwd: None,
|
||||||
is_headless: false,
|
is_headless: false,
|
||||||
cli_subagents: None,
|
cli_subagents: None,
|
||||||
cli_web_search_model: None,
|
|
||||||
cli_session_summary_model: None,
|
cli_session_summary_model: None,
|
||||||
cli_experimental_memory: false,
|
cli_experimental_memory: false,
|
||||||
cli_no_memory: false,
|
cli_no_memory: false,
|
||||||
@@ -9228,10 +9101,6 @@ hooks = true
|
|||||||
assert!(!cfg.respect_gitignore);
|
assert!(!cfg.respect_gitignore);
|
||||||
assert!(cfg.managed_mcps_enabled);
|
assert!(cfg.managed_mcps_enabled);
|
||||||
assert!(!cfg.managed_mcp_gateway_tools_enabled);
|
assert!(!cfg.managed_mcp_gateway_tools_enabled);
|
||||||
assert_eq!(
|
|
||||||
cfg.web_search_model,
|
|
||||||
crate::models::default_web_search_model()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.session_summary_model,
|
cfg.session_summary_model,
|
||||||
Some(crate::models::default_session_summary_model().to_owned())
|
Some(crate::models::default_session_summary_model().to_owned())
|
||||||
@@ -9251,7 +9120,6 @@ hooks = true
|
|||||||
cwd: None,
|
cwd: None,
|
||||||
is_headless: true,
|
is_headless: true,
|
||||||
cli_subagents: None,
|
cli_subagents: None,
|
||||||
cli_web_search_model: None,
|
|
||||||
cli_session_summary_model: None,
|
cli_session_summary_model: None,
|
||||||
cli_experimental_memory: false,
|
cli_experimental_memory: false,
|
||||||
cli_no_memory: false,
|
cli_no_memory: false,
|
||||||
@@ -9283,7 +9151,6 @@ hooks = true
|
|||||||
cwd: None,
|
cwd: None,
|
||||||
is_headless: false,
|
is_headless: false,
|
||||||
cli_subagents: None,
|
cli_subagents: None,
|
||||||
cli_web_search_model: None,
|
|
||||||
cli_session_summary_model: None,
|
cli_session_summary_model: None,
|
||||||
cli_experimental_memory: false,
|
cli_experimental_memory: false,
|
||||||
cli_no_memory: false,
|
cli_no_memory: false,
|
||||||
@@ -9306,7 +9173,6 @@ hooks = true
|
|||||||
cwd: None,
|
cwd: None,
|
||||||
is_headless: false,
|
is_headless: false,
|
||||||
cli_subagents: None,
|
cli_subagents: None,
|
||||||
cli_web_search_model: None,
|
|
||||||
cli_session_summary_model: None,
|
cli_session_summary_model: None,
|
||||||
cli_experimental_memory: false,
|
cli_experimental_memory: false,
|
||||||
cli_no_memory: false,
|
cli_no_memory: false,
|
||||||
@@ -9329,7 +9195,6 @@ hooks = true
|
|||||||
cwd: None,
|
cwd: None,
|
||||||
is_headless: false,
|
is_headless: false,
|
||||||
cli_subagents: Some(true),
|
cli_subagents: Some(true),
|
||||||
cli_web_search_model: None,
|
|
||||||
cli_session_summary_model: None,
|
cli_session_summary_model: None,
|
||||||
cli_experimental_memory: false,
|
cli_experimental_memory: false,
|
||||||
cli_no_memory: false,
|
cli_no_memory: false,
|
||||||
@@ -9353,7 +9218,6 @@ hooks = true
|
|||||||
cwd: None,
|
cwd: None,
|
||||||
is_headless: false,
|
is_headless: false,
|
||||||
cli_subagents: None,
|
cli_subagents: None,
|
||||||
cli_web_search_model: None,
|
|
||||||
cli_session_summary_model: None,
|
cli_session_summary_model: None,
|
||||||
cli_experimental_memory: false,
|
cli_experimental_memory: false,
|
||||||
cli_no_memory: false,
|
cli_no_memory: false,
|
||||||
@@ -9377,7 +9241,6 @@ hooks = true
|
|||||||
cwd: None,
|
cwd: None,
|
||||||
is_headless: false,
|
is_headless: false,
|
||||||
cli_subagents: None,
|
cli_subagents: None,
|
||||||
cli_web_search_model: Some("custom-ws"),
|
|
||||||
cli_session_summary_model: Some("custom-ss"),
|
cli_session_summary_model: Some("custom-ss"),
|
||||||
cli_experimental_memory: false,
|
cli_experimental_memory: false,
|
||||||
cli_no_memory: false,
|
cli_no_memory: false,
|
||||||
@@ -9386,7 +9249,6 @@ hooks = true
|
|||||||
laziness_debug_log: None,
|
laziness_debug_log: None,
|
||||||
storage_mode: None,
|
storage_mode: None,
|
||||||
});
|
});
|
||||||
assert_eq!(cfg.web_search_model, "custom-ws");
|
|
||||||
assert_eq!(cfg.session_summary_model, Some("custom-ss".to_owned()));
|
assert_eq!(cfg.session_summary_model, Some("custom-ss".to_owned()));
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
@@ -9405,7 +9267,6 @@ hooks = true
|
|||||||
cwd: None,
|
cwd: None,
|
||||||
is_headless: false,
|
is_headless: false,
|
||||||
cli_subagents: None,
|
cli_subagents: None,
|
||||||
cli_web_search_model: None,
|
|
||||||
cli_session_summary_model: None,
|
cli_session_summary_model: None,
|
||||||
cli_experimental_memory: false,
|
cli_experimental_memory: false,
|
||||||
cli_no_memory: false,
|
cli_no_memory: false,
|
||||||
@@ -9428,7 +9289,6 @@ hooks = true
|
|||||||
cwd: None,
|
cwd: None,
|
||||||
is_headless: false,
|
is_headless: false,
|
||||||
cli_subagents: None,
|
cli_subagents: None,
|
||||||
cli_web_search_model: None,
|
|
||||||
cli_session_summary_model: None,
|
cli_session_summary_model: None,
|
||||||
cli_experimental_memory: false,
|
cli_experimental_memory: false,
|
||||||
cli_no_memory: false,
|
cli_no_memory: false,
|
||||||
@@ -9441,12 +9301,12 @@ hooks = true
|
|||||||
let first_subagents = cfg.subagents_enabled;
|
let first_subagents = cfg.subagents_enabled;
|
||||||
let first_gitignore = cfg.respect_gitignore;
|
let first_gitignore = cfg.respect_gitignore;
|
||||||
let first_mcps = cfg.managed_mcps_enabled;
|
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);
|
cfg.resolve_runtime_fields(&ctx);
|
||||||
assert_eq!(cfg.subagents_enabled, first_subagents);
|
assert_eq!(cfg.subagents_enabled, first_subagents);
|
||||||
assert_eq!(cfg.respect_gitignore, first_gitignore);
|
assert_eq!(cfg.respect_gitignore, first_gitignore);
|
||||||
assert_eq!(cfg.managed_mcps_enabled, first_mcps);
|
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]
|
#[test]
|
||||||
fn version_overrides_apply_into_typed_config() {
|
fn version_overrides_apply_into_typed_config() {
|
||||||
|
|||||||
@@ -834,20 +834,29 @@ impl MvpAgent {
|
|||||||
tier_restricted: false,
|
tier_restricted: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub(super) fn prepare_web_search_sampling_config(&self) -> Option<SamplingConfig> {
|
/// Web search config (PRD F5). The Kimi search service exists only on
|
||||||
let model_id = self.cfg.borrow().web_search_model.clone();
|
/// the Kimi Code subscription channel (`POST {coding_base}/search`,
|
||||||
let models = self.models_manager.models();
|
/// kimi-cli `auth/platforms.py`), so this is `Enabled` only for OAuth
|
||||||
let session = self.current_or_buffered_auth();
|
/// sessions — API-key-only sessions get `Disabled` and the tool is
|
||||||
let alpha_test_key = self.cfg.borrow().endpoints.alpha_test_key.clone();
|
/// absent. The live token is refreshed per request via the session
|
||||||
let client_version = self.cfg.borrow().client_version.clone();
|
/// api-key provider; the config key is just the bootstrap value.
|
||||||
let mut cfg = config::resolve_web_search_sampling_config(
|
pub(super) fn prepare_web_search_config(
|
||||||
&model_id,
|
&self,
|
||||||
&models,
|
) -> kigi_tools::implementations::WebSearchConfig {
|
||||||
session.as_ref().map(|a| a.key.as_str()),
|
use kigi_tools::implementations::WebSearchConfig;
|
||||||
alpha_test_key.clone(),
|
if self.cfg.borrow().disable_web_search {
|
||||||
&self.cfg.borrow().endpoints,
|
return WebSearchConfig::Disabled;
|
||||||
)?;
|
}
|
||||||
Some(cfg)
|
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
|
/// Returns `Err` with a user-facing message on invalid config; the caller at
|
||||||
/// the process boundary prints it and exits.
|
/// the process boundary prints it and exits.
|
||||||
@@ -867,11 +876,14 @@ impl MvpAgent {
|
|||||||
/// Prepare the web fetch configuration based on feature flags.
|
/// Prepare the web fetch configuration based on feature flags.
|
||||||
///
|
///
|
||||||
/// Enabled gate: `disable_web_search` kill-switch > `KIGI_WEB_FETCH` env >
|
/// 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):
|
/// Params resolution (TOML > env > remote settings > default):
|
||||||
/// - `proxy_endpoint`: `[toolset.web_fetch] proxy_endpoint` > `KIGI_WEB_FETCH_PROXY` > remote settings > None
|
/// - `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
|
/// - `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(
|
pub(super) fn prepare_web_fetch_config(
|
||||||
&self,
|
&self,
|
||||||
) -> kigi_tools::implementations::grok_build::web_fetch::WebFetchConfig {
|
) -> kigi_tools::implementations::grok_build::web_fetch::WebFetchConfig {
|
||||||
@@ -886,7 +898,7 @@ impl MvpAgent {
|
|||||||
return WebFetchConfig::Disabled;
|
return WebFetchConfig::Disabled;
|
||||||
}
|
}
|
||||||
let context_window = Some(self.sampling_config.borrow().context_window);
|
let context_window = Some(self.sampling_config.borrow().context_window);
|
||||||
let params = cfg
|
let mut params = cfg
|
||||||
.toolset
|
.toolset
|
||||||
.web_fetch
|
.web_fetch
|
||||||
.resolve_params(
|
.resolve_params(
|
||||||
@@ -898,6 +910,20 @@ impl MvpAgent {
|
|||||||
tracing::info!("web_fetch disabled: allowed_domains is explicitly empty");
|
tracing::info!("web_fetch disabled: allowed_domains is explicitly empty");
|
||||||
return WebFetchConfig::Disabled;
|
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 }
|
WebFetchConfig::Enabled { params }
|
||||||
}
|
}
|
||||||
/// Construct from pre-built components. Use when the caller needs the
|
/// 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)
|
.find(|entry| entry.info.model == sampling_config.model)
|
||||||
.and_then(|entry| entry.info.max_retries);
|
.and_then(|entry| entry.info.max_retries);
|
||||||
let origin_client = self.origin_client_info_from_meta(init.meta.as_ref());
|
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 image_gen_config = self.prepare_image_gen_config();
|
||||||
let video_gen_config = self.prepare_video_gen_config();
|
let video_gen_config = self.prepare_video_gen_config();
|
||||||
let app_builder_deployer_config = self.prepare_app_builder_deployer_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()),
|
origin_client.as_ref().map(|o| o.product.clone()),
|
||||||
inference_idle_timeout_secs,
|
inference_idle_timeout_secs,
|
||||||
model_max_retries,
|
model_max_retries,
|
||||||
web_search_sampling_config,
|
web_search_config,
|
||||||
web_fetch_config,
|
web_fetch_config,
|
||||||
image_gen_config,
|
image_gen_config,
|
||||||
video_gen_config,
|
video_gen_config,
|
||||||
|
|||||||
@@ -427,7 +427,7 @@ impl MvpAgent {
|
|||||||
terminal,
|
terminal,
|
||||||
session_env,
|
session_env,
|
||||||
memory_config: self.memory_config.clone(),
|
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(),
|
web_fetch_config: self.prepare_web_fetch_config(),
|
||||||
image_gen_config: self.prepare_image_gen_config(),
|
image_gen_config: self.prepare_image_gen_config(),
|
||||||
video_gen_config: self.prepare_video_gen_config(),
|
video_gen_config: self.prepare_video_gen_config(),
|
||||||
|
|||||||
@@ -1067,7 +1067,7 @@ pub(crate) async fn handle_subagent_request(
|
|||||||
None,
|
None,
|
||||||
ctx.inference_idle_timeout_secs,
|
ctx.inference_idle_timeout_secs,
|
||||||
None,
|
None,
|
||||||
ctx.web_search_sampling_config.clone(),
|
ctx.web_search_config.clone(),
|
||||||
ctx.web_fetch_config.clone(),
|
ctx.web_fetch_config.clone(),
|
||||||
ctx.image_gen_config.clone(),
|
ctx.image_gen_config.clone(),
|
||||||
ctx.video_gen_config.clone(),
|
ctx.video_gen_config.clone(),
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ pub(crate) struct SubagentSpawnContext {
|
|||||||
/// cross-session memory store.
|
/// cross-session memory store.
|
||||||
pub memory_config: Option<crate::config::MemoryConfig>,
|
pub memory_config: Option<crate::config::MemoryConfig>,
|
||||||
/// Resolved sampling config for web_search.
|
/// Resolved sampling config for web_search.
|
||||||
pub web_search_sampling_config: Option<kigi_sampler::SamplerConfig>,
|
pub web_search_config: kigi_tools::implementations::WebSearchConfig,
|
||||||
/// Resolved config for web fetch.
|
/// Resolved config for web fetch.
|
||||||
pub web_fetch_config: kigi_tools::implementations::grok_build::web_fetch::WebFetchConfig,
|
pub web_fetch_config: kigi_tools::implementations::grok_build::web_fetch::WebFetchConfig,
|
||||||
/// Image generation config (parent-inherited).
|
/// Image generation config (parent-inherited).
|
||||||
|
|||||||
@@ -531,10 +531,9 @@ impl ManagedMcpsConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Auxiliary model overrides under `[models]`.
|
/// Auxiliary model overrides under `[models]`.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct ModelOverrideConfig {
|
pub struct ModelOverrideConfig {
|
||||||
pub web_search: String,
|
|
||||||
/// `None` = current model.
|
/// `None` = current model.
|
||||||
pub session_summary: Option<String>,
|
pub session_summary: Option<String>,
|
||||||
/// Compiled default (`kigi_models::default_model()`) when unset locally, remotely, and via env.
|
/// Compiled default (`kigi_models::default_model()`) when unset locally, remotely, and via env.
|
||||||
@@ -544,16 +543,6 @@ pub struct ModelOverrideConfig {
|
|||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub prompt_suggestion: PromptSuggestModelPin,
|
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
|
/// Resolved model pin for the next-prompt suggestion call (tab-autocomplete
|
||||||
/// ghost text), `env > config.toml > remote` — see
|
/// ghost text), `env > config.toml > remote` — see
|
||||||
/// [`ModelOverrideConfig::resolve`].
|
/// [`ModelOverrideConfig::resolve`].
|
||||||
@@ -600,7 +589,6 @@ impl ModelOverrideConfig {
|
|||||||
/// a model string (no CLI flag; the default and the catalog guard live at
|
/// a model string (no CLI flag; the default and the catalog guard live at
|
||||||
/// the consumer, `handle_suggest_prompt`).
|
/// the consumer, `handle_suggest_prompt`).
|
||||||
pub fn resolve(
|
pub fn resolve(
|
||||||
cli_web_search_model: Option<&str>,
|
|
||||||
cli_session_summary_model: Option<&str>,
|
cli_session_summary_model: Option<&str>,
|
||||||
config: &toml::Value,
|
config: &toml::Value,
|
||||||
remote: Option<&crate::util::config::RemoteSettings>,
|
remote: Option<&crate::util::config::RemoteSettings>,
|
||||||
@@ -610,16 +598,12 @@ impl ModelOverrideConfig {
|
|||||||
.and_then(|v| v.clone().try_into().ok())
|
.and_then(|v| v.clone().try_into().ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let mut result = Self {
|
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()),
|
session_summary: non_empty_model_override(parsed_models.session_summary.as_deref()),
|
||||||
image_description: non_empty_model_override(parsed_models.image_description.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())
|
prompt_suggestion: non_empty_model_override(parsed_models.prompt_suggestion.as_deref())
|
||||||
.map(PromptSuggestModelPin::Pinned)
|
.map(PromptSuggestModelPin::Pinned)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
};
|
};
|
||||||
let has_local_ws = models_table.and_then(|m| m.get("web_search")).is_some();
|
|
||||||
let has_local_ss = models_table
|
let has_local_ss = models_table
|
||||||
.and_then(|m| m.get("session_summary"))
|
.and_then(|m| m.get("session_summary"))
|
||||||
.is_some();
|
.is_some();
|
||||||
@@ -627,9 +611,6 @@ impl ModelOverrideConfig {
|
|||||||
.and_then(|m| m.get("image_description"))
|
.and_then(|m| m.get("image_description"))
|
||||||
.is_some();
|
.is_some();
|
||||||
if let Some(remote) = remote {
|
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 {
|
if !has_local_ss {
|
||||||
result.session_summary =
|
result.session_summary =
|
||||||
non_empty_model_override(remote.session_summary_model.as_deref());
|
non_empty_model_override(remote.session_summary_model.as_deref());
|
||||||
@@ -644,12 +625,6 @@ impl ModelOverrideConfig {
|
|||||||
result.prompt_suggestion = PromptSuggestModelPin::Pinned(v);
|
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") {
|
if let Ok(v) = std::env::var("KIGI_SESSION_SUMMARY_MODEL") {
|
||||||
result.session_summary = non_empty_model_override(Some(v.as_str()));
|
result.session_summary = non_empty_model_override(Some(v.as_str()));
|
||||||
}
|
}
|
||||||
@@ -661,9 +636,6 @@ impl ModelOverrideConfig {
|
|||||||
{
|
{
|
||||||
result.prompt_suggestion = PromptSuggestModelPin::Env(v);
|
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 {
|
if let Some(v) = cli_session_summary_model {
|
||||||
result.session_summary = non_empty_model_override(Some(v));
|
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", "default", config.models.default);
|
||||||
enforce_str!("models", "web_search", config.models.web_search);
|
|
||||||
enforce_str!("cli", "channel", config.cli.channel);
|
enforce_str!("cli", "channel", config.cli.channel);
|
||||||
enforce_str!("cli", "minimum_version", config.cli.minimum_version);
|
enforce_str!("cli", "minimum_version", config.cli.minimum_version);
|
||||||
if let Some(val) = req_str(req, "endpoints", "xai_api_base_url")
|
if let Some(val) = req_str(req, "endpoints", "xai_api_base_url")
|
||||||
|
|||||||
@@ -1350,7 +1350,6 @@ fn managed_mcp_gateway_tools_enabled_with_managed_master() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
fn with_model_overrides_env_full<T>(
|
fn with_model_overrides_env_full<T>(
|
||||||
ws: Option<&str>,
|
|
||||||
ss: Option<&str>,
|
ss: Option<&str>,
|
||||||
id: Option<&str>,
|
id: Option<&str>,
|
||||||
ps: Option<&str>,
|
ps: Option<&str>,
|
||||||
@@ -1359,9 +1358,6 @@ fn with_model_overrides_env_full<T>(
|
|||||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
let _guard = LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
with_env_var_opt(
|
with_env_var_opt(
|
||||||
"KIGI_WEB_SEARCH_MODEL",
|
|
||||||
ws,
|
|
||||||
|| with_env_var_opt(
|
|
||||||
"KIGI_SESSION_SUMMARY_MODEL",
|
"KIGI_SESSION_SUMMARY_MODEL",
|
||||||
ss,
|
ss,
|
||||||
|| with_env_var_opt(
|
|| with_env_var_opt(
|
||||||
@@ -1369,85 +1365,28 @@ fn with_model_overrides_env_full<T>(
|
|||||||
id,
|
id,
|
||||||
|| with_env_var_opt("KIGI_PROMPT_SUGGESTIONS_MODEL", ps, f),
|
|| with_env_var_opt("KIGI_PROMPT_SUGGESTIONS_MODEL", ps, f),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
fn with_model_overrides_env<T>(
|
fn with_model_overrides_env<T>(
|
||||||
ws: Option<&str>,
|
|
||||||
ss: Option<&str>,
|
ss: Option<&str>,
|
||||||
id: Option<&str>,
|
id: Option<&str>,
|
||||||
f: impl FnOnce() -> T,
|
f: impl FnOnce() -> T,
|
||||||
) -> T {
|
) -> T {
|
||||||
with_model_overrides_env_full(ws, ss, id, None, f)
|
with_model_overrides_env_full(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()));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_remote_settings_applies_without_local_config() {
|
fn model_overrides_remote_settings_applies_without_local_config() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|| {
|
|| {
|
||||||
let empty = toml::Value::Table(toml::map::Map::new());
|
let empty = toml::Value::Table(toml::map::Map::new());
|
||||||
let remote = crate::util::config::RemoteSettings {
|
let remote = crate::util::config::RemoteSettings {
|
||||||
web_search_model: Some("remote-ws".to_owned()),
|
|
||||||
session_summary_model: Some("remote-ss".to_owned()),
|
session_summary_model: Some("remote-ss".to_owned()),
|
||||||
image_description_model: Some("remote-id".to_owned()),
|
image_description_model: Some("remote-id".to_owned()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let cfg = ModelOverrideConfig::resolve(None, None, &empty, Some(&remote));
|
let cfg = ModelOverrideConfig::resolve(None, &empty, Some(&remote));
|
||||||
assert_eq!(cfg.web_search, "remote-ws");
|
|
||||||
assert_eq!(cfg.session_summary, Some("remote-ss".to_owned()));
|
assert_eq!(cfg.session_summary, Some("remote-ss".to_owned()));
|
||||||
assert_eq!(cfg.image_description, Some("remote-id".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]
|
#[test]
|
||||||
fn model_overrides_local_image_description_wins_over_remote() {
|
fn model_overrides_local_image_description_wins_over_remote() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|| {
|
|| {
|
||||||
@@ -1471,7 +1409,7 @@ fn model_overrides_local_image_description_wins_over_remote() {
|
|||||||
image_description_model: Some("remote-id".to_owned()),
|
image_description_model: Some("remote-id".to_owned()),
|
||||||
..Default::default()
|
..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()));
|
assert_eq!(cfg.image_description, Some("local-id".to_owned()));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1479,12 +1417,11 @@ fn model_overrides_local_image_description_wins_over_remote() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_default_image_description_is_grok_build() {
|
fn model_overrides_default_image_description_is_grok_build() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|| {
|
|| {
|
||||||
let empty = toml::Value::Table(toml::map::Map::new());
|
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!(
|
assert_eq!(
|
||||||
cfg.image_description, Some(crate
|
cfg.image_description, Some(crate
|
||||||
::models::default_image_description_model().to_owned())
|
::models::default_image_description_model().to_owned())
|
||||||
@@ -1495,12 +1432,11 @@ fn model_overrides_default_image_description_is_grok_build() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_default_session_summary_is_grok_build() {
|
fn model_overrides_default_session_summary_is_grok_build() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|| {
|
|| {
|
||||||
let empty = toml::Value::Table(toml::map::Map::new());
|
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!(
|
assert_eq!(
|
||||||
cfg.session_summary, Some(crate ::models::default_session_summary_model()
|
cfg.session_summary, Some(crate ::models::default_session_summary_model()
|
||||||
.to_owned())
|
.to_owned())
|
||||||
@@ -1511,7 +1447,6 @@ fn model_overrides_default_session_summary_is_grok_build() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_local_session_summary_wins_over_remote() {
|
fn model_overrides_local_session_summary_wins_over_remote() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|| {
|
|| {
|
||||||
@@ -1526,7 +1461,7 @@ fn model_overrides_local_session_summary_wins_over_remote() {
|
|||||||
session_summary_model: Some("remote-ss".to_owned()),
|
session_summary_model: Some("remote-ss".to_owned()),
|
||||||
..Default::default()
|
..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()));
|
assert_eq!(cfg.session_summary, Some("local-ss".to_owned()));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1534,7 +1469,6 @@ fn model_overrides_local_session_summary_wins_over_remote() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_env_session_summary_overrides_remote() {
|
fn model_overrides_env_session_summary_overrides_remote() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
Some("env-ss"),
|
Some("env-ss"),
|
||||||
None,
|
None,
|
||||||
|| {
|
|| {
|
||||||
@@ -1543,7 +1477,7 @@ fn model_overrides_env_session_summary_overrides_remote() {
|
|||||||
session_summary_model: Some("remote-ss".to_owned()),
|
session_summary_model: Some("remote-ss".to_owned()),
|
||||||
..Default::default()
|
..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()));
|
assert_eq!(cfg.session_summary, Some("env-ss".to_owned()));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1551,7 +1485,6 @@ fn model_overrides_env_session_summary_overrides_remote() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_env_session_summary_overrides_local() {
|
fn model_overrides_env_session_summary_overrides_local() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
Some("env-ss"),
|
Some("env-ss"),
|
||||||
None,
|
None,
|
||||||
|| {
|
|| {
|
||||||
@@ -1562,7 +1495,7 @@ fn model_overrides_env_session_summary_overrides_local() {
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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()));
|
assert_eq!(cfg.session_summary, Some("env-ss".to_owned()));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1570,7 +1503,6 @@ fn model_overrides_env_session_summary_overrides_local() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_empty_session_summary_toml_uses_default() {
|
fn model_overrides_empty_session_summary_toml_uses_default() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|| {
|
|| {
|
||||||
@@ -1581,7 +1513,7 @@ fn model_overrides_empty_session_summary_toml_uses_default() {
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = ModelOverrideConfig::resolve(None, None, &config, None);
|
let cfg = ModelOverrideConfig::resolve(None, &config, None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.session_summary, Some(crate ::models::default_session_summary_model()
|
cfg.session_summary, Some(crate ::models::default_session_summary_model()
|
||||||
.to_owned())
|
.to_owned())
|
||||||
@@ -1592,7 +1524,6 @@ fn model_overrides_empty_session_summary_toml_uses_default() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_empty_session_summary_remote_uses_default() {
|
fn model_overrides_empty_session_summary_remote_uses_default() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|| {
|
|| {
|
||||||
@@ -1601,7 +1532,7 @@ fn model_overrides_empty_session_summary_remote_uses_default() {
|
|||||||
session_summary_model: Some(" ".to_owned()),
|
session_summary_model: Some(" ".to_owned()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let cfg = ModelOverrideConfig::resolve(None, None, &empty, Some(&remote));
|
let cfg = ModelOverrideConfig::resolve(None, &empty, Some(&remote));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.session_summary, Some(crate ::models::default_session_summary_model()
|
cfg.session_summary, Some(crate ::models::default_session_summary_model()
|
||||||
.to_owned())
|
.to_owned())
|
||||||
@@ -1612,7 +1543,6 @@ fn model_overrides_empty_session_summary_remote_uses_default() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_cli_session_summary_overrides_everything() {
|
fn model_overrides_cli_session_summary_overrides_everything() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
Some("env-ss"),
|
Some("env-ss"),
|
||||||
None,
|
None,
|
||||||
|| {
|
|| {
|
||||||
@@ -1628,7 +1558,6 @@ fn model_overrides_cli_session_summary_overrides_everything() {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let cfg = ModelOverrideConfig::resolve(
|
let cfg = ModelOverrideConfig::resolve(
|
||||||
None,
|
|
||||||
Some("cli-ss"),
|
Some("cli-ss"),
|
||||||
&config,
|
&config,
|
||||||
Some(&remote),
|
Some(&remote),
|
||||||
@@ -1640,12 +1569,11 @@ fn model_overrides_cli_session_summary_overrides_everything() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_empty_cli_session_summary_uses_default() {
|
fn model_overrides_empty_cli_session_summary_uses_default() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|| {
|
|| {
|
||||||
let empty = toml::Value::Table(toml::map::Map::new());
|
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!(
|
assert_eq!(
|
||||||
cfg.session_summary, Some(crate ::models::default_session_summary_model()
|
cfg.session_summary, Some(crate ::models::default_session_summary_model()
|
||||||
.to_owned())
|
.to_owned())
|
||||||
@@ -1656,7 +1584,6 @@ fn model_overrides_empty_cli_session_summary_uses_default() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_env_image_description_overrides_remote() {
|
fn model_overrides_env_image_description_overrides_remote() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
Some("env-id"),
|
Some("env-id"),
|
||||||
|| {
|
|| {
|
||||||
@@ -1665,7 +1592,7 @@ fn model_overrides_env_image_description_overrides_remote() {
|
|||||||
image_description_model: Some("remote-id".to_owned()),
|
image_description_model: Some("remote-id".to_owned()),
|
||||||
..Default::default()
|
..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()));
|
assert_eq!(cfg.image_description, Some("env-id".to_owned()));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1673,7 +1600,6 @@ fn model_overrides_env_image_description_overrides_remote() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_env_image_description_overrides_local() {
|
fn model_overrides_env_image_description_overrides_local() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
Some("env-id"),
|
Some("env-id"),
|
||||||
|| {
|
|| {
|
||||||
@@ -1684,7 +1610,7 @@ fn model_overrides_env_image_description_overrides_local() {
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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()));
|
assert_eq!(cfg.image_description, Some("env-id".to_owned()));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1692,7 +1618,6 @@ fn model_overrides_env_image_description_overrides_local() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_empty_image_description_toml_uses_default() {
|
fn model_overrides_empty_image_description_toml_uses_default() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|| {
|
|| {
|
||||||
@@ -1703,7 +1628,7 @@ fn model_overrides_empty_image_description_toml_uses_default() {
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = ModelOverrideConfig::resolve(None, None, &config, None);
|
let cfg = ModelOverrideConfig::resolve(None, &config, None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.image_description, Some(crate
|
cfg.image_description, Some(crate
|
||||||
::models::default_image_description_model().to_owned())
|
::models::default_image_description_model().to_owned())
|
||||||
@@ -1714,7 +1639,6 @@ fn model_overrides_empty_image_description_toml_uses_default() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_empty_image_description_remote_uses_default() {
|
fn model_overrides_empty_image_description_remote_uses_default() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|| {
|
|| {
|
||||||
@@ -1723,7 +1647,7 @@ fn model_overrides_empty_image_description_remote_uses_default() {
|
|||||||
image_description_model: Some(" ".to_owned()),
|
image_description_model: Some(" ".to_owned()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let cfg = ModelOverrideConfig::resolve(None, None, &empty, Some(&remote));
|
let cfg = ModelOverrideConfig::resolve(None, &empty, Some(&remote));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.image_description, Some(crate
|
cfg.image_description, Some(crate
|
||||||
::models::default_image_description_model().to_owned())
|
::models::default_image_description_model().to_owned())
|
||||||
@@ -1734,12 +1658,11 @@ fn model_overrides_empty_image_description_remote_uses_default() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_prompt_suggestion_unpinned_by_default() {
|
fn model_overrides_prompt_suggestion_unpinned_by_default() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|| {
|
|| {
|
||||||
let empty = toml::Value::Table(toml::map::Map::new());
|
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);
|
assert_eq!(cfg.prompt_suggestion, PromptSuggestModelPin::Unpinned);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1747,7 +1670,6 @@ fn model_overrides_prompt_suggestion_unpinned_by_default() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_prompt_suggestion_local_wins_over_remote() {
|
fn model_overrides_prompt_suggestion_local_wins_over_remote() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|| {
|
|| {
|
||||||
@@ -1762,7 +1684,7 @@ fn model_overrides_prompt_suggestion_local_wins_over_remote() {
|
|||||||
prompt_suggestion_model: Some("remote-ps".to_owned()),
|
prompt_suggestion_model: Some("remote-ps".to_owned()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let cfg = ModelOverrideConfig::resolve(None, None, &config, Some(&remote));
|
let cfg = ModelOverrideConfig::resolve(None, &config, Some(&remote));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.prompt_suggestion, PromptSuggestModelPin::Pinned("local-ps"
|
cfg.prompt_suggestion, PromptSuggestModelPin::Pinned("local-ps"
|
||||||
.to_owned())
|
.to_owned())
|
||||||
@@ -1773,7 +1695,6 @@ fn model_overrides_prompt_suggestion_local_wins_over_remote() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_prompt_suggestion_remote_applies_without_local() {
|
fn model_overrides_prompt_suggestion_remote_applies_without_local() {
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|| {
|
|| {
|
||||||
@@ -1782,7 +1703,7 @@ fn model_overrides_prompt_suggestion_remote_applies_without_local() {
|
|||||||
prompt_suggestion_model: Some("remote-ps".to_owned()),
|
prompt_suggestion_model: Some("remote-ps".to_owned()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let cfg = ModelOverrideConfig::resolve(None, None, &empty, Some(&remote));
|
let cfg = ModelOverrideConfig::resolve(None, &empty, Some(&remote));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.prompt_suggestion, PromptSuggestModelPin::Pinned("remote-ps"
|
cfg.prompt_suggestion, PromptSuggestModelPin::Pinned("remote-ps"
|
||||||
.to_owned())
|
.to_owned())
|
||||||
@@ -1793,7 +1714,6 @@ fn model_overrides_prompt_suggestion_remote_applies_without_local() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn model_overrides_prompt_suggestion_env_wins_over_local_and_remote() {
|
fn model_overrides_prompt_suggestion_env_wins_over_local_and_remote() {
|
||||||
with_model_overrides_env_full(
|
with_model_overrides_env_full(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some("env-ps"),
|
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()),
|
prompt_suggestion_model: Some("remote-ps".to_owned()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let cfg = ModelOverrideConfig::resolve(None, None, &config, Some(&remote));
|
let cfg = ModelOverrideConfig::resolve(None, &config, Some(&remote));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.prompt_suggestion, PromptSuggestModelPin::Env("env-ps".to_owned())
|
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]
|
#[test]
|
||||||
fn model_overrides_prompt_suggestion_blank_values_are_unset() {
|
fn model_overrides_prompt_suggestion_blank_values_are_unset() {
|
||||||
with_model_overrides_env_full(
|
with_model_overrides_env_full(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(" "),
|
Some(" "),
|
||||||
@@ -1831,7 +1750,7 @@ fn model_overrides_prompt_suggestion_blank_values_are_unset() {
|
|||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let cfg = ModelOverrideConfig::resolve(None, None, &config, None);
|
let cfg = ModelOverrideConfig::resolve(None, &config, None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.prompt_suggestion, PromptSuggestModelPin::Pinned("local-ps"
|
cfg.prompt_suggestion, PromptSuggestModelPin::Pinned("local-ps"
|
||||||
.to_owned())
|
.to_owned())
|
||||||
@@ -1839,7 +1758,6 @@ fn model_overrides_prompt_suggestion_blank_values_are_unset() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
with_model_overrides_env(
|
with_model_overrides_env(
|
||||||
None,
|
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|| {
|
|| {
|
||||||
@@ -1854,7 +1772,7 @@ fn model_overrides_prompt_suggestion_blank_values_are_unset() {
|
|||||||
prompt_suggestion_model: Some(" ".to_owned()),
|
prompt_suggestion_model: Some(" ".to_owned()),
|
||||||
..Default::default()
|
..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);
|
assert_eq!(cfg.prompt_suggestion, PromptSuggestModelPin::Unpinned);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -2668,13 +2586,13 @@ fn config_layers_system_managed_lowest_priority() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn apply_requirements_value_overrides_user_settings() {
|
fn apply_requirements_value_overrides_user_settings() {
|
||||||
let raw_config: toml::Value = toml::from_str(
|
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();
|
.unwrap();
|
||||||
let mut cfg = crate::agent::config::Config::new_from_toml_cfg(&raw_config).unwrap();
|
let mut cfg = crate::agent::config::Config::new_from_toml_cfg(&raw_config).unwrap();
|
||||||
cfg.default_yolo_mode = true;
|
cfg.default_yolo_mode = true;
|
||||||
let requirements: toml::Value = toml::from_str(
|
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();
|
.unwrap();
|
||||||
let source = RequirementSource::Requirements {
|
let source = RequirementSource::Requirements {
|
||||||
@@ -2694,7 +2612,6 @@ fn apply_requirements_value_overrides_user_settings() {
|
|||||||
assert!(! cfg.ui.yolo);
|
assert!(! cfg.ui.yolo);
|
||||||
assert!(! cfg.default_yolo_mode);
|
assert!(! cfg.default_yolo_mode);
|
||||||
assert_eq!(Some("managed-model"), cfg.models.default.as_deref());
|
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("stable"), cfg.cli.channel.as_deref());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Some("https://managed-proxy.example/v1"), cfg.endpoints.coding_api_base_url
|
Some("https://managed-proxy.example/v1"), cfg.endpoints.coding_api_base_url
|
||||||
|
|||||||
@@ -449,7 +449,6 @@ fn handle_reload_models(agent: &MvpAgent) -> ExtResult {
|
|||||||
{
|
{
|
||||||
let agent_config = agent.cfg.borrow();
|
let agent_config = agent.cfg.borrow();
|
||||||
let overrides = crate::config::ModelOverrideConfig::resolve(
|
let overrides = crate::config::ModelOverrideConfig::resolve(
|
||||||
agent_config.web_search_model_override.as_deref(),
|
|
||||||
agent_config.session_summary_model_override.as_deref(),
|
agent_config.session_summary_model_override.as_deref(),
|
||||||
&disk_config,
|
&disk_config,
|
||||||
agent_config.remote_settings.as_ref(),
|
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();
|
let mut agent_config = agent.cfg.borrow_mut();
|
||||||
agent_config.models = toml_config.models.clone();
|
agent_config.models = toml_config.models.clone();
|
||||||
agent_config.config_models = toml_config.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.session_summary_model = overrides.session_summary;
|
||||||
agent_config.image_description_model = overrides.image_description;
|
agent_config.image_description_model = overrides.image_description;
|
||||||
agent_config.prompt_suggest_model_pin = overrides.prompt_suggestion;
|
agent_config.prompt_suggest_model_pin = overrides.prompt_suggestion;
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ pub(crate) async fn spawn_session_actor(
|
|||||||
session_client_identifier: Option<String>,
|
session_client_identifier: Option<String>,
|
||||||
inference_idle_timeout_secs: u64,
|
inference_idle_timeout_secs: u64,
|
||||||
max_retries: Option<u32>,
|
max_retries: Option<u32>,
|
||||||
web_search_sampling_config: Option<kigi_sampler::SamplerConfig>,
|
web_search_config: kigi_tools::implementations::WebSearchConfig,
|
||||||
web_fetch_config: kigi_tools::implementations::grok_build::web_fetch::WebFetchConfig,
|
web_fetch_config: kigi_tools::implementations::grok_build::web_fetch::WebFetchConfig,
|
||||||
image_gen_config: kigi_tools::implementations::grok_build::image_gen::ImageGenConfig,
|
image_gen_config: kigi_tools::implementations::grok_build::image_gen::ImageGenConfig,
|
||||||
video_gen_config: kigi_tools::implementations::grok_build::video_gen::VideoGenConfig,
|
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 primary_model_id = sampling_config.model.clone();
|
||||||
let web_search_config = if disable_web_search {
|
let web_search_config = if disable_web_search {
|
||||||
kigi_tools::implementations::WebSearchConfig::Disabled
|
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 {
|
} else {
|
||||||
tracing::warn!("web_search disabled: resolved config has no API key");
|
web_search_config
|
||||||
kigi_tools::implementations::WebSearchConfig::Disabled
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tracing::warn!("web_search disabled: configured model could not be resolved");
|
|
||||||
kigi_tools::implementations::WebSearchConfig::Disabled
|
|
||||||
};
|
};
|
||||||
let embed_base_url = sampling_config.base_url.clone();
|
let embed_base_url = sampling_config.base_url.clone();
|
||||||
let embed_api_key = sampling_config.api_key.clone();
|
let embed_api_key = sampling_config.api_key.clone();
|
||||||
@@ -1597,7 +1583,7 @@ pub(crate) async fn spawn_session_on_thread(
|
|||||||
session_client_identifier: Option<String>,
|
session_client_identifier: Option<String>,
|
||||||
inference_idle_timeout_secs: u64,
|
inference_idle_timeout_secs: u64,
|
||||||
max_retries: Option<u32>,
|
max_retries: Option<u32>,
|
||||||
web_search_sampling_config: Option<kigi_sampler::SamplerConfig>,
|
web_search_config: kigi_tools::implementations::WebSearchConfig,
|
||||||
web_fetch_config: kigi_tools::implementations::grok_build::web_fetch::WebFetchConfig,
|
web_fetch_config: kigi_tools::implementations::grok_build::web_fetch::WebFetchConfig,
|
||||||
image_gen_config: kigi_tools::implementations::grok_build::image_gen::ImageGenConfig,
|
image_gen_config: kigi_tools::implementations::grok_build::image_gen::ImageGenConfig,
|
||||||
video_gen_config: kigi_tools::implementations::grok_build::video_gen::VideoGenConfig,
|
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,
|
session_client_identifier,
|
||||||
inference_idle_timeout_secs,
|
inference_idle_timeout_secs,
|
||||||
max_retries,
|
max_retries,
|
||||||
web_search_sampling_config,
|
web_search_config,
|
||||||
web_fetch_config,
|
web_fetch_config,
|
||||||
image_gen_config,
|
image_gen_config,
|
||||||
video_gen_config,
|
video_gen_config,
|
||||||
|
|||||||
@@ -1,151 +1,11 @@
|
|||||||
use axum::{Json, Router, extract::State, routing::post};
|
|
||||||
use kigi_tools::computer::local::{LocalFs, LocalTerminalBackend};
|
use kigi_tools::computer::local::{LocalFs, LocalTerminalBackend};
|
||||||
use kigi_tools::computer::types::{AsyncFileSystem, TerminalBackend};
|
use kigi_tools::computer::types::{AsyncFileSystem, TerminalBackend};
|
||||||
use kigi_tools::notification::ToolNotificationHandle;
|
use kigi_tools::notification::ToolNotificationHandle;
|
||||||
use kigi_tools::registry::types::{SessionContext, ToolConfig, ToolServerConfig};
|
use kigi_tools::registry::types::{SessionContext, ToolConfig, ToolServerConfig};
|
||||||
use serde_json::{Value, json};
|
use serde_json::json;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn web_search_uses_model_override_from_config_end_to_end() {
|
async fn web_search_errors_when_disabled() {
|
||||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
|
|
||||||
async fn handle_request(
|
|
||||||
State(tx): State<tokio::sync::mpsc::UnboundedSender<Value>>,
|
|
||||||
Json(body): Json<Value>,
|
|
||||||
) -> Json<Value> {
|
|
||||||
let _ = tx.send(body);
|
|
||||||
Json(json!({
|
|
||||||
"id": "resp_test",
|
|
||||||
"object": "response",
|
|
||||||
"created_at": 1234567890,
|
|
||||||
"status": "completed",
|
|
||||||
"model": "enterprise-search",
|
|
||||||
"output": [{
|
|
||||||
"type": "message",
|
|
||||||
"id": "msg_1",
|
|
||||||
"status": "completed",
|
|
||||||
"role": "assistant",
|
|
||||||
"content": [{
|
|
||||||
"type": "output_text",
|
|
||||||
"text": "search result",
|
|
||||||
"annotations": []
|
|
||||||
}]
|
|
||||||
}]
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
let app = Router::new()
|
|
||||||
.route("/responses", post(handle_request))
|
|
||||||
.with_state(tx);
|
|
||||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
||||||
let addr = listener.local_addr().unwrap();
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
axum::serve(listener, app).await.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
let raw_config: toml::Value = toml::from_str(&format!(
|
|
||||||
r#"
|
|
||||||
[models]
|
|
||||||
web_search = "enterprise-search"
|
|
||||||
|
|
||||||
[model.enterprise-search]
|
|
||||||
model = "enterprise-search"
|
|
||||||
base_url = "http://{addr}"
|
|
||||||
api_key = "enterprise-key"
|
|
||||||
context_window = 256000
|
|
||||||
api_backend = "responses"
|
|
||||||
"#,
|
|
||||||
))
|
|
||||||
.unwrap();
|
|
||||||
let web_search_model =
|
|
||||||
crate::config::ModelOverrideConfig::resolve(None, None, &raw_config, None).web_search;
|
|
||||||
let agent_cfg = crate::agent::config::Config::new_from_toml_cfg(&raw_config).unwrap();
|
|
||||||
let models = crate::agent::config::resolve_model_list(&agent_cfg, None);
|
|
||||||
let entry = models.get(web_search_model.as_str()).unwrap();
|
|
||||||
let resolved = crate::agent::config::sampling_config_for_model(
|
|
||||||
entry,
|
|
||||||
crate::agent::config::resolve_credentials(entry, None),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let web_search_sampling = crate::tools::config::web_search_sampling_config(resolved);
|
|
||||||
|
|
||||||
let builder = crate::tools::bridge::ToolBridge::get_builder();
|
|
||||||
let config = ToolServerConfig {
|
|
||||||
tools: vec![ToolConfig {
|
|
||||||
id: "GrokBuild:web_search".into(),
|
|
||||||
params: None,
|
|
||||||
name_override: None,
|
|
||||||
params_name_overrides: None,
|
|
||||||
description_override: None,
|
|
||||||
behavior_version: None,
|
|
||||||
kind: None,
|
|
||||||
}],
|
|
||||||
behavior_preset: None,
|
|
||||||
};
|
|
||||||
let fs: std::sync::Arc<dyn AsyncFileSystem> = std::sync::Arc::new(LocalFs);
|
|
||||||
let terminal: std::sync::Arc<dyn TerminalBackend> =
|
|
||||||
std::sync::Arc::new(LocalTerminalBackend::new());
|
|
||||||
let ctx = SessionContext {
|
|
||||||
backend: terminal,
|
|
||||||
fs,
|
|
||||||
cwd: std::env::temp_dir(),
|
|
||||||
session_folder: std::env::temp_dir().join("grok-web-search-e2e"),
|
|
||||||
session_env: std::sync::Arc::new(std::collections::HashMap::new()),
|
|
||||||
notification_handle: ToolNotificationHandle::noop(),
|
|
||||||
owner_session_id: None,
|
|
||||||
parent_scheduler_handle: None,
|
|
||||||
skills: vec![],
|
|
||||||
state_path: std::env::temp_dir().join("grok-web-search-e2e/state.json"),
|
|
||||||
memory_backend: None,
|
|
||||||
web_search_config: kigi_tools::implementations::web_search::WebSearchConfig::Enabled {
|
|
||||||
api_key: web_search_sampling.api_key.clone().unwrap(),
|
|
||||||
base_url: web_search_sampling.base_url.clone(),
|
|
||||||
model: web_search_sampling.model.clone(),
|
|
||||||
extra_headers: web_search_sampling.extra_headers.clone(),
|
|
||||||
// The optional extra access key is no longer carried on
|
|
||||||
// `SamplerConfig`. The shell-level value flows in via
|
|
||||||
// `Credentials` at session-spawn time; in this self-contained
|
|
||||||
// test fixture there's no extra access key in scope.
|
|
||||||
alpha_test_key: None,
|
|
||||||
},
|
|
||||||
web_fetch_config: Default::default(),
|
|
||||||
lsp: None,
|
|
||||||
image_gen_config: Default::default(),
|
|
||||||
video_gen_config: Default::default(),
|
|
||||||
app_builder_deployer_config: Default::default(),
|
|
||||||
api_key_provider: None,
|
|
||||||
auth_provider: None,
|
|
||||||
attribution_callback: None,
|
|
||||||
system_reminder_tag: kigi_tools::reminders::DEFAULT_REMINDER_TAG,
|
|
||||||
};
|
|
||||||
let bridge = crate::tools::bridge::ToolBridge::finalize_builder(builder, config, ctx)
|
|
||||||
.await
|
|
||||||
.expect("finalize_builder should succeed");
|
|
||||||
let result = bridge
|
|
||||||
.call(
|
|
||||||
"web_search",
|
|
||||||
json!({
|
|
||||||
"query": "test query",
|
|
||||||
"allowed_domains": ["example.com"]
|
|
||||||
}),
|
|
||||||
"web-search-e2e",
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert!(
|
|
||||||
result.is_ok(),
|
|
||||||
"web_search should succeed: {:?}",
|
|
||||||
result.err()
|
|
||||||
);
|
|
||||||
|
|
||||||
let request = rx.recv().await.expect("mock server should receive request");
|
|
||||||
assert_eq!(
|
|
||||||
request.get("model").and_then(|v| v.as_str()),
|
|
||||||
Some(web_search_model.as_str())
|
|
||||||
);
|
|
||||||
|
|
||||||
server.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn web_search_errors_when_configured_model_cannot_be_resolved() {
|
|
||||||
let builder = crate::tools::bridge::ToolBridge::get_builder();
|
let builder = crate::tools::bridge::ToolBridge::get_builder();
|
||||||
let config = ToolServerConfig {
|
let config = ToolServerConfig {
|
||||||
tools: vec![ToolConfig {
|
tools: vec![ToolConfig {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnCon
|
|||||||
)),
|
)),
|
||||||
session_env: Arc::new(HashMap::new()),
|
session_env: Arc::new(HashMap::new()),
|
||||||
memory_config: None,
|
memory_config: None,
|
||||||
web_search_sampling_config: None,
|
web_search_config: Default::default(),
|
||||||
web_fetch_config: Default::default(),
|
web_fetch_config: Default::default(),
|
||||||
image_gen_config: Default::default(),
|
image_gen_config: Default::default(),
|
||||||
video_gen_config: Default::default(),
|
video_gen_config: Default::default(),
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
use crate::models;
|
|
||||||
use kigi_sampler::SamplerConfig;
|
|
||||||
use kigi_tools::implementations::grok_build;
|
use kigi_tools::implementations::grok_build;
|
||||||
use kigi_tools::registry::types::ToolConfig;
|
use kigi_tools::registry::types::ToolConfig;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -153,15 +151,13 @@ impl WebFetchToolConfig {
|
|||||||
|
|
||||||
/// Top-level toolset configuration for the shell layer.
|
/// Top-level toolset configuration for the shell layer.
|
||||||
///
|
///
|
||||||
/// This is the *shell-side* config that holds sampling-level settings
|
/// This is the *shell-side* config for per-tool settings. It is distinct
|
||||||
/// (e.g., web search API key from the sampling client). It is distinct
|
|
||||||
/// from `kigi_tools::registry::types::ToolsetConfig` which holds
|
/// from `kigi_tools::registry::types::ToolsetConfig` which holds
|
||||||
/// tool-implementation-level config (bash limits, web search mode).
|
/// tool-implementation-level config (bash limits, web search mode).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct ShellToolsetConfig {
|
pub struct ShellToolsetConfig {
|
||||||
pub bash: BashToolConfig,
|
pub bash: BashToolConfig,
|
||||||
pub web_search: SamplerConfig,
|
|
||||||
/// Web fetch tool parameters (`[toolset.web_fetch]`).
|
/// Web fetch tool parameters (`[toolset.web_fetch]`).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub web_fetch: WebFetchToolConfig,
|
pub web_fetch: WebFetchToolConfig,
|
||||||
@@ -176,85 +172,7 @@ pub struct ShellToolsetConfig {
|
|||||||
pub hashline: HashlineSchemeConfig,
|
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 {
|
impl ShellToolsetConfig {
|
||||||
/// Optionally layers sampling credentials onto the web search config.
|
|
||||||
pub fn new(base: Option<Self>, sampling_config: Option<SamplerConfig>) -> Self {
|
|
||||||
let default_base = SamplerConfig {
|
|
||||||
api_key: None,
|
|
||||||
base_url: kigi_env::coding_api_base_url(),
|
|
||||||
model: String::new(),
|
|
||||||
max_completion_tokens: None,
|
|
||||||
temperature: None,
|
|
||||||
top_p: None,
|
|
||||||
api_backend: Default::default(),
|
|
||||||
auth_scheme: Default::default(),
|
|
||||||
extra_headers: indexmap::IndexMap::new(),
|
|
||||||
context_window: 256_000,
|
|
||||||
reasoning_effort: None,
|
|
||||||
force_http1: false,
|
|
||||||
max_retries: None,
|
|
||||||
stream_tool_calls: false,
|
|
||||||
idle_timeout_secs: None,
|
|
||||||
origin_client: None,
|
|
||||||
// Default base for the in-process web-search tool config.
|
|
||||||
// Real `SamplerConfig`s (e.g. from `sampling_config_for_model`)
|
|
||||||
// overwrite this entire struct via the `..base` pattern in
|
|
||||||
// `web_search_sampling_config`, so leaving the callback
|
|
||||||
// `None` here is fine -- it is only the placeholder for the
|
|
||||||
// "no base provided" path. The live attribution
|
|
||||||
// wiring lives at the production SamplerConfig sites in
|
|
||||||
// agent/config.rs and acp_session.rs.
|
|
||||||
attribution_callback: None,
|
|
||||||
bearer_resolver: None,
|
|
||||||
supports_backend_search: false,
|
|
||||||
compactions_remaining: None,
|
|
||||||
compaction_at_tokens: None,
|
|
||||||
doom_loop_recovery: None,
|
|
||||||
header_injector: None,
|
|
||||||
};
|
|
||||||
let mut toolset = base.unwrap_or_else(|| Self {
|
|
||||||
bash: BashToolConfig::default(),
|
|
||||||
web_search: web_search_sampling_config(default_base),
|
|
||||||
web_fetch: WebFetchToolConfig::default(),
|
|
||||||
ask_user_question: AskUserQuestionToolConfig::default(),
|
|
||||||
file_toolset: FileToolset::default(),
|
|
||||||
hashline: HashlineSchemeConfig::default(),
|
|
||||||
});
|
|
||||||
if let Some(sc) = sampling_config {
|
|
||||||
toolset.web_search = web_search_sampling_config(sc);
|
|
||||||
}
|
|
||||||
toolset
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if web search is enabled based on config.
|
|
||||||
pub fn web_search_enabled(&self) -> bool {
|
|
||||||
self.web_search.api_key.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve the effective file toolset. Local config takes precedence;
|
/// Resolve the effective file toolset. Local config takes precedence;
|
||||||
/// remote `/v1/settings` is used as fallback when local is the default.
|
/// remote `/v1/settings` is used as fallback when local is the default.
|
||||||
pub fn resolve_file_toolset(
|
pub fn resolve_file_toolset(
|
||||||
|
|||||||
@@ -153,9 +153,9 @@ pub(crate) fn atomic_write_string(path: &std::path::Path, content: &str) -> std:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Merge `[toolset.ask_user_question]` into the root table. `[toolset]` is
|
/// Merge `[toolset.ask_user_question]` into the root table. `[toolset]` is
|
||||||
/// deliberately NOT merged wholesale — it carries runtime-only structs
|
/// deliberately NOT merged wholesale — its other sub-tables (`bash` etc.)
|
||||||
/// (`web_search` sampler etc.) whose serialized defaults must never land in
|
/// carry serialized defaults that must never land in the user file — so only
|
||||||
/// the user file — so only this settings-writable sub-table round-trips.
|
/// this settings-writable sub-table round-trips.
|
||||||
fn merge_ask_user_question_section(
|
fn merge_ask_user_question_section(
|
||||||
table: &mut TomlMap<String, TomlValue>,
|
table: &mut TomlMap<String, TomlValue>,
|
||||||
ask: &crate::tools::config::AskUserQuestionToolConfig,
|
ask: &crate::tools::config::AskUserQuestionToolConfig,
|
||||||
@@ -245,7 +245,7 @@ mod tests {
|
|||||||
|
|
||||||
/// The `[toolset.ask_user_question]` settings write merges only that
|
/// The `[toolset.ask_user_question]` settings write merges only that
|
||||||
/// sub-table: the toggled field lands, hand-written sibling keys survive,
|
/// 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.
|
/// the user file. All-None leaves the file untouched.
|
||||||
#[test]
|
#[test]
|
||||||
fn ask_user_question_merge_writes_subtable_without_splatting_toolset() {
|
fn ask_user_question_merge_writes_subtable_without_splatting_toolset() {
|
||||||
@@ -830,7 +830,6 @@ auto_update = true
|
|||||||
if let TomlValue::Table(t) = v {
|
if let TomlValue::Table(t) = v {
|
||||||
assert_eq!(t.len(), 1);
|
assert_eq!(t.len(), 1);
|
||||||
assert!(t.contains_key("default"));
|
assert!(t.contains_key("default"));
|
||||||
assert!(!t.contains_key("web_search"));
|
|
||||||
assert!(!t.contains_key("session_summary"));
|
assert!(!t.contains_key("session_summary"));
|
||||||
assert!(!t.contains_key("image_description"));
|
assert!(!t.contains_key("image_description"));
|
||||||
assert!(!t.contains_key("hidden_models"));
|
assert!(!t.contains_key("hidden_models"));
|
||||||
@@ -935,7 +934,10 @@ auto_update = true
|
|||||||
fn merge_section_models_only_updates_set_fields_preserves_others() {
|
fn merge_section_models_only_updates_set_fields_preserves_others() {
|
||||||
let mut table = TomlMap::new();
|
let mut table = TomlMap::new();
|
||||||
let mut models = 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()));
|
models.insert("unmodeled_foo".into(), TomlValue::String("keep-me".into()));
|
||||||
table.insert("models".into(), TomlValue::Table(models));
|
table.insert("models".into(), TomlValue::Table(models));
|
||||||
let cfg = crate::agent::config::ModelsConfig {
|
let cfg = crate::agent::config::ModelsConfig {
|
||||||
@@ -946,19 +948,19 @@ auto_update = true
|
|||||||
let m = table.get("models").unwrap().as_table().unwrap();
|
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("default").and_then(|v| v.as_str()), Some("grok-new"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
m.get("web_search").and_then(|v| v.as_str()),
|
m.get("session_summary").and_then(|v| v.as_str()),
|
||||||
Some("old-search")
|
Some("old-title")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
m.get("unmodeled_foo").and_then(|v| v.as_str()),
|
m.get("unmodeled_foo").and_then(|v| v.as_str()),
|
||||||
Some("keep-me")
|
Some("keep-me")
|
||||||
);
|
);
|
||||||
assert!(!m.contains_key("session_summary"));
|
assert!(!m.contains_key("image_description"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn persist_preferred_model_flow_roundtrips_via_load_and_new_from_toml_cfg() {
|
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 root: TomlValue = toml::from_str(original).unwrap();
|
||||||
let mut cfg = load_config_from_toml(&root);
|
let mut cfg = load_config_from_toml(&root);
|
||||||
cfg.models.default = Some("grok-persisted".to_string());
|
cfg.models.default = Some("grok-persisted".to_string());
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ pub struct WebFetchClient {
|
|||||||
image_writer: SessionFileWriter,
|
image_writer: SessionFileWriter,
|
||||||
video_writer: SessionFileWriter,
|
video_writer: SessionFileWriter,
|
||||||
overflow: OverflowHandler,
|
overflow: OverflowHandler,
|
||||||
|
/// Live-token source for the Kimi fetch service (OAuth refresh).
|
||||||
|
api_key_provider: Option<crate::types::SharedApiKeyProvider>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ProcessedText {
|
struct ProcessedText {
|
||||||
@@ -42,7 +44,10 @@ struct ProcessedText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl WebFetchClient {
|
impl WebFetchClient {
|
||||||
pub fn new(params: &WebFetchParams) -> Result<Self, WebFetchError> {
|
pub fn new(
|
||||||
|
params: &WebFetchParams,
|
||||||
|
api_key_provider: Option<crate::types::SharedApiKeyProvider>,
|
||||||
|
) -> Result<Self, WebFetchError> {
|
||||||
let converter = Arc::new(
|
let converter = Arc::new(
|
||||||
htmd::HtmlToMarkdown::builder()
|
htmd::HtmlToMarkdown::builder()
|
||||||
.skip_tags(vec![
|
.skip_tags(vec![
|
||||||
@@ -64,9 +69,67 @@ impl WebFetchClient {
|
|||||||
image_writer: SessionFileWriter::new("images", "jpg"),
|
image_writer: SessionFileWriter::new("images", "jpg"),
|
||||||
video_writer: SessionFileWriter::new("videos", "mp4"),
|
video_writer: SessionFileWriter::new("videos", "mp4"),
|
||||||
overflow: OverflowHandler::new(),
|
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<WebFetchOutput, WebFetchError> {
|
||||||
|
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.
|
/// Fetch a URL and return its content as markdown.
|
||||||
///
|
///
|
||||||
/// Handles: validation, HTTPS upgrade, SSRF check, HTTP fetch with
|
/// Handles: validation, HTTPS upgrade, SSRF check, HTTP fetch with
|
||||||
@@ -76,6 +139,7 @@ impl WebFetchClient {
|
|||||||
pub async fn fetch(
|
pub async fn fetch(
|
||||||
&self,
|
&self,
|
||||||
raw_url: &str,
|
raw_url: &str,
|
||||||
|
tool_call_id: &str,
|
||||||
session_folder: Option<&Path>,
|
session_folder: Option<&Path>,
|
||||||
read_tool_name: Option<&str>,
|
read_tool_name: Option<&str>,
|
||||||
execute_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::check_ssrf(&url).await?;
|
ssrf::check_ssrf(&url).await?;
|
||||||
|
|
||||||
@@ -817,6 +908,96 @@ fn strip_base64_data_uris(content: String) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 {
|
fn test_converter() -> htmd::HtmlToMarkdown {
|
||||||
htmd::HtmlToMarkdown::builder()
|
htmd::HtmlToMarkdown::builder()
|
||||||
.skip_tags(vec![
|
.skip_tags(vec![
|
||||||
@@ -828,10 +1009,13 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn oversized_html_persists_exact_pre_truncation_markdown() {
|
async fn oversized_html_persists_exact_pre_truncation_markdown() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let client = WebFetchClient::new(&WebFetchParams {
|
let client = WebFetchClient::new(
|
||||||
|
&WebFetchParams {
|
||||||
context_window_tokens: Some(100),
|
context_window_tokens: Some(100),
|
||||||
..WebFetchParams::default()
|
..WebFetchParams::default()
|
||||||
})
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let tail = "TAIL-MUST-REMAIN-RECOVERABLE";
|
let tail = "TAIL-MUST-REMAIN-RECOVERABLE";
|
||||||
let html = format!(
|
let html = format!(
|
||||||
@@ -1291,7 +1475,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Client builds successfully with the proxy endpoint set.
|
// Client builds successfully with the proxy endpoint set.
|
||||||
let client = WebFetchClient::new(¶ms);
|
let client = WebFetchClient::new(¶ms, None);
|
||||||
assert!(client.is_ok());
|
assert!(client.is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ use crate::register_resource;
|
|||||||
// Safety-boundary constants. Not configurable.
|
// Safety-boundary constants. Not configurable.
|
||||||
pub const MAX_URL_LENGTH: usize = 2_000;
|
pub const MAX_URL_LENGTH: usize = 2_000;
|
||||||
pub const MAX_REDIRECTS: usize = 10;
|
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.
|
/// Runtime-configurable parameters for the `web_fetch` tool.
|
||||||
///
|
///
|
||||||
@@ -40,6 +41,12 @@ pub struct WebFetchParams {
|
|||||||
/// routed through this URL.
|
/// routed through this URL.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub proxy_endpoint: Option<String>,
|
pub proxy_endpoint: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
register_resource!("grok_build", "WebFetch", WebFetchParams);
|
register_resource!("grok_build", "WebFetch", WebFetchParams);
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ pub enum WebFetchError {
|
|||||||
#[error("invalid URL: {0}")]
|
#[error("invalid URL: {0}")]
|
||||||
InvalidUrl(#[from] url::ParseError),
|
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))]
|
#[error("SSRF blocked: {host} resolves to private/internal IP {ip}{}", ssrf_recovery_hint(.host))]
|
||||||
SsrfBlocked { host: String, ip: IpAddr },
|
SsrfBlocked { host: String, ip: IpAddr },
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ impl kigi_tool_runtime::Tool for WebFetchTool {
|
|||||||
let output = client
|
let output = client
|
||||||
.fetch(
|
.fetch(
|
||||||
&input.url,
|
&input.url,
|
||||||
|
ctx.call_id.as_str(),
|
||||||
session_folder.as_deref(),
|
session_folder.as_deref(),
|
||||||
read_tool_name.as_deref(),
|
read_tool_name.as_deref(),
|
||||||
execute_tool_name.as_deref(),
|
execute_tool_name.as_deref(),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
//! `web_search` tool — new architecture (`Tool` trait).
|
//! `web_search` tool — new architecture (`Tool` trait).
|
||||||
//!
|
//!
|
||||||
//! Calls the Responses API with web search capability. Reads the
|
//! Calls the Kimi search service (PRD F5; kimi-cli `tools/web/search.py`
|
||||||
//! pre-constructed `WebSearchClient` from Resources (inserted by
|
//! parity). Reads the pre-constructed `WebSearchClient` from Resources
|
||||||
//! `with_backend()` when the config is `Enabled`).
|
//! (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::implementations::web_search::client::WebSearchClient;
|
||||||
use crate::types::output::WebSearchOutput;
|
use crate::types::output::WebSearchOutput;
|
||||||
@@ -15,12 +16,27 @@ use crate::types::tool::{ToolKind, ToolNamespace};
|
|||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
|
||||||
pub struct WebSearchInput {
|
pub struct WebSearchInput {
|
||||||
#[schemars(description = "The search query to perform.")]
|
#[schemars(description = "The query text to search for.")]
|
||||||
pub query: String,
|
pub query: String,
|
||||||
#[schemars(description = "Optional list of domains to restrict search to.")]
|
#[schemars(
|
||||||
pub allowed_domains: Option<Vec<String>>,
|
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<u8>,
|
||||||
|
#[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<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
// Tool implementation
|
||||||
// ───────────────────────────────────────────────────────────────────────────
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
@@ -87,21 +103,21 @@ impl kigi_tool_runtime::Tool for WebSearchTool {
|
|||||||
client = res.require::<WebSearchClient>()?.clone();
|
client = res.require::<WebSearchClient>()?.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let limit = input.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
|
||||||
let (content, citations) = client
|
let (content, citations) = client
|
||||||
.search(&input.query, input.allowed_domains.clone())
|
.search(
|
||||||
.await
|
&input.query,
|
||||||
.map_err(|e| {
|
limit,
|
||||||
kigi_tool_runtime::ToolError::execution(
|
input.include_content.unwrap_or(false),
|
||||||
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
|
ctx.call_id.as_str(),
|
||||||
e.to_string(),
|
|
||||||
)
|
)
|
||||||
})?;
|
.await?;
|
||||||
|
|
||||||
Ok(WebSearchOutput {
|
Ok(WebSearchOutput {
|
||||||
query: input.query.clone(),
|
query: input.query.clone(),
|
||||||
content,
|
content,
|
||||||
citations,
|
citations,
|
||||||
allowed_domains: input.allowed_domains.clone(),
|
allowed_domains: None,
|
||||||
pre_formatted: None,
|
pre_formatted: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -136,7 +152,8 @@ mod tests {
|
|||||||
test_ctx_with_call_id(resources.into_shared(), "test-call"),
|
test_ctx_with_call_id(resources.into_shared(), "test-call"),
|
||||||
WebSearchInput {
|
WebSearchInput {
|
||||||
query: "test".into(),
|
query: "test".into(),
|
||||||
allowed_domains: None,
|
limit: None,
|
||||||
|
include_content: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|||||||
@@ -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 <token>` and
|
||||||
|
//! `X-Msh-Tool-Call-Id: <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 super::types::WebSearchConfig;
|
||||||
use crate::attribution::{SharedAttributionCallback, ToolConsumer};
|
use crate::attribution::{SharedAttributionCallback, ToolConsumer};
|
||||||
use crate::types::SharedApiKeyProvider;
|
use crate::types::SharedApiKeyProvider;
|
||||||
use async_openai::types::responses as rs;
|
|
||||||
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue};
|
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<String>) -> 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<SearchResult>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal, purpose-built HTTP client for the Kimi search service.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct WebSearchClient {
|
pub struct WebSearchClient {
|
||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
base_url: String,
|
search_url: String,
|
||||||
model: String,
|
api_key: String,
|
||||||
api_key_provider: Option<SharedApiKeyProvider>,
|
api_key_provider: Option<SharedApiKeyProvider>,
|
||||||
/// Optional 401-attribution hook. Callers can wire this so a 401
|
/// Optional 401-attribution hook. Callers can wire this so a 401 from
|
||||||
/// from the Responses API emits an `auth_401_attribution` event
|
/// the search service emits an `auth_401_attribution` event with
|
||||||
/// with `consumer == "WebSearch"`.
|
/// `consumer == "WebSearch"`.
|
||||||
attribution_callback: Option<SharedAttributionCallback>,
|
attribution_callback: Option<SharedAttributionCallback>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebSearchClient {
|
impl WebSearchClient {
|
||||||
/// Create a new web search client from `WebSearchConfig::Enabled`.
|
/// Create a new web search client from `WebSearchConfig::Enabled`.
|
||||||
///
|
///
|
||||||
@@ -25,62 +74,38 @@ impl WebSearchClient {
|
|||||||
api_key_provider: Option<SharedApiKeyProvider>,
|
api_key_provider: Option<SharedApiKeyProvider>,
|
||||||
) -> Result<Self, kigi_tool_runtime::ToolError> {
|
) -> Result<Self, kigi_tool_runtime::ToolError> {
|
||||||
let WebSearchConfig::Enabled {
|
let WebSearchConfig::Enabled {
|
||||||
|
search_url,
|
||||||
api_key,
|
api_key,
|
||||||
base_url,
|
|
||||||
model,
|
|
||||||
extra_headers,
|
extra_headers,
|
||||||
alpha_test_key,
|
|
||||||
} = config
|
} = config
|
||||||
else {
|
else {
|
||||||
return Err(kigi_tool_runtime::ToolError::execution(
|
return Err(tool_error(
|
||||||
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
|
"Cannot create WebSearchClient from disabled config",
|
||||||
"Cannot create WebSearchClient from disabled config".to_string(),
|
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
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 {
|
for (key, value) in extra_headers {
|
||||||
let header_name = HeaderName::from_bytes(key.as_bytes()).map_err(|e| {
|
let header_name = HeaderName::from_bytes(key.as_bytes())
|
||||||
kigi_tool_runtime::ToolError::execution(
|
.map_err(|e| tool_error(format!("Invalid header name '{key}': {e}")))?;
|
||||||
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
|
let header_value = HeaderValue::from_str(value)
|
||||||
format!("Invalid header name '{key}': {e}"),
|
.map_err(|e| tool_error(format!("Invalid header value for '{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}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
headers.insert(header_name, header_value);
|
headers.insert(header_name, header_value);
|
||||||
}
|
}
|
||||||
let _ = alpha_test_key;
|
|
||||||
let http = reqwest::Client::builder()
|
let http = reqwest::Client::builder()
|
||||||
.default_headers(headers)
|
.default_headers(headers)
|
||||||
|
.timeout(std::time::Duration::from_secs(SEARCH_TIMEOUT_SECS))
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| {
|
.map_err(|e| tool_error(format!("Failed to build HTTP client: {e}")))?;
|
||||||
kigi_tool_runtime::ToolError::execution(
|
|
||||||
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
|
|
||||||
format!("Failed to build HTTP client: {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
http,
|
http,
|
||||||
base_url: base_url.clone(),
|
search_url: search_url.clone(),
|
||||||
model: model.clone(),
|
api_key: api_key.clone(),
|
||||||
api_key_provider,
|
api_key_provider,
|
||||||
attribution_callback: None,
|
attribution_callback: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wire a 401-attribution callback into this client. Idempotent;
|
/// Wire a 401-attribution callback into this client. Idempotent;
|
||||||
/// safe to call before or after the first request.
|
/// safe to call before or after the first request.
|
||||||
pub fn with_attribution_callback(
|
pub fn with_attribution_callback(
|
||||||
@@ -90,501 +115,255 @@ impl WebSearchClient {
|
|||||||
self.attribution_callback = callback;
|
self.attribution_callback = callback;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
async fn current_bearer(&self) -> Option<String> {
|
|
||||||
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(
|
crate::attribution::emit_401(
|
||||||
self.attribution_callback.as_ref(),
|
self.attribution_callback.as_ref(),
|
||||||
ToolConsumer::WebSearch,
|
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
|
/// `tool_call_id` rides along as `X-Msh-Tool-Call-Id` (search.py:85) so
|
||||||
/// and citations are unique URLs found in the response annotations.
|
/// the service can correlate the request with the agent turn.
|
||||||
pub async fn search(
|
pub async fn search(
|
||||||
&self,
|
&self,
|
||||||
query: &str,
|
query: &str,
|
||||||
allowed_domains: Option<Vec<String>>,
|
limit: u8,
|
||||||
|
include_content: bool,
|
||||||
|
tool_call_id: &str,
|
||||||
) -> Result<(String, Vec<String>), kigi_tool_runtime::ToolError> {
|
) -> Result<(String, Vec<String>), kigi_tool_runtime::ToolError> {
|
||||||
let web_search = rs::WebSearchToolArgs::default()
|
let bearer = self.current_bearer().await;
|
||||||
.filters(rs::WebSearchToolFilters { allowed_domains })
|
let response = self
|
||||||
.build()
|
.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| {
|
.map_err(|e| {
|
||||||
kigi_tool_runtime::ToolError::execution(
|
tool_error(format!(
|
||||||
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
|
"Search request failed: {e}. The search service may be unavailable."
|
||||||
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();
|
let status = response.status();
|
||||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.record_401_attribution(sent_bearer.as_deref());
|
self.record_401_attribution(&bearer);
|
||||||
let body = response
|
return Err(kigi_tool_runtime::ToolError::unauthorized(
|
||||||
.text()
|
"Search service returned 401 Unauthorized".to_string(),
|
||||||
.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}"),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
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<Vec<String>>,
|
|
||||||
) -> 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() {
|
if !status.is_success() {
|
||||||
let body = response
|
return Err(tool_error(format!(
|
||||||
.text()
|
"Failed to search. Status: {status}. This may indicate that the \
|
||||||
|
search service is currently unavailable."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let results = response
|
||||||
|
.json::<SearchResponse>()
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|_| "Failed to read error body".to_string());
|
.map_err(|e| tool_error(format!("Failed to parse search results: {e}")))?
|
||||||
return Err(kigi_tool_runtime::ToolError::execution(
|
.search_results;
|
||||||
kigi_tool_protocol::ToolId::new("web_search").expect("valid"),
|
Ok(render_results(&results))
|
||||||
format!("Responses API returned {status}: {body}"),
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>) {
|
||||||
|
let mut content = String::new();
|
||||||
|
let mut citations: Vec<String> = 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));
|
||||||
}
|
}
|
||||||
let bytes = response.bytes().await.map_err(|e| {
|
if !citations.contains(&result.url) {
|
||||||
kigi_tool_runtime::ToolError::execution(
|
citations.push(result.url.clone());
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Extract citation URLs from the Response output items.
|
(content, citations)
|
||||||
/// 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<String> {
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
/// Helper to create a Response from JSON for testing.
|
|
||||||
fn response_from_json(json: serde_json::Value) -> rs::Response {
|
fn enabled_config(url: &str) -> WebSearchConfig {
|
||||||
serde_json::from_value(json).expect("Failed to parse test Response JSON")
|
WebSearchConfig::Enabled {
|
||||||
}
|
search_url: url.to_string(),
|
||||||
#[test]
|
|
||||||
fn test_new_client_uses_configured_model() {
|
|
||||||
let config = WebSearchConfig::Enabled {
|
|
||||||
api_key: "test-key".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(),
|
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<Vec<(ToolConsumer, Option<String>)>>,
|
|
||||||
}
|
|
||||||
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]
|
#[test]
|
||||||
fn record_401_attribution_passes_truncated_prefix_to_callback() {
|
fn new_rejects_disabled_config() {
|
||||||
let cb = std::sync::Arc::new(CountingCallback::default());
|
assert!(WebSearchClient::new(&WebSearchConfig::Disabled, None).is_err());
|
||||||
let cb_dyn: crate::attribution::SharedAttributionCallback = cb.clone();
|
}
|
||||||
|
|
||||||
|
#[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 {
|
let config = WebSearchConfig::Enabled {
|
||||||
api_key: "ignored".to_string(),
|
search_url: "https://api.kimi.com/coding/v1/search".to_string(),
|
||||||
base_url: "https://api.x.ai/v1".to_string(),
|
api_key: "k".to_string(),
|
||||||
model: "test-model".to_string(),
|
extra_headers: headers,
|
||||||
extra_headers: IndexMap::new(),
|
|
||||||
alpha_test_key: None,
|
|
||||||
};
|
};
|
||||||
let client = WebSearchClient::new(&config, None)
|
assert!(WebSearchClient::new(&config, None).is_err());
|
||||||
.expect("client should build")
|
}
|
||||||
.with_attribution_callback(Some(cb_dyn));
|
|
||||||
client.record_401_attribution(Some("bearer-with-long-tail-aaaaaaaaaa"));
|
#[test]
|
||||||
let calls = cb.invocations.lock().unwrap();
|
fn render_results_follows_kimi_cli_schema() {
|
||||||
assert_eq!(calls.len(), 1);
|
let results = vec![
|
||||||
assert_eq!(calls[0].0, ToolConsumer::WebSearch);
|
SearchResult {
|
||||||
assert_eq!(calls[0].1.as_deref(), Some("bearer-with-"));
|
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!(
|
assert_eq!(
|
||||||
calls[0].1.as_deref().map(str::len),
|
content,
|
||||||
Some(crate::attribution::SENT_BEARER_PREFIX_LEN),
|
"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]
|
#[test]
|
||||||
fn record_401_attribution_is_noop_without_callback() {
|
fn render_results_deduplicates_citations() {
|
||||||
let config = WebSearchConfig::Enabled {
|
let hit = SearchResult {
|
||||||
api_key: "test-key".to_string(),
|
site_name: String::new(),
|
||||||
base_url: "https://api.x.ai/v1".to_string(),
|
title: "T".into(),
|
||||||
model: "test-model".to_string(),
|
url: "https://same.example".into(),
|
||||||
extra_headers: IndexMap::new(),
|
snippet: "S".into(),
|
||||||
alpha_test_key: None,
|
content: String::new(),
|
||||||
|
date: String::new(),
|
||||||
};
|
};
|
||||||
let client = WebSearchClient::new(&config, None).expect("client should build");
|
let (_, citations) = render_results(&[hit.clone(), hit]);
|
||||||
client.record_401_attribution(Some("any-bearer"));
|
assert_eq!(citations, ["https://same.example"]);
|
||||||
client.record_401_attribution(None);
|
|
||||||
}
|
}
|
||||||
#[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<String> {
|
|
||||||
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]
|
#[tokio::test]
|
||||||
async fn static_api_key_is_fallback_when_provider_returns_none() {
|
async fn search_sends_kimi_wire_contract_and_parses_results() {
|
||||||
use wiremock::matchers::{header, method, path};
|
use wiremock::matchers::{body_json, header, method, path};
|
||||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
let server = MockServer::start().await;
|
let server = MockServer::start().await;
|
||||||
Mock::given(method("POST"))
|
Mock::given(method("POST"))
|
||||||
.and(path("/responses"))
|
.and(path("/search"))
|
||||||
.and(header("Authorization", "Bearer static-key-from-config"))
|
.and(header("authorization", "Bearer test-key"))
|
||||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!(
|
.and(header("x-msh-tool-call-id", "call-42"))
|
||||||
{ "id" : "resp_test", "object" : "response", "created_at" :
|
.and(body_json(serde_json::json!({
|
||||||
1234567890, "status" : "completed", "model" : "test-model",
|
"text_query": "rust ownership",
|
||||||
"output" : [{ "type" : "message", "id" : "msg_1", "status" :
|
"limit": 5,
|
||||||
"completed", "role" : "assistant", "content" : [{ "type" :
|
"enable_page_crawling": false,
|
||||||
"output_text", "text" : "search result", "annotations" : []
|
"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)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
let config = WebSearchConfig::Enabled {
|
|
||||||
api_key: "static-key-from-config".to_string(),
|
let client =
|
||||||
base_url: server.uri(),
|
WebSearchClient::new(&enabled_config(&format!("{}/search", server.uri())), None)
|
||||||
model: "test-model".to_string(),
|
.unwrap();
|
||||||
extra_headers: IndexMap::new(),
|
let (content, citations) = client
|
||||||
alpha_test_key: None,
|
.search("rust ownership", 5, false, "call-42")
|
||||||
};
|
|
||||||
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)
|
|
||||||
.await
|
.await
|
||||||
.expect("search must succeed with static key fallback");
|
.unwrap();
|
||||||
assert_eq!(content, "search result");
|
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]
|
#[tokio::test]
|
||||||
async fn provider_key_overrides_static_key() {
|
async fn search_maps_401_to_unauthorized() {
|
||||||
use wiremock::matchers::{header, method, path};
|
use wiremock::matchers::{method, path};
|
||||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
struct FreshProvider;
|
|
||||||
impl crate::types::ApiKeyProvider for FreshProvider {
|
|
||||||
fn current_api_key(&self) -> Option<String> {
|
|
||||||
Some("fresh-key-from-provider".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let server = MockServer::start().await;
|
let server = MockServer::start().await;
|
||||||
Mock::given(method("POST"))
|
Mock::given(method("POST"))
|
||||||
.and(path("/responses"))
|
.and(path("/search"))
|
||||||
.and(header("Authorization", "Bearer fresh-key-from-provider"))
|
.respond_with(ResponseTemplate::new(401))
|
||||||
.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" : [] }]
|
|
||||||
}] }
|
|
||||||
)))
|
|
||||||
.mount(&server)
|
.mount(&server)
|
||||||
.await;
|
.await;
|
||||||
let config = WebSearchConfig::Enabled {
|
let client =
|
||||||
api_key: "stale-static-key".to_string(),
|
WebSearchClient::new(&enabled_config(&format!("{}/search", server.uri())), None)
|
||||||
base_url: server.uri(),
|
.unwrap();
|
||||||
model: "test-model".to_string(),
|
let err = client.search("q", 5, false, "c").await.unwrap_err();
|
||||||
extra_headers: IndexMap::new(),
|
assert!(err.to_string().contains("401"), "{err}");
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
#[test]
|
|
||||||
fn test_extract_citations_no_annotations() {
|
#[tokio::test]
|
||||||
let response = response_from_json(serde_json::json!(
|
async fn search_surfaces_server_errors() {
|
||||||
{ "id" : "resp_test", "object" : "response", "created_at" : 1234567890,
|
use wiremock::matchers::{method, path};
|
||||||
"status" : "completed", "model" : "test-model", "output" : [{ "type" :
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
"message", "id" : "msg_1", "status" : "completed", "role" : "assistant",
|
let server = MockServer::start().await;
|
||||||
"content" : [{ "type" : "output_text", "text" :
|
Mock::given(method("POST"))
|
||||||
"Plain text with no annotations", "annotations" : [] }] }] }
|
.and(path("/search"))
|
||||||
));
|
.respond_with(ResponseTemplate::new(503))
|
||||||
let citations = extract_citations(&response);
|
.mount(&server)
|
||||||
assert!(citations.is_empty());
|
.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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
use indexmap::IndexMap;
|
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.
|
/// The Kimi search service exists only on the Kimi Code subscription channel
|
||||||
/// Use `Enabled { … }` to provide credentials and endpoint configuration.
|
/// (`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)]
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||||
#[serde(tag = "status", rename_all = "snake_case")]
|
#[serde(tag = "status", rename_all = "snake_case")]
|
||||||
pub enum WebSearchConfig {
|
pub enum WebSearchConfig {
|
||||||
#[default]
|
#[default]
|
||||||
Disabled,
|
Disabled,
|
||||||
Enabled {
|
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,
|
api_key: String,
|
||||||
base_url: String,
|
|
||||||
model: String,
|
|
||||||
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
||||||
extra_headers: IndexMap<String, String>,
|
extra_headers: IndexMap<String, String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
alpha_test_key: Option<String>,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,24 +29,19 @@ impl WebSearchConfig {
|
|||||||
matches!(self, Self::Enabled { .. })
|
matches!(self, Self::Enabled { .. })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a copy safe for returning to clients.
|
/// Return a copy safe for returning to clients: the `api_key` is
|
||||||
///
|
/// replaced with `"***REDACTED***"`.
|
||||||
/// The `api_key` is replaced with `"***REDACTED***"` and the optional
|
|
||||||
/// extra access key field is stripped.
|
|
||||||
pub fn redacted(&self) -> Self {
|
pub fn redacted(&self) -> Self {
|
||||||
match self {
|
match self {
|
||||||
Self::Disabled => Self::Disabled,
|
Self::Disabled => Self::Disabled,
|
||||||
Self::Enabled {
|
Self::Enabled {
|
||||||
base_url,
|
search_url,
|
||||||
model,
|
|
||||||
extra_headers,
|
extra_headers,
|
||||||
..
|
..
|
||||||
} => Self::Enabled {
|
} => Self::Enabled {
|
||||||
|
search_url: search_url.clone(),
|
||||||
api_key: "***REDACTED***".to_string(),
|
api_key: "***REDACTED***".to_string(),
|
||||||
base_url: base_url.clone(),
|
|
||||||
model: model.clone(),
|
|
||||||
extra_headers: extra_headers.clone(),
|
extra_headers: extra_headers.clone(),
|
||||||
alpha_test_key: None,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,71 +57,38 @@ mod tests {
|
|||||||
assert!(!config.is_enabled());
|
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]
|
#[test]
|
||||||
fn test_config_redacted() {
|
fn test_config_redacted() {
|
||||||
let mut headers = IndexMap::new();
|
let mut headers = IndexMap::new();
|
||||||
headers.insert("X-Custom".to_string(), "value".to_string());
|
headers.insert("X-Custom".to_string(), "value".to_string());
|
||||||
let config = WebSearchConfig::Enabled {
|
let config = WebSearchConfig::Enabled {
|
||||||
|
search_url: "https://api.kimi.com/coding/v1/search".to_string(),
|
||||||
api_key: "secret-key-12345".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,
|
extra_headers: headers,
|
||||||
alpha_test_key: Some("alpha-secret".to_string()),
|
|
||||||
};
|
};
|
||||||
let redacted = config.redacted();
|
match config.redacted() {
|
||||||
match redacted {
|
|
||||||
WebSearchConfig::Enabled {
|
WebSearchConfig::Enabled {
|
||||||
|
search_url,
|
||||||
api_key,
|
api_key,
|
||||||
base_url,
|
|
||||||
model,
|
|
||||||
extra_headers,
|
extra_headers,
|
||||||
alpha_test_key,
|
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(api_key, "***REDACTED***");
|
assert_eq!(api_key, "***REDACTED***");
|
||||||
assert_eq!(base_url, "https://api.x.ai/v1");
|
assert_eq!(search_url, "https://api.kimi.com/coding/v1/search");
|
||||||
assert_eq!(model, "test-web-search-model");
|
|
||||||
assert_eq!(extra_headers.get("X-Custom").unwrap(), "value");
|
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]
|
#[test]
|
||||||
fn test_config_serde_roundtrip() {
|
fn test_config_serde_roundtrip() {
|
||||||
let config = WebSearchConfig::Enabled {
|
let config = WebSearchConfig::Enabled {
|
||||||
|
search_url: "https://api.kimi.com/coding/v1/search".to_string(),
|
||||||
api_key: "key".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(),
|
extra_headers: IndexMap::new(),
|
||||||
alpha_test_key: None,
|
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
let parsed: WebSearchConfig = serde_json::from_str(&json).unwrap();
|
let parsed: WebSearchConfig = serde_json::from_str(&json).unwrap();
|
||||||
assert!(parsed.is_enabled());
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1034,7 +1034,10 @@ impl ToolRegistryBuilder {
|
|||||||
if let crate::implementations::grok_build::web_fetch::WebFetchConfig::Enabled { params } =
|
if let crate::implementations::grok_build::web_fetch::WebFetchConfig::Enabled { params } =
|
||||||
&ctx.web_fetch_config
|
&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) => {
|
Ok(client) => {
|
||||||
resources.insert(client);
|
resources.insert(client);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,3 +24,22 @@ pub(crate) async fn resolve_bearer(provider: Option<&SharedApiKeyProvider>) -> O
|
|||||||
None => None,
|
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<String> {
|
||||||
|
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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -187,7 +187,8 @@ mod tests {
|
|||||||
assert_eq!(kill.unwrap().task_id, "t1");
|
assert_eq!(kill.unwrap().task_id, "t1");
|
||||||
let ws: Result<WebSearchInput, _> = ToolInput::WebSearch(WebSearchInput {
|
let ws: Result<WebSearchInput, _> = ToolInput::WebSearch(WebSearchInput {
|
||||||
query: "q".into(),
|
query: "q".into(),
|
||||||
allowed_domains: None,
|
limit: None,
|
||||||
|
include_content: None,
|
||||||
})
|
})
|
||||||
.try_into();
|
.try_into();
|
||||||
assert_eq!(ws.unwrap().query, "q");
|
assert_eq!(ws.unwrap().query, "q");
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ auto_update = true # check for updates on launch
|
|||||||
|
|
||||||
[models]
|
[models]
|
||||||
default = "grok-build" # model used for new sessions
|
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.<id>] value always wins.
|
# Defaults applied to every model; a per-model [model.<id>] value always wins.
|
||||||
# See "Custom Models" for the per-model overrides and full details.
|
# See "Custom Models" for the per-model overrides and full details.
|
||||||
|
|||||||
@@ -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
|
## Using Custom Models
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ pub async fn connect(cancel: &CancellationToken, flags: ConnectFlags) -> Result<
|
|||||||
cwd: None,
|
cwd: None,
|
||||||
is_headless: false,
|
is_headless: false,
|
||||||
cli_subagents: Some(flags.subagents),
|
cli_subagents: Some(flags.subagents),
|
||||||
cli_web_search_model: None,
|
|
||||||
cli_session_summary_model: None,
|
cli_session_summary_model: None,
|
||||||
cli_experimental_memory: flags.experimental_memory,
|
cli_experimental_memory: flags.experimental_memory,
|
||||||
cli_no_memory: flags.no_memory,
|
cli_no_memory: flags.no_memory,
|
||||||
|
|||||||
@@ -1730,9 +1730,9 @@ pub(in crate::app::dispatch) fn clear_fork_secondary_model(app: &mut AppView) ->
|
|||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
// `web_search_model`, `session_summary_model`, and
|
// `session_summary_model` and `default_reasoning_effort` setters were
|
||||||
// `default_reasoning_effort` setters were removed alongside their
|
// removed alongside their registry entries. Mirror fields and TOML
|
||||||
// registry entries. Mirror fields and TOML schema stay for compat.
|
// schema stay for compat.
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// max_thoughts_width — Int-valued setting. Registry surface is `i64`;
|
// max_thoughts_width — Int-valued setting. Registry surface is `i64`;
|
||||||
|
|||||||
@@ -5668,9 +5668,8 @@ mod tests {
|
|||||||
"plan_mode",
|
"plan_mode",
|
||||||
// SHELL-owned default_model (Models category).
|
// SHELL-owned default_model (Models category).
|
||||||
"default_model",
|
"default_model",
|
||||||
// Models category. `default_reasoning_effort`,
|
// Models category. `default_reasoning_effort` and
|
||||||
// `web_search_model`, and `session_summary_model` are
|
// `session_summary_model` are not exposed in the modal.
|
||||||
// not exposed in the modal.
|
|
||||||
"fork_secondary_model",
|
"fork_secondary_model",
|
||||||
// `auto_compact_threshold_percent` (Session category) is
|
// `auto_compact_threshold_percent` (Session category) is
|
||||||
// not exposed in the modal.
|
// not exposed in the modal.
|
||||||
|
|||||||
@@ -594,7 +594,8 @@ mod tests {
|
|||||||
use kigi_tools::types::ToolInput;
|
use kigi_tools::types::ToolInput;
|
||||||
let input = ToolInput::WebSearch(WebSearchInput {
|
let input = ToolInput::WebSearch(WebSearchInput {
|
||||||
query: "rust lang".into(),
|
query: "rust lang".into(),
|
||||||
allowed_domains: None,
|
limit: None,
|
||||||
|
include_content: None,
|
||||||
});
|
});
|
||||||
let access = AccessKind::from(&input);
|
let access = AccessKind::from(&input);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -428,11 +428,9 @@ impl SessionContextFactory for WorkspaceSessionContextFactory {
|
|||||||
tier_restricted: false,
|
tier_restricted: false,
|
||||||
},
|
},
|
||||||
WebSearchConfig::Enabled {
|
WebSearchConfig::Enabled {
|
||||||
|
search_url: format!("{}/search", url.trim_end_matches('/')),
|
||||||
api_key: token,
|
api_key: token,
|
||||||
base_url: url.clone(),
|
|
||||||
model: default_web_search_model(),
|
|
||||||
extra_headers: headers,
|
extra_headers: headers,
|
||||||
alpha_test_key: None,
|
|
||||||
},
|
},
|
||||||
AppBuilderDeployerConfig::default(),
|
AppBuilderDeployerConfig::default(),
|
||||||
)
|
)
|
||||||
@@ -515,9 +513,6 @@ fn build_web_fetch_config() -> kigi_tools::implementations::grok_build::web_fetc
|
|||||||
}
|
}
|
||||||
WebFetchConfig::Enabled { params }
|
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"))]
|
#[cfg(any(test, feature = "test-support"))]
|
||||||
pub mod test_support {
|
pub mod test_support {
|
||||||
use crate::config::SessionContextFactory;
|
use crate::config::SessionContextFactory;
|
||||||
|
|||||||
Reference in New Issue
Block a user