The 7th registry row and the first pure-pattern cycle: id "groq", GROQ_API_KEY > auth.json "groq" scope, https://api.groq.com/openai/v1 with KIGI_GROQ_BASE_URL override, OpenAI listing + ChatCompletions + Passthrough dialect (Groq accepts the OpenAI-style reasoning_effort scalar verbatim), enrichment-backed metadata with the tool-calling restriction (the listing carries whisper/tts/guard noise — 8 of 15 enrichment entries are non-chat). Review verdict: faithful pattern repeat, zero blocking findings — row facts verified against live Groq docs, registry integrity at 7, e2e proven strong on both axes (restriction + dialect mapping). Added the recommended pin: managed keys split on the FIRST slash, so Groq's provider-native slashed ids (openai/gpt-oss-120b and 10 more) round-trip as groq/openai/gpt-oss-120b → (Groq, openai/gpt-oss-120b). Also fixes a pre-existing test race this cycle surfaced: the enterprise- endpoints test asserted moonshot's fixed base while non-serial, racing serial tests that legitimately point KIGI_MOONSHOT_CN_BASE_URL at wiremock; now serial + env-unset like its documented siblings. Two display-only advisories logged as tracked debt (Groq's delta.reasoning field invisible in the TUI; platform-generic rate-limit copy).
9741 lines
390 KiB
Rust
9741 lines
390 KiB
Rust
use crate::agent::auth_method::ModelByok;
|
||
use crate::agent::models_fetch::DEFAULT_CONTEXT_WINDOW;
|
||
use crate::auth::{AuthManager, KimiCodeConfig};
|
||
use crate::{config::StorageMode, sampling::ApiBackend, tools::config::ShellToolsetConfig};
|
||
use agent_client_protocol as acp;
|
||
use indexmap::IndexMap;
|
||
use kigi_agent::prompt::skills::SkillsConfig;
|
||
use kigi_sampler::{AuthScheme, SamplerConfig};
|
||
use kigi_sampling_types::{
|
||
CompactionAtTokens, CompactionsRemaining, REASONING_EFFORT_META_KEY,
|
||
REASONING_EFFORTS_META_KEY, ReasoningEffort, ReasoningEffortOption,
|
||
reasoning_effort_meta_value, reasoning_efforts_meta_value,
|
||
};
|
||
use kigi_tools::types::compat::{
|
||
COMPAT_CELLS, CompatConfig, CompatConfigToml, CompatRemoteKey, CompatSurface, CompatVendor,
|
||
};
|
||
use serde::{Deserialize, Serialize};
|
||
use std::num::NonZeroU64;
|
||
use std::path::PathBuf;
|
||
use std::sync::Arc;
|
||
/// The mode in which the agent is running.
|
||
/// Determines behavior like relay sync enablement.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum AgentMode {
|
||
/// TUI interactive mode - full UI with relay sync support
|
||
Tui,
|
||
/// Headless mode - no UI, connected to relay WebSocket
|
||
Headless,
|
||
/// Stdio mode - JSON-RPC over stdin/stdout
|
||
Stdio,
|
||
/// Server mode - WebSocket server for external clients
|
||
Serve,
|
||
/// Leader mode - IPC server for follower clients
|
||
Leader,
|
||
/// Generic/unknown mode
|
||
#[default]
|
||
Generic,
|
||
}
|
||
/// Default agent type when the server or user config doesn't specify one.
|
||
pub const DEFAULT_AGENT_TYPE: &str = "kigi-plan";
|
||
/// Serde default for `ModelInfo.agent_type` and `ModelEntryConfig.agent_type`.
|
||
pub fn default_agent_type() -> String {
|
||
DEFAULT_AGENT_TYPE.to_owned()
|
||
}
|
||
/// One or more environment variable names that may hold a model API key.
|
||
///
|
||
/// Serde `untagged`: accepts a string or an array in TOML/JSON.
|
||
///
|
||
/// ```toml
|
||
/// env_key = "ANTHROPIC_AUTH_TOKEN"
|
||
/// # or
|
||
/// env_key = ["ANTHROPIC_AUTH_TOKEN", "LC_ANTHROPIC_AUTH_TOKEN"]
|
||
/// ```
|
||
///
|
||
/// At resolve time the **first set, non-blank** value wins (e.g. SSH
|
||
/// `AcceptEnv LC_*` forwarding of the Bottlerocket token).
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(untagged)]
|
||
pub enum EnvKeys {
|
||
One(String),
|
||
Many(Vec<String>),
|
||
}
|
||
impl EnvKeys {
|
||
/// Single-name convenience constructor.
|
||
pub fn single(name: impl Into<String>) -> Self {
|
||
Self::One(name.into())
|
||
}
|
||
/// Construct from an ordered list (empty names dropped; 0/1/N → Many/One/Many).
|
||
pub fn new(names: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
||
let names: Vec<String> = names
|
||
.into_iter()
|
||
.map(Into::into)
|
||
.filter(|s| !s.is_empty())
|
||
.collect();
|
||
match names.as_slice() {
|
||
[] => Self::Many(Vec::new()),
|
||
[_] => Self::One(names.into_iter().next().expect("len 1")),
|
||
_ => Self::Many(names),
|
||
}
|
||
}
|
||
pub fn is_empty(&self) -> bool {
|
||
match self {
|
||
Self::One(s) => s.is_empty(),
|
||
Self::Many(v) => v.is_empty(),
|
||
}
|
||
}
|
||
/// Configured names in priority order.
|
||
pub fn names(&self) -> Vec<&str> {
|
||
match self {
|
||
Self::One(s) => vec![s.as_str()],
|
||
Self::Many(v) => v.iter().map(String::as_str).collect(),
|
||
}
|
||
}
|
||
/// First name only (useful for single-key assertions / display).
|
||
pub fn primary(&self) -> Option<&str> {
|
||
match self {
|
||
Self::One(s) if !s.is_empty() => Some(s.as_str()),
|
||
Self::One(_) => None,
|
||
Self::Many(v) => v.iter().map(String::as_str).find(|s| !s.is_empty()),
|
||
}
|
||
}
|
||
/// Resolve the first set, non-blank process env value among configured names.
|
||
pub fn resolve_value(&self) -> Option<String> {
|
||
self.resolve_value_with(|name| std::env::var(name).ok())
|
||
}
|
||
/// Testable resolve with an injected getenv.
|
||
pub fn resolve_value_with(
|
||
&self,
|
||
mut getenv: impl FnMut(&str) -> Option<String>,
|
||
) -> Option<String> {
|
||
for name in self.names() {
|
||
if let Some(value) = getenv(name)
|
||
&& !value.trim().is_empty()
|
||
{
|
||
return Some(value);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
}
|
||
/// Semantic equality: compares the ordered name lists, so `One("X")` and
|
||
/// `Many(["X"])` (the shape serde produces for `["X"]`) compare equal.
|
||
impl PartialEq for EnvKeys {
|
||
fn eq(&self, other: &Self) -> bool {
|
||
self.names() == other.names()
|
||
}
|
||
}
|
||
impl Eq for EnvKeys {}
|
||
impl std::fmt::Display for EnvKeys {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.write_str(&self.names().join(", "))
|
||
}
|
||
}
|
||
/// Configuration for API endpoints.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct EndpointsConfig {
|
||
/// cli chat proxy base URL. `None` = unset (resolvers apply the default);
|
||
/// `Some` = explicitly configured. Tracking explicitness (vs comparing to the
|
||
/// default value) lets an org pin the proxy to the default on purpose.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub coding_api_base_url: Option<String>,
|
||
/// Base URL for direct (BYOK / external-API-key) API calls.
|
||
///
|
||
/// There is NO built-in default endpoint: a BYOK/custom endpoint must be
|
||
/// explicitly configured via `[endpoints] api_base_url` in config.toml,
|
||
/// the `KIGI_API_BASE_URL` env var, `--api-base-url`, or a managed
|
||
/// requirements pin. `None` = not configured.
|
||
/// Accepts the legacy `xai_api_base_url` config key.
|
||
#[serde(alias = "xai_api_base_url", skip_serializing_if = "Option::is_none")]
|
||
pub api_base_url: Option<String>,
|
||
/// Optional extra access-header value (applied only with the optional
|
||
/// non-production feature, and only for matching first-party hosts).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub alpha_test_key: Option<String>,
|
||
/// Env: `KIGI_MODELS_BASE_URL`. Enables custom endpoint mode.
|
||
/// List URL defaults to `{models_base_url}/models`.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub models_base_url: Option<String>,
|
||
/// Env: `KIGI_MODELS_LIST_URL`. Overrides the default `{base}/models` list URL.
|
||
#[serde(alias = "models_endpoint", skip_serializing_if = "Option::is_none")]
|
||
pub models_list_url: Option<String>,
|
||
/// Env: `KIGI_FEEDBACK_BASE_URL`. Where feedback submissions go.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub feedback_base_url: Option<String>,
|
||
/// Env: `KIGI_DEPLOYMENT_KEY`. Management API key for enterprise deployments.
|
||
/// Sent on telemetry and service requests for deployment-level attribution.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub deployment_key: Option<String>,
|
||
/// Env: `KIGI_MANAGED_CONFIG_URL`. Override the managed config endpoint.
|
||
/// Defaults to `{proxy_url()}/deployment/config`.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub managed_config_url: Option<String>,
|
||
/// Read by `load_management_api_key_sync()`. Declared for `serde_ignored`.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub management_api_key: Option<String>,
|
||
/// Read by `load_gcs_service_account_key_sync()`. Declared for `serde_ignored`.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub gcs_service_account_key: Option<String>,
|
||
}
|
||
/// A blank or whitespace-only override counts as unset. Single source of truth
|
||
/// for the "empty value = not configured" rule shared by the endpoint resolvers.
|
||
fn blank_as_unset(opt: &Option<String>) -> Option<String> {
|
||
opt.as_deref()
|
||
.filter(|s| !s.trim().is_empty())
|
||
.map(str::to_owned)
|
||
}
|
||
impl EndpointsConfig {
|
||
pub fn has_custom_endpoint(&self) -> bool {
|
||
self.models_base_url.is_some() || self.models_list_url.is_some()
|
||
}
|
||
/// `default()` plus merged managed/requirements endpoint overrides, so
|
||
/// startup fetches use the configured (not public) endpoints. Only merges
|
||
/// layers — never derives one endpoint from another. Falls back to
|
||
/// `default()` on load failure.
|
||
pub fn from_effective_config() -> Self {
|
||
match crate::config::load_effective_config() {
|
||
Ok(cfg) => Self::from_config_value(&cfg),
|
||
Err(_) => Self::default(),
|
||
}
|
||
}
|
||
/// Layer the `[endpoints]` table from `config` over the env/default base.
|
||
/// No field is derived from another — defaulting is done by the resolvers.
|
||
pub(crate) fn from_config_value(config: &toml::Value) -> Self {
|
||
let mut base = match toml::Value::try_from(Self::default()) {
|
||
Ok(v) => v,
|
||
Err(_) => return Self::default(),
|
||
};
|
||
if let Some(endpoints) = config.get("endpoints") {
|
||
crate::config::deep_merge_toml(&mut base, endpoints);
|
||
}
|
||
base.try_into().unwrap_or_default()
|
||
}
|
||
/// The subscription proxy base URL through which all auxiliary services (and
|
||
/// OAuth/session inference) resolve: explicit `coding_api_base_url`, else
|
||
/// [`kigi_env::coding_api_base_url`]. NEVER falls back to `api_base_url` —
|
||
/// that is the inference endpoint (API-key auth) only.
|
||
pub fn proxy_url(&self) -> String {
|
||
blank_as_unset(&self.coding_api_base_url).unwrap_or_else(kigi_env::coding_api_base_url)
|
||
}
|
||
pub fn resolve_inference_base_url(&self) -> String {
|
||
self.models_base_url
|
||
.clone()
|
||
.unwrap_or_else(|| self.proxy_url())
|
||
}
|
||
/// Feedback endpoint — an auxiliary service, so it defaults to the
|
||
/// cli-chat-proxy, never `api_base_url`.
|
||
pub fn resolve_feedback_base_url(&self) -> String {
|
||
blank_as_unset(&self.feedback_base_url).unwrap_or_else(|| self.proxy_url())
|
||
}
|
||
/// Managed deployment-config URL (`kigi setup`): explicit `managed_config_url`,
|
||
/// else `proxy_url` + `/deployment/config`. Never `api_base_url`, so the
|
||
/// deployment key reaches the proxy, not the inference host.
|
||
pub fn resolve_managed_config_url(&self) -> String {
|
||
blank_as_unset(&self.managed_config_url).unwrap_or_else(|| {
|
||
format!(
|
||
"{}/deployment/config",
|
||
self.proxy_url().trim_end_matches('/')
|
||
)
|
||
})
|
||
}
|
||
/// `models_list_url` > `{models_base_url}/models` > `{proxy_base_url}/models`.
|
||
pub fn resolve_models_list_url(&self) -> String {
|
||
if let Some(ref url) = self.models_list_url {
|
||
return url.clone();
|
||
}
|
||
let base = self
|
||
.models_base_url
|
||
.clone()
|
||
.unwrap_or_else(|| self.proxy_url());
|
||
format!("{}/models", base)
|
||
}
|
||
}
|
||
impl Default for EndpointsConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
coding_api_base_url: std::env::var("KIGI_CODE_BASE_URL").ok(),
|
||
api_base_url: env_string("KIGI_API_BASE_URL"),
|
||
alpha_test_key: None,
|
||
models_base_url: env_string("KIGI_MODELS_BASE_URL"),
|
||
models_list_url: env_string("KIGI_MODELS_LIST_URL"),
|
||
feedback_base_url: env_string("KIGI_FEEDBACK_BASE_URL"),
|
||
deployment_key: env_string("KIGI_DEPLOYMENT_KEY"),
|
||
managed_config_url: env_string("KIGI_MANAGED_CONFIG_URL"),
|
||
management_api_key: None,
|
||
gcs_service_account_key: None,
|
||
}
|
||
}
|
||
}
|
||
pub use kigi_config_types::{BoolFlag, ConfigSource, LazinessDetectorPerModelConfig, Resolved};
|
||
/// Resolution result for a `/goal` role's model selection.
|
||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||
pub(crate) enum GoalRoleModelChoice {
|
||
/// Use the current (parent) model + the parent's agent type.
|
||
#[default]
|
||
InheritCurrent,
|
||
/// Use this explicit pair (subject to auth/fail-open at spawn time).
|
||
Explicit(crate::util::config::GoalRoleModel),
|
||
}
|
||
/// A requirement pin from `requirements.toml`. Wins over all other sources.
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct Constrained<T> {
|
||
pin: Option<T>,
|
||
source: Option<crate::config::RequirementSource>,
|
||
}
|
||
impl<T: Clone> Constrained<T> {
|
||
pub fn pin(&mut self, value: T, source: crate::config::RequirementSource) {
|
||
self.pin = Some(value);
|
||
self.source = Some(source);
|
||
}
|
||
pub fn pinned(&self) -> Option<T> {
|
||
self.pin.clone()
|
||
}
|
||
pub fn source(&self) -> Option<&crate::config::RequirementSource> {
|
||
self.source.as_ref()
|
||
}
|
||
}
|
||
/// Enforced requirements from `requirements.toml`. Pinned values win over all other sources.
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct Requirements {
|
||
pub feedback: Constrained<bool>,
|
||
pub lsp_tools: Constrained<bool>,
|
||
pub tool_search: Constrained<bool>,
|
||
pub web_fetch: Constrained<bool>,
|
||
pub ask_user_question: Constrained<bool>,
|
||
pub write_file: Constrained<bool>,
|
||
/// Voice dictation (STT). Pin via requirements/managed `[features] voice_mode`.
|
||
pub voice_mode: Constrained<bool>,
|
||
pub sandbox_auto_allow_bash: Constrained<bool>,
|
||
pub sandbox_profile: Constrained<String>,
|
||
pub respect_gitignore: Constrained<bool>,
|
||
pub remote_fetch: Constrained<bool>,
|
||
}
|
||
/// Inputs for resolving `#[serde(skip)]` runtime fields after `new_from_toml_cfg()`.
|
||
///
|
||
/// Constructed by each binary from its CLI args and startup state, then passed
|
||
/// to [`Config::resolve_runtime_fields`].
|
||
pub struct RuntimeResolutionContext<'a> {
|
||
pub raw_config: &'a toml::Value,
|
||
pub remote_settings: Option<&'a crate::util::config::RemoteSettings>,
|
||
pub cwd: Option<&'a std::path::Path>,
|
||
pub is_headless: bool,
|
||
/// `Some(true)` = CLI explicitly enabled, `None` = defer to config/env/remote.
|
||
pub cli_subagents: Option<bool>,
|
||
pub cli_session_summary_model: Option<&'a str>,
|
||
/// CLI `--experimental-memory` flag. Enables cross-session memory.
|
||
pub cli_experimental_memory: bool,
|
||
/// CLI `--no-memory` flag. Overrides all other memory settings.
|
||
pub cli_no_memory: bool,
|
||
/// CLI `--disable-web-search` flag. ORed with config.toml value.
|
||
pub disable_web_search: bool,
|
||
/// CLI `--todo-gate` flag. Session-scoped — not persisted.
|
||
pub todo_gate: bool,
|
||
/// CLI `--laziness-debug-log <path>`. When `Some`, the Layer-3
|
||
/// classifier fires after every turn (bypassing the idle wait /
|
||
/// per-model gate / nudge cap) and writes a JSONL line per fire.
|
||
/// Observation-only. Session-scoped — not persisted.
|
||
pub laziness_debug_log: Option<&'a std::path::Path>,
|
||
/// CLI `--storage-mode` override. `None` = defer to env/remote/default.
|
||
pub storage_mode: Option<&'a str>,
|
||
}
|
||
/// Read an env var as a trimmed string. Returns `None` if unset or empty/whitespace-only.
|
||
pub(crate) fn env_string(name: &str) -> Option<String> {
|
||
let value = std::env::var(name).ok()?;
|
||
let trimmed = value.trim();
|
||
if trimmed.is_empty() {
|
||
None
|
||
} else {
|
||
Some(trimmed.to_string())
|
||
}
|
||
}
|
||
pub use kigi_config::env_bool;
|
||
/// Compaction-mode precedence (env > config > remote settings > default, with
|
||
/// unrecognized values at each source falling through). `remote` sits just
|
||
/// above the default, mirroring `feature_flag` in `resolve_bool_flag`. Pure so
|
||
/// it's unit-testable without mutating process env.
|
||
fn resolve_compaction_mode_from(
|
||
env: Option<&str>,
|
||
config: Option<&str>,
|
||
remote: Option<&str>,
|
||
) -> kigi_chat_state::CompactionMode {
|
||
use kigi_chat_state::CompactionMode;
|
||
env.and_then(CompactionMode::parse)
|
||
.or_else(|| config.and_then(CompactionMode::parse))
|
||
.or_else(|| remote.and_then(CompactionMode::parse))
|
||
.unwrap_or_default()
|
||
}
|
||
/// Compaction-detail precedence (env > config > remote settings > default). Pure.
|
||
/// Controls the per-turn verbatim detail in `segments` mode (default `verbose`).
|
||
fn resolve_compaction_detail_from(
|
||
env: Option<&str>,
|
||
config: Option<&str>,
|
||
remote: Option<&str>,
|
||
) -> kigi_chat_state::CompactionDetail {
|
||
use kigi_chat_state::CompactionDetail;
|
||
env.and_then(CompactionDetail::parse)
|
||
.or_else(|| config.and_then(CompactionDetail::parse))
|
||
.or_else(|| remote.and_then(CompactionDetail::parse))
|
||
.unwrap_or_default()
|
||
}
|
||
/// Resolve a single vendor-compat cell: env > `[compat]` TOML > remote settings
|
||
/// remote flag > default ON.
|
||
fn resolve_compat_cell(
|
||
env: &str,
|
||
cfg: Option<bool>,
|
||
remote: Option<bool>,
|
||
default: bool,
|
||
) -> Resolved<bool> {
|
||
resolve_compat_cell_with_env(kigi_config::env_bool(env), cfg, remote, default)
|
||
}
|
||
pub(crate) fn resolve_compat_cell_with_env(
|
||
env: Option<bool>,
|
||
cfg: Option<bool>,
|
||
remote: Option<bool>,
|
||
default: bool,
|
||
) -> Resolved<bool> {
|
||
if let Some(value) = env {
|
||
Resolved::new(value, ConfigSource::Env)
|
||
} else if let Some(value) = cfg {
|
||
Resolved::new(value, ConfigSource::Config)
|
||
} else if let Some(value) = remote {
|
||
Resolved::new(value, ConfigSource::Remote)
|
||
} else {
|
||
Resolved::new(default, ConfigSource::Default)
|
||
}
|
||
}
|
||
fn remote_compat_value(
|
||
remote: Option<&crate::util::config::RemoteSettings>,
|
||
key: Option<CompatRemoteKey>,
|
||
) -> Option<bool> {
|
||
let remote = remote?;
|
||
match key? {
|
||
CompatRemoteKey::CursorSkills => remote.cursor_skills_enabled,
|
||
CompatRemoteKey::CursorRules => remote.cursor_rules_enabled,
|
||
CompatRemoteKey::CursorAgents => remote.cursor_agents_enabled,
|
||
CompatRemoteKey::CursorMcps => remote.cursor_mcps_enabled,
|
||
CompatRemoteKey::CursorHooks => remote.cursor_hooks_enabled,
|
||
CompatRemoteKey::CursorSessions => remote.cursor_sessions_enabled,
|
||
CompatRemoteKey::ClaudeSkills => remote.claude_skills_enabled,
|
||
CompatRemoteKey::ClaudeRules => remote.claude_rules_enabled,
|
||
CompatRemoteKey::ClaudeAgents => remote.claude_agents_enabled,
|
||
CompatRemoteKey::ClaudeMcps => remote.claude_mcps_enabled,
|
||
CompatRemoteKey::ClaudeHooks => remote.claude_hooks_enabled,
|
||
CompatRemoteKey::ClaudeSessions => remote.claude_sessions_enabled,
|
||
CompatRemoteKey::CodexSessions => remote.codex_sessions_enabled,
|
||
}
|
||
}
|
||
/// Resolve vendor compatibility cells from TOML and remote settings.
|
||
fn resolve_compat_config(
|
||
config: &CompatConfigToml,
|
||
remote: Option<&crate::util::config::RemoteSettings>,
|
||
) -> CompatConfig {
|
||
let defaults = CompatConfig::default();
|
||
let mut resolved = defaults;
|
||
for cell in COMPAT_CELLS {
|
||
resolved.set(
|
||
cell,
|
||
resolve_compat_cell(
|
||
cell.env_var(),
|
||
config.value(cell),
|
||
remote_compat_value(remote, cell.remote_key()),
|
||
defaults.value(cell),
|
||
)
|
||
.value,
|
||
);
|
||
}
|
||
resolved
|
||
}
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub(crate) enum CompatConfigCellError {
|
||
Unavailable,
|
||
Malformed,
|
||
}
|
||
pub(crate) fn compat_config_cell(
|
||
raw_config: Result<&toml::Value, ()>,
|
||
cell: kigi_tools::types::compat::CompatCell,
|
||
) -> Result<Option<bool>, CompatConfigCellError> {
|
||
let raw = raw_config.map_err(|()| CompatConfigCellError::Unavailable)?;
|
||
let Some(compat) = raw.get("compat") else {
|
||
return Ok(None);
|
||
};
|
||
let compat = compat.as_table().ok_or(CompatConfigCellError::Malformed)?;
|
||
let Some(vendor) = compat.get(cell.vendor().as_str()) else {
|
||
return Ok(None);
|
||
};
|
||
let vendor = vendor.as_table().ok_or(CompatConfigCellError::Malformed)?;
|
||
let Some(value) = vendor.get(cell.surface().as_str()) else {
|
||
return Ok(None);
|
||
};
|
||
value
|
||
.as_bool()
|
||
.map(Some)
|
||
.ok_or(CompatConfigCellError::Malformed)
|
||
}
|
||
/// Resolve only picker-facing session cells from raw config independently.
|
||
pub fn resolve_compat_sessions_from_raw(
|
||
raw_config: Result<&toml::Value, ()>,
|
||
remote: Option<&crate::util::config::RemoteSettings>,
|
||
) -> CompatConfig {
|
||
let mut config = CompatConfigToml::default();
|
||
for cell in COMPAT_CELLS
|
||
.into_iter()
|
||
.filter(|cell| cell.surface() == CompatSurface::Sessions)
|
||
{
|
||
let value = match compat_config_cell(raw_config, cell) {
|
||
Ok(value) => value,
|
||
Err(error) => {
|
||
tracing::warn!(
|
||
vendor = cell.vendor().as_str(),
|
||
?error,
|
||
"invalid compat config; disabling foreign sessions"
|
||
);
|
||
Some(false)
|
||
}
|
||
};
|
||
match cell.vendor() {
|
||
CompatVendor::Cursor => config.cursor.sessions = value,
|
||
CompatVendor::Claude => config.claude.sessions = value,
|
||
CompatVendor::Codex => config.codex.sessions = value,
|
||
}
|
||
}
|
||
resolve_compat_config(&config, remote)
|
||
}
|
||
/// Resolve a string setting: cli > env > config > feature flag. `None` if no source provides a value.
|
||
pub(crate) fn resolve_string_flag(
|
||
cli_arg: Option<&str>,
|
||
env_var: &str,
|
||
config_val: Option<&str>,
|
||
feature_flag_val: Option<&str>,
|
||
) -> Option<Resolved<String>> {
|
||
if let Some(val) = cli_arg.filter(|s| !s.is_empty()) {
|
||
return Some(Resolved::new(val.to_owned(), ConfigSource::Cli));
|
||
}
|
||
if let Some(val) = env_string(env_var) {
|
||
return Some(Resolved::new(val, ConfigSource::Env));
|
||
}
|
||
if let Some(val) = config_val.filter(|s| !s.is_empty()) {
|
||
return Some(Resolved::new(val.to_owned(), ConfigSource::Config));
|
||
}
|
||
if let Some(val) = feature_flag_val.filter(|s| !s.is_empty()) {
|
||
return Some(Resolved::new(val.to_owned(), ConfigSource::Remote));
|
||
}
|
||
None
|
||
}
|
||
/// Resolve `enabled` for section-based configs (memory, subagents, etc.).
|
||
/// Feature flag only applies when the TOML section is absent.
|
||
pub(crate) fn resolve_enabled(
|
||
cli_flag: Option<bool>,
|
||
env_var: &str,
|
||
config_enabled: bool,
|
||
has_local_section: bool,
|
||
feature_flag_val: Option<bool>,
|
||
default: bool,
|
||
) -> Resolved<bool> {
|
||
let config_val = if has_local_section {
|
||
Some(config_enabled)
|
||
} else {
|
||
None
|
||
};
|
||
BoolFlag::env(env_var)
|
||
.cli(cli_flag)
|
||
.config(config_val)
|
||
.feature_flag(feature_flag_val)
|
||
.default(default)
|
||
.resolve()
|
||
}
|
||
|
||
/// Plugin system configuration from `[plugins]` section in config.toml.
|
||
///
|
||
/// ```toml
|
||
/// [plugins]
|
||
/// paths = ["~/my-plugins/custom-tools"]
|
||
/// disabled = ["user/a1b2c3d4/noisy-plugin"]
|
||
/// ```
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
pub struct PluginsConfig {
|
||
/// Additional plugin directory paths to load.
|
||
#[serde(default)]
|
||
pub paths: Vec<String>,
|
||
/// Plugin IDs or names to disable. Disabled plugins are discovered
|
||
/// but their components are not loaded into the session.
|
||
#[serde(default)]
|
||
pub disabled: Vec<String>,
|
||
/// Plugin IDs or names to explicitly enable. Used for project-scope plugins
|
||
/// which are disabled by default — adding a plugin here overrides that default.
|
||
#[serde(default)]
|
||
pub enabled: Vec<String>,
|
||
/// CLI `--plugin-dir` paths (populated by CLI arg processing, not config file).
|
||
#[serde(skip)]
|
||
pub cli_plugin_dirs: Vec<std::path::PathBuf>,
|
||
}
|
||
impl PluginsConfig {
|
||
/// Merge `enabledPlugins` from Claude settings files into this config.
|
||
///
|
||
/// Reads `enabledPlugins` from `~/.claude/settings.json` only (user scope).
|
||
/// Project-level `<git_root>/.claude/settings.json` is intentionally NOT
|
||
/// read here: a malicious repo could pre-populate `enabledPlugins` to
|
||
/// bypass the project-plugin auto-disable logic in `populate_plugin_lists`,
|
||
/// enabling attacker-controlled hooks (e.g. SessionStart → RCE).
|
||
/// Native `.kigi/config.toml` entries already present take precedence:
|
||
/// a name is only added if it isn't already in the opposite list.
|
||
pub fn merge_claude_enabled_plugins(&mut self, _cwd: Option<&std::path::Path>) {
|
||
if crate::claude_import::is_claude_import_marked_with_log("merge_claude_enabled_plugins") {
|
||
return;
|
||
}
|
||
let mut paths = Vec::new();
|
||
if let Some(home) = dirs::home_dir() {
|
||
paths.push(home.join(".claude").join("settings.json"));
|
||
}
|
||
for path in &paths {
|
||
let (claude_enabled, claude_disabled) =
|
||
kigi_agent::plugins::marketplace::load_enabled_disabled_plugins(path);
|
||
for name in claude_enabled {
|
||
if !self.disabled.contains(&name) && !self.enabled.contains(&name) {
|
||
self.enabled.push(name);
|
||
}
|
||
}
|
||
for name in claude_disabled {
|
||
if !self.enabled.contains(&name) && !self.disabled.contains(&name) {
|
||
self.disabled.push(name);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
/// Build a `DiscoveryConfig` from this plugins config.
|
||
pub fn to_discovery_config(&self) -> kigi_agent::plugins::discovery::DiscoveryConfig {
|
||
kigi_agent::plugins::discovery::DiscoveryConfig {
|
||
cli_plugin_dirs: self.cli_plugin_dirs.clone(),
|
||
config_paths: self.paths.iter().map(std::path::PathBuf::from).collect(),
|
||
disabled: self.disabled.clone(),
|
||
enabled: self.enabled.clone(),
|
||
}
|
||
}
|
||
}
|
||
/// Feedback submission configuration (`[feedback]` in config.toml).
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct FeedbackConfig {}
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct CompactionConfig {
|
||
pub memory_flush: Option<crate::config::MemoryFlushConfig>,
|
||
pub pruning: Option<crate::config::PruningConfig>,
|
||
}
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct CliConfig {
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub auto_update: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub dismissed_version: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub installer: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub npm_registry: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub channel: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub use_leader: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub show_tips: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub worktree_type: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub session_registry: Option<bool>,
|
||
/// User-layer value; use [`crate::util::config::resolve_minimum_version`]
|
||
/// for enforcement (semver-max across layers; managed floors can't be lowered).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub minimum_version: Option<String>,
|
||
/// Group sessions by repo in the picker and CLI listings.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub session_picker_grouped: Option<bool>,
|
||
}
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct DiagnosticsConfig {
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub crash_handler: Option<bool>,
|
||
}
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct ModelsConfig {
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub default: Option<String>,
|
||
/// The pre-campaign `models.default` (merged user/managed/requirements)
|
||
/// captured when a campaign is overriding the default, so model resolution can
|
||
/// recover if the campaign points at a model missing from the catalog. `None`
|
||
/// when there is nothing to recover to. Runtime-only; never serialized.
|
||
#[serde(skip)]
|
||
pub pre_campaign_default: Option<String>,
|
||
/// Whether an active campaign is currently overriding `models.default`. The
|
||
/// authoritative campaign-driven-default signal (set from the resolved active
|
||
/// set), correct even when the user has no base default. Runtime-only.
|
||
#[serde(skip)]
|
||
pub default_is_campaign_driven: bool,
|
||
/// Persisted effort for the default model; applied in `resolve_model_catalog`.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub default_reasoning_effort: Option<ReasoningEffort>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub session_summary: Option<String>,
|
||
/// Vision model used to transcribe user-supplied
|
||
/// images via a separate endpoint.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub image_description: Option<String>,
|
||
/// Model pin for next-prompt suggestions (tab-autocomplete ghost text).
|
||
/// Unset = remote pin, then the client hint / built-in bundled-model
|
||
/// default with the catalog guard; see `ModelOverrideConfig::resolve`.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub prompt_suggestion: Option<String>,
|
||
/// Restricts which models are user-selectable for normal chat (picker,
|
||
/// `/model`, `-m`). Non-matching models stay in the catalog but are never
|
||
/// shown, defaulted to, or selectable. Special/internal models (web_search,
|
||
/// image_description, subagents, fork secondary) are exempt.
|
||
///
|
||
/// Glob patterns (`*`, `?`, `[...]`) match the model id or catalog key,
|
||
/// case-sensitive. Empty = no restriction; an excluded explicit `default`/`-m`
|
||
/// is rejected at startup.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub allowed_models: Option<Vec<String>>,
|
||
/// Force `hidden = true` on these model IDs (still usable via `-m`).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub hidden_models: Option<Vec<String>>,
|
||
/// Remove these model IDs from the catalog entirely. Wins over `hidden_models`.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub disabled_models: Option<Vec<String>>,
|
||
/// Fallback `agent_type` for models without a per-model override.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub agent_type: Option<String>,
|
||
/// Global default request headers applied to every model. A per-model
|
||
/// `[model.<id>].extra_headers` entry overrides per key (case-insensitive).
|
||
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
||
pub extra_headers: IndexMap<String, String>,
|
||
/// Global default values applied to every model that leaves the field
|
||
/// unset; a per-model `[model.<id>]` value always wins. A deliberately
|
||
/// small, allow-listed subset of the per-model fields (only `Option` ones,
|
||
/// so "unset" is unambiguous). Future: these could consolidate into a
|
||
/// `[models.defaults]` sub-table mirroring the per-model schema 1:1; kept
|
||
/// flat for now as that is a larger refactor.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub temperature: Option<f32>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub top_p: Option<f32>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub max_completion_tokens: Option<u32>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub max_retries: Option<u32>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub inference_idle_timeout_secs: Option<u64>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub stream_tool_calls: Option<bool>,
|
||
}
|
||
/// `[platforms.<id>]` section from config.toml (PRD F2): API keys for the
|
||
/// fixed platform registry ([`kigi_models::PlatformId`]).
|
||
///
|
||
/// ```toml
|
||
/// [platforms.moonshot-cn]
|
||
/// api_key = "sk-..."
|
||
///
|
||
/// [platforms.moonshot-ai]
|
||
/// api_key = "sk-..."
|
||
/// ```
|
||
///
|
||
/// Env vars win over the config file:
|
||
/// `KIGI_MOONSHOT_CN_API_KEY` / `KIGI_MOONSHOT_AI_API_KEY` (platform-scoped)
|
||
/// then `KIGI_MOONSHOT_API_KEY` (both open platforms). The subscription
|
||
/// platform (`kimi-code`) authenticates via OAuth and takes no API key.
|
||
///
|
||
/// SECURITY: key values are never logged and never re-serialized
|
||
/// (`Config.platforms` is `skip_serializing`); only presence booleans may
|
||
/// appear in diagnostics.
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
pub struct PlatformsConfig {
|
||
#[serde(flatten)]
|
||
pub entries: IndexMap<String, PlatformCredentialConfig>,
|
||
}
|
||
|
||
impl PlatformsConfig {
|
||
/// The config-file API key for `platform`, blank-as-unset. Unknown
|
||
/// platform ids in `[platforms.*]` are warned about at load
|
||
/// ([`Self::warn_unknown_platforms`]) and never resolve.
|
||
pub fn config_api_key(&self, platform: kigi_models::PlatformId) -> Option<String> {
|
||
self.entries
|
||
.get(platform.as_str())
|
||
.and_then(|e| e.api_key.as_deref())
|
||
.filter(|k| !k.trim().is_empty())
|
||
.map(str::to_owned)
|
||
}
|
||
|
||
/// Warn (once per load) about `[platforms.<id>]` tables that don't name a
|
||
/// registry platform, so a typo like `moonshot_cn` fails loudly instead of
|
||
/// silently never matching. Key values are not logged.
|
||
pub fn warn_unknown_platforms(&self) {
|
||
for id in self.entries.keys() {
|
||
if kigi_models::PlatformId::parse(id).is_none() {
|
||
tracing::warn!(
|
||
platform = %id,
|
||
known = ?kigi_models::PlatformId::ALL
|
||
.iter()
|
||
.map(|p| p.as_str())
|
||
.collect::<Vec<_>>(),
|
||
"[platforms.{id}] does not match any registry platform; its api_key is ignored"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One `[platforms.<id>]` table.
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct PlatformCredentialConfig {
|
||
/// API key for this platform. NEVER logged; never re-serialized.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub api_key: Option<String>,
|
||
}
|
||
|
||
/// Resolve the API key for an API-key registry platform with injected env
|
||
/// and auth.json readers. Precedence: env > auth.json > config file — the
|
||
/// same "env always wins" rule the config-file layer already follows.
|
||
/// `None` for OAuth platforms and when nothing is configured. The batch
|
||
/// production caller is `PlatformApiKeys::resolve` (reads auth.json once
|
||
/// for the whole registry sweep). The returned value must never be logged.
|
||
pub(crate) fn resolve_platform_api_key_with(
|
||
platform: kigi_models::PlatformId,
|
||
platforms: &PlatformsConfig,
|
||
mut getenv: impl FnMut(&str) -> Option<String>,
|
||
stored: impl FnOnce(kigi_models::PlatformId) -> Option<String>,
|
||
) -> Option<String> {
|
||
for name in platform.api_key_env_names() {
|
||
if let Some(value) = getenv(name)
|
||
&& !value.trim().is_empty()
|
||
{
|
||
return Some(value);
|
||
}
|
||
}
|
||
if let Some(value) = stored(platform)
|
||
&& !value.trim().is_empty()
|
||
{
|
||
return Some(value);
|
||
}
|
||
platforms.config_api_key(platform)
|
||
}
|
||
|
||
/// Persist a platform API key into `~/.kigi/auth.json` under the platform-id
|
||
/// scope — the per-provider auth.json key contract that
|
||
/// [`resolve_platform_api_key`] reads back (env vars still win). Shared
|
||
/// writer for the CLI and the TUI login screen. Legacy `[platforms.*]`
|
||
/// config.toml keys remain a read-only fallback source.
|
||
///
|
||
/// SECURITY: the key lands in the file by design; it must never be logged,
|
||
/// and errors carry only path/IO context — never the key.
|
||
pub async fn save_platform_api_key(
|
||
platform: kigi_models::PlatformId,
|
||
api_key: &str,
|
||
) -> anyhow::Result<()> {
|
||
save_platform_api_key_in(&crate::util::kigi_home::kigi_home(), platform, api_key)
|
||
}
|
||
|
||
/// Home-injectable core of [`save_platform_api_key`] (tests use a tempdir).
|
||
pub fn save_platform_api_key_in(
|
||
kigi_home: &std::path::Path,
|
||
platform: kigi_models::PlatformId,
|
||
api_key: &str,
|
||
) -> anyhow::Result<()> {
|
||
anyhow::ensure!(
|
||
!platform.uses_oauth(),
|
||
"{} authenticates via OAuth and takes no API key",
|
||
platform.as_str(),
|
||
);
|
||
let api_key = api_key.trim();
|
||
anyhow::ensure!(!api_key.is_empty(), "API key must not be empty");
|
||
crate::auth::store_platform_api_key(kigi_home, platform, api_key).map_err(|e| {
|
||
anyhow::anyhow!(
|
||
"saving {} API key to auth.json in {}: {e}",
|
||
platform.as_str(),
|
||
kigi_home.display()
|
||
)
|
||
})
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct HarnessConfig {
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub block_for_upload: Option<bool>,
|
||
/// Budget (seconds) for the turn-end upload flush when
|
||
/// `block_for_upload` is active. Default 60.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub upload_flush_timeout_secs: Option<u64>,
|
||
}
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct RemoteConfig {
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub secret: Option<String>,
|
||
}
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct WorktreePoolConfig {
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub enabled: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub pool_size: Option<usize>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub file_count_threshold: Option<usize>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub parallelism: Option<usize>,
|
||
}
|
||
/// `[sandbox]` section from config.toml.
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct SandboxSettingsConfig {
|
||
/// "off", "workspace", "devbox", "read-only", "strict", or custom name.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub profile: Option<String>,
|
||
/// Skip bash permission prompts when sandbox is active.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub auto_allow_bash: Option<bool>,
|
||
}
|
||
impl SandboxSettingsConfig {
|
||
pub fn from_effective_config() -> Self {
|
||
crate::config::load_effective_config()
|
||
.ok()
|
||
.and_then(|v| v.get("sandbox")?.clone().try_into().ok())
|
||
.unwrap_or_default()
|
||
}
|
||
/// Resolve sandbox profile: requirement > CLI > env > config > "off".
|
||
pub fn resolve_profile(
|
||
&self,
|
||
cli_arg: Option<&str>,
|
||
requirement: Option<&str>,
|
||
) -> Resolved<String> {
|
||
if let Some(val) = requirement {
|
||
return Resolved::new(val.to_owned(), ConfigSource::Requirement);
|
||
}
|
||
resolve_string_flag(cli_arg, "KIGI_SANDBOX", self.profile.as_deref(), None)
|
||
.unwrap_or_else(|| Resolved::new("off".to_owned(), ConfigSource::Default))
|
||
}
|
||
/// Resolve auto_allow_bash: requirement > env > config > default (false).
|
||
pub fn resolve_auto_allow_bash(&self, requirement: Option<bool>) -> Resolved<bool> {
|
||
BoolFlag::env("KIGI_SANDBOX_AUTO_ALLOW_BASH")
|
||
.requirement(requirement)
|
||
.config(self.auto_allow_bash)
|
||
.resolve()
|
||
}
|
||
}
|
||
/// `[suggestions]` section from config.toml.
|
||
///
|
||
/// Controls the shell command suggestion pipeline (history, path, AI).
|
||
///
|
||
/// ```toml
|
||
/// [suggestions]
|
||
/// enabled = true
|
||
/// ai_enabled = true
|
||
/// ai_model = "kimi-for-coding"
|
||
/// debounce_ms = 50
|
||
/// ```
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct SuggestionsConfig {
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub enabled: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub ai_enabled: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub ai_model: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub debounce_ms: Option<u64>,
|
||
}
|
||
impl SuggestionsConfig {
|
||
pub fn resolve_enabled(
|
||
&self,
|
||
remote: Option<&crate::util::config::RemoteSettings>,
|
||
) -> Resolved<bool> {
|
||
BoolFlag::env("KIGI_SUGGESTIONS")
|
||
.config(self.enabled)
|
||
.feature_flag(remote.and_then(|r| r.suggestions_enabled))
|
||
.default(false)
|
||
.resolve()
|
||
}
|
||
pub fn resolve_ai_enabled(
|
||
&self,
|
||
remote: Option<&crate::util::config::RemoteSettings>,
|
||
) -> Resolved<bool> {
|
||
BoolFlag::env("KIGI_SUGGESTIONS_AI")
|
||
.config(self.ai_enabled)
|
||
.feature_flag(remote.and_then(|r| r.suggestions_ai_enabled))
|
||
.default(false)
|
||
.resolve()
|
||
}
|
||
pub fn resolve_ai_model(&self) -> String {
|
||
resolve_string_flag(
|
||
None,
|
||
"KIGI_SUGGESTIONS_AI_MODEL",
|
||
self.ai_model.as_deref(),
|
||
None,
|
||
)
|
||
.map(|r| r.value)
|
||
.unwrap_or_else(|| crate::models::default_model().to_owned())
|
||
}
|
||
}
|
||
/// `[storage]` section from config.toml.
|
||
///
|
||
/// Controls session persistence settings like cleanup TTL.
|
||
/// Read by `resolve_cleanup_ttl_days()` in `session/persistence.rs`.
|
||
#[derive(Clone, Debug, Default, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct StorageConfig {
|
||
/// Number of days to keep stale sessions before cleanup. Default: 30.
|
||
pub cleanup_ttl_days: Option<u32>,
|
||
}
|
||
/// `[paths]` configuration: extra directories to scan for skills, rules, etc.
|
||
///
|
||
/// These supplement the built-in scan locations (`.kigi/skills/`,
|
||
/// `.agents/skills/`, `~/.kigi/skills/`). They're written by `/import-claude`
|
||
/// to preserve previously-discovered Claude directories after the runtime
|
||
/// `.claude/` cutoff (see `[claude_compat] imported`).
|
||
///
|
||
/// Example:
|
||
/// ```toml
|
||
/// [paths]
|
||
/// extra_skill_dirs = ["~/.claude/skills", "/path/to/.claude/skills"]
|
||
/// extra_rule_dirs = ["~/.claude/rules"]
|
||
/// ```
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct PathsConfig {
|
||
/// Additional directories to scan for skills (each contains `<skill>/SKILL.md`).
|
||
pub extra_skill_dirs: Vec<String>,
|
||
/// Additional directories to scan for rules (each contains `*.md`).
|
||
pub extra_rule_dirs: Vec<String>,
|
||
}
|
||
/// `[permission]` known keys, declared for the unrecognized-key scan only;
|
||
/// consumed out-of-band. Keys stay typed so a typo (e.g. `denny`) still warns.
|
||
#[derive(Clone, Debug, Default, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct PermissionKnownKeys {
|
||
/// Compact rule arrays (`parse_toml_permission_section`).
|
||
pub allow: Option<toml::Value>,
|
||
pub deny: Option<toml::Value>,
|
||
pub ask: Option<toml::Value>,
|
||
/// Verbose `[[permission.rules]]` form.
|
||
pub rules: Option<toml::Value>,
|
||
}
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
pub struct Config {
|
||
pub features: Features,
|
||
/// `[goal]` section: canonical `/goal` configuration. See [`GoalConfig`].
|
||
#[serde(default)]
|
||
pub goal: GoalConfig,
|
||
/// `[doom_loop_recovery]` section: the shared settings struct — ONE type
|
||
/// serves this TOML table and the remote remote settings `doom_loop_recovery`
|
||
/// object. See [`crate::util::config::DoomLoopRecoverySettings`].
|
||
#[serde(default)]
|
||
pub doom_loop_recovery: crate::util::config::DoomLoopRecoverySettings,
|
||
/// `[auto_mode]` section: Auto permission-mode configuration. See [`AutoModeConfig`].
|
||
#[serde(default)]
|
||
pub auto_mode: AutoModeConfig,
|
||
/// `[model.*]` overrides from config.toml. Resolve via `resolve_model_list()`.
|
||
#[serde(skip)]
|
||
pub config_models: IndexMap<String, ConfigModelOverride>,
|
||
/// Warnings from `[model.*]` parsing; surfaced by `kigi inspect`.
|
||
#[serde(skip)]
|
||
pub model_override_warnings: Vec<super::config_model_override_parse::ModelOverrideWarning>,
|
||
pub kimi_code_config: KimiCodeConfig,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub shortcuts: Option<toml::Value>,
|
||
/// Written by the client via `config_toml_edit`; absorbed so it isn't
|
||
/// flagged as an unrecognized key.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub hints: Option<toml::Value>,
|
||
#[serde(default)]
|
||
pub ui: UiConfig,
|
||
#[serde(default)]
|
||
pub toolset: ShellToolsetConfig,
|
||
#[serde(default)]
|
||
pub endpoints: EndpointsConfig,
|
||
/// Session behavior configuration.
|
||
#[serde(default)]
|
||
pub session: SessionConfig,
|
||
/// Agent definition selection configuration.
|
||
/// Set in `config.toml` under `[agent]` to choose which agent definition
|
||
/// is used for all sessions (unless overridden by CLI flag or ACP meta).
|
||
#[serde(default)]
|
||
pub agent: AgentSelectionConfig,
|
||
#[serde(default)]
|
||
pub repo_changes_dedup: RepoChangesDedupConfig,
|
||
/// Skills discovery configuration.
|
||
#[serde(default)]
|
||
pub skills: SkillsConfig,
|
||
/// Raw `[compat]` vendor-compatibility config (per-vendor × per-surface
|
||
/// toggles). Resolved into [`Config::compat_resolved`] by
|
||
/// `resolve_runtime_fields`.
|
||
#[serde(default)]
|
||
pub compat: CompatConfigToml,
|
||
/// Plugin system configuration.
|
||
#[serde(default)]
|
||
pub plugins: PluginsConfig,
|
||
/// Feedback submission configuration.
|
||
#[serde(default)]
|
||
pub feedback: FeedbackConfig,
|
||
/// Filesystem path overrides (`[paths]` in config.toml).
|
||
#[serde(default)]
|
||
pub paths: PathsConfig,
|
||
#[serde(default, skip_serializing)]
|
||
pub cli: CliConfig,
|
||
#[serde(default, skip_serializing)]
|
||
pub models: ModelsConfig,
|
||
/// `[platforms.<id>]` — per-platform credentials for the fixed Kimi
|
||
/// platform registry (PRD F2). `skip_serializing` so API keys are never
|
||
/// re-emitted by any Config serialization.
|
||
#[serde(default, skip_serializing)]
|
||
pub platforms: PlatformsConfig,
|
||
#[serde(default, skip_serializing)]
|
||
pub harness: HarnessConfig,
|
||
#[serde(default, skip_serializing)]
|
||
pub remote: RemoteConfig,
|
||
#[serde(default, skip_serializing)]
|
||
pub worktree_pool: WorktreePoolConfig,
|
||
#[serde(default, skip_serializing)]
|
||
pub sandbox: SandboxSettingsConfig,
|
||
#[serde(default, skip_serializing)]
|
||
pub mcp_servers: std::collections::HashMap<String, crate::util::config::McpServerConfig>,
|
||
#[serde(default, skip_serializing)]
|
||
pub disabled_mcp_servers: Vec<String>,
|
||
#[serde(default, skip_serializing)]
|
||
pub disabled_mcp_tools: std::collections::HashMap<String, Vec<String>>,
|
||
#[serde(default, skip_serializing)]
|
||
pub subagents: crate::config::SubagentsConfig,
|
||
#[serde(default, skip_serializing)]
|
||
pub memory: crate::config::MemoryConfig,
|
||
#[serde(default, skip_serializing)]
|
||
pub compaction: CompactionConfig,
|
||
/// `[auth]` alias — consumed by `expand_auth_alias` before serde.
|
||
/// Typed as `KimiCodeConfig` (same schema) so sub-field typos are caught.
|
||
#[serde(default, skip_serializing)]
|
||
pub auth: Option<KimiCodeConfig>,
|
||
/// `[desktop]` section — owned by kigi-desktop (Electron app), opaque to the CLI agent.
|
||
#[serde(default, skip_serializing)]
|
||
pub desktop: Option<toml::Value>,
|
||
/// `[tips]` section — consumed by `merge_tips`.
|
||
#[serde(default, skip_serializing)]
|
||
pub tips: Option<crate::util::config::TipsOverride>,
|
||
/// `[permission]` — consumed out-of-band; see [`PermissionKnownKeys`].
|
||
#[serde(default, skip_serializing)]
|
||
pub permission: PermissionKnownKeys,
|
||
/// `[tools]` — also read by `ToolsConfig::resolve()`.
|
||
#[serde(default, skip_serializing)]
|
||
pub tools: crate::config::ToolsConfig,
|
||
/// `[storage]` — also read by `resolve_cleanup_ttl_days()`.
|
||
#[serde(default, skip_serializing)]
|
||
pub storage: StorageConfig,
|
||
/// `[suggestions]` — shell command suggestion pipeline settings.
|
||
#[serde(default, skip_serializing)]
|
||
pub suggestions: SuggestionsConfig,
|
||
/// `[diagnostics]` — crash handler toggle (`load_crash_handler_enabled_sync`).
|
||
#[serde(default, skip_serializing)]
|
||
pub diagnostics: DiagnosticsConfig,
|
||
/// Storage mode for session persistence.
|
||
/// When running in relay/headless mode, this should be set to Writeback.
|
||
/// Defaults to reading from KIGI_STORAGE_MODE env var.
|
||
#[serde(skip)]
|
||
pub storage_mode: StorageMode,
|
||
/// CLI override for the default model ID.
|
||
#[serde(skip)]
|
||
pub default_model_override: Option<String>,
|
||
/// CLI override for reasoning effort.
|
||
#[serde(skip)]
|
||
pub reasoning_effort_override: Option<ReasoningEffort>,
|
||
/// CLI override for the session summary model ID.
|
||
#[serde(skip)]
|
||
pub session_summary_model_override: Option<String>,
|
||
/// CLI override for YOLO mode (auto-approve all permissions).
|
||
/// Takes precedence over default settings.
|
||
#[serde(skip)]
|
||
pub default_yolo_mode: bool,
|
||
/// Start sessions in auto permission mode (classifier) when no per-session override.
|
||
pub default_auto_mode: bool,
|
||
/// CLI `--experimental-memory` flag. Stored for `ConfigReloader` hot-reload re-resolution.
|
||
#[serde(skip)]
|
||
pub cli_experimental_memory: bool,
|
||
/// CLI `--no-memory` flag. Stored for `ConfigReloader` hot-reload re-resolution.
|
||
#[serde(skip)]
|
||
pub cli_no_memory: bool,
|
||
/// Original CLI `--subagents` tri-state, preserved for re-resolution
|
||
/// when remote settings settings are refreshed on /new.
|
||
#[serde(skip)]
|
||
pub cli_subagents: Option<bool>,
|
||
/// Resolved memory configuration. `None` when memory is disabled.
|
||
/// Resolved by [`RuntimeResolutionContext`] in [`Config::resolve_runtime_fields`].
|
||
#[serde(skip)]
|
||
pub memory_config: Option<crate::config::MemoryConfig>,
|
||
/// CLI override: path to an agent profile (.md file with YAML frontmatter).
|
||
#[serde(skip)]
|
||
pub agent_profile_path: Option<PathBuf>,
|
||
/// Client version string (e.g., "0.1.77 (abc1234)").
|
||
/// Set by the TUI/CLI launcher and used as fallback when clients don't provide clientVersion.
|
||
#[serde(skip)]
|
||
pub client_version: Option<String>,
|
||
/// The mode in which the agent is running.
|
||
/// Determines behavior like relay sync enablement (only enabled in TUI mode).
|
||
#[serde(skip)]
|
||
pub mode: AgentMode,
|
||
/// Remote settings fetched from cli-chat-proxy at startup.
|
||
/// Used for upload limits (replaces on-demand /v1/storage/limits fetch).
|
||
#[serde(skip)]
|
||
pub remote_settings: Option<crate::util::config::RemoteSettings>,
|
||
#[serde(skip)]
|
||
pub cli_agents: Vec<kigi_agent::config::AgentDefinition>,
|
||
#[serde(skip)]
|
||
pub cli_agent_overrides: CliAgentOverrides,
|
||
/// Whether subagent (task tool) support is enabled. Enabled by default;
|
||
/// disabled only via `KIGI_SUBAGENTS=0` or `[subagents] enabled = false`.
|
||
/// Not remotely gated.
|
||
#[serde(skip)]
|
||
pub subagents_enabled: bool,
|
||
/// Per-subagent model ID overrides from `[subagents.models]` in config.toml.
|
||
/// Keys are agent names, values are model IDs. Set alongside `subagents_enabled`
|
||
/// from `SubagentsConfig::resolve()`.
|
||
#[serde(skip)]
|
||
pub subagent_model_overrides: std::collections::HashMap<String, String>,
|
||
/// Per-subagent enable/disable toggles from `[subagents.toggle]` in config.toml.
|
||
/// Keys are agent names, values are booleans. Omitted agents default to enabled.
|
||
#[serde(skip)]
|
||
pub subagent_toggle: std::collections::HashMap<String, bool>,
|
||
/// Per-subagent role definitions from `[subagents.roles]` in config.toml
|
||
/// and `.kigi/roles/*.toml` file discovery.
|
||
#[serde(skip)]
|
||
pub subagent_roles:
|
||
std::collections::HashMap<String, kigi_subagent_resolution::config::SubagentRole>,
|
||
#[serde(skip)]
|
||
pub subagent_personas:
|
||
std::collections::HashMap<String, kigi_subagent_resolution::config::SubagentPersona>,
|
||
/// Whether web search is force-disabled via `--disable-web-search` CLI flag.
|
||
/// When true, the web search tool is never added to the agent toolset
|
||
/// regardless of available credentials.
|
||
#[serde(default)]
|
||
pub disable_web_search: bool,
|
||
/// Whether the runtime turn-end TodoGate is force-enabled via the
|
||
/// `--todo-gate` CLI flag. Session-scoped — not persisted. When
|
||
/// true, flips the runtime policy's `enabled` bit on regardless of
|
||
/// remote settings or the built-in default (which is `false`).
|
||
/// The gate runs only while a `/goal` is active (goal reminders
|
||
/// inject `<task_completion_discipline>`); global built-in templates
|
||
/// do not activate it.
|
||
#[serde(skip)]
|
||
pub todo_gate: bool,
|
||
/// Path for the Layer-3 LazinessDetector debug log
|
||
/// (`--laziness-debug-log`). When `Some`, the classifier fires
|
||
/// after every turn (bypassing the idle wait, the per-model
|
||
/// enable gate, and the nudge cap) and appends a JSONL line per
|
||
/// fire to this file. Observation-only — no nudges are injected
|
||
/// in this mode. Session-scoped, not persisted.
|
||
#[serde(skip)]
|
||
pub laziness_debug_log: Option<std::path::PathBuf>,
|
||
/// Whether tools should respect `.gitignore` patterns.
|
||
/// When `true`, all tools including `read_file` block gitignored files.
|
||
/// When `false` (default), each tool applies its own default
|
||
/// (`read_file` allows, others block).
|
||
/// Resolved by [`crate::config::ToolsConfig::resolve`].
|
||
#[serde(skip)]
|
||
pub respect_gitignore: bool,
|
||
/// Whether to enrich path-not-found errors with CWD reminders,
|
||
/// "dropped repo folder" correction, and similar-name suggestions.
|
||
/// Default `false`. Enabled via remote settings.
|
||
/// Serialized to `config.json` on GCS so traces can distinguish
|
||
/// which sessions had path-not-found hints active.
|
||
#[serde(default)]
|
||
pub path_not_found_hints: bool,
|
||
/// Whether auto-wake is enabled: when a background task or subagent
|
||
/// completes, immediately inject a synthetic prompt instead of waiting
|
||
/// for the idle-gated notification drain.
|
||
#[serde(skip)]
|
||
pub auto_wake_enabled: bool,
|
||
/// Resolved vendor-compat config (env → `[compat]` TOML → feature flag →
|
||
/// default ON), built from `compat` + `remote_settings` in
|
||
/// `resolve_runtime_fields`. Threaded into skills / rules / AGENTS.md
|
||
/// discovery.
|
||
#[serde(skip)]
|
||
pub compat_resolved: CompatConfig,
|
||
/// Enforced requirement pins from `requirements.toml`.
|
||
#[serde(skip)]
|
||
pub requirements: Requirements,
|
||
/// Session title model. Resolved to the compiled default
|
||
/// (`default_session_summary_model`) when unset; see `ModelOverrideConfig::resolve`.
|
||
#[serde(skip)]
|
||
pub session_summary_model: Option<String>,
|
||
/// Image describe model (bundled default via `ModelOverrideConfig::resolve`).
|
||
#[serde(skip)]
|
||
pub image_description_model: Option<String>,
|
||
/// Next-prompt suggestion model pin (`env > [models] prompt_suggestion >
|
||
/// remote`), consumed catalog-guarded by `handle_suggest_prompt`; see
|
||
/// `ModelOverrideConfig::resolve`.
|
||
#[serde(skip)]
|
||
pub prompt_suggest_model_pin: crate::config::PromptSuggestModelPin,
|
||
}
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct CliAgentOverrides {
|
||
pub tools: Option<Vec<String>>,
|
||
pub disallowed_tools: Option<Vec<String>>,
|
||
pub permission_rules: Vec<kigi_workspace::permission::types::PermissionRule>,
|
||
pub max_turns: Option<u32>,
|
||
pub permission_mode: Option<kigi_agent::config::PermissionMode>,
|
||
}
|
||
impl CliAgentOverrides {
|
||
/// Apply to the *main-session* agent, which the operator defines directly:
|
||
/// the flags are authoritative, so they replace the agent's own fields.
|
||
/// Spawned subagents instead layer these on top of an author's definition —
|
||
/// see [`Self::apply_to_subagent_definition`].
|
||
pub fn apply_to_definition(&self, def: &mut kigi_agent::config::AgentDefinition) {
|
||
if let Some(ref tools) = self.tools {
|
||
def.tools = tools.clone();
|
||
}
|
||
if let Some(ref dt) = self.disallowed_tools {
|
||
def.disallowed_tools = dt.clone();
|
||
}
|
||
if let Some(ref pm) = self.permission_mode {
|
||
def.permission_mode = pm.clone();
|
||
}
|
||
}
|
||
/// Subagent variant of [`Self::apply_to_definition`]: records the flags as
|
||
/// session-clamp state (see [`AgentDefinition::session_tools_allowlist`])
|
||
/// instead of overwriting the agent author's own fields.
|
||
pub fn apply_to_subagent_definition(&self, def: &mut kigi_agent::config::AgentDefinition) {
|
||
def.session_tools_allowlist = self.tools.clone();
|
||
def.session_tools_denylist = self.disallowed_tools.clone();
|
||
if let Some(ref parent_mode) = self.permission_mode
|
||
&& def.plugin_name.is_none()
|
||
{
|
||
def.permission_mode =
|
||
resolve_subagent_permission_mode(def.permission_mode.clone(), parent_mode);
|
||
}
|
||
}
|
||
pub fn has_definition_overrides(&self) -> bool {
|
||
self.tools.is_some() || self.disallowed_tools.is_some() || self.permission_mode.is_some()
|
||
}
|
||
}
|
||
/// Parent bypassPermissions/acceptEdits/auto override the subagent's own mode
|
||
/// (spec); any other parent mode keeps it.
|
||
fn resolve_subagent_permission_mode(
|
||
own: PermissionMode,
|
||
parent: &PermissionMode,
|
||
) -> PermissionMode {
|
||
match parent {
|
||
PermissionMode::BypassPermissions | PermissionMode::AcceptEdits | PermissionMode::Auto => {
|
||
parent.clone()
|
||
}
|
||
_ => own,
|
||
}
|
||
}
|
||
pub use kigi_agent::config::AgentDefinition;
|
||
pub use kigi_agent::config::Effort;
|
||
pub use kigi_agent::config::PermissionMode;
|
||
pub use kigi_shared::ui_config::{ContextualHints, UiConfig};
|
||
/// Configuration for selecting the agent definition.
|
||
///
|
||
/// Set in `config.toml` under `[agent]`:
|
||
///
|
||
/// ```toml
|
||
/// [agent]
|
||
/// # Use a named agent (looked up via discovery: .kigi/agents/, ~/.kigi/agents/, built-ins)
|
||
/// name = "my-custom-agent"
|
||
///
|
||
/// # OR: path to an agent definition file (.md with YAML frontmatter)
|
||
/// definition = "/path/to/my-agent.md"
|
||
/// ```
|
||
///
|
||
/// Priority (highest to lowest):
|
||
/// 1. ACP session-level `_meta.agentProfile`
|
||
/// 2. CLI `--agent-profile` flag
|
||
/// 3. `[agent]` config.toml section (this config)
|
||
/// 4. `KIGI_AGENT` env var
|
||
/// 5. Default `kigi` agent
|
||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct AgentSelectionConfig {
|
||
/// Name of a built-in or discovered agent definition.
|
||
/// Looked up via `kigi_agent::discovery::by_name_in_cwd()`.
|
||
/// Examples: "kigi", "browser-use", or a custom agent name.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub name: Option<String>,
|
||
/// Path to an agent definition file (.md with YAML frontmatter).
|
||
/// When set, the agent is loaded from this file.
|
||
/// Supports environment variable expansion (e.g., `$HOME/.kigi/agents/my-agent.md`).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub definition: Option<PathBuf>,
|
||
/// Global system-prompt identity label. Per-model override wins.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub system_prompt_label: Option<String>,
|
||
}
|
||
/// Configuration for session behavior.
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||
#[serde(default)]
|
||
pub struct SessionConfig {
|
||
/// Context window usage percentage (0-100) at which auto-compact is triggered.
|
||
/// When the session's token usage exceeds this percentage of the model's context window,
|
||
/// the conversation will be automatically summarized to free up space.
|
||
///
|
||
/// `None` means "user didn't set it"; the resolver in
|
||
/// `crate::util::config::resolve_auto_compact_threshold_percent` falls
|
||
/// through to remote tiers and ultimately the hardcoded default 85.
|
||
/// Read this field via the resolver — not directly — to honor the full
|
||
/// precedence chain (env, per-model, remote, default).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub auto_compact_threshold_percent: Option<u8>,
|
||
/// Whether to load environment variables from .envrc files.
|
||
/// When enabled, the session will parse .envrc in the workspace directory
|
||
/// and inject the environment variables into bash commands.
|
||
/// Defaults to `true` when unset. `Option<bool>` so `None`
|
||
/// round-trips as absent on disk (managed config wins over default).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub load_envrc: Option<bool>,
|
||
}
|
||
/// Configuration for change-archive deduplication.
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct RepoChangesDedupConfig {
|
||
pub enabled: bool,
|
||
/// Include inline content even when references exist.
|
||
pub include_inline_fallback: bool,
|
||
/// Omit inline content larger than this (0 = no limit).
|
||
pub max_inline_bytes: usize,
|
||
/// Deduplicate untracked file content.
|
||
pub dedup_untracked: bool,
|
||
/// Deduplicate binary file blobs.
|
||
pub dedup_binary: bool,
|
||
/// Skip untracked files larger than this (0 = no limit).
|
||
pub untracked_max_bytes: usize,
|
||
/// Optional glob patterns to exclude untracked paths.
|
||
pub untracked_exclude_globs: Vec<String>,
|
||
}
|
||
impl RepoChangesDedupConfig {}
|
||
impl Default for RepoChangesDedupConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
enabled: true,
|
||
include_inline_fallback: false,
|
||
max_inline_bytes: 0,
|
||
dedup_untracked: true,
|
||
dedup_binary: true,
|
||
untracked_max_bytes: 0,
|
||
untracked_exclude_globs: Vec::new(),
|
||
}
|
||
}
|
||
}
|
||
impl Default for Config {
|
||
fn default() -> Self {
|
||
let endpoints = EndpointsConfig::default();
|
||
|
||
Self {
|
||
features: Features::default(),
|
||
goal: GoalConfig::default(),
|
||
doom_loop_recovery: crate::util::config::DoomLoopRecoverySettings::default(),
|
||
auto_mode: AutoModeConfig::default(),
|
||
config_models: IndexMap::new(),
|
||
model_override_warnings: Vec::new(),
|
||
kimi_code_config: KimiCodeConfig::default(),
|
||
shortcuts: None,
|
||
hints: None,
|
||
ui: UiConfig::default(),
|
||
toolset: ShellToolsetConfig::default(),
|
||
endpoints,
|
||
session: SessionConfig::default(),
|
||
agent: AgentSelectionConfig::default(),
|
||
repo_changes_dedup: RepoChangesDedupConfig::default(),
|
||
skills: SkillsConfig::default(),
|
||
compat: CompatConfigToml::default(),
|
||
plugins: PluginsConfig::default(),
|
||
feedback: FeedbackConfig::default(),
|
||
paths: PathsConfig::default(),
|
||
cli: CliConfig::default(),
|
||
models: ModelsConfig::default(),
|
||
platforms: PlatformsConfig::default(),
|
||
harness: HarnessConfig::default(),
|
||
remote: RemoteConfig::default(),
|
||
worktree_pool: WorktreePoolConfig::default(),
|
||
sandbox: SandboxSettingsConfig::default(),
|
||
mcp_servers: std::collections::HashMap::new(),
|
||
disabled_mcp_servers: Vec::new(),
|
||
disabled_mcp_tools: std::collections::HashMap::new(),
|
||
subagents: crate::config::SubagentsConfig::default(),
|
||
memory: crate::config::MemoryConfig::default(),
|
||
compaction: CompactionConfig::default(),
|
||
auth: None,
|
||
desktop: None,
|
||
tips: None,
|
||
permission: PermissionKnownKeys::default(),
|
||
tools: crate::config::ToolsConfig::default(),
|
||
storage: StorageConfig::default(),
|
||
suggestions: SuggestionsConfig::default(),
|
||
diagnostics: DiagnosticsConfig::default(),
|
||
storage_mode: StorageMode::resolve(None, None),
|
||
default_model_override: None,
|
||
reasoning_effort_override: None,
|
||
session_summary_model_override: None,
|
||
default_yolo_mode: false,
|
||
default_auto_mode: false,
|
||
agent_profile_path: None,
|
||
client_version: Some(kigi_version::VERSION.to_string()),
|
||
mode: AgentMode::default(),
|
||
remote_settings: None,
|
||
cli_agents: Vec::new(),
|
||
cli_agent_overrides: CliAgentOverrides::default(),
|
||
subagents_enabled: true,
|
||
subagent_model_overrides: std::collections::HashMap::new(),
|
||
subagent_toggle: std::collections::HashMap::new(),
|
||
subagent_roles: std::collections::HashMap::new(),
|
||
subagent_personas: std::collections::HashMap::new(),
|
||
disable_web_search: false,
|
||
todo_gate: false,
|
||
laziness_debug_log: None,
|
||
respect_gitignore: false,
|
||
path_not_found_hints: false,
|
||
cli_experimental_memory: false,
|
||
cli_no_memory: false,
|
||
cli_subagents: None,
|
||
memory_config: None,
|
||
auto_wake_enabled: true,
|
||
compat_resolved: CompatConfig::default(),
|
||
requirements: Requirements::default(),
|
||
session_summary_model: None,
|
||
image_description_model: None,
|
||
prompt_suggest_model_pin: crate::config::PromptSuggestModelPin::Unpinned,
|
||
}
|
||
}
|
||
}
|
||
impl Config {
|
||
/// Reject invalid glob patterns in the model-filter lists at config load, so
|
||
/// a typo fails loudly instead of silently changing availability.
|
||
pub fn validate_model_filters(&self) -> Result<(), String> {
|
||
for (field, list) in [
|
||
("allowed_models", &self.models.allowed_models),
|
||
("disabled_models", &self.models.disabled_models),
|
||
("hidden_models", &self.models.hidden_models),
|
||
] {
|
||
if let Err(bad) = crate::agent::models::ModelGlobSet::compile(list.as_ref()) {
|
||
return Err(format!(
|
||
"{field} has an invalid pattern: {}. Patterns use * and ? wildcards.",
|
||
bad.join(", ")
|
||
));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
/// Build an `AuthManager` for this configuration.
|
||
pub fn create_auth_manager(&self) -> AuthManager {
|
||
AuthManager::new(
|
||
&crate::util::kigi_home::kigi_home(),
|
||
self.kimi_code_config.clone(),
|
||
)
|
||
}
|
||
/// Deserialize the merged `base` document, also returning the ignored key
|
||
/// paths whose top-level key appears in `user_config`. Paths outside it
|
||
/// can only come from the serialized-defaults half of the merge and must
|
||
/// not be blamed on the user.
|
||
fn deserialize_collecting_unrecognized(
|
||
base: toml::Value,
|
||
user_config: &toml::Value,
|
||
) -> Result<(Self, Vec<String>), String> {
|
||
let mut unused_keys = Vec::new();
|
||
let config: Self = serde_ignored::deserialize(base, |path| {
|
||
unused_keys.push(path.to_string());
|
||
})
|
||
.map_err(|e| e.to_string())?;
|
||
let user_unused = match user_config.as_table() {
|
||
Some(user_table) => unused_keys
|
||
.into_iter()
|
||
.filter(|path| {
|
||
let top_level = path.split('.').next().unwrap_or(path);
|
||
user_table.contains_key(top_level)
|
||
})
|
||
.collect(),
|
||
None => Vec::new(),
|
||
};
|
||
Ok((config, user_unused))
|
||
}
|
||
pub fn new_from_toml_cfg(raw_config: &toml::Value) -> Result<Self, String> {
|
||
let raw_config = &Self::expand_auth_alias(raw_config);
|
||
let super::config_model_override_parse::ParsedModelOverrides {
|
||
models: config_models,
|
||
warnings: model_override_warnings,
|
||
} = super::config_model_override_parse::parse_model_overrides(raw_config);
|
||
super::config_model_override_parse::log_model_override_warnings(&model_override_warnings);
|
||
let mut base = toml::Value::try_from(Self::default()).map_err(|e| e.to_string())?;
|
||
if let toml::Value::Table(ref mut t) = base {
|
||
t.remove("model");
|
||
}
|
||
let mut raw_without_model_sections = raw_config.clone();
|
||
if let toml::Value::Table(ref mut t) = raw_without_model_sections {
|
||
t.remove("model");
|
||
}
|
||
crate::config::deep_merge_toml(&mut base, &raw_without_model_sections);
|
||
let (mut config, user_unused) =
|
||
Self::deserialize_collecting_unrecognized(base, &raw_without_model_sections)?;
|
||
if !user_unused.is_empty() {
|
||
let keys = user_unused.join(", ");
|
||
tracing::warn!(
|
||
"config has unrecognized key(s): {keys}. Run /help for config reference."
|
||
);
|
||
}
|
||
config.config_models = config_models;
|
||
config.model_override_warnings = model_override_warnings;
|
||
config.platforms.warn_unknown_platforms();
|
||
if config.client_version.is_none() {
|
||
config.client_version = Self::default().client_version;
|
||
}
|
||
let model_overrides = crate::config::ModelOverrideConfig::resolve(None, raw_config, None);
|
||
config.session_summary_model = model_overrides.session_summary;
|
||
config.image_description_model = model_overrides.image_description;
|
||
config.prompt_suggest_model_pin = model_overrides.prompt_suggestion;
|
||
Ok(config)
|
||
}
|
||
/// Populate `#[serde(skip)]` subagent fields from `SubagentsConfig::resolve()`.
|
||
///
|
||
/// Must be called after `new_from_toml_cfg` on the **primary startup path**
|
||
/// before the config is handed to `MvpAgent`. Model-reload and API-key-reload
|
||
/// paths only read model/key fields and do not need this call.
|
||
pub fn resolve_subagents(
|
||
&mut self,
|
||
cli_flag: bool,
|
||
raw_config: &toml::Value,
|
||
cwd: Option<&std::path::Path>,
|
||
) {
|
||
let sa = crate::config::SubagentsConfig::resolve(cli_flag, raw_config, cwd);
|
||
self.subagents_enabled = sa.enabled;
|
||
self.subagent_model_overrides = sa.models;
|
||
self.subagent_toggle = sa.toggle;
|
||
self.subagent_roles = sa.roles;
|
||
self.subagent_personas = sa.personas;
|
||
}
|
||
/// Resolve all `#[serde(skip)]` runtime fields that have resolver functions.
|
||
///
|
||
/// Call immediately after `new_from_toml_cfg()`. Fields resolved:
|
||
/// - subagents (6 fields) via `SubagentsConfig::resolve`
|
||
/// - respect_gitignore via `ToolsConfig::resolve`
|
||
/// - session_summary_model / image_description_model /
|
||
/// prompt_suggest_model_pin via `ModelOverrideConfig::resolve`
|
||
/// - memory_config via `MemoryConfig::resolve`
|
||
/// - disable_web_search (CLI flag ORed with config.toml)
|
||
/// - storage_mode via `StorageMode::resolve`
|
||
/// - path_not_found_hints from remote_settings
|
||
///
|
||
/// Note: `worktree_type` is resolved directly in `MvpAgent::new` via
|
||
/// `resolve_worktree_type` since it's an agent-level field, not a Config field.
|
||
pub fn resolve_runtime_fields(&mut self, ctx: &RuntimeResolutionContext<'_>) {
|
||
self.cli_subagents = ctx.cli_subagents;
|
||
self.session_summary_model_override = ctx.cli_session_summary_model.map(|s| s.to_owned());
|
||
let cli_flag = ctx.cli_subagents.unwrap_or(false);
|
||
self.resolve_subagents(cli_flag, ctx.raw_config, ctx.cwd);
|
||
let tools = crate::config::ToolsConfig::resolve(ctx.raw_config);
|
||
self.respect_gitignore = match self.requirements.respect_gitignore.pinned() {
|
||
Some(pinned) => pinned,
|
||
None => tools.respect_gitignore,
|
||
};
|
||
let models = crate::config::ModelOverrideConfig::resolve(
|
||
ctx.cli_session_summary_model,
|
||
ctx.raw_config,
|
||
ctx.remote_settings,
|
||
);
|
||
self.session_summary_model = models.session_summary;
|
||
self.image_description_model = models.image_description;
|
||
self.prompt_suggest_model_pin = models.prompt_suggestion;
|
||
self.cli_experimental_memory = ctx.cli_experimental_memory;
|
||
self.cli_no_memory = ctx.cli_no_memory;
|
||
let mem = crate::config::MemoryConfig::resolve(
|
||
ctx.cli_experimental_memory,
|
||
ctx.cli_no_memory,
|
||
ctx.raw_config,
|
||
ctx.remote_settings,
|
||
);
|
||
self.memory_config = if mem.enabled { Some(mem) } else { None };
|
||
self.disable_web_search = self.disable_web_search || ctx.disable_web_search;
|
||
self.todo_gate = ctx.todo_gate;
|
||
self.laziness_debug_log = ctx.laziness_debug_log.map(std::path::Path::to_path_buf);
|
||
self.storage_mode =
|
||
crate::config::StorageMode::resolve(ctx.storage_mode, ctx.remote_settings);
|
||
if let Some(v) = ctx.remote_settings.and_then(|s| s.path_not_found_hints) {
|
||
self.path_not_found_hints = v;
|
||
}
|
||
self.auto_wake_enabled = BoolFlag::env("KIGI_AUTO_WAKE")
|
||
.config(self.features.auto_wake)
|
||
.feature_flag(ctx.remote_settings.and_then(|r| r.auto_wake_enabled))
|
||
.default(true)
|
||
.resolve()
|
||
.value;
|
||
self.compat_resolved = resolve_compat_config(&self.compat, ctx.remote_settings);
|
||
}
|
||
/// Re-resolve eagerly-resolved runtime fields using the current `Config`
|
||
/// state and fresh `raw_config` + `cwd`. Builds a
|
||
/// [`RuntimeResolutionContext`] from the CLI flags already stored on this
|
||
/// `Config` so callers don't need to manually extract each field.
|
||
///
|
||
/// Integration test coverage: `tests/test_settings_refresh.rs`.
|
||
pub fn re_resolve_runtime_fields(
|
||
&mut self,
|
||
raw_config: &toml::Value,
|
||
cwd: Option<&std::path::Path>,
|
||
) {
|
||
let remote_settings = self.remote_settings.clone();
|
||
let cli_session_summary_model = self.session_summary_model_override.clone();
|
||
let laziness_debug_log = self.laziness_debug_log.clone();
|
||
let ctx = RuntimeResolutionContext {
|
||
raw_config,
|
||
remote_settings: remote_settings.as_ref(),
|
||
cwd,
|
||
is_headless: self.mode == AgentMode::Headless,
|
||
cli_subagents: self.cli_subagents,
|
||
cli_session_summary_model: cli_session_summary_model.as_deref(),
|
||
cli_experimental_memory: self.cli_experimental_memory,
|
||
cli_no_memory: self.cli_no_memory,
|
||
disable_web_search: self.disable_web_search,
|
||
todo_gate: self.todo_gate,
|
||
laziness_debug_log: laziness_debug_log.as_deref(),
|
||
storage_mode: None,
|
||
};
|
||
self.resolve_runtime_fields(&ctx);
|
||
crate::util::config::set_remote_campaigns_from_settings(self.remote_settings.as_ref());
|
||
}
|
||
/// If the TOML contains `[auth]`, copy its contents under `[kimi_code_config]`.
|
||
/// `[kimi_code_config]` takes precedence if both are present (explicit wins).
|
||
///
|
||
/// This lets customers write the shorter `[auth.oidc]` instead of `[kimi_code_config.oidc]`.
|
||
fn expand_auth_alias(raw_config: &toml::Value) -> toml::Value {
|
||
let mut config = raw_config.clone();
|
||
if let toml::Value::Table(ref mut table) = config
|
||
&& let Some(auth) = table.remove("auth")
|
||
{
|
||
if let Some(gcc) = table.get_mut("kimi_code_config") {
|
||
if let (toml::Value::Table(gcc_table), toml::Value::Table(auth_table)) =
|
||
(gcc, &auth)
|
||
{
|
||
for (k, v) in auth_table {
|
||
gcc_table.entry(k.clone()).or_insert(v.clone());
|
||
}
|
||
}
|
||
} else {
|
||
table.insert("kimi_code_config".to_owned(), auth);
|
||
}
|
||
}
|
||
config
|
||
}
|
||
pub fn is_feedback_enabled(&self) -> bool {
|
||
self.resolve_feedback().value
|
||
}
|
||
pub fn is_session_recap_enabled(&self) -> bool {
|
||
self.resolve_session_recap().value
|
||
}
|
||
pub fn is_voice_mode_enabled(&self) -> bool {
|
||
self.resolve_voice_mode().value
|
||
}
|
||
/// Two-pass (prefire) compaction gate. Default OFF (opt-in) — enable via
|
||
/// remote settings `two_pass_compaction_enabled`, the `[features] two_pass_compaction`
|
||
/// config.toml key, or `KIGI_TWO_PASS_COMPACTION` env.
|
||
pub fn is_two_pass_compaction_enabled(&self) -> bool {
|
||
self.resolve_two_pass_compaction().value
|
||
}
|
||
pub(crate) fn resolve_feedback(&self) -> Resolved<bool> {
|
||
let ff = self
|
||
.remote_settings
|
||
.as_ref()
|
||
.and_then(|s| s.feedback_enabled);
|
||
BoolFlag::env("KIGI_FEEDBACK_ENABLED")
|
||
.requirement(self.requirements.feedback.pinned())
|
||
.config(self.features.feedback)
|
||
.feature_flag(ff)
|
||
.default(true)
|
||
.resolve()
|
||
}
|
||
pub(crate) fn resolve_two_pass_compaction(&self) -> Resolved<bool> {
|
||
let ff = self
|
||
.remote_settings
|
||
.as_ref()
|
||
.and_then(|s| s.two_pass_compaction_enabled);
|
||
BoolFlag::env("KIGI_TWO_PASS_COMPACTION")
|
||
.config(self.features.two_pass_compaction)
|
||
.feature_flag(ff)
|
||
.default(false)
|
||
.resolve()
|
||
}
|
||
/// Server-side doom-loop check policy (the `x-kigi-doom-loop-check`
|
||
/// header, trigger parsing, and confident-signal resampling, all
|
||
/// applied by the sampler). Merged
|
||
/// PER-FIELD across the `[doom_loop_recovery]` TOML table and the
|
||
/// remote settings `doom_loop_recovery` object (a partial remote object only
|
||
/// overrides the fields it sets). Gate precedence: env
|
||
/// `KIGI_DOOM_LOOP_RECOVERY` > TOML `enabled` > remote `enabled` >
|
||
/// default off — `None` IS the off state, so disabled has exactly one
|
||
/// spelling. Tunables have no env layer (TOML > remote > default) and
|
||
/// are clamped to their documented ranges. Returns the composite runtime
|
||
/// policy rather than `Resolved` because each knob resolves from its own
|
||
/// source (the `resolve_reminder_policy` pattern).
|
||
pub(crate) fn resolve_doom_loop_recovery(
|
||
&self,
|
||
) -> Option<kigi_sampling_types::DoomLoopRecoveryPolicy> {
|
||
use kigi_sampling_types::DoomLoopRecoveryPolicy as Policy;
|
||
let remote = self
|
||
.remote_settings
|
||
.as_ref()
|
||
.and_then(|s| s.doom_loop_recovery.as_ref());
|
||
let enabled = BoolFlag::env("KIGI_DOOM_LOOP_RECOVERY")
|
||
.config(self.doom_loop_recovery.enabled)
|
||
.feature_flag(remote.and_then(|s| s.enabled))
|
||
.default(false)
|
||
.resolve()
|
||
.value;
|
||
enabled.then(|| Policy {
|
||
max_threshold: self
|
||
.doom_loop_recovery
|
||
.max_threshold
|
||
.or(remote.and_then(|s| s.max_threshold))
|
||
.map_or(Policy::DEFAULT_MAX_THRESHOLD, Policy::clamp_max_threshold),
|
||
max_retries: self
|
||
.doom_loop_recovery
|
||
.max_retries
|
||
.or(remote.and_then(|s| s.max_retries))
|
||
.map_or(Policy::DEFAULT_MAX_RETRIES, Policy::clamp_max_retries),
|
||
})
|
||
}
|
||
pub(crate) fn resolve_lsp_tools(&self) -> Resolved<bool> {
|
||
let ff = self
|
||
.remote_settings
|
||
.as_ref()
|
||
.and_then(|s| s.lsp_tools_enabled);
|
||
BoolFlag::env("KIGI_LSP_TOOLS")
|
||
.requirement(self.requirements.lsp_tools.pinned())
|
||
.config(self.features.lsp_tools)
|
||
.feature_flag(ff)
|
||
.resolve()
|
||
}
|
||
/// `web_fetch` tool gate; default ON (kimi-cli parity: `FetchURL` is
|
||
/// always offered). `KIGI_WEB_FETCH=0` / `[features] web_fetch = false`
|
||
/// are the kill switches.
|
||
pub(crate) fn resolve_web_fetch(&self) -> Resolved<bool> {
|
||
let ff = self
|
||
.remote_settings
|
||
.as_ref()
|
||
.and_then(|s| s.web_fetch_enabled);
|
||
BoolFlag::env("KIGI_WEB_FETCH")
|
||
.requirement(self.requirements.web_fetch.pinned())
|
||
.config(self.features.web_fetch)
|
||
.feature_flag(ff)
|
||
.default(true)
|
||
.resolve()
|
||
}
|
||
/// `ask_user_question` tool gate; default ON. remote settings
|
||
/// `ask_user_question_enabled: false` (or `[features]` / env) is a remote
|
||
/// kill-switch. The `_meta.askUserQuestion` override (`--no-ask-user`) is
|
||
/// applied at the spawn site and outranks this resolver.
|
||
pub(crate) fn resolve_ask_user_question(&self) -> Resolved<bool> {
|
||
let ff = self
|
||
.remote_settings
|
||
.as_ref()
|
||
.and_then(|s| s.ask_user_question_enabled);
|
||
BoolFlag::env("KIGI_ASK_USER_QUESTION")
|
||
.requirement(self.requirements.ask_user_question.pinned())
|
||
.config(self.features.ask_user_question)
|
||
.feature_flag(ff)
|
||
.default(true)
|
||
.resolve()
|
||
}
|
||
/// Session recap gate (the `/recap` command + automatic return-from-away
|
||
/// recap). Default ON — disable via remote settings `session_recap`, the
|
||
/// `[features] session_recap` config.toml key, or `KIGI_SESSION_RECAP` env.
|
||
pub(crate) fn resolve_session_recap(&self) -> Resolved<bool> {
|
||
let ff = self.remote_settings.as_ref().and_then(|s| s.session_recap);
|
||
BoolFlag::env("KIGI_SESSION_RECAP")
|
||
.config(self.features.session_recap)
|
||
.feature_flag(ff)
|
||
.default(true)
|
||
.resolve()
|
||
}
|
||
/// Voice dictation gate. Default on.
|
||
///
|
||
/// Precedence: requirements > `KIGI_VOICE_MODE` > config/managed
|
||
/// `[features] voice_mode` > remote `voice_mode_enabled` > default true.
|
||
/// The pager may force API-key sessions on when only remote is off.
|
||
pub(crate) fn resolve_voice_mode(&self) -> Resolved<bool> {
|
||
let ff = self
|
||
.remote_settings
|
||
.as_ref()
|
||
.and_then(|s| s.voice_mode_enabled);
|
||
BoolFlag::env("KIGI_VOICE_MODE")
|
||
.requirement(self.requirements.voice_mode.pinned())
|
||
.config(self.features.voice_mode)
|
||
.feature_flag(ff)
|
||
.default(true)
|
||
.resolve()
|
||
}
|
||
/// Goal mode (`/goal`) master switch. Default ON: deployments that can't
|
||
/// reach cli-chat-proxy `/v1/settings` (custom `models_base_url`, external
|
||
/// `auth_provider_command`, air-gapped proxies) never receive the
|
||
/// remote settings `goal_enabled` flag, so the default must not carve them out.
|
||
/// Env, `[goal] enabled`, and the remote flag (`Some(false)` kill-switch)
|
||
/// all still override.
|
||
pub(crate) fn resolve_goal(&self) -> Resolved<bool> {
|
||
let ff = self.remote_settings.as_ref().and_then(|s| s.goal_enabled);
|
||
BoolFlag::env("KIGI_GOAL")
|
||
.config(self.goal.enabled)
|
||
.feature_flag(ff)
|
||
.default(true)
|
||
.resolve()
|
||
}
|
||
/// Graph mode (`/graph`) master switch. Default OFF — gray-released via
|
||
/// `KIGI_GRAPH=1` only (plan.md G0 gate). Graph mode additionally
|
||
/// requires the goal harness (nodes execute as goals), enforced at
|
||
/// availability time, not here.
|
||
pub(crate) fn resolve_graph(&self) -> Resolved<bool> {
|
||
BoolFlag::env("KIGI_GRAPH").default(false).resolve()
|
||
}
|
||
/// Max graph nodes running concurrently (`KIGI_GRAPH_CONCURRENCY`).
|
||
/// 1 = serial (G0-identical); clamped to [1, 8] — the coordinator has
|
||
/// no cap of its own, so this is the only brake on worker fan-out.
|
||
pub(crate) fn resolve_graph_concurrency(&self) -> u32 {
|
||
std::env::var("KIGI_GRAPH_CONCURRENCY")
|
||
.ok()
|
||
.and_then(|v| v.parse::<u32>().ok())
|
||
.unwrap_or(3)
|
||
.clamp(1, 8)
|
||
}
|
||
/// Graph topology optimizer master switch (`KIGI_GRAPH_OPTIMIZER`;
|
||
/// default on, `0` disables). Runs at plan boundaries only.
|
||
pub(crate) fn resolve_graph_optimizer_enabled(&self) -> bool {
|
||
!matches!(
|
||
std::env::var("KIGI_GRAPH_OPTIMIZER").ok().as_deref(),
|
||
Some("0") | Some("false")
|
||
)
|
||
}
|
||
/// Max replan passes per graph (`KIGI_GRAPH_REPLAN_CAP`); 0 turns
|
||
/// dynamic replanning off. Past the cap, discoveries drain to
|
||
/// history only — the graph must still converge. Clamped to [0, 10].
|
||
pub(crate) fn resolve_graph_replan_cap(&self) -> u32 {
|
||
std::env::var("KIGI_GRAPH_REPLAN_CAP")
|
||
.ok()
|
||
.and_then(|v| v.parse::<u32>().ok())
|
||
.unwrap_or(3)
|
||
.clamp(0, 10)
|
||
}
|
||
/// Max worker↔verifier rounds per parallel graph node
|
||
/// (`KIGI_GRAPH_NODE_ROUNDS`); exhausting them fails the node.
|
||
/// Clamped to [1, 8].
|
||
pub(crate) fn resolve_graph_node_rounds(&self) -> u32 {
|
||
std::env::var("KIGI_GRAPH_NODE_ROUNDS")
|
||
.ok()
|
||
.and_then(|v| v.parse::<u32>().ok())
|
||
.unwrap_or(3)
|
||
.clamp(1, 8)
|
||
}
|
||
/// Classifier, planner, and summary all default to goal mode itself: when
|
||
/// `/goal` is on they are on unless config/env/remote says otherwise.
|
||
/// `goal_enabled` is the session's already-resolved master switch (the same
|
||
/// value the actor stores), passed in so a sub-role default can never
|
||
/// disagree with whether `/goal` is on.
|
||
pub(crate) fn resolve_goal_classifier_enabled(&self, goal_enabled: bool) -> Resolved<bool> {
|
||
BoolFlag::env("KIGI_GOAL_CLASSIFIER")
|
||
.config(self.goal.classifier_enabled)
|
||
.feature_flag(
|
||
self.remote_settings
|
||
.as_ref()
|
||
.and_then(|s| s.goal_classifier_enabled),
|
||
)
|
||
.default(goal_enabled)
|
||
.resolve()
|
||
}
|
||
pub(crate) fn resolve_goal_planner_enabled(&self, goal_enabled: bool) -> Resolved<bool> {
|
||
BoolFlag::env("KIGI_GOAL_PLANNER")
|
||
.config(self.goal.planner_enabled)
|
||
.feature_flag(
|
||
self.remote_settings
|
||
.as_ref()
|
||
.and_then(|s| s.goal_planner_enabled),
|
||
)
|
||
.default(goal_enabled)
|
||
.resolve()
|
||
}
|
||
pub(crate) fn resolve_goal_summary_enabled(&self, goal_enabled: bool) -> Resolved<bool> {
|
||
BoolFlag::env("KIGI_GOAL_SUMMARY")
|
||
.config(self.goal.summary_enabled)
|
||
.feature_flag(
|
||
self.remote_settings
|
||
.as_ref()
|
||
.and_then(|s| s.goal_summary_enabled),
|
||
)
|
||
.default(goal_enabled)
|
||
.resolve()
|
||
}
|
||
/// Goal count resolver: env(parse) > config > remote > default, then clamp.
|
||
/// An unparseable env value falls through to the next source.
|
||
fn resolve_goal_u32(
|
||
env_var: &str,
|
||
config: Option<u32>,
|
||
remote: Option<u32>,
|
||
default: u32,
|
||
clamp: impl Fn(u32) -> u32,
|
||
) -> Resolved<u32> {
|
||
if let Some(env_value) = env_string(env_var)
|
||
&& let Ok(parsed) = env_value.parse::<u32>()
|
||
{
|
||
return Resolved::new(clamp(parsed), ConfigSource::Env);
|
||
}
|
||
if let Some(v) = config {
|
||
return Resolved::new(clamp(v), ConfigSource::Config);
|
||
}
|
||
if let Some(v) = remote {
|
||
return Resolved::new(clamp(v), ConfigSource::Remote);
|
||
}
|
||
Resolved::new(default, ConfigSource::Default)
|
||
}
|
||
/// Per-attempt adversarial-skeptic count, clamped to
|
||
/// `[GOAL_VERIFIER_SKEPTIC_MIN, GOAL_VERIFIER_SKEPTIC_MAX]`.
|
||
pub(crate) fn resolve_goal_verifier_count(&self) -> Resolved<u32> {
|
||
use crate::session::goal_classifier::{
|
||
GOAL_VERIFIER_SKEPTIC_COUNT, GOAL_VERIFIER_SKEPTIC_MAX, GOAL_VERIFIER_SKEPTIC_MIN,
|
||
};
|
||
Self::resolve_goal_u32(
|
||
"KIGI_GOAL_VERIFIER_N",
|
||
self.goal.verifier_count,
|
||
self.remote_settings
|
||
.as_ref()
|
||
.and_then(|s| s.goal_verifier_count),
|
||
GOAL_VERIFIER_SKEPTIC_COUNT,
|
||
|v| v.clamp(GOAL_VERIFIER_SKEPTIC_MIN, GOAL_VERIFIER_SKEPTIC_MAX),
|
||
)
|
||
}
|
||
/// Per-goal classifier run cap, floored at `GOAL_CLASSIFIER_MAX_RUNS_MIN`
|
||
/// with no upper ceiling.
|
||
pub(crate) fn resolve_goal_classifier_max_runs(&self) -> Resolved<u32> {
|
||
use crate::session::goal_classifier::{
|
||
GOAL_CLASSIFIER_MAX_RUNS_DEFAULT, GOAL_CLASSIFIER_MAX_RUNS_MIN,
|
||
};
|
||
Self::resolve_goal_u32(
|
||
"KIGI_GOAL_CLASSIFIER_MAX",
|
||
self.goal.classifier_max_runs,
|
||
self.remote_settings
|
||
.as_ref()
|
||
.and_then(|s| s.goal_classifier_max_runs),
|
||
GOAL_CLASSIFIER_MAX_RUNS_DEFAULT,
|
||
|v| v.max(GOAL_CLASSIFIER_MAX_RUNS_MIN),
|
||
)
|
||
}
|
||
/// Stall-triggered strategist cadence N (fires every N consecutive
|
||
/// `NotAchieved`). Default tracks the resolved classifier cap
|
||
/// (`max(1, cap / 2)`); floored at 1 so it can never silently disable.
|
||
pub(crate) fn resolve_goal_strategist_every(&self, classifier_max_runs: u32) -> Resolved<u32> {
|
||
Self::resolve_goal_u32(
|
||
"KIGI_GOAL_STRATEGIST_EVERY",
|
||
self.goal.strategist_every,
|
||
self.remote_settings
|
||
.as_ref()
|
||
.and_then(|s| s.goal_strategist_every),
|
||
(classifier_max_runs / 2).max(1),
|
||
|v| v.max(1),
|
||
)
|
||
}
|
||
/// Re-verify escalation threshold; floored at 1. No remote layer.
|
||
pub(crate) fn resolve_goal_reverify_after(&self) -> Resolved<u32> {
|
||
Self::resolve_goal_u32(
|
||
"KIGI_GOAL_REVERIFY_AFTER",
|
||
self.goal.reverify_after,
|
||
None,
|
||
crate::session::acp_session::GOAL_REVERIFY_AFTER_DEFAULT,
|
||
|v| v.max(1),
|
||
)
|
||
}
|
||
/// When `true`, every `/goal` role inherits the current model regardless of
|
||
/// configured pairs.
|
||
pub(crate) fn resolve_goal_use_current_model_only(&self) -> Resolved<bool> {
|
||
BoolFlag::env("KIGI_GOAL_USE_CURRENT_MODEL_ONLY")
|
||
.config(self.goal.use_current_model_only)
|
||
.default(false)
|
||
.resolve()
|
||
}
|
||
/// Shared single-pair resolution. Precedence: kill-switch ⇒
|
||
/// `InheritCurrent`/`Config` > `config_pair` ⇒ `Explicit`/`Config` >
|
||
/// `remote_pair` ⇒ `Explicit`/`Remote` > `InheritCurrent`/`Default`. The
|
||
/// chosen pair is cloned only on its branch.
|
||
fn resolve_single_role_model(
|
||
use_current_only: bool,
|
||
config_pair: Option<&crate::util::config::GoalRoleModel>,
|
||
remote_pair: Option<&crate::util::config::GoalRoleModel>,
|
||
) -> Resolved<GoalRoleModelChoice> {
|
||
if use_current_only {
|
||
return Resolved::new(GoalRoleModelChoice::InheritCurrent, ConfigSource::Config);
|
||
}
|
||
if let Some(pair) = config_pair {
|
||
return Resolved::new(
|
||
GoalRoleModelChoice::Explicit(pair.clone()),
|
||
ConfigSource::Config,
|
||
);
|
||
}
|
||
match remote_pair {
|
||
Some(pair) => Resolved::new(
|
||
GoalRoleModelChoice::Explicit(pair.clone()),
|
||
ConfigSource::Remote,
|
||
),
|
||
None => Resolved::new(GoalRoleModelChoice::InheritCurrent, ConfigSource::Default),
|
||
}
|
||
}
|
||
/// Planner role model: `[goal]` config then remote. No env layer (only the
|
||
/// kill-switch reads env).
|
||
///
|
||
/// An `Explicit` pair is applied as `runtime_overrides.model`, resolved before
|
||
/// `resolve_subagent_sampling_config`, so it wins over a user
|
||
/// `[subagents.models]` pin; `InheritCurrent` hands precedence back to that pin.
|
||
pub(crate) fn resolve_goal_planner_model(
|
||
&self,
|
||
use_current_only: bool,
|
||
) -> Resolved<GoalRoleModelChoice> {
|
||
Self::resolve_single_role_model(
|
||
use_current_only,
|
||
self.goal.planner_model.as_ref(),
|
||
self.remote_settings
|
||
.as_ref()
|
||
.and_then(|s| s.goal_planner_model.as_ref()),
|
||
)
|
||
}
|
||
/// Strategist role model; same precedence as [`Self::resolve_goal_planner_model`].
|
||
pub(crate) fn resolve_goal_strategist_model(
|
||
&self,
|
||
use_current_only: bool,
|
||
) -> Resolved<GoalRoleModelChoice> {
|
||
Self::resolve_single_role_model(
|
||
use_current_only,
|
||
self.goal.strategist_model.as_ref(),
|
||
self.remote_settings
|
||
.as_ref()
|
||
.and_then(|s| s.goal_strategist_model.as_ref()),
|
||
)
|
||
}
|
||
/// Skeptic pool; same precedence as [`Self::resolve_goal_planner_model`] but
|
||
/// over a pool. Pool order is preserved for the round-robin expansion in
|
||
/// `expand_skeptic_assignment`.
|
||
pub(crate) fn resolve_goal_skeptic_models(
|
||
&self,
|
||
use_current_only: bool,
|
||
) -> Resolved<Vec<GoalRoleModelChoice>> {
|
||
if use_current_only {
|
||
return Resolved::new(Vec::new(), ConfigSource::Config);
|
||
}
|
||
let to_choices = |pool: &[crate::util::config::GoalRoleModel]| {
|
||
pool.iter()
|
||
.cloned()
|
||
.map(GoalRoleModelChoice::Explicit)
|
||
.collect::<Vec<_>>()
|
||
};
|
||
if !self.goal.skeptic_models.is_empty() {
|
||
return Resolved::new(to_choices(&self.goal.skeptic_models), ConfigSource::Config);
|
||
}
|
||
match self
|
||
.remote_settings
|
||
.as_ref()
|
||
.map(|s| s.goal_skeptic_models.as_slice())
|
||
{
|
||
Some(pool) if !pool.is_empty() => Resolved::new(to_choices(pool), ConfigSource::Remote),
|
||
_ => Resolved::new(Vec::new(), ConfigSource::Default),
|
||
}
|
||
}
|
||
pub(crate) fn resolve_write_file(&self) -> Resolved<bool> {
|
||
let ff = self
|
||
.remote_settings
|
||
.as_ref()
|
||
.and_then(|s| s.write_file_enabled);
|
||
BoolFlag::env("KIGI_WRITE_FILE")
|
||
.requirement(self.requirements.write_file.pinned())
|
||
.config(self.features.write_file)
|
||
.feature_flag(ff)
|
||
.default(true)
|
||
.resolve()
|
||
}
|
||
pub(crate) fn resolve_backend_tools(&self) -> Resolved<bool> {
|
||
BoolFlag::env("KIGI_BACKEND_SEARCH")
|
||
.config(self.features.backend_tools)
|
||
.default(true)
|
||
.resolve()
|
||
}
|
||
/// Resolve the mode (env `KIGI_COMPACTION_MODE` > config > remote settings >
|
||
/// default, unrecognized falling through) and, for `Segments`, attach the
|
||
/// separately-resolved detail level.
|
||
pub(crate) fn resolve_compaction_mode(&self) -> kigi_chat_state::CompactionMode {
|
||
resolve_compaction_mode_from(
|
||
env_string("KIGI_COMPACTION_MODE").as_deref(),
|
||
self.features.compaction_mode.as_deref(),
|
||
self.remote_settings
|
||
.as_ref()
|
||
.and_then(|r| r.compaction_mode.as_deref()),
|
||
)
|
||
.with_segment_detail(self.resolve_compaction_detail())
|
||
}
|
||
/// Resolve verbatim-input flag: env `KIGI_COMPACTION_VERBATIM_INPUT` > config > remote settings > default `true`.
|
||
pub(crate) fn resolve_compaction_verbatim_input(&self) -> bool {
|
||
BoolFlag::env("KIGI_COMPACTION_VERBATIM_INPUT")
|
||
.config(self.features.compaction_verbatim_input)
|
||
.feature_flag(
|
||
self.remote_settings
|
||
.as_ref()
|
||
.and_then(|r| r.compaction_verbatim_input),
|
||
)
|
||
.default(true)
|
||
.resolve()
|
||
.value
|
||
}
|
||
/// Precedence: env `KIGI_COMPACTION_DETAIL`, then config
|
||
/// `features.compaction_detail`, then remote settings
|
||
/// `remote_settings.compaction_detail`, then default (`verbose`). Drives the
|
||
/// `segments` verbatim detail level.
|
||
fn resolve_compaction_detail(&self) -> kigi_chat_state::CompactionDetail {
|
||
resolve_compaction_detail_from(
|
||
env_string("KIGI_COMPACTION_DETAIL").as_deref(),
|
||
self.features.compaction_detail.as_deref(),
|
||
self.remote_settings
|
||
.as_ref()
|
||
.and_then(|r| r.compaction_detail.as_deref()),
|
||
)
|
||
}
|
||
pub fn resolve_cancel_rewind(&self) -> Resolved<bool> {
|
||
let ff = self
|
||
.remote_settings
|
||
.as_ref()
|
||
.and_then(|s| s.cancel_rewind_enabled);
|
||
BoolFlag::env("KIGI_CANCEL_REWIND")
|
||
.config(self.features.cancel_rewind)
|
||
.feature_flag(ff)
|
||
.default(true)
|
||
.resolve()
|
||
}
|
||
/// Resolve whether to spawn the per-`Ready`-client transport
|
||
/// liveness pollers and the session-actor `StatusDispatcher`.
|
||
///
|
||
/// Thin delegate to the canonical
|
||
/// [`resolve_mcp_liveness_watchers`] free function, which unifies
|
||
/// the two previous implementations so they can't drift. CLI / managed / feature-flag inputs are
|
||
/// `None` here because the `Config` method only has visibility
|
||
/// into the embedded `Features` table; richer call sites (e.g.
|
||
/// the session-actor spawn path) go through
|
||
/// [`crate::util::config::resolve_mcp_liveness_watchers`] which
|
||
/// stacks all 7 layers.
|
||
pub fn resolve_mcp_liveness_watchers(&self) -> Resolved<bool> {
|
||
resolve_mcp_liveness_watchers(None, None, self.features.mcp_liveness_watchers, None, None)
|
||
}
|
||
/// Resolve whether the bounded stdio auto-restart task is allowed
|
||
/// to fire. Thin delegate to
|
||
/// [`resolve_mcp_auto_restart`]; mirrors
|
||
/// [`Self::resolve_mcp_liveness_watchers`]. The 7-step precedence
|
||
/// stack lives in the canonical free function. CLI / managed /
|
||
/// feature-flag inputs are `None` here because the `Config`
|
||
/// method only has visibility into the embedded `Features`
|
||
/// table; richer call sites go through
|
||
/// [`crate::util::config::resolve_mcp_auto_restart`] which stacks
|
||
/// all 7 layers.
|
||
pub fn resolve_mcp_auto_restart(&self) -> Resolved<bool> {
|
||
resolve_mcp_auto_restart(None, None, self.features.mcp_auto_restart, None, None)
|
||
}
|
||
/// Resolve whether the pager subscribes to the per-server
|
||
/// `kigi/mcp/server_status` push.
|
||
///
|
||
/// Thin delegate to the canonical
|
||
/// [`resolve_mcp_push_server_status`] free function — mirrors the
|
||
/// `resolve_mcp_liveness_watchers` pattern so the two
|
||
/// implementations can't drift. CLI / managed / feature-flag
|
||
/// inputs are `None` here because the `Config` method only has
|
||
/// visibility into the embedded `Features` table; richer call
|
||
/// sites go through
|
||
/// [`crate::util::config::resolve_mcp_push_server_status`] which
|
||
/// stacks all 7 layers.
|
||
pub fn resolve_mcp_push_server_status(&self) -> Resolved<bool> {
|
||
resolve_mcp_push_server_status(None, None, self.features.mcp_push_server_status, None, None)
|
||
}
|
||
/// Resolve whether the leader's `ConfigFileWatcher` adds the two
|
||
/// narrow non-recursive watches for `<cwd>/` and `<cwd>/.kigi/`.
|
||
///
|
||
/// Thin delegate to the canonical
|
||
/// [`resolve_mcp_recursive_config_watch`] free function — mirrors
|
||
/// the same delegation pattern. CLI / managed /
|
||
/// feature-flag inputs are `None` here because the `Config`
|
||
/// method only sees the embedded `Features` table; richer call
|
||
/// sites (notably the leader's watcher spawn path) go through
|
||
/// [`crate::util::config::resolve_mcp_recursive_config_watch`]
|
||
/// which stacks all 7 layers.
|
||
pub fn resolve_mcp_recursive_config_watch(&self) -> Resolved<bool> {
|
||
resolve_mcp_recursive_config_watch(
|
||
None,
|
||
None,
|
||
self.features.mcp_recursive_config_watch,
|
||
None,
|
||
None,
|
||
)
|
||
}
|
||
}
|
||
/// Canonical resolver for `mcp.liveness_watchers`. Stacks the full
|
||
/// 7-step `BoolFlag` precedence:
|
||
///
|
||
/// `requirement > cli > env (KIGI_MCP_LIVENESS_WATCHERS) > config >
|
||
/// managed > feature_flag > default (true)`.
|
||
///
|
||
/// Both `Config::resolve_mcp_liveness_watchers` and
|
||
/// `util::config::resolve_mcp_liveness_watchers` delegate here so the
|
||
/// precedence is single-sourced.
|
||
///
|
||
/// The default is `true` — it gates the watcher + dispatcher
|
||
/// default-on, with this flag existing primarily as a kill switch
|
||
/// during the rollout.
|
||
pub fn resolve_mcp_liveness_watchers(
|
||
requirement: Option<bool>,
|
||
cli: Option<bool>,
|
||
config: Option<bool>,
|
||
managed: Option<bool>,
|
||
feature_flag: Option<bool>,
|
||
) -> Resolved<bool> {
|
||
BoolFlag::env("KIGI_MCP_LIVENESS_WATCHERS")
|
||
.requirement(requirement)
|
||
.cli(cli)
|
||
.config(config)
|
||
.managed(managed)
|
||
.feature_flag(feature_flag)
|
||
.default(true)
|
||
.resolve()
|
||
}
|
||
/// Canonical resolver for `mcp.auto_restart`. Stacks the full 7-step
|
||
/// `BoolFlag` precedence:
|
||
///
|
||
/// `requirement > cli > env (KIGI_MCP_AUTO_RESTART) > config >
|
||
/// managed > feature_flag > default (true)`.
|
||
///
|
||
/// Mirrors [`resolve_mcp_liveness_watchers`]. Both
|
||
/// `Config::resolve_mcp_auto_restart` and
|
||
/// `util::config::resolve_mcp_auto_restart` delegate here so the
|
||
/// precedence is single-sourced.
|
||
///
|
||
/// Recovery is on by default; opt out via `KIGI_MCP_AUTO_RESTART=false`,
|
||
/// `[features] mcp_auto_restart`, or `requirements.toml`.
|
||
pub fn resolve_mcp_auto_restart(
|
||
requirement: Option<bool>,
|
||
cli: Option<bool>,
|
||
config: Option<bool>,
|
||
managed: Option<bool>,
|
||
feature_flag: Option<bool>,
|
||
) -> Resolved<bool> {
|
||
BoolFlag::env("KIGI_MCP_AUTO_RESTART")
|
||
.requirement(requirement)
|
||
.cli(cli)
|
||
.config(config)
|
||
.managed(managed)
|
||
.feature_flag(feature_flag)
|
||
.default(true)
|
||
.resolve()
|
||
}
|
||
/// Canonical resolver for `mcp.push_server_status`. Stacks the same
|
||
/// 7-step `BoolFlag` precedence as
|
||
/// [`resolve_mcp_liveness_watchers`]:
|
||
///
|
||
/// `requirement > cli > env (KIGI_MCP_PUSH_SERVER_STATUS) > config >
|
||
/// managed > feature_flag > default (true)`.
|
||
///
|
||
/// Both `Config::resolve_mcp_push_server_status` and
|
||
/// `util::config::resolve_mcp_push_server_status` delegate here so
|
||
/// the precedence is single-sourced.
|
||
///
|
||
/// The default is `true` — the pager's subscription to
|
||
/// `kigi/mcp/server_status` is wired default-on, with this
|
||
/// flag existing primarily as a kill switch.
|
||
pub fn resolve_mcp_push_server_status(
|
||
requirement: Option<bool>,
|
||
cli: Option<bool>,
|
||
config: Option<bool>,
|
||
managed: Option<bool>,
|
||
feature_flag: Option<bool>,
|
||
) -> Resolved<bool> {
|
||
BoolFlag::env("KIGI_MCP_PUSH_SERVER_STATUS")
|
||
.requirement(requirement)
|
||
.cli(cli)
|
||
.config(config)
|
||
.managed(managed)
|
||
.feature_flag(feature_flag)
|
||
.default(true)
|
||
.resolve()
|
||
}
|
||
/// Canonical resolver for `mcp.recursive_config_watch`. Stacks the
|
||
/// same 7-step `BoolFlag` precedence as
|
||
/// [`resolve_mcp_liveness_watchers`]:
|
||
///
|
||
/// `requirement > cli > env (KIGI_MCP_RECURSIVE_CONFIG_WATCH) >
|
||
/// config > managed > feature_flag > default (true)`.
|
||
///
|
||
/// Both `Config::resolve_mcp_recursive_config_watch` and
|
||
/// `util::config::resolve_mcp_recursive_config_watch` delegate here
|
||
/// so the precedence is single-sourced.
|
||
///
|
||
/// The default is `true`. It enables the two narrow
|
||
/// non-recursive cwd watches default-on. The flag exists primarily
|
||
/// as a kill switch during the rollout: if the FSEvents flakiness
|
||
/// on macOS or an inotify-quota issue on Linux causes a regression,
|
||
/// operators flip this flag (e.g. via `KIGI_MCP_RECURSIVE_CONFIG_
|
||
/// WATCH=0`) and the leader falls back to the prior behavior (no cwd
|
||
/// watches; user-triggered refresh is the only project-config
|
||
/// reload path).
|
||
///
|
||
/// Note the **name is a slight misnomer**: the watches themselves
|
||
/// are non-recursive (by design, to avoid blowing through
|
||
/// `fs.inotify.max_user_watches` on large repos). The flag name
|
||
/// follows the rollout-gate naming convention.
|
||
pub fn resolve_mcp_recursive_config_watch(
|
||
requirement: Option<bool>,
|
||
cli: Option<bool>,
|
||
config: Option<bool>,
|
||
managed: Option<bool>,
|
||
feature_flag: Option<bool>,
|
||
) -> Resolved<bool> {
|
||
BoolFlag::env("KIGI_MCP_RECURSIVE_CONFIG_WATCH")
|
||
.requirement(requirement)
|
||
.cli(cli)
|
||
.config(config)
|
||
.managed(managed)
|
||
.feature_flag(feature_flag)
|
||
.default(true)
|
||
.resolve()
|
||
}
|
||
/// Sync analogue of [`BoolFlag`] for callers that run before the tokio
|
||
/// runtime (e.g. `init_sentry`). Loads from disk + env directly rather than
|
||
/// from a pre-built `Config`.
|
||
///
|
||
/// Same convention as [`BoolFlag`]: `resolve()` returns the *enabled* value.
|
||
/// `disable_env` is sugar for "force-off if this env is truthy" and does not
|
||
/// invert the convention.
|
||
///
|
||
/// Layer precedence:
|
||
/// 1. `requirements.toml` (admin pin)
|
||
/// 2. `managed_settings.json` env (Claude admin pin, force-off)
|
||
/// 3. process env via `disable_env` (force-off)
|
||
/// 4. process env via `enable_env` (either direction)
|
||
/// 5. merged config (user/managed defaults)
|
||
/// 6. `inherit`, then `default`
|
||
pub struct SyncBoolFlag {
|
||
extract_toml: fn(&toml::Value) -> Option<bool>,
|
||
disable_env: Option<&'static str>,
|
||
enable_env: Option<fn() -> Option<bool>>,
|
||
inherit: Option<fn() -> bool>,
|
||
default: bool,
|
||
}
|
||
impl SyncBoolFlag {
|
||
pub const fn new(extract_toml: fn(&toml::Value) -> Option<bool>) -> Self {
|
||
Self {
|
||
extract_toml,
|
||
disable_env: None,
|
||
enable_env: None,
|
||
inherit: None,
|
||
default: false,
|
||
}
|
||
}
|
||
/// Force-off env name (e.g. `"DISABLE_TELEMETRY"`). Truthy at this name
|
||
/// in `managed_settings.json` or process env disables the flag.
|
||
pub const fn disable_env(mut self, name: &'static str) -> Self {
|
||
self.disable_env = Some(name);
|
||
self
|
||
}
|
||
/// Either-direction env resolver (typically `KIGI_*`). Returns
|
||
/// `Some(enabled)` for an explicit signal, `None` to fall through.
|
||
pub const fn enable_env(mut self, resolver: fn() -> Option<bool>) -> Self {
|
||
self.enable_env = Some(resolver);
|
||
self
|
||
}
|
||
/// Fallback when no source above fires.
|
||
pub const fn inherit(mut self, resolver: fn() -> bool) -> Self {
|
||
self.inherit = Some(resolver);
|
||
self
|
||
}
|
||
pub const fn default(mut self, val: bool) -> Self {
|
||
self.default = val;
|
||
self
|
||
}
|
||
pub fn resolve(&self) -> bool {
|
||
if let Some(enabled) = read_requirements_toml()
|
||
.as_ref()
|
||
.and_then(|r| (self.extract_toml)(r))
|
||
{
|
||
return enabled;
|
||
}
|
||
if let Some(name) = self.disable_env
|
||
&& managed_settings_env_flag(name) == Some(true)
|
||
{
|
||
return false;
|
||
}
|
||
if let Some(name) = self.disable_env
|
||
&& env_bool(name) == Some(true)
|
||
{
|
||
return false;
|
||
}
|
||
if let Some(resolver) = self.enable_env
|
||
&& let Some(enabled) = resolver()
|
||
{
|
||
return enabled;
|
||
}
|
||
if let Some(enabled) = crate::config::load_effective_config()
|
||
.ok()
|
||
.as_ref()
|
||
.and_then(|r| (self.extract_toml)(r))
|
||
{
|
||
return enabled;
|
||
}
|
||
self.inherit.map_or(self.default, |f| f())
|
||
}
|
||
}
|
||
/// Sync gate for the error-reporting (Sentry) pipeline, usable before the
|
||
/// tokio runtime (e.g. `init_sentry`). `true` unless explicitly enabled.
|
||
pub fn is_error_reporting_disabled_sync() -> bool {
|
||
!SyncBoolFlag::new(error_reporting_enabled_from_toml)
|
||
.disable_env("DISABLE_ERROR_REPORTING")
|
||
.enable_env(|| env_bool("KIGI_ERROR_REPORTING"))
|
||
.resolve()
|
||
}
|
||
/// `[diagnostics] error_reporting` as enabled bool. Bool-only; no
|
||
/// `session_metrics` equivalent. `None` falls through to the default.
|
||
fn error_reporting_enabled_from_toml(root: &toml::Value) -> Option<bool> {
|
||
root.get("diagnostics")?
|
||
.as_table()?
|
||
.get("error_reporting")?
|
||
.as_bool()
|
||
}
|
||
/// Load `~/.kigi/requirements.toml` standalone so the admin pin can beat
|
||
/// env vars. The merged config layer can't express that — last-merge-wins
|
||
/// loses provenance.
|
||
pub(crate) fn read_requirements_toml() -> Option<toml::Value> {
|
||
let path = crate::util::kigi_home::kigi_home().join("requirements.toml");
|
||
let content = std::fs::read_to_string(&path).ok()?;
|
||
toml::from_str(&content).ok()
|
||
}
|
||
/// Deployment id derived from a deployment/API key: UUIDv5 of the key.
|
||
/// Stable per key, never reversible to the key bytes.
|
||
pub fn deployment_id_from_key(key: &str) -> String {
|
||
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, key.as_bytes()).to_string()
|
||
}
|
||
/// Seed free-function remote caches after writing `Config.remote_settings`.
|
||
pub fn apply_remote_settings_side_effects(settings: Option<&crate::util::config::RemoteSettings>) {
|
||
crate::util::config::cache_remote_mcp_startup_timeout_secs(
|
||
settings.and_then(|s| s.mcp_startup_timeout_secs),
|
||
);
|
||
crate::util::config::cache_remote_max_mcp_output_bytes(
|
||
settings.and_then(|s| s.max_mcp_output_bytes),
|
||
);
|
||
crate::util::config::cache_remote_auto_mode(settings.and_then(|s| s.auto_mode.clone()));
|
||
crate::util::config::cache_remote_remember_tool_approvals(
|
||
settings.and_then(|s| s.remember_tool_approvals),
|
||
);
|
||
crate::util::config::cache_remote_crash_handler_enabled(
|
||
settings.and_then(|s| s.crash_handler_enabled),
|
||
);
|
||
}
|
||
/// Read `env.<key>` from Claude-compat `managed_settings.json`. `Some(true)`
|
||
/// indicates a force-off signal from a Mac-MDM-style admin policy.
|
||
fn managed_settings_env_flag(key: &str) -> Option<bool> {
|
||
let path = kigi_config::claude_managed_settings_path()?;
|
||
let content = std::fs::read_to_string(&path).ok()?;
|
||
let json: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||
kigi_workspace::permission::resolution::json_env_flag(json.get("env"), key)
|
||
}
|
||
/// Assemble the final model map. Priority (highest wins):
|
||
/// config.toml `[model.*]` > prefetched (remote) > hardcoded defaults.
|
||
pub(crate) fn resolve_model_list(
|
||
cfg: &Config,
|
||
prefetched: Option<IndexMap<String, ModelEntry>>,
|
||
platform_keys: &crate::agent::models::PlatformApiKeys,
|
||
) -> IndexMap<String, ModelEntry> {
|
||
let mut resolved: IndexMap<String, ModelEntry> = IndexMap::new();
|
||
if cfg.endpoints.has_custom_endpoint() {
|
||
tracing::info!(
|
||
models_base_url = ? cfg.endpoints.models_base_url, models_list_url = ? cfg
|
||
.endpoints.models_list_url,
|
||
"custom models endpoint active, skipping built-in defaults",
|
||
);
|
||
} else {
|
||
let defaults = default_model_entries(&cfg.endpoints);
|
||
tracing::debug!(count = defaults.len(), "loaded default models");
|
||
resolved.extend(defaults);
|
||
}
|
||
if let Some(mut prefetched) = prefetched {
|
||
tracing::debug!(count = prefetched.len(), "loaded prefetched models");
|
||
let default_cw = DEFAULT_CONTEXT_WINDOW;
|
||
for (key, entry) in prefetched.iter_mut() {
|
||
let donor = resolved.get(key);
|
||
if let Some(donor) = donor {
|
||
if entry.info.context_window.get() == default_cw
|
||
&& donor.info.context_window.get() != default_cw
|
||
{
|
||
tracing::debug!(
|
||
model_key = % key, model = % entry.info.model, client_default =
|
||
default_cw, inherited = donor.info.context_window.get(),
|
||
donor_model = % donor.info.model,
|
||
"prefetched model missing context_window, inheriting from hardcoded default"
|
||
);
|
||
entry.info.context_window = donor.info.context_window;
|
||
}
|
||
if entry.info.agent_type == DEFAULT_AGENT_TYPE {
|
||
entry.info.agent_type.clone_from(&donor.info.agent_type);
|
||
}
|
||
if entry.info.api_backend == ApiBackend::default() {
|
||
entry.info.api_backend.clone_from(&donor.info.api_backend);
|
||
}
|
||
}
|
||
if resolved.contains_key(key) {
|
||
tracing::debug!(
|
||
model_key = % key, "prefetched model overriding default"
|
||
);
|
||
}
|
||
}
|
||
resolved = prefetched;
|
||
}
|
||
for (key, model_override) in &cfg.config_models {
|
||
let had_base = resolved.contains_key(key);
|
||
let base = resolved.shift_remove(key);
|
||
if !had_base {
|
||
tracing::debug!(
|
||
model_key = % key,
|
||
"config model adding new entry (not in defaults/prefetched)"
|
||
);
|
||
if model_override.context_window.is_none() {
|
||
tracing::debug!(
|
||
model_key = % key, default = 200_000,
|
||
"new model missing context_window, defaulting to 200000 — set context_window in [model.{}] to override",
|
||
key,
|
||
);
|
||
}
|
||
}
|
||
let entry = model_override.apply(key, base, &cfg.endpoints);
|
||
tracing::debug!(
|
||
model_key = % key, base_url = % entry.info.base_url, has_api_key = entry
|
||
.api_key.is_some(), env_key = ? entry.env_key, had_base,
|
||
"config model override applied"
|
||
);
|
||
resolved.insert(key.clone(), entry);
|
||
}
|
||
{
|
||
let default_cw = DEFAULT_CONTEXT_WINDOW;
|
||
let donors: std::collections::HashMap<String, (std::num::NonZeroU64, ApiBackend)> =
|
||
resolved
|
||
.values()
|
||
.filter(|e| e.info.context_window.get() != default_cw)
|
||
.map(|e| {
|
||
(
|
||
e.info.model.clone(),
|
||
(e.info.context_window, e.info.api_backend.clone()),
|
||
)
|
||
})
|
||
.collect();
|
||
for entry in resolved.values_mut() {
|
||
if let Some((donor_cw, donor_backend)) = donors.get(&entry.info.model) {
|
||
if entry.info.context_window.get() == default_cw {
|
||
tracing::debug!(
|
||
model = % entry.info.model, from = default_cw, to = donor_cw
|
||
.get(),
|
||
"slug-match: inheriting context_window from sibling catalog entry"
|
||
);
|
||
entry.info.context_window = *donor_cw;
|
||
}
|
||
if entry.info.api_backend == ApiBackend::default()
|
||
&& *donor_backend != ApiBackend::default()
|
||
{
|
||
entry.info.api_backend.clone_from(donor_backend);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let Some(ref global_agent_type) = cfg.models.agent_type {
|
||
tracing::warn!(
|
||
global_agent_type = % global_agent_type,
|
||
"[models] agent_type is deprecated. Set agent_type on each [model.X] entry instead."
|
||
);
|
||
for entry in resolved.values_mut() {
|
||
if entry.info.agent_type == DEFAULT_AGENT_TYPE {
|
||
entry.info.agent_type = global_agent_type.clone();
|
||
}
|
||
}
|
||
}
|
||
apply_global_extra_headers(&mut resolved, &cfg.models);
|
||
apply_global_scalar_defaults(&mut resolved, &cfg.models);
|
||
apply_platform_credentials(&mut resolved, &cfg.platforms, platform_keys);
|
||
for entry in resolved.values_mut() {
|
||
entry.info.derive_reasoning_effort_fields();
|
||
}
|
||
resolved
|
||
}
|
||
/// Layer 8 of [`resolve_model_list`]: wire the fixed platform registry's
|
||
/// credentials into open-platform entries (PRD F2). Entries are recognized by
|
||
/// their `{platform_id}/{model_id}` catalog id.
|
||
///
|
||
/// - `env_key` defaults to the platform's `KIGI_MOONSHOT_*` env names so an
|
||
/// env-provided key resolves at request time.
|
||
/// - a `[platforms.<id>].api_key` from config.toml is stamped only when no
|
||
/// env name currently resolves, preserving env > config precedence
|
||
/// (`first_own_credential` checks `api_key` before `env_key`).
|
||
///
|
||
/// A per-model `[model.*]` `api_key`/`env_key` always wins (stamped earlier;
|
||
/// this layer never overwrites). In-memory only: the models disk cache
|
||
/// persists the *pre-resolution* fetched entries, so config-file keys never
|
||
/// reach disk. Key values are never logged.
|
||
fn apply_platform_credentials(
|
||
resolved: &mut IndexMap<String, ModelEntry>,
|
||
platforms: &PlatformsConfig,
|
||
platform_keys: &crate::agent::models::PlatformApiKeys,
|
||
) {
|
||
for (key, entry) in resolved.iter_mut() {
|
||
let id = entry.info.id.as_deref().unwrap_or(key.as_str());
|
||
let Some((platform, _)) = kigi_models::parse_managed_model_key(id) else {
|
||
continue;
|
||
};
|
||
if platform.uses_oauth() {
|
||
continue;
|
||
}
|
||
if entry.env_key.is_none() {
|
||
entry.env_key = Some(EnvKeys::new(platform.api_key_env_names().iter().copied()));
|
||
}
|
||
let env_resolves = entry
|
||
.env_key
|
||
.as_ref()
|
||
.is_some_and(|k| k.resolve_value().is_some());
|
||
if entry.api_key.is_none() && !env_resolves {
|
||
// The resolved snapshot (env > auth.json > config, minus env
|
||
// which stays live via env_key above) wins over a raw config.toml
|
||
// read, so a key rotated via the TUI login can never lose to a
|
||
// stale `[platforms.*]` entry. The config fallback keeps callers
|
||
// that pass an empty snapshot (tests, pure-config paths) working.
|
||
let stamped = platform_keys
|
||
.key_for(platform)
|
||
.map(str::to_owned)
|
||
.or_else(|| platforms.config_api_key(platform));
|
||
if let Some(stamped) = stamped {
|
||
tracing::debug!(
|
||
model_key = %key, platform = platform.as_str(),
|
||
"stamped resolved platform api_key onto open-platform entry"
|
||
);
|
||
entry.api_key = Some(stamped);
|
||
}
|
||
}
|
||
// A credentialed open-platform entry is usable by API-key users.
|
||
if entry.has_own_credentials() {
|
||
entry.info.supported_in_api = true;
|
||
}
|
||
}
|
||
}
|
||
/// Layer 6 of [`resolve_model_list`]: fold the global `[models].extra_headers`
|
||
/// into every model as a base. The presence check is case-insensitive because
|
||
/// the sampler lowers these into an `http::HeaderMap`, so a global `X-Foo` must
|
||
/// not shadow a per-model `x-foo`; a per-model `[model.<id>].extra_headers`
|
||
/// (applied earlier) therefore wins per key.
|
||
fn apply_global_extra_headers(resolved: &mut IndexMap<String, ModelEntry>, models: &ModelsConfig) {
|
||
if models.extra_headers.is_empty() {
|
||
return;
|
||
}
|
||
tracing::debug!(
|
||
header_keys = ? models.extra_headers.keys().collect::< Vec < _ >> (), model_count
|
||
= resolved.len(), "applying global [models].extra_headers default to all models"
|
||
);
|
||
for entry in resolved.values_mut() {
|
||
for (k, v) in &models.extra_headers {
|
||
let present = entry
|
||
.info
|
||
.extra_headers
|
||
.keys()
|
||
.any(|ek| ek.eq_ignore_ascii_case(k));
|
||
if !present {
|
||
entry.info.extra_headers.insert(k.clone(), v.clone());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
/// Layer 7 of [`resolve_model_list`]: fill scalar `[models]` defaults into any
|
||
/// model that left the field unset. Per-model (Layer 3) and remote-prefetched
|
||
/// (Layer 2) values already populated theirs, so they win via `get_or_insert`
|
||
/// (the global default is a fallback, not a clamp).
|
||
fn apply_global_scalar_defaults(
|
||
resolved: &mut IndexMap<String, ModelEntry>,
|
||
models: &ModelsConfig,
|
||
) {
|
||
for entry in resolved.values_mut() {
|
||
let info = &mut entry.info;
|
||
if let Some(v) = models.temperature {
|
||
info.temperature.get_or_insert(v);
|
||
}
|
||
if let Some(v) = models.top_p {
|
||
info.top_p.get_or_insert(v);
|
||
}
|
||
if let Some(v) = models.max_completion_tokens {
|
||
info.max_completion_tokens.get_or_insert(v);
|
||
}
|
||
if let Some(v) = models.max_retries {
|
||
info.max_retries.get_or_insert(v);
|
||
}
|
||
if let Some(v) = models.inference_idle_timeout_secs {
|
||
info.inference_idle_timeout_secs.get_or_insert(v);
|
||
}
|
||
if let Some(v) = models.stream_tool_calls {
|
||
info.stream_tool_calls.get_or_insert(v);
|
||
}
|
||
}
|
||
}
|
||
/// Built-in default models. Prefer `resolve_model_list()`.
|
||
pub fn default_model_entries(endpoints: &EndpointsConfig) -> IndexMap<String, ModelEntry> {
|
||
default_models(endpoints)
|
||
.into_iter()
|
||
.map(|(key, entry)| (key, ModelEntry::from_config_entry(&entry)))
|
||
.collect()
|
||
}
|
||
/// Resolve a model against the available model map.
|
||
/// Checks the map key (id) first, then falls back to a slug scan.
|
||
pub fn find_model_by_id<'a>(
|
||
models: &'a IndexMap<String, ModelEntry>,
|
||
model_id: &str,
|
||
) -> Option<&'a ModelEntry> {
|
||
models
|
||
.get(model_id)
|
||
.or_else(|| models.values().find(|m| m.model == model_id))
|
||
}
|
||
/// Whether the EFFECTIVE Auto-mode classifier model supports reasoning effort:
|
||
/// the model actually routed to (`aux_model` when the aux sampler resolved) else
|
||
/// the session model the worker falls back to. Not-found-in-catalog ⇒ `false`
|
||
/// (conservative; also covers the Tier-2 synthetic proxy entry). Drives the
|
||
/// built-in `low` effort default.
|
||
pub fn effective_classifier_supports_re(
|
||
aux_model: Option<&str>,
|
||
session_model: &str,
|
||
models: &IndexMap<String, ModelEntry>,
|
||
) -> bool {
|
||
find_model_by_id(models, aux_model.unwrap_or(session_model))
|
||
.map(|e| e.info().supports_reasoning_effort)
|
||
.unwrap_or(false)
|
||
}
|
||
/// JSON-only subset of `ModelEntryConfig`.
|
||
#[derive(Debug, Default, Deserialize)]
|
||
#[serde(default)]
|
||
struct DefaultModelJson {
|
||
id: Option<String>,
|
||
model: String,
|
||
name: Option<String>,
|
||
description: Option<String>,
|
||
context_window: Option<NonZeroU64>,
|
||
temperature: Option<f32>,
|
||
top_p: Option<f32>,
|
||
max_completion_tokens: Option<u32>,
|
||
api_backend: ApiBackend,
|
||
#[serde(default = "default_agent_type")]
|
||
agent_type: String,
|
||
inference_idle_timeout_secs: Option<u64>,
|
||
hidden: bool,
|
||
reasoning_effort: Option<ReasoningEffort>,
|
||
#[serde(default)]
|
||
supports_reasoning_effort: bool,
|
||
#[serde(default)]
|
||
reasoning_efforts: Vec<ReasoningEffortOption>,
|
||
/// Kimi capability set (PRD F4), sourced per entry (see kigi-models docs).
|
||
#[serde(default)]
|
||
capabilities: Vec<kigi_models::ModelCapability>,
|
||
/// When false, only OAuth users see this in the picker.
|
||
#[serde(default = "default_true")]
|
||
supported_in_api: bool,
|
||
#[serde(default)]
|
||
supports_backend_search: bool,
|
||
#[serde(default)]
|
||
compactions_remaining: Option<CompactionsRemaining>,
|
||
#[serde(default)]
|
||
compaction_at_tokens: Option<CompactionAtTokens>,
|
||
#[serde(default)]
|
||
show_model_fingerprint: bool,
|
||
}
|
||
fn default_models(endpoints: &EndpointsConfig) -> IndexMap<String, ModelEntryConfig> {
|
||
let root: serde_json::Value = serde_json::from_str(crate::models::DEFAULT_MODELS_JSON)
|
||
.expect("default_models.json: invalid JSON");
|
||
let entries: Vec<DefaultModelJson> = serde_json::from_value(
|
||
root.get("models")
|
||
.expect("default_models.json: missing 'models' array")
|
||
.clone(),
|
||
)
|
||
.expect("default_models.json: invalid 'models' array");
|
||
tracing::debug!(
|
||
count = entries.len(),
|
||
"loaded default models from embedded JSON"
|
||
);
|
||
entries
|
||
.into_iter()
|
||
.map(|m| {
|
||
assert!(
|
||
!m.model.is_empty(),
|
||
"default_models.json: entry id={:?} has empty `model` field",
|
||
m.id
|
||
);
|
||
let key = m.id.clone().unwrap_or_else(|| m.model.clone());
|
||
// Bundled entries are keyed `{platform_id}/{model_id}` (PRD F4);
|
||
// each routes to its platform's base URL. The subscription
|
||
// platform honors the endpoint overrides; the open platforms get
|
||
// their env-key names so a moonshot key is usable offline.
|
||
let platform = kigi_models::parse_managed_model_key(&key).map(|(p, _)| p);
|
||
let base_url = match platform {
|
||
Some(kigi_models::PlatformId::KimiCode) | None => {
|
||
endpoints.resolve_inference_base_url()
|
||
}
|
||
Some(open) => open.base_url(),
|
||
};
|
||
let env_key = platform
|
||
.filter(|p| !p.uses_oauth())
|
||
.map(|p| EnvKeys::new(p.api_key_env_names().iter().copied()));
|
||
let context_window = m
|
||
.context_window
|
||
.unwrap_or_else(|| NonZeroU64::new(200_000).expect("200000 is non-zero"));
|
||
let config = ModelEntryConfig {
|
||
id: m.id,
|
||
model: m.model,
|
||
base_url,
|
||
api_base_url: None,
|
||
name: m.name,
|
||
description: m.description,
|
||
context_window,
|
||
auto_compact_threshold_percent: None,
|
||
system_prompt_label: None,
|
||
temperature: m.temperature,
|
||
top_p: m.top_p,
|
||
max_completion_tokens: m.max_completion_tokens,
|
||
api_backend: m.api_backend,
|
||
auth_scheme: None,
|
||
agent_type: m.agent_type,
|
||
inference_idle_timeout_secs: m.inference_idle_timeout_secs,
|
||
max_retries: None,
|
||
api_key: None,
|
||
env_key,
|
||
extra_headers: IndexMap::new(),
|
||
use_concise: false,
|
||
hidden: m.hidden,
|
||
supported_in_api: m.supported_in_api,
|
||
reasoning_effort: m.reasoning_effort,
|
||
supports_reasoning_effort: m.supports_reasoning_effort,
|
||
reasoning_efforts: m.reasoning_efforts,
|
||
capabilities: m.capabilities,
|
||
supports_backend_search: m.supports_backend_search,
|
||
compactions_remaining: m.compactions_remaining,
|
||
compaction_at_tokens: m.compaction_at_tokens,
|
||
show_model_fingerprint: m.show_model_fingerprint,
|
||
stream_tool_calls: None,
|
||
laziness_detector: LazinessDetectorPerModelConfig::default(),
|
||
};
|
||
(key, config)
|
||
})
|
||
.collect()
|
||
}
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ModelEntryConfig {
|
||
/// Stable unique identifier for this catalog entry. When present,
|
||
/// used as the catalog map key. Falls back to `model` when absent.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub id: Option<String>,
|
||
/// The routing slug sent in API requests.
|
||
pub model: String,
|
||
/// The base URL of the model. e.g. "https://byok.example/v1"
|
||
pub base_url: String,
|
||
/// Human-readable display name of the model.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub name: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub description: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub max_completion_tokens: Option<u32>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub temperature: Option<f32>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub top_p: Option<f32>,
|
||
/// The API key for this model's provider.
|
||
/// If not set, falls back to env_key, then XAI_API_KEY.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub api_key: Option<String>,
|
||
/// Environment variable name(s) that hold the provider API key.
|
||
/// Accepts a string or an array (first set, non-empty value wins).
|
||
/// If not set, falls back to XAI_API_KEY.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub env_key: Option<EnvKeys>,
|
||
/// Which API backend to use for this model.
|
||
/// Values: "chat_completions" (default), "responses"
|
||
#[serde(default)]
|
||
pub api_backend: ApiBackend,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub auth_scheme: Option<AuthScheme>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub reasoning_effort: Option<ReasoningEffort>,
|
||
#[serde(default, skip_serializing_if = "is_false")]
|
||
pub supports_reasoning_effort: bool,
|
||
/// Per-model reasoning-effort menu (source of truth). The two legacy fields
|
||
/// above are derived from this list when it is non-empty.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub reasoning_efforts: Vec<ReasoningEffortOption>,
|
||
/// Kimi capability set (PRD F4); see [`ModelInfo::capabilities`].
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub capabilities: Vec<kigi_models::ModelCapability>,
|
||
/// Extra headers to send with requests to this model's endpoint.
|
||
/// Useful for BYOK (Bring Your Own Key) scenarios.
|
||
/// Example: { "x-anthropic-api-key" = "sk-ant-..." }
|
||
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
||
pub extra_headers: IndexMap<String, String>,
|
||
/// The total context window size in tokens for this model.
|
||
/// Used for auto-compact threshold calculations.
|
||
/// Required — BYOK users must explicitly set this in config.toml.
|
||
pub context_window: NonZeroU64,
|
||
/// Per-model auto-compact threshold (0-100). When the session's token
|
||
/// usage exceeds this percentage of `context_window`, the conversation
|
||
/// is summarized. Resolver precedence:
|
||
/// requirements > env > user (per-model > global) > managed (per-model > global)
|
||
/// > remote per-model (this field) > remote global > 85.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub auto_compact_threshold_percent: Option<u8>,
|
||
/// Per-model system-prompt identity label (not UI `name`).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub system_prompt_label: Option<String>,
|
||
/// The base URL to use when authenticating with an API key (non-session auth).
|
||
/// When set, `base_url` is used for session-based auth and `api_base_url` for API key auth.
|
||
/// When not set, `base_url` is used for all auth methods (e.g. BYOK / third-party models).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub api_base_url: Option<String>,
|
||
/// When true, this model uses concise mode (compact system prompt,
|
||
/// concise tool output, concise user message prefix, reduced toolset).
|
||
/// Defaults to false — when omitted or false, nothing changes.
|
||
#[serde(default, skip_serializing_if = "is_false")]
|
||
pub use_concise: bool,
|
||
/// The type of system prompt to use for this model.
|
||
/// e.g. "kigi", "codex".
|
||
#[serde(default = "default_agent_type")]
|
||
pub agent_type: String,
|
||
/// Maximum seconds to wait between SSE chunks during inference streaming.
|
||
/// When no chunk is received within this duration, the request fails with
|
||
/// a non-retryable `IdleTimeout` error. This is a per-chunk deadline that
|
||
/// resets on every received chunk — NOT a total-turn timeout.
|
||
/// Default: 300 seconds (5 minutes).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub inference_idle_timeout_secs: Option<u64>,
|
||
/// Maximum number of retries for transient API errors (429, 500, 502, etc.)
|
||
/// during a single inference request. Default: 5.
|
||
/// Can also be set via the `KIGI_MAX_RETRIES` environment variable.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub max_retries: Option<u32>,
|
||
/// Exclude from the client model picker; still usable internally (web_search, etc.).
|
||
#[serde(default, skip_serializing_if = "is_false")]
|
||
pub hidden: bool,
|
||
/// When false, only OAuth users see this in the picker.
|
||
#[serde(default = "default_true")]
|
||
pub supported_in_api: bool,
|
||
#[serde(default, skip_serializing_if = "is_false")]
|
||
pub supports_backend_search: bool,
|
||
/// Per-model config for the `x-compactions-remaining` header; `None` disables it.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub compactions_remaining: Option<CompactionsRemaining>,
|
||
/// Per-model config for the `x-compaction-at` header; `None` disables it.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub compaction_at_tokens: Option<CompactionAtTokens>,
|
||
#[serde(default, skip_serializing_if = "is_false")]
|
||
pub show_model_fingerprint: bool,
|
||
/// Inject `stream_tool_calls: true` into the request body
|
||
/// so the upstream emits per-chunk `function_call_arguments.delta`
|
||
/// Without this set, xAI API models send args as one delta
|
||
/// event, defeating the purpose of streaming.
|
||
///
|
||
/// Per-model opt-in -- BYOK endpoints that don't understand the
|
||
/// flag should leave this unset to avoid request errors.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub stream_tool_calls: Option<bool>,
|
||
/// Per-model Layer-3 LazinessDetector configuration. Defaults to
|
||
/// the all-disabled state via `#[serde(default)]`.
|
||
#[serde(default, skip_serializing_if = "is_default_laziness_detector")]
|
||
pub laziness_detector: LazinessDetectorPerModelConfig,
|
||
}
|
||
/// True when `cfg` equals the all-disabled default. Derives `PartialEq`
|
||
/// on `f32`, which is fine for the current shape because both `f32`
|
||
/// fields default to `None` — there's no parsed-vs-literal `0.7` float
|
||
/// equality footgun. If a future default introduces `Some(0.7)`, this
|
||
/// helper must be reworked (e.g. compare on tolerance, or switch to a
|
||
/// bit-pattern compare) so `skip_serializing_if` doesn't start emitting
|
||
/// `[laziness_detector]` blocks for every model in `config.toml`.
|
||
fn is_default_laziness_detector(cfg: &LazinessDetectorPerModelConfig) -> bool {
|
||
cfg == &LazinessDetectorPerModelConfig::default()
|
||
}
|
||
/// A `[model.foo]` entry from config.toml, parsed directly from raw TOML
|
||
/// (bypassing deep merge). Scalar fields are `Option` so absent means "inherit
|
||
/// from defaults/prefetched"; the collection fields (`extra_headers`,
|
||
/// `reasoning_efforts`) merge only when non-empty and so cannot express
|
||
/// "override to empty."
|
||
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
|
||
#[serde(default)]
|
||
pub struct ConfigModelOverride {
|
||
pub model: Option<String>,
|
||
pub base_url: Option<String>,
|
||
pub name: Option<String>,
|
||
pub description: Option<String>,
|
||
pub api_key: Option<String>,
|
||
/// Env var name(s) for the provider key — string or array in config.toml.
|
||
pub env_key: Option<EnvKeys>,
|
||
pub api_base_url: Option<String>,
|
||
pub max_completion_tokens: Option<u32>,
|
||
pub temperature: Option<f32>,
|
||
pub top_p: Option<f32>,
|
||
pub api_backend: Option<ApiBackend>,
|
||
#[serde(default)]
|
||
pub extra_headers: IndexMap<String, String>,
|
||
pub context_window: Option<u64>,
|
||
/// Per-model auto-compact threshold override (0-100) from `[model.<id>]`.
|
||
/// Read directly by `resolve_auto_compact_threshold_percent`; intentionally
|
||
/// NOT merged into `ModelInfo.auto_compact_threshold_percent` so the
|
||
/// resolver can keep user-per-model distinct from GB-per-model.
|
||
pub auto_compact_threshold_percent: Option<u8>,
|
||
/// Per-model system-prompt identity; not merged into `ModelInfo` (tiered resolve).
|
||
pub system_prompt_label: Option<String>,
|
||
pub use_concise: Option<bool>,
|
||
pub agent_type: Option<String>,
|
||
pub inference_idle_timeout_secs: Option<u64>,
|
||
pub max_retries: Option<u32>,
|
||
pub hidden: Option<bool>,
|
||
pub supported_in_api: Option<bool>,
|
||
pub reasoning_effort: Option<ReasoningEffort>,
|
||
pub supports_reasoning_effort: Option<bool>,
|
||
pub reasoning_efforts: Vec<ReasoningEffortOption>,
|
||
/// Kimi capability override; merges only when non-empty (cannot express
|
||
/// "override to empty", same as `reasoning_efforts`).
|
||
pub capabilities: Vec<kigi_models::ModelCapability>,
|
||
pub supports_backend_search: Option<bool>,
|
||
/// Aliases must be registered in `config_model_override_parse::ALIASES`;
|
||
/// serde rejects a table that contains both spellings otherwise.
|
||
#[serde(alias = "send_compactions_remaining")]
|
||
pub compactions_remaining: Option<CompactionsRemaining>,
|
||
pub compaction_at_tokens: Option<CompactionAtTokens>,
|
||
pub show_model_fingerprint: Option<bool>,
|
||
pub stream_tool_calls: Option<bool>,
|
||
}
|
||
impl ConfigModelOverride {
|
||
pub(crate) fn apply(
|
||
&self,
|
||
key: &str,
|
||
base: Option<ModelEntry>,
|
||
endpoints: &EndpointsConfig,
|
||
) -> ModelEntry {
|
||
let mut entry = base.unwrap_or_else(|| ModelEntry::fallback(key, endpoints));
|
||
if let Some(ref v) = self.model {
|
||
entry.info.model = v.clone();
|
||
}
|
||
if let Some(ref v) = self.base_url {
|
||
entry.info.base_url = v.clone();
|
||
if self.api_base_url.is_none() {
|
||
entry.api_base_url = None;
|
||
}
|
||
}
|
||
if self.name.is_some() {
|
||
entry.info.name.clone_from(&self.name);
|
||
}
|
||
if self.description.is_some() {
|
||
entry.info.description.clone_from(&self.description);
|
||
}
|
||
if self.max_completion_tokens.is_some() {
|
||
entry.info.max_completion_tokens = self.max_completion_tokens;
|
||
}
|
||
if self.temperature.is_some() {
|
||
entry.info.temperature = self.temperature;
|
||
}
|
||
if self.top_p.is_some() {
|
||
entry.info.top_p = self.top_p;
|
||
}
|
||
if let Some(ref v) = self.api_backend {
|
||
entry.info.api_backend = v.clone();
|
||
}
|
||
if !self.extra_headers.is_empty() {
|
||
entry.info.extra_headers = self.extra_headers.clone();
|
||
}
|
||
if let Some(cw) = self.context_window.and_then(NonZeroU64::new) {
|
||
entry.info.context_window = cw;
|
||
}
|
||
if let Some(v) = self.use_concise {
|
||
entry.info.use_concise = v;
|
||
}
|
||
if let Some(ref at) = self.agent_type {
|
||
entry.info.agent_type.clone_from(at);
|
||
}
|
||
if self.inference_idle_timeout_secs.is_some() {
|
||
entry.info.inference_idle_timeout_secs = self.inference_idle_timeout_secs;
|
||
}
|
||
if self.max_retries.is_some() {
|
||
entry.info.max_retries = self.max_retries;
|
||
}
|
||
if let Some(v) = self.hidden {
|
||
entry.info.hidden = v;
|
||
}
|
||
if let Some(v) = self.supported_in_api {
|
||
entry.info.supported_in_api = v;
|
||
}
|
||
if self.reasoning_effort.is_some() {
|
||
entry.info.reasoning_effort = self.reasoning_effort;
|
||
}
|
||
if let Some(v) = self.supports_reasoning_effort {
|
||
entry.info.supports_reasoning_effort = v;
|
||
} else if !entry.info.supports_reasoning_effort
|
||
&& matches!(entry.info.api_backend, ApiBackend::Messages)
|
||
{
|
||
entry.info.supports_reasoning_effort = true;
|
||
}
|
||
if !self.reasoning_efforts.is_empty() {
|
||
entry.info.reasoning_efforts = self.reasoning_efforts.clone();
|
||
}
|
||
if !self.capabilities.is_empty() {
|
||
entry.info.capabilities = self.capabilities.clone();
|
||
}
|
||
if let Some(v) = self.supports_backend_search {
|
||
entry.info.supports_backend_search = v;
|
||
}
|
||
if self.compactions_remaining.is_some() {
|
||
entry.info.compactions_remaining = self.compactions_remaining;
|
||
}
|
||
if self.compaction_at_tokens.is_some() {
|
||
entry.info.compaction_at_tokens = self.compaction_at_tokens;
|
||
}
|
||
if let Some(v) = self.show_model_fingerprint {
|
||
entry.info.show_model_fingerprint = v;
|
||
}
|
||
if self.stream_tool_calls.is_some() {
|
||
entry.info.stream_tool_calls = self.stream_tool_calls;
|
||
}
|
||
if self.api_key.is_some() {
|
||
entry.api_key.clone_from(&self.api_key);
|
||
}
|
||
if self.env_key.is_some() {
|
||
entry.env_key.clone_from(&self.env_key);
|
||
}
|
||
if self.api_base_url.is_some() {
|
||
entry.api_base_url.clone_from(&self.api_base_url);
|
||
}
|
||
if self.supported_in_api.is_none() && (self.api_key.is_some() || self.env_key.is_some()) {
|
||
entry.info.supported_in_api = true;
|
||
}
|
||
entry
|
||
}
|
||
}
|
||
/// Shared model metadata — the common fields across all model sources.
|
||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||
pub struct ModelInfo {
|
||
/// Stable unique identifier for this catalog entry.
|
||
/// Falls back to `model` when absent.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub id: Option<String>,
|
||
/// The routing slug sent in API requests.
|
||
pub model: String,
|
||
/// The base URL of the model (session endpoint). e.g. "https://api.kimi.com/coding/v1"
|
||
pub base_url: String,
|
||
/// Human-readable name of the model. Honored by both the picker
|
||
/// (`/model`) and `/session-info` -- when set, that's the label shown
|
||
/// to users in either consumer.
|
||
pub name: Option<String>,
|
||
pub description: Option<String>,
|
||
pub max_completion_tokens: Option<u32>,
|
||
pub temperature: Option<f32>,
|
||
pub top_p: Option<f32>,
|
||
pub api_backend: ApiBackend,
|
||
pub auth_scheme: AuthScheme,
|
||
pub extra_headers: IndexMap<String, String>,
|
||
pub context_window: NonZeroU64,
|
||
/// Per-model auto-compact threshold (0-100). `None` defers to the
|
||
/// global / default tiers in `resolve_auto_compact_threshold_percent`.
|
||
pub auto_compact_threshold_percent: Option<u8>,
|
||
/// Per-model system-prompt identity (not UI picker `name`).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub system_prompt_label: Option<String>,
|
||
/// When true, this model uses concise mode (compact system prompt,
|
||
/// concise tool output, concise user message prefix, reduced toolset).
|
||
pub use_concise: bool,
|
||
/// The type of agent configuration to use for this model.
|
||
/// Always has a value; defaults to `"kigi-plan"` when the server
|
||
/// or user config doesn't specify one.
|
||
#[serde(default = "default_agent_type")]
|
||
pub agent_type: String,
|
||
/// Per-chunk idle timeout for inference streaming (see `ModelEntryConfig`).
|
||
pub inference_idle_timeout_secs: Option<u64>,
|
||
pub max_retries: Option<u32>,
|
||
/// Never show in picker (any auth). See also `supported_in_api`.
|
||
pub hidden: bool,
|
||
/// May the user select this model for normal chat? Derived from
|
||
/// `allowed_models` in `resolve_model_catalog`; never persisted.
|
||
#[serde(skip_serializing, default = "default_true")]
|
||
pub user_selectable: bool,
|
||
/// When false, only OAuth users see this in the picker.
|
||
#[serde(default = "default_true")]
|
||
pub supported_in_api: bool,
|
||
pub reasoning_effort: Option<ReasoningEffort>,
|
||
/// When true, the UI shows effort controls for this model.
|
||
pub supports_reasoning_effort: bool,
|
||
/// Per-model reasoning-effort menu (source of truth); legacy fields derived from it.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub reasoning_efforts: Vec<ReasoningEffortOption>,
|
||
/// Kimi capability set derived from the `/models` listing (PRD F4);
|
||
/// see [`kigi_models::derive_capabilities`]. Empty when the source
|
||
/// (bundled JSON, `[model.*]`, remote) declared none.
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub capabilities: Vec<kigi_models::ModelCapability>,
|
||
pub supports_backend_search: bool,
|
||
/// Per-model config for the `x-compactions-remaining` header; `None` disables it.
|
||
pub compactions_remaining: Option<CompactionsRemaining>,
|
||
/// Per-model config for the `x-compaction-at` header; `None` disables it.
|
||
pub compaction_at_tokens: Option<CompactionAtTokens>,
|
||
pub show_model_fingerprint: bool,
|
||
/// When `Some(true)`, the sampler injects `stream_tool_calls: true`
|
||
pub stream_tool_calls: Option<bool>,
|
||
/// Per-model Layer-3 LazinessDetector configuration. Defaults to
|
||
/// the all-disabled state — the feature is per-model opt-in with a
|
||
/// second-step `max_nudges_per_session > 0` opt-in for actually
|
||
/// injecting nudges. See [`LazinessDetectorPerModelConfig`].
|
||
#[serde(default)]
|
||
pub laziness_detector: LazinessDetectorPerModelConfig,
|
||
}
|
||
impl ModelInfo {
|
||
/// Minimal fallback descriptor for an unknown model slug.
|
||
/// Used when a configured model ID isn't found in presets or remote models.
|
||
pub fn fallback(slug: &str) -> Self {
|
||
ModelInfo {
|
||
user_selectable: true,
|
||
id: None,
|
||
model: slug.to_owned(),
|
||
base_url: String::new(),
|
||
name: None,
|
||
description: None,
|
||
max_completion_tokens: None,
|
||
temperature: None,
|
||
top_p: None,
|
||
api_backend: ApiBackend::default(),
|
||
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: false,
|
||
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(),
|
||
}
|
||
}
|
||
/// Extract shared model metadata from a flat config entry.
|
||
pub fn from_config(entry: &ModelEntryConfig) -> Self {
|
||
ModelInfo {
|
||
user_selectable: true,
|
||
id: entry.id.clone(),
|
||
model: entry.model.clone(),
|
||
base_url: entry.base_url.clone(),
|
||
name: entry.name.clone(),
|
||
description: entry.description.clone(),
|
||
max_completion_tokens: entry.max_completion_tokens,
|
||
temperature: entry.temperature,
|
||
top_p: entry.top_p,
|
||
api_backend: entry.api_backend.clone(),
|
||
auth_scheme: entry.auth_scheme.unwrap_or_default(),
|
||
extra_headers: entry.extra_headers.clone(),
|
||
context_window: entry.context_window,
|
||
auto_compact_threshold_percent: entry.auto_compact_threshold_percent,
|
||
system_prompt_label: entry.system_prompt_label.clone(),
|
||
use_concise: entry.use_concise,
|
||
agent_type: entry.agent_type.clone(),
|
||
inference_idle_timeout_secs: entry.inference_idle_timeout_secs,
|
||
max_retries: entry.max_retries,
|
||
hidden: entry.hidden,
|
||
supported_in_api: entry.supported_in_api,
|
||
reasoning_effort: entry.reasoning_effort,
|
||
supports_reasoning_effort: entry.supports_reasoning_effort,
|
||
reasoning_efforts: entry.reasoning_efforts.clone(),
|
||
capabilities: entry.capabilities.clone(),
|
||
supports_backend_search: entry.supports_backend_search,
|
||
compactions_remaining: entry.compactions_remaining,
|
||
compaction_at_tokens: entry.compaction_at_tokens,
|
||
show_model_fingerprint: entry.show_model_fingerprint,
|
||
stream_tool_calls: entry.stream_tool_calls,
|
||
laziness_detector: entry.laziness_detector.clone(),
|
||
}
|
||
}
|
||
/// Derive the legacy effort gate/default from `reasoning_efforts` so the
|
||
/// shell's internal reads (support gate, wire default, session modes) treat
|
||
/// a menu-only model as supported. The single derive site; `to_acp_model_info`
|
||
/// then just reads these fields. Idempotent (the remote/CCP path already sets
|
||
/// them); the empty-list path leaves both legacy fields untouched.
|
||
fn derive_reasoning_effort_fields(&mut self) {
|
||
if self.reasoning_efforts.is_empty() {
|
||
return;
|
||
}
|
||
self.supports_reasoning_effort = true;
|
||
if self.reasoning_effort.is_none() {
|
||
let default = self
|
||
.reasoning_efforts
|
||
.iter()
|
||
.find(|opt| opt.default)
|
||
.or_else(|| self.reasoning_efforts.first())
|
||
.map(|opt| opt.value);
|
||
self.reasoning_effort = default;
|
||
}
|
||
}
|
||
/// Whether this model appears in the picker for the given auth mode.
|
||
///
|
||
/// | `hidden` | `supported_in_api` | OAuth user | API-key user |
|
||
/// |----------|--------------------|------------|--------------|
|
||
/// | true | _ | hidden | hidden |
|
||
/// | false | true | visible | visible |
|
||
/// | false | false | visible | **hidden** |
|
||
pub fn visible_for_auth(&self, is_session_auth: bool) -> bool {
|
||
!self.hidden && (is_session_auth || self.supported_in_api)
|
||
}
|
||
}
|
||
/// Flat struct so credential and endpoint fields coexist after deep-merge.
|
||
/// Routing reads fields, not provenance.
|
||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||
pub struct ModelEntry {
|
||
pub info: ModelInfo,
|
||
pub api_key: Option<String>,
|
||
pub env_key: Option<EnvKeys>,
|
||
/// When set, `base_url` is used for session auth, `api_base_url` for API-key auth.
|
||
pub api_base_url: Option<String>,
|
||
}
|
||
impl ModelEntry {
|
||
/// Minimal fallback entry for an unknown model slug.
|
||
pub fn fallback(slug: &str, endpoints: &EndpointsConfig) -> Self {
|
||
let mut info = ModelInfo::fallback(slug);
|
||
info.base_url = endpoints.resolve_inference_base_url();
|
||
Self {
|
||
info,
|
||
api_key: None,
|
||
env_key: None,
|
||
api_base_url: None,
|
||
}
|
||
}
|
||
pub fn info(&self) -> &ModelInfo {
|
||
&self.info
|
||
}
|
||
pub fn from_config_entry(entry: &ModelEntryConfig) -> Self {
|
||
Self {
|
||
info: ModelInfo::from_config(entry),
|
||
api_key: entry.api_key.clone(),
|
||
env_key: entry.env_key.clone(),
|
||
api_base_url: entry.api_base_url.clone(),
|
||
}
|
||
}
|
||
/// The model's own (BYOK) credential: a non-empty `api_key`, else the first
|
||
/// set, non-empty `env_key` value. `None` means the model has no usable own
|
||
/// credential and resolution should fall through to the session / global key.
|
||
fn own_credential(&self) -> Option<String> {
|
||
first_own_credential(self.api_key.as_deref(), self.env_key.as_ref())
|
||
}
|
||
/// `true` when the model has a non-empty `api_key` or an `env_key` that
|
||
/// resolves to a non-empty value.
|
||
/// Probes `std::env::var` at call time — result is not stable across env changes.
|
||
pub fn has_own_credentials(&self) -> bool {
|
||
self.own_credential().is_some()
|
||
}
|
||
}
|
||
impl std::ops::Deref for ModelEntry {
|
||
type Target = ModelInfo;
|
||
fn deref(&self) -> &ModelInfo {
|
||
&self.info
|
||
}
|
||
}
|
||
fn is_false(v: &bool) -> bool {
|
||
!v
|
||
}
|
||
fn default_true() -> bool {
|
||
true
|
||
}
|
||
/// Codebase indexing setting for `[features] codebase_indexing`.
|
||
///
|
||
/// Patterns are matched against the git root when available, otherwise the cwd,
|
||
/// which allows explicitly indexing non-git directories.
|
||
///
|
||
/// ```toml
|
||
/// codebase_indexing = false # disable
|
||
/// codebase_indexing = true # any git repo (default)
|
||
/// codebase_indexing = ["/Users/*/xai*", "!/Users/*/old-*"] # globs, ! to exclude
|
||
/// ```
|
||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
#[serde(untagged)]
|
||
pub enum CodebaseIndexingSetting {
|
||
Enabled(bool),
|
||
Patterns(Vec<String>),
|
||
}
|
||
impl Default for CodebaseIndexingSetting {
|
||
fn default() -> Self {
|
||
Self::Enabled(true)
|
||
}
|
||
}
|
||
impl CodebaseIndexingSetting {
|
||
/// Should `path` be indexed? For `Enabled(true)`, always yes (caller gates on git-root).
|
||
/// For `Patterns`, path must match an include and not match any `!exclude`.
|
||
pub fn should_index(&self, path: &std::path::Path) -> bool {
|
||
match self {
|
||
Self::Enabled(b) => *b,
|
||
Self::Patterns(patterns) => {
|
||
let path_str = path.to_string_lossy();
|
||
let matches_any = |pats: &[&str]| {
|
||
pats.iter()
|
||
.any(|p| glob::Pattern::new(p).is_ok_and(|pat| pat.matches(&path_str)))
|
||
};
|
||
let (excludes, includes): (Vec<_>, Vec<_>) =
|
||
patterns.iter().partition(|p| p.starts_with('!'));
|
||
let excludes: Vec<&str> = excludes
|
||
.iter()
|
||
.map(|p| p.strip_prefix('!').unwrap_or(p.as_str()))
|
||
.collect();
|
||
let includes: Vec<&str> = includes.iter().map(|p| p.as_str()).collect();
|
||
let included = includes.is_empty() || matches_any(&includes);
|
||
let excluded = matches_any(&excludes);
|
||
included && !excluded
|
||
}
|
||
}
|
||
}
|
||
}
|
||
/// Optional role pair that drops a malformed value to `None` (with a warn)
|
||
/// instead of failing the whole config parse — one typo must not wipe the
|
||
/// config. Mirrors the remote tolerance in `util::config::remote`.
|
||
fn de_tolerant_goal_role_model<'de, D>(
|
||
deserializer: D,
|
||
) -> Result<Option<crate::util::config::GoalRoleModel>, D::Error>
|
||
where
|
||
D: serde::Deserializer<'de>,
|
||
{
|
||
let value = Option::<toml::Value>::deserialize(deserializer)?;
|
||
Ok(value.and_then(|v| {
|
||
v.try_into()
|
||
.map_err(|e| {
|
||
tracing::warn!(
|
||
error = % e, "[goal] role model: dropped malformed value"
|
||
)
|
||
})
|
||
.ok()
|
||
}))
|
||
}
|
||
/// Skeptic pool variant of [`de_tolerant_goal_role_model`]: a non-array yields
|
||
/// an empty pool; malformed entries are dropped, survivor order preserved (the
|
||
/// skeptic round-robin depends on it).
|
||
fn de_tolerant_goal_role_models<'de, D>(
|
||
deserializer: D,
|
||
) -> Result<Vec<crate::util::config::GoalRoleModel>, D::Error>
|
||
where
|
||
D: serde::Deserializer<'de>,
|
||
{
|
||
let value = Option::<toml::Value>::deserialize(deserializer)?;
|
||
Ok(match value {
|
||
Some(toml::Value::Array(arr)) => arr
|
||
.into_iter()
|
||
.filter_map(|v| {
|
||
v.try_into()
|
||
.map_err(|e| {
|
||
tracing::warn!(
|
||
error = % e, "[goal] skeptic model: dropped malformed entry"
|
||
);
|
||
})
|
||
.ok()
|
||
})
|
||
.collect(),
|
||
_ => Vec::new(),
|
||
})
|
||
}
|
||
/// `[goal]` section: the canonical home for `/goal` configuration. Field names
|
||
/// mirror the remote `goal_*` keys with the prefix dropped, so config and remote
|
||
/// stay 1:1. Per-key precedence is env > this config > remote > default.
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct GoalConfig {
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub enabled: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub classifier_enabled: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub planner_enabled: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub summary_enabled: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub use_current_model_only: Option<bool>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub verifier_count: Option<u32>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub classifier_max_runs: Option<u32>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub strategist_every: Option<u32>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub reverify_after: Option<u32>,
|
||
#[serde(
|
||
default,
|
||
deserialize_with = "de_tolerant_goal_role_model",
|
||
skip_serializing_if = "Option::is_none"
|
||
)]
|
||
pub planner_model: Option<crate::util::config::GoalRoleModel>,
|
||
#[serde(
|
||
default,
|
||
deserialize_with = "de_tolerant_goal_role_model",
|
||
skip_serializing_if = "Option::is_none"
|
||
)]
|
||
pub strategist_model: Option<crate::util::config::GoalRoleModel>,
|
||
#[serde(
|
||
default,
|
||
deserialize_with = "de_tolerant_goal_role_models",
|
||
skip_serializing_if = "Vec::is_empty"
|
||
)]
|
||
pub skeptic_models: Vec<crate::util::config::GoalRoleModel>,
|
||
}
|
||
/// `[auto_mode]` section: server-side configuration for Auto permission mode.
|
||
/// ONE struct serves both the local `[auto_mode]` TOML table and the remote
|
||
/// remote settings `auto_mode` JSON object (coerced via `serde_json::from_value`), so
|
||
/// the two stay 1:1. All fields are plain scalars/enums, so they deserialize
|
||
/// cleanly from both formats (no custom tolerant deser needed). Unset fields stay
|
||
/// `None` here; the wire fn applies the built-in defaults once auto mode is
|
||
/// enabled (current model, `low` effort if the model supports it, `just_command`
|
||
/// prompt). Precedence: local config > remote > those built-in defaults.
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct AutoModeConfig {
|
||
/// The Auto-mode gate. Lowest-precedence layer of the gate chain (env and
|
||
/// local `[auto_mode] enabled` config win over this remote value).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub enabled: Option<bool>,
|
||
/// How much context the classifier prompt includes. `None` ⇒ the wire fn's
|
||
/// built-in default (`just_command`).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub prompt_type: Option<kigi_workspace::permission::ClassifierPromptType>,
|
||
/// Routing slug for a dedicated classifier model. `None` ⇒ inherit the
|
||
/// session model. Resolved via `resolve_aux_model_sampling_config`.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub classifier_model: Option<String>,
|
||
/// Classifier reasoning effort. Applies on BOTH the routed-model path and the
|
||
/// inherited session-model path; `None` ⇒ the wire fn's built-in default
|
||
/// (`low` if the effective model supports reasoning effort, else unset).
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub reasoning_effort: Option<ReasoningEffort>,
|
||
}
|
||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||
pub struct Features {
|
||
/// when set, the agent may ask permission for tool executions
|
||
#[serde(default)]
|
||
pub support_permission: bool,
|
||
/// Codebase graph indexing for go-to-definition/references.
|
||
/// Accepts: true | false | ["glob", "!negative-glob", ...]
|
||
/// Default: true (index any git repo). Patterns can explicitly match non-git directories.
|
||
#[serde(default)]
|
||
pub codebase_indexing: CodebaseIndexingSetting,
|
||
/// Show a blocking warning when Kigi starts outside a Git repository.
|
||
/// Default: false. Used as the local fallback when the `non_git_warning` remote settings
|
||
/// flag in `kigi_settings` is absent. When the remote flag is present it takes
|
||
/// precedence — `Some(false)` from remote settings overrides `true` here.
|
||
#[serde(default)]
|
||
pub non_git_warning: bool,
|
||
/// Feedback system (heuristic popups + `/feedback` slash command).
|
||
/// `None` = defer to remote settings / default (false).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub feedback: Option<bool>,
|
||
/// Managed config fetching (managed_config.toml + requirements.toml).
|
||
/// `None` = defer to env / default (true).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub managed_config: Option<bool>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub lsp_tools: Option<bool>,
|
||
/// MCP tool search/discovery. `None` = defer to remote settings / env / default (true).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub tool_search: Option<bool>,
|
||
/// Web fetch tool. `None` = defer to remote settings / env / default (false).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub web_fetch: Option<bool>,
|
||
/// Ask-user-question tool. `None` = defer to remote settings / env / default (true).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub ask_user_question: Option<bool>,
|
||
/// Session recap (`/recap` + automatic return-from-away recap).
|
||
/// `None` = defer to remote settings / env / default (`true`).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub session_recap: Option<bool>,
|
||
/// Voice dictation (STT). `None` = env / remote / default on.
|
||
/// Set `false` in requirements or managed config to force off.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub voice_mode: Option<bool>,
|
||
/// Two-pass (prefire) compaction: speculatively summarize the history
|
||
/// prefix in the background, then summarize NOTE₁ + recent tail at
|
||
/// compaction. `None` = defer to remote settings / env / default (`false`).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub two_pass_compaction: Option<bool>,
|
||
/// Write file tool. `None` = defer to remote settings / env / default (true).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub write_file: Option<bool>,
|
||
/// Cancel-rewind: Ctrl+C before first activity restores the prompt.
|
||
/// `None` = defer to remote settings / env / default (true).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub cancel_rewind: Option<bool>,
|
||
/// Auto-wake: immediately inject a synthetic prompt when a background
|
||
/// task or subagent completes, instead of waiting for the idle drain.
|
||
/// `None` = defer to remote settings / env / default (true).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub auto_wake: Option<bool>,
|
||
/// Backend-executed tools (web_search, x_search run server-side).
|
||
/// `None` = defer to env / default (true). Set `false` to force
|
||
/// client-side tool execution.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub backend_tools: Option<bool>,
|
||
/// `summary` (default) | `transcript` | `segments`. `None` = defer to CLI /
|
||
/// env (`KIGI_COMPACTION_MODE`). Parsed via `CompactionMode::parse`.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub compaction_mode: Option<String>,
|
||
/// `none` | `minimal` | `balanced` | `verbose` (default). `None` = defer to
|
||
/// env (`KIGI_COMPACTION_DETAIL`). The `segments` verbatim detail level.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub compaction_detail: Option<String>,
|
||
/// Feed the summarizer the verbatim conversation instead of the lossy rewrite; `None` = defer to env/remote settings/default (true).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub compaction_verbatim_input: Option<bool>,
|
||
/// Snapshot a completed subagent's isolated worktree into a durable git ref
|
||
/// and delete its directory (resume rehydrates from the ref). This is the
|
||
/// per-deployment rollout lever (set in managed_config.toml `[features]`).
|
||
/// `None` = defer to remote settings / default (false).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub subagent_worktree_snapshot: Option<bool>,
|
||
/// Per-`Ready`-client transport-liveness pollers + the
|
||
/// session-actor `StatusDispatcher`.
|
||
///
|
||
/// When `true` (default), each successfully-handshaken MCP
|
||
/// client gets a poller that detects rmcp service-loop
|
||
/// termination and pushes `kigi/mcp/server_status` updates to
|
||
/// the client. When `false`, neither watchers nor the
|
||
/// dispatcher are spawned — useful as an emergency kill switch
|
||
/// for the rollout. `None` = defer to env / default (true).
|
||
///
|
||
/// Resolved via [`Config::resolve_mcp_liveness_watchers`].
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub mcp_liveness_watchers: Option<bool>,
|
||
/// Bounded stdio auto-restart task.
|
||
///
|
||
/// When `true`, the session-actor `StatusDispatcher` reacts to
|
||
/// `TransportClosed` / `HandshakeFailed` events on stdio MCP
|
||
/// servers by scheduling up to 3 respawn attempts with
|
||
/// `[1s, 4s, 16s]` backoff. HTTP / HttpAuth servers are NOT
|
||
/// auto-restarted (their existing `reset_transport` path
|
||
/// covers the recovery). `None` = defer to env / default
|
||
/// (recovery is on by default; set `false` here / via
|
||
/// `KIGI_MCP_AUTO_RESTART` to opt out).
|
||
///
|
||
/// Resolved via [`Config::resolve_mcp_auto_restart`].
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub mcp_auto_restart: Option<bool>,
|
||
/// Pager-side subscription to the `kigi/mcp/server_status` push.
|
||
///
|
||
/// When `true` (default), the pager subscribes to the per-server
|
||
/// status delta the shell emits via the dispatcher and
|
||
/// patches the MCP servers modal in-place (no re-fetch round
|
||
/// trip). When `false`, the pager ignores the push and falls
|
||
/// back to the legacy `kigi/mcp/tools_changed` debounced refetch
|
||
/// path. `None` = defer to env / default (true).
|
||
///
|
||
/// The pager-side gate
|
||
/// (`acp_handler::push_server_status_enabled`) uses an
|
||
/// **env-only** OnceLock cache via
|
||
/// [`crate::util::config::resolve_mcp_push_server_status(None, None, None)`].
|
||
/// That function consults `BoolFlag::env` and the default `true`
|
||
/// — it does NOT read this `Features` field. The shell-side
|
||
/// `Config::resolve_mcp_push_server_status` does delegate
|
||
/// through this field, but the pager never holds a `Config`.
|
||
///
|
||
/// Practical consequence: setting
|
||
/// `[features] mcp_push_server_status = false` in
|
||
/// `~/.kigi/config.toml` will NOT disable the pager's
|
||
/// subscription on a freshly-launched process. To disable the
|
||
/// pager subscription, set `KIGI_MCP_PUSH_SERVER_STATUS=0` in
|
||
/// the env before launch.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub mcp_push_server_status: Option<bool>,
|
||
/// Whether the leader's `ConfigFileWatcher` adds the two narrow
|
||
/// non-recursive watches for `<cwd>/` and `<cwd>/.kigi/`.
|
||
///
|
||
/// When `true` (default), edits to `<cwd>/.mcp.json`,
|
||
/// `<cwd>/.kigi/config.toml`, or `<cwd>/.claude.json` flow
|
||
/// through the watcher → reloader → `ConfigUpdate::
|
||
/// ProjectMcpServersChanged { cwd }` → `app.rs` ACP-injection
|
||
/// pipeline and the affected sessions reload their MCP servers
|
||
/// within the debounce window (~ 1 s). When `false`, the leader
|
||
/// skips the cwd watches entirely and the only way to pick up a
|
||
/// project-config edit is the user-triggered refresh button.
|
||
///
|
||
/// The watches are **always non-recursive** — the name follows
|
||
/// the convention for the rollout-gate flag. See
|
||
/// `crate::config::watcher::ConfigFileWatcher::watch_path` for
|
||
/// the inotify-quota rationale.
|
||
///
|
||
/// The name is a documented misnomer — it gates
|
||
/// the existence of the **cwd** watches, NOT their recursion
|
||
/// mode. A future rename to `mcp_cwd_config_watch` would align
|
||
/// name and behavior; deferred to a follow-up to avoid widening
|
||
/// the config surface across requirements.toml / managed configs.
|
||
///
|
||
/// Resolved via [`Config::resolve_mcp_recursive_config_watch`].
|
||
/// `None` = defer to env / default (true).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub mcp_recursive_config_watch: Option<bool>,
|
||
}
|
||
/// Resolved credentials for a model session.
|
||
pub struct ResolvedCredentials {
|
||
pub api_key: Option<String>,
|
||
pub base_url: String,
|
||
pub auth_type: kigi_chat_state::AuthType,
|
||
pub auth_scheme: AuthScheme,
|
||
}
|
||
/// First usable BYOK credential: a non-empty (trimmed) api_key, else the first
|
||
/// set, non-empty env_key value. Single source of truth for has_own_credentials,
|
||
/// resolve_credentials, and the JWT-reload path.
|
||
pub(crate) fn first_own_credential(
|
||
api_key: Option<&str>,
|
||
env_key: Option<&EnvKeys>,
|
||
) -> Option<String> {
|
||
api_key
|
||
.filter(|k| !k.trim().is_empty())
|
||
.map(str::to_owned)
|
||
.or_else(|| env_key.and_then(EnvKeys::resolve_value))
|
||
}
|
||
/// Resolve credentials for a model.
|
||
/// Priority: model api_key/env_key > session token > XAI_API_KEY.
|
||
///
|
||
/// When `env_key` lists multiple names, the first set non-empty value is used.
|
||
pub fn resolve_credentials(model: &ModelEntry, session_key: Option<&str>) -> ResolvedCredentials {
|
||
let info = model.info();
|
||
let (api_key, base_url, auth_type) = if let Some(key) = model.own_credential() {
|
||
(
|
||
Some(key),
|
||
info.base_url.clone(),
|
||
kigi_chat_state::AuthType::ApiKey,
|
||
)
|
||
} else if let Some(key) = session_key {
|
||
(
|
||
Some(key.to_owned()),
|
||
info.base_url.clone(),
|
||
kigi_chat_state::AuthType::SessionToken,
|
||
)
|
||
} else if let Ok(key) = crate::agent::auth_method::read_xai_api_key_env() {
|
||
let url = model
|
||
.api_base_url
|
||
.clone()
|
||
.unwrap_or_else(|| info.base_url.clone());
|
||
(Some(key), url, kigi_chat_state::AuthType::ApiKey)
|
||
} else {
|
||
if let Some(ref env_keys) = model.env_key
|
||
&& !env_keys.is_empty()
|
||
{
|
||
tracing::warn!(
|
||
model = % info.model, env_key = % env_keys,
|
||
"model has env_key configured but none of the environment variables are set — \
|
||
requests will have no API key",
|
||
);
|
||
}
|
||
(
|
||
None,
|
||
info.base_url.clone(),
|
||
kigi_chat_state::AuthType::ApiKey,
|
||
)
|
||
};
|
||
let auth_scheme = info.auth_scheme;
|
||
tracing::debug!(
|
||
model = % info.model, auth_type = ? auth_type, "resolved credentials"
|
||
);
|
||
ResolvedCredentials {
|
||
api_key,
|
||
base_url,
|
||
auth_type,
|
||
auth_scheme,
|
||
}
|
||
}
|
||
|
||
/// Try to resolve credentials for a model by loading the effective config.
|
||
/// Returns `None` (with a warning) if config loading, parsing, or model
|
||
/// lookup fails. `session_key` should only be passed when `auth_type` is
|
||
/// `SessionToken` — callers must guard this.
|
||
pub fn try_resolve_model_credentials(
|
||
model_id: &str,
|
||
session_key: Option<&str>,
|
||
) -> Option<ResolvedCredentials> {
|
||
let raw = crate::config::load_effective_config()
|
||
.map_err(|e| tracing::warn!(error = % e, "config load failed for credential resolution"))
|
||
.ok()?;
|
||
let cfg = Config::new_from_toml_cfg(&raw)
|
||
.map_err(|e| tracing::warn!(error = % e, "config parse failed for credential resolution"))
|
||
.ok()?;
|
||
let models = resolve_model_list(&cfg, None, &Default::default());
|
||
let entry = find_model_by_id(&models, model_id)?;
|
||
let credentials = resolve_credentials(entry, session_key);
|
||
Some(credentials)
|
||
}
|
||
/// Per-model auth facts (BYOK status + auth scheme) from one effective-config
|
||
/// load, memoized by the session actor.
|
||
#[derive(Clone, Copy)]
|
||
pub struct ModelAuthFacts {
|
||
pub byok: ModelByok,
|
||
pub auth_scheme: AuthScheme,
|
||
}
|
||
/// Resolve `model_id` to its auth facts from one effective-config load.
|
||
/// Load/parse failure → `byok = Unknown`; model absent from the catalog →
|
||
/// `NotByok`. An empty `model_id` (no sampling config yet) → `Unknown`, not
|
||
/// `NotByok`, so the gate isn't activated for an unidentified model.
|
||
pub fn resolve_model_auth_facts(model_id: &str) -> ModelAuthFacts {
|
||
if model_id.is_empty() {
|
||
return ModelAuthFacts {
|
||
byok: ModelByok::Unknown,
|
||
auth_scheme: AuthScheme::default(),
|
||
};
|
||
}
|
||
with_resolved_model(model_id, |lookup| ModelAuthFacts {
|
||
byok: byok_from_lookup(&lookup),
|
||
auth_scheme: match lookup {
|
||
ModelLookup::Loaded(Some(e)) => e.info().auth_scheme,
|
||
_ => AuthScheme::default(),
|
||
},
|
||
})
|
||
}
|
||
fn byok_from_lookup(lookup: &ModelLookup) -> ModelByok {
|
||
match lookup {
|
||
ModelLookup::ConfigUnavailable => ModelByok::Unknown,
|
||
ModelLookup::Loaded(Some(e)) if e.has_own_credentials() => ModelByok::Byok,
|
||
ModelLookup::Loaded(_) => ModelByok::NotByok,
|
||
}
|
||
}
|
||
enum ModelLookup<'a> {
|
||
/// `None` if `model_id` is absent from the catalog.
|
||
Loaded(Option<&'a ModelEntry>),
|
||
ConfigUnavailable,
|
||
}
|
||
/// Load + parse the effective config and hand the `model_id` lookup to `f`,
|
||
/// keeping "config unavailable" distinct from "model absent" so callers can
|
||
/// stay conservative on a transient config failure.
|
||
fn with_resolved_model<T>(model_id: &str, f: impl FnOnce(ModelLookup) -> T) -> T {
|
||
let Some(raw) = crate::config::load_effective_config()
|
||
.map_err(|e| tracing::warn!(error = % e, "config load failed for model auth lookup"))
|
||
.ok()
|
||
else {
|
||
return f(ModelLookup::ConfigUnavailable);
|
||
};
|
||
let Some(cfg) = Config::new_from_toml_cfg(&raw)
|
||
.map_err(|e| tracing::warn!(error = % e, "config parse failed for model auth lookup"))
|
||
.ok()
|
||
else {
|
||
return f(ModelLookup::ConfigUnavailable);
|
||
};
|
||
let models = resolve_model_list(&cfg, None, &Default::default());
|
||
f(ModelLookup::Loaded(find_model_by_id(&models, model_id)))
|
||
}
|
||
/// Resolve a standalone `SamplerConfig` for an auxiliary model slug (image
|
||
/// description, session summary, ...), resolved through the catalog so a
|
||
/// `[model.*]` override redirects it to its own endpoint, credentials, and
|
||
/// routing `model`. `None` → caller falls back to the active session's model.
|
||
pub fn resolve_aux_model_sampling_config(
|
||
model_id: &str,
|
||
models: &IndexMap<String, ModelEntry>,
|
||
endpoints: &EndpointsConfig,
|
||
session_key: Option<&str>,
|
||
alpha_test_key: Option<String>,
|
||
) -> Option<SamplerConfig> {
|
||
let catalog_entry = find_model_by_id(models, model_id).cloned();
|
||
if let Some(entry) = &catalog_entry {
|
||
let credentials = resolve_credentials(entry, session_key);
|
||
let sampler = sampling_config_for_model(entry, credentials, alpha_test_key.clone());
|
||
if sampler.api_key.is_some() {
|
||
return Some(sampler);
|
||
}
|
||
}
|
||
let xai_bearer = session_key
|
||
.map(|s| s.to_owned())
|
||
.or_else(|| crate::agent::auth_method::read_xai_api_key_env().ok())
|
||
.or_else(|| endpoints.deployment_key.clone());
|
||
if let Some(bearer) = xai_bearer {
|
||
let entry = ModelEntry {
|
||
info: ModelInfo {
|
||
user_selectable: true,
|
||
id: None,
|
||
model: catalog_entry
|
||
.map(|e| e.info.model)
|
||
.unwrap_or_else(|| 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,
|
||
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: Some(bearer),
|
||
env_key: None,
|
||
api_base_url: None,
|
||
};
|
||
let credentials = resolve_credentials(&entry, session_key);
|
||
let sampler = sampling_config_for_model(&entry, credentials, alpha_test_key);
|
||
return Some(sampler);
|
||
}
|
||
tracing::warn!(
|
||
aux_model = % model_id,
|
||
"no credentials for auxiliary model; falling back to active model",
|
||
);
|
||
None
|
||
}
|
||
/// Finalize image-describe model + sampler config for user attachments.
|
||
/// Shared so the aux resolve happy path and the
|
||
/// `None` fallback cannot diverge between those entry points.
|
||
///
|
||
/// On aux resolve `Some`, stamp session-local fields (attribution, bearer,
|
||
/// retries) onto the helper config. On `None`, fall back to the active session model and
|
||
/// full config (not forcing `image_description_model` onto the agent endpoint, which 404s
|
||
/// on BYOK / non-proxy routes for internal slugs).
|
||
/// Stamp the session-local fields (attribution, bearer resolver, retries)
|
||
/// from the active session onto a routed aux `SamplerConfig` so a
|
||
/// helper model keeps the session's auth/attribution. Shared by image-describe
|
||
/// and the auto-mode classifier so the two can't drift.
|
||
pub fn stamp_session_local_sampler_fields(
|
||
cfg: &mut SamplerConfig,
|
||
active_session_config: &SamplerConfig,
|
||
max_retries: Option<u32>,
|
||
) {
|
||
cfg.attribution_callback = active_session_config.attribution_callback.clone();
|
||
cfg.bearer_resolver = active_session_config.bearer_resolver.clone();
|
||
cfg.max_retries = max_retries;
|
||
}
|
||
pub fn finalize_image_describe_sampler_config(
|
||
resolved_aux: Option<SamplerConfig>,
|
||
active_session_config: &SamplerConfig,
|
||
max_retries: Option<u32>,
|
||
) -> (String, SamplerConfig) {
|
||
match resolved_aux {
|
||
Some(mut describe_cfg) => {
|
||
stamp_session_local_sampler_fields(
|
||
&mut describe_cfg,
|
||
active_session_config,
|
||
max_retries,
|
||
);
|
||
let model = describe_cfg.model.clone();
|
||
(model, describe_cfg)
|
||
}
|
||
None => {
|
||
let model = active_session_config.model.clone();
|
||
(model, active_session_config.clone())
|
||
}
|
||
}
|
||
}
|
||
/// Re-derive `auth_type` from the model's own credentials so BYOK env-key
|
||
/// models stay on `ApiKey` even when a session token is present. Falls
|
||
/// back to `fallback` when the model isn't in the on-disk catalog.
|
||
pub fn resolve_chat_state_auth_type(
|
||
model_id: &str,
|
||
session_key: Option<&str>,
|
||
fallback: kigi_chat_state::AuthType,
|
||
) -> kigi_chat_state::AuthType {
|
||
try_resolve_model_credentials(model_id, session_key)
|
||
.map(|r| r.auth_type)
|
||
.unwrap_or(fallback)
|
||
}
|
||
pub fn sampling_config_for_model(
|
||
model: &ModelEntry,
|
||
credentials: ResolvedCredentials,
|
||
alpha_test_key: Option<String>,
|
||
) -> SamplerConfig {
|
||
let info = model.info();
|
||
let model_name = info.model.clone();
|
||
let max_completion_tokens = info.max_completion_tokens;
|
||
let temperature = info.temperature;
|
||
let top_p = info.top_p;
|
||
let mut extra_headers = info.extra_headers.clone();
|
||
inject_url_derived_headers(
|
||
&mut extra_headers,
|
||
alpha_test_key.as_deref(),
|
||
&credentials.base_url,
|
||
);
|
||
let api_backend = info.api_backend.clone();
|
||
// Managed platform entries speak their registry dialect; BYOK/custom
|
||
// entries keep the historical Kimi body adaptation.
|
||
let chat_compat = info
|
||
.id
|
||
.as_deref()
|
||
.and_then(kigi_models::parse_managed_model_key)
|
||
.map(|(platform, _)| match platform.chat_compat() {
|
||
kigi_models::PlatformChatCompat::Kimi => kigi_sampling_types::ChatCompat::Kimi,
|
||
kigi_models::PlatformChatCompat::DeepSeek => kigi_sampling_types::ChatCompat::DeepSeek,
|
||
kigi_models::PlatformChatCompat::Passthrough => {
|
||
kigi_sampling_types::ChatCompat::Passthrough
|
||
}
|
||
})
|
||
.unwrap_or_default();
|
||
SamplerConfig {
|
||
api_key: credentials.api_key,
|
||
model: model_name,
|
||
base_url: credentials.base_url,
|
||
max_completion_tokens,
|
||
temperature,
|
||
top_p,
|
||
api_backend,
|
||
auth_scheme: credentials.auth_scheme,
|
||
chat_compat,
|
||
extra_headers,
|
||
context_window: info.context_window.get(),
|
||
reasoning_effort: info.reasoning_effort,
|
||
force_http1: false,
|
||
max_retries: info.max_retries,
|
||
stream_tool_calls: info.stream_tool_calls.unwrap_or(false),
|
||
idle_timeout_secs: None,
|
||
origin_client: None,
|
||
attribution_callback: None,
|
||
bearer_resolver: None,
|
||
supports_backend_search: info.supports_backend_search,
|
||
compactions_remaining: info.compactions_remaining,
|
||
compaction_at_tokens: info.compaction_at_tokens,
|
||
doom_loop_recovery: None,
|
||
header_injector: None,
|
||
}
|
||
}
|
||
/// Fold URL-derived headers into `extra_headers`.
|
||
///
|
||
/// The sampler crate is intentionally URL-agnostic: it does not inspect
|
||
/// `base_url` to decide which auth or identity headers to add. Replicate the
|
||
/// URL-derived header logic at the shell boundary so callers downstream see a
|
||
/// single homogenous header bag.
|
||
///
|
||
/// * First-party (Kimi subscription) bases get the `X-Msh-Device-*` identity
|
||
/// headers, mirroring the official client, which sends its OAuth device
|
||
/// headers on every inference request (kimi-cli src/kimi_cli/llm.py:317-323
|
||
/// `_kimi_default_headers` merges `oauth.common_headers()`). Third-party /
|
||
/// Moonshot-open-platform bases get none — only the bearer and User-Agent.
|
||
///
|
||
/// A device-id failure only skips the headers (with a warning): inference
|
||
/// must not hard-fail because `~/.kigi/device_id` is unwritable — unlike
|
||
/// OAuth login, where the id is mandatory.
|
||
///
|
||
/// Existing entries are never overwritten so callers can pre-set a value.
|
||
pub fn inject_url_derived_headers(
|
||
headers: &mut IndexMap<String, String>,
|
||
alpha_test_key: Option<&str>,
|
||
base_url: &str,
|
||
) {
|
||
if crate::util::is_production_coding_api_url(base_url) {
|
||
match crate::auth::device_headers() {
|
||
Ok(device_headers) => {
|
||
for (name, value) in device_headers {
|
||
headers.entry(name.to_string()).or_insert(value);
|
||
}
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!(
|
||
error = %e,
|
||
"device identity headers unavailable; sending inference request without them"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
let _ = (alpha_test_key, base_url);
|
||
}
|
||
pub fn resolve_model_to_sampling_config(
|
||
model_id: &str,
|
||
models: &IndexMap<String, ModelEntry>,
|
||
session_key: Option<&str>,
|
||
alpha_test_key: Option<String>,
|
||
fallback_entry: Option<ModelEntry>,
|
||
) -> Option<SamplerConfig> {
|
||
let entry = find_model_by_id(models, model_id)
|
||
.cloned()
|
||
.or(fallback_entry)?;
|
||
let credentials = resolve_credentials(&entry, session_key);
|
||
Some(sampling_config_for_model(
|
||
&entry,
|
||
credentials,
|
||
alpha_test_key,
|
||
))
|
||
}
|
||
pub fn to_acp_model_info(
|
||
models: &IndexMap<String, ModelEntry>,
|
||
) -> IndexMap<acp::ModelId, acp::ModelInfo> {
|
||
models
|
||
.iter()
|
||
.map(|(key, model)| {
|
||
let info = model.info();
|
||
let model_id = acp::ModelId::new(Arc::from(key.clone()));
|
||
let total_context_tokens = info.context_window.get();
|
||
let meta = {
|
||
let mut map = serde_json::Map::new();
|
||
map.insert(
|
||
"totalContextTokens".to_string(),
|
||
serde_json::Value::Number(total_context_tokens.into()),
|
||
);
|
||
map.insert(
|
||
"agentType".to_string(),
|
||
serde_json::Value::String(info.agent_type.clone()),
|
||
);
|
||
if info.supports_reasoning_effort {
|
||
map.insert(
|
||
"supportsReasoningEffort".to_string(),
|
||
serde_json::Value::Bool(true),
|
||
);
|
||
if let Some(effort) = info.reasoning_effort {
|
||
map.insert(
|
||
REASONING_EFFORT_META_KEY.to_string(),
|
||
reasoning_effort_meta_value(effort),
|
||
);
|
||
}
|
||
}
|
||
if !info.reasoning_efforts.is_empty() {
|
||
map.insert(
|
||
REASONING_EFFORTS_META_KEY.to_string(),
|
||
reasoning_efforts_meta_value(&info.reasoning_efforts),
|
||
);
|
||
}
|
||
if map.is_empty() { None } else { Some(map) }
|
||
};
|
||
(
|
||
model_id.clone(),
|
||
acp::ModelInfo::new(
|
||
model_id,
|
||
info.name.clone().unwrap_or_else(|| info.model.clone()),
|
||
)
|
||
.description(info.description.clone())
|
||
.meta(meta),
|
||
)
|
||
})
|
||
.collect()
|
||
}
|
||
/// Error code for model switch rejection due to agent type mismatch.
|
||
pub const MODEL_SWITCH_INCOMPATIBLE_AGENT: &str = "MODEL_SWITCH_INCOMPATIBLE_AGENT";
|
||
/// Error code for model switch failure during the zero-turn full harness
|
||
/// rebuild path. Emitted when `RebuildAgentForDefinition` fails (definition
|
||
/// could not be resolved at handler time, `AgentBuilder::build()` errored,
|
||
/// or a turn started racing the rebuild).
|
||
pub const MODEL_SWITCH_REBUILD_FAILED: &str = "MODEL_SWITCH_REBUILD_FAILED";
|
||
/// Structured error payload for model switch rejection due to agent type
|
||
/// incompatibility. Serialized into `acp::Error.data` by the shell and
|
||
/// deserialized by the TUI for user-friendly error rendering.
|
||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct ModelSwitchIncompatibleAgentError {
|
||
/// Stable machine-readable error code (always `MODEL_SWITCH_INCOMPATIBLE_AGENT`).
|
||
pub code: String,
|
||
/// The agent type currently active in the session.
|
||
pub active_agent_type: String,
|
||
/// The agent type required by the target model.
|
||
pub required_agent_type: String,
|
||
/// The model ID that was requested.
|
||
pub model_id: String,
|
||
/// Remediation hint for the client.
|
||
pub suggestion: String,
|
||
}
|
||
impl ModelSwitchIncompatibleAgentError {
|
||
/// Build an `acp::Error` with this structured payload.
|
||
pub fn into_acp_error(self) -> acp::Error {
|
||
let message = format!(
|
||
"Cannot switch to model '{}': it requires agent '{}' but the active agent is '{}'. \
|
||
Start a new session to use this model.",
|
||
self.model_id, self.required_agent_type, self.active_agent_type,
|
||
);
|
||
acp::Error::new(acp::ErrorCode::InvalidRequest.into(), message)
|
||
.data(serde_json::to_value(&self).ok())
|
||
}
|
||
/// Try to parse from an `acp::Error.data` field.
|
||
pub fn from_acp_error(err: &acp::Error) -> Option<Self> {
|
||
let data = err.data.as_ref()?;
|
||
let code = data.get("code")?.as_str()?;
|
||
if code != MODEL_SWITCH_INCOMPATIBLE_AGENT {
|
||
return None;
|
||
}
|
||
serde_json::from_value(data.clone()).ok()
|
||
}
|
||
/// Render a user-friendly error message for the TUI.
|
||
pub fn user_message(&self) -> String {
|
||
format!(
|
||
"Cannot switch to '{}' — it requires agent '{}' but the active agent is '{}'. \
|
||
Start /new to use this model.",
|
||
self.model_id, self.required_agent_type, self.active_agent_type,
|
||
)
|
||
}
|
||
}
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use kigi_test_support::EnvGuard;
|
||
use serial_test::serial;
|
||
/// Catalog key of the bundled fallback default (`default_models.json`):
|
||
/// `{platform_id}/{model_id}` for `crate::models::default_model()`.
|
||
const BUNDLED_DEFAULT_KEY: &str = "kimi-code/kimi-for-coding";
|
||
#[test]
|
||
fn main_cli_tools_override_preserves_profile_injection_policy() {
|
||
let overrides = CliAgentOverrides {
|
||
tools: Some(vec!["read_file".into()]),
|
||
..Default::default()
|
||
};
|
||
let mut cases = vec![(AgentDefinition::default_kigi(), true)];
|
||
for (mut definition, expected_injection) in cases {
|
||
overrides.apply_to_definition(&mut definition);
|
||
assert_eq!(definition.tools, vec!["read_file".to_string()]);
|
||
assert_eq!(definition.inject_default_tools, expected_injection);
|
||
}
|
||
}
|
||
/// `AutoModeConfig` parses identically from a local `[auto_mode]` TOML table
|
||
/// and an equivalent remote settings JSON object (serde is format-agnostic). The
|
||
/// lean shape is all scalars/enums, so no custom tolerant deser is needed.
|
||
#[test]
|
||
fn auto_mode_config_parses_from_toml_and_json_equivalently() {
|
||
use kigi_workspace::permission::ClassifierPromptType;
|
||
let toml_src = r#"
|
||
enabled = true
|
||
prompt_type = "no_user_tool_prefix"
|
||
classifier_model = "kigi-4.5"
|
||
reasoning_effort = "low"
|
||
"#;
|
||
let from_toml: AutoModeConfig = toml::from_str(toml_src).unwrap();
|
||
let json = serde_json::json!(
|
||
{ "enabled" : true, "prompt_type" : "no_user_tool_prefix", "classifier_model"
|
||
: "kigi-4.5", "reasoning_effort" : "low" }
|
||
);
|
||
let from_json: AutoModeConfig = serde_json::from_value(json).unwrap();
|
||
for cfg in [&from_toml, &from_json] {
|
||
assert_eq!(cfg.enabled, Some(true));
|
||
assert_eq!(
|
||
cfg.prompt_type,
|
||
Some(ClassifierPromptType::NoUserToolPrefix)
|
||
);
|
||
assert_eq!(cfg.classifier_model.as_deref(), Some("kigi-4.5"));
|
||
assert_eq!(cfg.reasoning_effort, Some(ReasoningEffort::Low));
|
||
}
|
||
let empty: AutoModeConfig = toml::from_str("").unwrap();
|
||
assert!(empty.enabled.is_none() && empty.prompt_type.is_none());
|
||
assert!(empty.classifier_model.is_none() && empty.reasoning_effort.is_none());
|
||
}
|
||
/// `prompt_type` wire values are the snake_case `ClassifierPromptType` names.
|
||
#[test]
|
||
fn auto_mode_prompt_type_parses_snake_case() {
|
||
use kigi_workspace::permission::ClassifierPromptType;
|
||
for (s, variant) in [
|
||
("full", ClassifierPromptType::Full),
|
||
(
|
||
"no_user_tool_prefix",
|
||
ClassifierPromptType::NoUserToolPrefix,
|
||
),
|
||
("bare_instructions", ClassifierPromptType::BareInstructions),
|
||
("just_command", ClassifierPromptType::JustCommand),
|
||
] {
|
||
let cfg: AutoModeConfig = toml::from_str(&format!("prompt_type = \"{s}\"")).unwrap();
|
||
assert_eq!(cfg.prompt_type, Some(variant));
|
||
}
|
||
}
|
||
#[test]
|
||
fn laziness_detector_default_is_all_disabled() {
|
||
let cfg = LazinessDetectorPerModelConfig::default();
|
||
assert!(!cfg.enabled);
|
||
assert_eq!(cfg.max_nudges_per_session, 0);
|
||
assert_eq!(cfg.idle_threshold_ms, None);
|
||
assert_eq!(cfg.min_confidence, None);
|
||
assert_eq!(
|
||
cfg.include_reasoning, None,
|
||
"include_reasoning defaults to None so the harness default applies",
|
||
);
|
||
}
|
||
#[test]
|
||
fn laziness_detector_absent_block_deserializes_to_default() {
|
||
let json = serde_json::json!(
|
||
{ "model" : "test", "base_url" : "https://test.api/v1", "context_window" :
|
||
200_000, }
|
||
);
|
||
let entry: ModelEntryConfig =
|
||
serde_json::from_value(json).expect("ModelEntryConfig deserializes without detector");
|
||
assert_eq!(
|
||
entry.laziness_detector,
|
||
LazinessDetectorPerModelConfig::default()
|
||
);
|
||
let info = ModelInfo::from_config(&entry);
|
||
assert!(!info.laziness_detector.enabled);
|
||
}
|
||
#[test]
|
||
fn laziness_detector_fallback_modelinfo_is_disabled() {
|
||
let info = ModelInfo::fallback("unknown-model");
|
||
assert_eq!(
|
||
info.laziness_detector,
|
||
LazinessDetectorPerModelConfig::default(),
|
||
);
|
||
assert!(!info.laziness_detector.enabled);
|
||
assert_eq!(info.laziness_detector.max_nudges_per_session, 0);
|
||
}
|
||
#[test]
|
||
fn laziness_detector_block_round_trips_through_serde() {
|
||
let json = serde_json::json!(
|
||
{ "enabled" : true, "max_nudges_per_session" : 3, "idle_threshold_ms" :
|
||
15_000, "min_confidence" : 0.8, "include_reasoning" : false, }
|
||
);
|
||
let cfg: LazinessDetectorPerModelConfig =
|
||
serde_json::from_value(json).expect("deserialize populated block");
|
||
assert!(cfg.enabled);
|
||
assert_eq!(cfg.max_nudges_per_session, 3);
|
||
assert_eq!(cfg.idle_threshold_ms, Some(15_000));
|
||
assert_eq!(cfg.min_confidence, Some(0.8));
|
||
assert_eq!(cfg.include_reasoning, Some(false));
|
||
}
|
||
/// Pins all three states of the per-model `include_reasoning`
|
||
/// override (`Some(true)`, `Some(false)`, absent → `None`) so a
|
||
/// future drift on the `#[serde(default)]` attribute or the field
|
||
/// type fails the test rather than silently changing the resolved
|
||
/// default.
|
||
#[test]
|
||
fn laziness_detector_include_reasoning_serde_states() {
|
||
let some_true: LazinessDetectorPerModelConfig =
|
||
serde_json::from_value(serde_json::json!({ "include_reasoning" : true }))
|
||
.expect("Some(true)");
|
||
assert_eq!(some_true.include_reasoning, Some(true));
|
||
let some_false: LazinessDetectorPerModelConfig =
|
||
serde_json::from_value(serde_json::json!({ "include_reasoning" : false }))
|
||
.expect("Some(false)");
|
||
assert_eq!(some_false.include_reasoning, Some(false));
|
||
let absent: LazinessDetectorPerModelConfig =
|
||
serde_json::from_value(serde_json::json!({})).expect("absent → None");
|
||
assert_eq!(absent.include_reasoning, None);
|
||
}
|
||
#[test]
|
||
fn subagent_permission_mode_precedence() {
|
||
let own = PermissionMode::Plan;
|
||
let cases = [
|
||
(
|
||
PermissionMode::BypassPermissions,
|
||
PermissionMode::BypassPermissions,
|
||
),
|
||
(PermissionMode::AcceptEdits, PermissionMode::AcceptEdits),
|
||
(PermissionMode::Auto, PermissionMode::Auto),
|
||
(PermissionMode::Default, own.clone()),
|
||
(PermissionMode::DontAsk, own.clone()),
|
||
(PermissionMode::Plan, own.clone()),
|
||
];
|
||
for (parent, expected) in cases {
|
||
assert_eq!(
|
||
resolve_subagent_permission_mode(own.clone(), &parent),
|
||
expected,
|
||
"parent={parent:?}"
|
||
);
|
||
}
|
||
}
|
||
#[test]
|
||
fn inject_url_derived_headers_adds_device_identity_for_first_party_url() {
|
||
let mut headers = IndexMap::new();
|
||
inject_url_derived_headers(
|
||
&mut headers,
|
||
None,
|
||
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
|
||
);
|
||
assert!(headers.get("X-Msh-Device-Id").is_some());
|
||
assert!(headers.get("X-Msh-Device-Name").is_some());
|
||
assert!(headers.get("X-XAI-Token-Auth").is_none());
|
||
}
|
||
#[test]
|
||
fn inject_url_derived_headers_skips_headers_for_external_url() {
|
||
let mut headers = IndexMap::new();
|
||
inject_url_derived_headers(&mut headers, None, "https://api.example.com/v1");
|
||
assert!(headers.get("X-Msh-Device-Id").is_none());
|
||
assert!(headers.get("X-Msh-Device-Name").is_none());
|
||
}
|
||
#[test]
|
||
fn inject_url_derived_headers_preserves_caller_extra_headers() {
|
||
let mut headers = IndexMap::new();
|
||
headers.insert("x-custom-byok".to_string(), "value".to_string());
|
||
inject_url_derived_headers(
|
||
&mut headers,
|
||
None,
|
||
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
|
||
);
|
||
assert_eq!(
|
||
headers.get("x-custom-byok").map(String::as_str),
|
||
Some("value")
|
||
);
|
||
}
|
||
#[test]
|
||
fn parses_toolset_overrides() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[toolset.bash]
|
||
timeout_secs = 123
|
||
|
||
[toolset.ask_user_question]
|
||
timeout_enabled = false
|
||
timeout_secs = 30
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
assert_eq!(cfg.toolset.bash.timeout_secs, Some(123.0));
|
||
assert_eq!(cfg.toolset.ask_user_question.timeout_enabled, Some(false));
|
||
assert_eq!(cfg.toolset.ask_user_question.timeout_secs, Some(30));
|
||
}
|
||
#[test]
|
||
fn parses_toolset_bash_float_timeout() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[toolset.bash]
|
||
timeout_secs = 30.5
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
assert_eq!(cfg.toolset.bash.timeout_secs, Some(30.5));
|
||
}
|
||
#[test]
|
||
fn resolve_runtime_fields_propagates_disable_web_search() {
|
||
fn ctx(raw: &toml::Value, disable_web_search: bool) -> RuntimeResolutionContext<'_> {
|
||
RuntimeResolutionContext {
|
||
raw_config: raw,
|
||
remote_settings: None,
|
||
cwd: None,
|
||
is_headless: true,
|
||
cli_subagents: None,
|
||
cli_session_summary_model: None,
|
||
cli_experimental_memory: false,
|
||
cli_no_memory: false,
|
||
disable_web_search,
|
||
todo_gate: false,
|
||
laziness_debug_log: None,
|
||
storage_mode: None,
|
||
}
|
||
}
|
||
let empty: toml::Value = toml::Value::Table(toml::map::Map::new());
|
||
let mut cfg = Config::new_from_toml_cfg(&empty).unwrap();
|
||
cfg.resolve_runtime_fields(&ctx(&empty, false));
|
||
assert!(!cfg.disable_web_search);
|
||
let mut cfg = Config::new_from_toml_cfg(&empty).unwrap();
|
||
cfg.resolve_runtime_fields(&ctx(&empty, true));
|
||
assert!(cfg.disable_web_search);
|
||
let toml_on: toml::Value = toml::from_str("disable_web_search = true").unwrap();
|
||
let mut cfg = Config::new_from_toml_cfg(&toml_on).unwrap();
|
||
cfg.resolve_runtime_fields(&ctx(&toml_on, false));
|
||
assert!(cfg.disable_web_search);
|
||
}
|
||
#[test]
|
||
fn new_from_toml_cfg_restores_session_summary_and_image_description_models() {
|
||
let empty: toml::Value = toml::Value::Table(toml::map::Map::new());
|
||
let cfg = Config::new_from_toml_cfg(&empty).expect("empty config should parse");
|
||
assert_eq!(
|
||
cfg.session_summary_model,
|
||
Some(crate::models::default_session_summary_model().to_owned()),
|
||
"empty config should produce compiled default session_summary model"
|
||
);
|
||
assert_eq!(
|
||
cfg.image_description_model,
|
||
Some(crate::models::default_image_description_model().to_owned()),
|
||
"empty config should produce compiled default image_description model"
|
||
);
|
||
let with_overrides: toml::Value = toml::from_str(
|
||
r#"
|
||
[models]
|
||
session_summary = "custom-ss-model"
|
||
image_description = "custom-id-model"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg2 = Config::new_from_toml_cfg(&with_overrides).expect("config should parse");
|
||
assert_eq!(
|
||
cfg2.session_summary_model,
|
||
Some("custom-ss-model".to_owned())
|
||
);
|
||
assert_eq!(
|
||
cfg2.image_description_model,
|
||
Some("custom-id-model".to_owned())
|
||
);
|
||
}
|
||
#[test]
|
||
fn finalize_image_describe_sampler_none_uses_active_session_model_not_forced_helper() {
|
||
let active = SamplerConfig {
|
||
model: "composer-session-model".into(),
|
||
..Default::default()
|
||
};
|
||
let (model, cfg) = finalize_image_describe_sampler_config(None, &active, Some(3));
|
||
assert_eq!(model, "composer-session-model");
|
||
assert_eq!(cfg.model, "composer-session-model");
|
||
assert_ne!(cfg.model, "kigi");
|
||
}
|
||
#[test]
|
||
fn finalize_image_describe_sampler_some_stamps_session_fields() {
|
||
let active = SamplerConfig {
|
||
model: "composer-session-model".into(),
|
||
..Default::default()
|
||
};
|
||
let aux = SamplerConfig {
|
||
model: "kigi".into(),
|
||
..Default::default()
|
||
};
|
||
let (model, cfg) = finalize_image_describe_sampler_config(Some(aux), &active, Some(7));
|
||
assert_eq!(model, "kigi");
|
||
assert_eq!(cfg.model, "kigi");
|
||
assert_eq!(cfg.max_retries, Some(7));
|
||
}
|
||
#[test]
|
||
fn resolve_aux_model_honors_kigi_override() {
|
||
let endpoints = EndpointsConfig::default();
|
||
let mut catalog = IndexMap::new();
|
||
catalog.insert(
|
||
"kigi".to_string(),
|
||
test_model_entry(
|
||
"v9m-rl-learnability-tp8",
|
||
"https://vendor.example/v1",
|
||
Some("vendor-key"),
|
||
None,
|
||
None,
|
||
),
|
||
);
|
||
let resolved = resolve_aux_model_sampling_config("kigi", &catalog, &endpoints, None, None)
|
||
.expect("override entry has an API key, so resolution succeeds");
|
||
assert_eq!(resolved.model, "v9m-rl-learnability-tp8");
|
||
assert_eq!(resolved.base_url, "https://vendor.example/v1");
|
||
assert_eq!(resolved.api_key.as_deref(), Some("vendor-key"));
|
||
}
|
||
#[test]
|
||
fn parses_model_api_key() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.my-custom-model]
|
||
model = "kigi-4.5"
|
||
base_url = "https://api.example.com/v1"
|
||
context_window = 200000
|
||
api_key = "sk-test-key-12345"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let model = resolved.get("my-custom-model").expect("model should exist");
|
||
assert_eq!(model.info.model, "kigi-4.5");
|
||
assert_eq!(model.info.base_url, "https://api.example.com/v1");
|
||
assert_eq!(model.api_key, Some("sk-test-key-12345".to_string()));
|
||
}
|
||
fn test_model_entry(
|
||
model: &str,
|
||
base_url: &str,
|
||
api_key: Option<&str>,
|
||
env_key: Option<&str>,
|
||
api_base_url: Option<&str>,
|
||
) -> ModelEntry {
|
||
ModelEntry {
|
||
info: ModelInfo {
|
||
user_selectable: true,
|
||
id: None,
|
||
model: model.to_string(),
|
||
base_url: base_url.to_string(),
|
||
name: None,
|
||
description: None,
|
||
max_completion_tokens: None,
|
||
temperature: None,
|
||
top_p: None,
|
||
api_backend: ApiBackend::default(),
|
||
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: false,
|
||
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: api_key.map(|s| s.to_string()),
|
||
env_key: env_key.map(EnvKeys::single),
|
||
api_base_url: api_base_url.map(|s| s.to_string()),
|
||
}
|
||
}
|
||
/// The effective-model RE-support lookup must use the model ACTUALLY used:
|
||
/// the resolved aux model when present, else the session model (an
|
||
/// unresolvable slug ⇒ aux `None` ⇒ session model's capability wins).
|
||
#[test]
|
||
fn effective_classifier_supports_re_uses_actually_used_model() {
|
||
let mut re_model = test_model_entry("v9", "https://x/v1", None, None, None);
|
||
re_model.info.supports_reasoning_effort = true;
|
||
let no_re_model = test_model_entry("legacy", "https://x/v1", None, None, None);
|
||
let mut models = IndexMap::new();
|
||
models.insert("v9".to_string(), re_model);
|
||
models.insert("legacy".to_string(), no_re_model);
|
||
assert!(effective_classifier_supports_re(
|
||
Some("v9"),
|
||
"legacy",
|
||
&models
|
||
));
|
||
assert!(effective_classifier_supports_re(None, "v9", &models));
|
||
assert!(!effective_classifier_supports_re(None, "legacy", &models));
|
||
assert!(!effective_classifier_supports_re(
|
||
Some("typo-slug"),
|
||
"v9",
|
||
&models
|
||
));
|
||
assert!(!effective_classifier_supports_re(None, "missing", &models));
|
||
}
|
||
#[test]
|
||
fn sampling_config_uses_model_api_key_over_fallback() {
|
||
let model = test_model_entry(
|
||
"test-model",
|
||
"https://test.api/v1",
|
||
Some("model-specific-key"),
|
||
None,
|
||
None,
|
||
);
|
||
let sampling_config =
|
||
sampling_config_for_model(&model, resolve_credentials(&model, None), None);
|
||
assert_eq!(
|
||
sampling_config.api_key,
|
||
Some("model-specific-key".to_string())
|
||
);
|
||
assert_eq!(sampling_config.base_url, "https://test.api/v1");
|
||
}
|
||
#[test]
|
||
fn sampling_config_uses_fallback_when_no_model_api_key() {
|
||
let model = test_model_entry("test-model", "https://test.api/v1", None, None, None);
|
||
let sampling_config = sampling_config_for_model(
|
||
&model,
|
||
ResolvedCredentials {
|
||
api_key: Some("fallback-key".to_string()),
|
||
base_url: model.info().base_url.clone(),
|
||
auth_type: kigi_chat_state::AuthType::ApiKey,
|
||
auth_scheme: AuthScheme::Bearer,
|
||
},
|
||
None,
|
||
);
|
||
assert_eq!(sampling_config.api_key, Some("fallback-key".to_string()));
|
||
}
|
||
#[test]
|
||
fn default_models_dual_endpoint_routing() {
|
||
let endpoints = EndpointsConfig::default();
|
||
for (model_id, entry) in default_model_entries(&endpoints) {
|
||
if entry.api_base_url.is_none() {
|
||
continue;
|
||
}
|
||
let session_creds = resolve_credentials(&entry, Some("tok"));
|
||
assert_eq!(
|
||
session_creds.base_url,
|
||
endpoints.proxy_url(),
|
||
"{model_id}: SessionToken must route to cli-chat-proxy"
|
||
);
|
||
let api_key_creds = ResolvedCredentials {
|
||
api_key: Some("key".into()),
|
||
base_url: entry
|
||
.api_base_url
|
||
.clone()
|
||
.unwrap_or(entry.info().base_url.clone()),
|
||
auth_type: kigi_chat_state::AuthType::ApiKey,
|
||
auth_scheme: AuthScheme::Bearer,
|
||
};
|
||
assert_eq!(
|
||
Some(api_key_creds.base_url.as_str()),
|
||
endpoints.api_base_url.as_deref(),
|
||
"{model_id}: ExternalApiKey must route to the configured BYOK endpoint"
|
||
);
|
||
}
|
||
}
|
||
#[test]
|
||
fn env_keys_deser_string_or_array() {
|
||
let one: EnvKeys = serde_json::from_str(r#""ANTHROPIC_AUTH_TOKEN""#).unwrap();
|
||
assert_eq!(one.names(), vec!["ANTHROPIC_AUTH_TOKEN"]);
|
||
let many: EnvKeys =
|
||
serde_json::from_str(r#"["ANTHROPIC_AUTH_TOKEN", "LC_ANTHROPIC_AUTH_TOKEN"]"#).unwrap();
|
||
assert_eq!(
|
||
many.names(),
|
||
vec!["ANTHROPIC_AUTH_TOKEN", "LC_ANTHROPIC_AUTH_TOKEN"]
|
||
);
|
||
let ser = serde_json::to_value(&one).unwrap();
|
||
assert_eq!(ser, serde_json::json!("ANTHROPIC_AUTH_TOKEN"));
|
||
let ser_many = serde_json::to_value(&many).unwrap();
|
||
assert_eq!(
|
||
ser_many,
|
||
serde_json::json!(["ANTHROPIC_AUTH_TOKEN", "LC_ANTHROPIC_AUTH_TOKEN"])
|
||
);
|
||
}
|
||
#[test]
|
||
fn env_keys_resolve_first_set_wins() {
|
||
let keys = EnvKeys::new(["KIGI_TEST_ENV_KEY_PRIMARY", "KIGI_TEST_ENV_KEY_FALLBACK"]);
|
||
assert_eq!(keys.resolve_value_with(|_| None), None, "none set");
|
||
assert_eq!(
|
||
keys.resolve_value_with(
|
||
|n| (n == "KIGI_TEST_ENV_KEY_FALLBACK").then(|| "from-fallback".into())
|
||
),
|
||
Some("from-fallback".into())
|
||
);
|
||
assert_eq!(
|
||
keys.resolve_value_with(|n| match n {
|
||
"KIGI_TEST_ENV_KEY_PRIMARY" => Some("from-primary".into()),
|
||
"KIGI_TEST_ENV_KEY_FALLBACK" => Some("from-fallback".into()),
|
||
_ => None,
|
||
}),
|
||
Some("from-primary".into()),
|
||
"primary wins when both set"
|
||
);
|
||
assert_eq!(
|
||
keys.resolve_value_with(|n| match n {
|
||
"KIGI_TEST_ENV_KEY_PRIMARY" => Some(String::new()),
|
||
"KIGI_TEST_ENV_KEY_FALLBACK" => Some("from-fallback".into()),
|
||
_ => None,
|
||
}),
|
||
Some("from-fallback".into())
|
||
);
|
||
}
|
||
#[test]
|
||
fn env_keys_single_and_array_are_semantically_equal() {
|
||
let from_array: EnvKeys = serde_json::from_str(r#"["X"]"#).unwrap();
|
||
assert_eq!(EnvKeys::new(["X"]), from_array);
|
||
let from_string: EnvKeys = serde_json::from_str(r#""X""#).unwrap();
|
||
assert_eq!(EnvKeys::new(["X"]), from_string);
|
||
}
|
||
#[test]
|
||
fn env_keys_resolve_skips_whitespace_only_value() {
|
||
let keys = EnvKeys::new(["KIGI_TEST_WS_PRIMARY", "KIGI_TEST_WS_FALLBACK"]);
|
||
assert_eq!(
|
||
keys.resolve_value_with(|n| match n {
|
||
"KIGI_TEST_WS_PRIMARY" => Some(" ".into()),
|
||
"KIGI_TEST_WS_FALLBACK" => Some("real".into()),
|
||
_ => None,
|
||
}),
|
||
Some("real".into())
|
||
);
|
||
assert_eq!(
|
||
EnvKeys::single("KIGI_TEST_WS_ONLY").resolve_value_with(|_| Some(" ".into())),
|
||
None
|
||
);
|
||
assert_eq!(
|
||
EnvKeys::single("KIGI_TEST_WS_PAD").resolve_value_with(|_| Some(" tok ".into())),
|
||
Some(" tok ".into())
|
||
);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn first_own_credential_empty_api_key_falls_through_to_env_key() {
|
||
use kigi_test_support::EnvGuard;
|
||
let var = "KIGI_TEST_FIRST_OWN_CRED_ENV";
|
||
let _guard = EnvGuard::set(var, "env-token");
|
||
let env_key = EnvKeys::single(var);
|
||
assert_eq!(
|
||
first_own_credential(Some(" "), Some(&env_key)).as_deref(),
|
||
Some("env-token")
|
||
);
|
||
assert_eq!(
|
||
first_own_credential(Some("real-key"), Some(&env_key)).as_deref(),
|
||
Some("real-key")
|
||
);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_credentials_multi_env_key_uses_lc_alias() {
|
||
use kigi_chat_state::AuthType;
|
||
let primary = "KIGI_TEST_MULTI_ENV_PRIMARY";
|
||
let alias = "KIGI_TEST_MULTI_ENV_LC_ALIAS";
|
||
unsafe {
|
||
std::env::remove_var(primary);
|
||
std::env::set_var(alias, "token-via-lc-alias");
|
||
}
|
||
let mut model = test_model_entry("m", "https://inference.example/v1", None, None, None);
|
||
model.env_key = Some(EnvKeys::new([primary, alias]));
|
||
assert!(
|
||
model.has_own_credentials(),
|
||
"alias alone should satisfy has_own_credentials"
|
||
);
|
||
let creds = resolve_credentials(&model, None);
|
||
assert_eq!(creds.auth_type, AuthType::ApiKey);
|
||
assert_eq!(creds.api_key.as_deref(), Some("token-via-lc-alias"));
|
||
unsafe {
|
||
std::env::remove_var(alias);
|
||
std::env::set_var(primary, "token-via-primary");
|
||
std::env::set_var(alias, "token-via-lc-alias");
|
||
}
|
||
let creds = resolve_credentials(&model, None);
|
||
assert_eq!(
|
||
creds.api_key.as_deref(),
|
||
Some("token-via-primary"),
|
||
"exact primary wins over LC alias when both set"
|
||
);
|
||
unsafe {
|
||
std::env::remove_var(primary);
|
||
std::env::remove_var(alias);
|
||
}
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_credentials_empty_env_key_falls_through_to_session() {
|
||
use kigi_chat_state::AuthType;
|
||
use kigi_test_support::EnvGuard;
|
||
let primary = "KIGI_TEST_EMPTY_ENV_PRIMARY";
|
||
let alias = "KIGI_TEST_EMPTY_ENV_LC_ALIAS";
|
||
let _primary = EnvGuard::set(primary, "");
|
||
let _alias = EnvGuard::set(alias, "");
|
||
let mut model = test_model_entry("m", "https://inference.example/v1", None, None, None);
|
||
model.env_key = Some(EnvKeys::new([primary, alias]));
|
||
assert!(!model.has_own_credentials());
|
||
let creds = resolve_credentials(&model, Some("session-jwt"));
|
||
assert_eq!(creds.auth_type, AuthType::SessionToken);
|
||
assert_eq!(creds.api_key.as_deref(), Some("session-jwt"));
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_credentials_empty_env_key_falls_through_to_global_key() {
|
||
use crate::agent::auth_method::{LEGACY_XAI_API_KEY_ENV_VAR, XAI_API_KEY_ENV_VAR};
|
||
use kigi_chat_state::AuthType;
|
||
use kigi_test_support::EnvGuard;
|
||
let sentinel = "xai-global-sentinel-key";
|
||
let primary = "KIGI_TEST_EMPTY_ENV_GLOBAL_PRIMARY";
|
||
let alias = "KIGI_TEST_EMPTY_ENV_GLOBAL_ALIAS";
|
||
let _primary = EnvGuard::set(primary, "");
|
||
let _alias = EnvGuard::set(alias, "");
|
||
let _global = EnvGuard::set(XAI_API_KEY_ENV_VAR, sentinel);
|
||
let _legacy = EnvGuard::unset(LEGACY_XAI_API_KEY_ENV_VAR);
|
||
let mut model = test_model_entry("m", "https://inference.example/v1", None, None, None);
|
||
model.env_key = Some(EnvKeys::new([primary, alias]));
|
||
assert!(!model.has_own_credentials());
|
||
let creds = resolve_credentials(&model, None);
|
||
assert_eq!(creds.auth_type, AuthType::ApiKey);
|
||
assert_eq!(creds.api_key.as_deref(), Some(sentinel));
|
||
}
|
||
#[test]
|
||
fn resolve_credentials_empty_api_key_falls_through_to_session() {
|
||
use kigi_chat_state::AuthType;
|
||
let model = test_model_entry("m", "https://inference.example/v1", Some(""), None, None);
|
||
assert!(!model.has_own_credentials());
|
||
let creds = resolve_credentials(&model, Some("session-jwt"));
|
||
assert_eq!(creds.auth_type, AuthType::SessionToken);
|
||
assert_eq!(creds.api_key.as_deref(), Some("session-jwt"));
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn config_toml_env_key_array_parses() {
|
||
let dm = crate::models::default_model();
|
||
let (_, models) = resolve_models_from_toml(
|
||
&format!(
|
||
r#"
|
||
[model."{dm}"]
|
||
model = "{dm}"
|
||
base_url = "https://inference.example.com/v1"
|
||
env_key = ["ANTHROPIC_AUTH_TOKEN", "LC_ANTHROPIC_AUTH_TOKEN"]
|
||
"#,
|
||
),
|
||
None,
|
||
);
|
||
let model = models.get(dm).expect("model should exist");
|
||
assert_eq!(
|
||
model.env_key.as_ref().map(|k| k.names()),
|
||
Some(vec!["ANTHROPIC_AUTH_TOKEN", "LC_ANTHROPIC_AUTH_TOKEN"])
|
||
);
|
||
}
|
||
#[test]
|
||
fn resolve_credentials_sets_auth_type() {
|
||
use kigi_chat_state::AuthType;
|
||
let model = test_model_entry("m", "https://example.com/v1", None, None, None);
|
||
let creds = resolve_credentials(&model, Some("tok"));
|
||
assert_eq!(creds.auth_type, AuthType::SessionToken);
|
||
let byok = test_model_entry("m", "https://example.com/v1", Some("key"), None, None);
|
||
let creds = resolve_credentials(&byok, Some("tok"));
|
||
assert_eq!(creds.auth_type, AuthType::ApiKey);
|
||
}
|
||
/// Regression: BYOK env-var auth must stay ApiKey even when signed in,
|
||
/// otherwise the bearer resolver overwrites the BYOK key with a session JWT.
|
||
#[test]
|
||
#[serial_test::serial]
|
||
fn resolve_credentials_env_key_byok_keeps_api_key_auth_with_session() {
|
||
use kigi_chat_state::AuthType;
|
||
let env_var = "REGRESSION_BYOK_TOKEN_FOR_AUTH_TYPE_TEST";
|
||
unsafe {
|
||
std::env::set_var(env_var, "sk-byok-test-value");
|
||
}
|
||
let model = test_model_entry(
|
||
"byok-gpt-test",
|
||
"https://llm.example.com/v1",
|
||
None,
|
||
Some(env_var),
|
||
None,
|
||
);
|
||
assert!(model.has_own_credentials());
|
||
let creds = resolve_credentials(&model, Some("session-jwt"));
|
||
assert_eq!(
|
||
creds.auth_type,
|
||
AuthType::ApiKey,
|
||
"BYOK env_key model must resolve to ApiKey even when a session token is available",
|
||
);
|
||
assert_eq!(
|
||
creds.api_key.as_deref(),
|
||
Some("sk-byok-test-value"),
|
||
"api_key must be the env value, not the session JWT",
|
||
);
|
||
unsafe {
|
||
std::env::remove_var(env_var);
|
||
}
|
||
}
|
||
#[test]
|
||
fn proxy_messages_models_use_bearer_auth_scheme() {
|
||
let mut model = test_model_entry(
|
||
"kigi-4.5",
|
||
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url,
|
||
None,
|
||
None,
|
||
None,
|
||
);
|
||
model.info.api_backend = ApiBackend::Messages;
|
||
let config =
|
||
sampling_config_for_model(&model, resolve_credentials(&model, Some("tok")), None);
|
||
assert_eq!(config.api_backend, ApiBackend::Messages);
|
||
assert_eq!(config.auth_scheme, AuthScheme::Bearer);
|
||
assert_eq!(config.api_key, Some("tok".to_string()));
|
||
assert_eq!(
|
||
config.base_url,
|
||
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url
|
||
);
|
||
assert!(
|
||
config.extra_headers.get("X-XAI-Token-Auth").is_none(),
|
||
"the xAI token-auth marker header must be gone"
|
||
);
|
||
}
|
||
/// Regression: without a session key, `resolve_credentials` falls through
|
||
/// to ApiKey. Session-based callers must override auth_type to SessionToken
|
||
/// when their auth manager has only a buffered/expired token.
|
||
#[test]
|
||
fn resolve_credentials_no_session_key_returns_api_key() {
|
||
let model = test_model_entry("m", "https://example.com/v1", None, None, None);
|
||
let creds = resolve_credentials(&model, None);
|
||
assert_eq!(creds.auth_type, kigi_chat_state::AuthType::ApiKey);
|
||
}
|
||
fn api_key_creds(base_url: &str) -> ResolvedCredentials {
|
||
ResolvedCredentials {
|
||
api_key: Some("xai-secret".to_string()),
|
||
base_url: base_url.to_string(),
|
||
auth_type: kigi_chat_state::AuthType::ApiKey,
|
||
auth_scheme: Default::default(),
|
||
}
|
||
}
|
||
#[test]
|
||
fn x_api_key_auth_scheme_flows_from_config_to_sampler() {
|
||
let mut model = test_model_entry(
|
||
"messages-compatible-model",
|
||
"https://messages.example.com/v1",
|
||
Some("sk-ant-test-key"),
|
||
None,
|
||
None,
|
||
);
|
||
model.info.api_backend = ApiBackend::Messages;
|
||
model.info.auth_scheme = AuthScheme::XApiKey;
|
||
let creds = resolve_credentials(&model, None);
|
||
assert_eq!(creds.auth_scheme, AuthScheme::XApiKey);
|
||
assert_eq!(creds.auth_type, kigi_chat_state::AuthType::ApiKey);
|
||
assert_eq!(creds.api_key, Some("sk-ant-test-key".to_string()));
|
||
let config = sampling_config_for_model(&model, creds, None);
|
||
assert_eq!(config.auth_scheme, AuthScheme::XApiKey);
|
||
assert_eq!(config.api_backend, ApiBackend::Messages);
|
||
let client = kigi_sampler::SamplingClient::new(config).expect("client should build");
|
||
let info = client.auth_info();
|
||
assert_eq!(info.auth_type, "x-api-key");
|
||
}
|
||
#[test]
|
||
fn auth_scheme_defaults_to_bearer_when_not_set_in_config() {
|
||
let model = test_model_entry(
|
||
"kigi-4.5",
|
||
"https://api.example.com/v1",
|
||
Some("sk-openai-test"),
|
||
None,
|
||
None,
|
||
);
|
||
assert_eq!(model.info.auth_scheme, AuthScheme::Bearer);
|
||
let creds = resolve_credentials(&model, None);
|
||
assert_eq!(creds.auth_scheme, AuthScheme::Bearer);
|
||
let config = sampling_config_for_model(&model, creds, None);
|
||
assert_eq!(config.auth_scheme, AuthScheme::Bearer);
|
||
let client = kigi_sampler::SamplingClient::new(config).expect("client should build");
|
||
let info = client.auth_info();
|
||
assert_eq!(info.auth_type, "bearer");
|
||
}
|
||
#[test]
|
||
fn has_own_credentials_guards_session_vs_external_key() {
|
||
let endpoints = EndpointsConfig::default();
|
||
for (model_id, entry) in default_model_entries(&endpoints) {
|
||
assert!(
|
||
!entry.has_own_credentials(),
|
||
"{model_id}: Default model must not claim own credentials"
|
||
);
|
||
}
|
||
let config_model = test_model_entry(
|
||
"my-model",
|
||
"https://api.example.com/v1",
|
||
Some("sk-external"),
|
||
None,
|
||
None,
|
||
);
|
||
assert!(config_model.has_own_credentials());
|
||
}
|
||
/// The `ConfigUnavailable → Unknown` arm matters for safety: a transient
|
||
/// config failure must not read as a definite `NotByok`, which would drive
|
||
/// the live resolver and could overwrite a per-model BYOK key.
|
||
#[test]
|
||
fn byok_from_lookup_classifies_all_states() {
|
||
assert_eq!(
|
||
byok_from_lookup(&ModelLookup::ConfigUnavailable),
|
||
ModelByok::Unknown,
|
||
);
|
||
assert_eq!(
|
||
byok_from_lookup(&ModelLookup::Loaded(None)),
|
||
ModelByok::NotByok,
|
||
);
|
||
let byok = test_model_entry(
|
||
"m",
|
||
"https://api.example.com/v1",
|
||
Some("sk-ext"),
|
||
None,
|
||
None,
|
||
);
|
||
assert_eq!(
|
||
byok_from_lookup(&ModelLookup::Loaded(Some(&byok))),
|
||
ModelByok::Byok,
|
||
);
|
||
let session = test_model_entry("m", "https://byok.example/v1", None, None, None);
|
||
assert_eq!(
|
||
byok_from_lookup(&ModelLookup::Loaded(Some(&session))),
|
||
ModelByok::NotByok,
|
||
);
|
||
}
|
||
#[test]
|
||
fn resolve_model_auth_facts_empty_model_id_is_unknown() {
|
||
assert_eq!(resolve_model_auth_facts("").byok, ModelByok::Unknown);
|
||
}
|
||
#[test]
|
||
fn user_override_adds_api_key_to_default_model() {
|
||
let dm = crate::models::default_model();
|
||
let raw_config: toml::Value = toml::from_str(&format!(
|
||
r#"
|
||
[model."{dm}"]
|
||
api_key = "user-custom-api-key"
|
||
"#,
|
||
))
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let model = resolved.get(dm).expect("model should exist");
|
||
assert_eq!(model.api_key, Some("user-custom-api-key".to_string()));
|
||
assert_eq!(model.info.model, dm);
|
||
assert_eq!(
|
||
model.info.base_url, "https://api.kimi.com/coding/v1",
|
||
"base_url should inherit from default, not be stale"
|
||
);
|
||
}
|
||
#[test]
|
||
fn config_override_applies_show_model_fingerprint() {
|
||
let endpoints = EndpointsConfig::default();
|
||
let override_on = ConfigModelOverride {
|
||
show_model_fingerprint: Some(true),
|
||
..Default::default()
|
||
};
|
||
let entry = override_on.apply("some-model", None, &endpoints);
|
||
assert!(
|
||
entry.info.show_model_fingerprint,
|
||
"Some(true) override should enable show_model_fingerprint"
|
||
);
|
||
let mut base = ModelEntry::fallback("some-model", &endpoints);
|
||
base.info.show_model_fingerprint = true;
|
||
let override_absent = ConfigModelOverride::default();
|
||
let entry = override_absent.apply("some-model", Some(base), &endpoints);
|
||
assert!(
|
||
entry.info.show_model_fingerprint,
|
||
"None override should preserve the base entry's show_model_fingerprint"
|
||
);
|
||
let mut base = ModelEntry::fallback("some-model", &endpoints);
|
||
base.info.show_model_fingerprint = true;
|
||
let override_off = ConfigModelOverride {
|
||
show_model_fingerprint: Some(false),
|
||
..Default::default()
|
||
};
|
||
let entry = override_off.apply("some-model", Some(base), &endpoints);
|
||
assert!(
|
||
!entry.info.show_model_fingerprint,
|
||
"Some(false) override should disable show_model_fingerprint over a true base"
|
||
);
|
||
}
|
||
#[test]
|
||
fn user_override_parses_compaction_at_tokens_from_toml() {
|
||
use kigi_sampling_types::CompactionAtTokens;
|
||
let dm = crate::models::default_model();
|
||
let raw_config: toml::Value = toml::from_str(&format!(
|
||
r#"
|
||
[model."{dm}"]
|
||
compaction_at_tokens = true
|
||
"#,
|
||
))
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let model = resolve_model_list(&cfg, None, &Default::default())
|
||
.get(dm)
|
||
.expect("model should exist")
|
||
.clone();
|
||
assert_eq!(
|
||
model.info.compaction_at_tokens,
|
||
Some(CompactionAtTokens::Enabled(true)),
|
||
);
|
||
let raw_config: toml::Value = toml::from_str(&format!(
|
||
r#"
|
||
[model."{dm}"]
|
||
compaction_at_tokens = 367000
|
||
"#,
|
||
))
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let model = resolve_model_list(&cfg, None, &Default::default())
|
||
.get(dm)
|
||
.expect("model should exist")
|
||
.clone();
|
||
assert_eq!(
|
||
model.info.compaction_at_tokens,
|
||
Some(CompactionAtTokens::Fixed(367_000)),
|
||
);
|
||
}
|
||
#[test]
|
||
fn user_override_parses_compactions_remaining_from_toml() {
|
||
use kigi_sampling_types::CompactionsRemaining;
|
||
let dm = crate::models::default_model();
|
||
let raw_config: toml::Value = toml::from_str(&format!(
|
||
r#"
|
||
[model."{dm}"]
|
||
compactions_remaining = true
|
||
"#,
|
||
))
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let model = resolve_model_list(&cfg, None, &Default::default())
|
||
.get(dm)
|
||
.expect("model should exist")
|
||
.clone();
|
||
assert_eq!(
|
||
model.info.compactions_remaining,
|
||
Some(CompactionsRemaining::Dynamic(true)),
|
||
);
|
||
let raw_config: toml::Value = toml::from_str(&format!(
|
||
r#"
|
||
[model."{dm}"]
|
||
compactions_remaining = 1
|
||
"#,
|
||
))
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let model = resolve_model_list(&cfg, None, &Default::default())
|
||
.get(dm)
|
||
.expect("model should exist")
|
||
.clone();
|
||
assert_eq!(
|
||
model.info.compactions_remaining,
|
||
Some(CompactionsRemaining::Fixed(1)),
|
||
);
|
||
let raw_config: toml::Value = toml::from_str(&format!(
|
||
r#"
|
||
[model."{dm}"]
|
||
send_compactions_remaining = true
|
||
"#,
|
||
))
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let model = resolve_model_list(&cfg, None, &Default::default())
|
||
.get(dm)
|
||
.expect("model should exist")
|
||
.clone();
|
||
assert_eq!(
|
||
model.info.compactions_remaining,
|
||
Some(CompactionsRemaining::Dynamic(true)),
|
||
);
|
||
}
|
||
#[test]
|
||
fn default_auto_compact_threshold_is_none() {
|
||
let cfg = Config::default();
|
||
assert_eq!(cfg.session.auto_compact_threshold_percent, None);
|
||
}
|
||
#[test]
|
||
fn parses_auto_compact_threshold_percent() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[session]
|
||
auto_compact_threshold_percent = 75
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
assert_eq!(cfg.session.auto_compact_threshold_percent, Some(75));
|
||
}
|
||
#[test]
|
||
fn compaction_mode_precedence_env_over_config_over_remote_over_default() {
|
||
use kigi_chat_state::CompactionMode;
|
||
assert_eq!(
|
||
resolve_compaction_mode_from(Some("transcript"), Some("segments"), Some("summary")),
|
||
CompactionMode::Transcript
|
||
);
|
||
assert_eq!(
|
||
resolve_compaction_mode_from(None, Some("segments"), Some("summary")),
|
||
CompactionMode::Segments(kigi_chat_state::CompactionDetail::default())
|
||
);
|
||
assert_eq!(
|
||
resolve_compaction_mode_from(None, None, Some("segments")),
|
||
CompactionMode::Segments(kigi_chat_state::CompactionDetail::default())
|
||
);
|
||
assert_eq!(
|
||
resolve_compaction_mode_from(Some("garbage"), None, Some("segments")),
|
||
CompactionMode::Segments(kigi_chat_state::CompactionDetail::default())
|
||
);
|
||
assert_eq!(
|
||
resolve_compaction_mode_from(None, None, None),
|
||
CompactionMode::Summary
|
||
);
|
||
}
|
||
/// Detail shares the env>config>remote>default combinator that the mode
|
||
/// test exercises; the detail-specific facts are remote settings routing and the
|
||
/// `Verbose` default (with unrecognized values falling through).
|
||
#[test]
|
||
fn compaction_detail_resolves_remote_settings_and_verbose_default() {
|
||
use kigi_chat_state::CompactionDetail;
|
||
assert_eq!(
|
||
resolve_compaction_detail_from(None, None, Some("minimal")),
|
||
CompactionDetail::Minimal
|
||
);
|
||
assert_eq!(
|
||
resolve_compaction_detail_from(Some("garbage"), None, None),
|
||
CompactionDetail::Verbose
|
||
);
|
||
}
|
||
#[test]
|
||
fn auto_compact_threshold_percent_defaults_when_not_specified() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[toolset.bash]
|
||
timeout_secs = 123
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
assert_eq!(cfg.session.auto_compact_threshold_percent, None);
|
||
}
|
||
#[test]
|
||
fn parses_repo_changes_dedup_config() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[repo_changes_dedup]
|
||
enabled = false
|
||
include_inline_fallback = true
|
||
max_inline_bytes = 1024
|
||
dedup_untracked = false
|
||
dedup_binary = false
|
||
untracked_max_bytes = 2048
|
||
untracked_exclude_globs = ["*.zip", "tmp/**"]
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let dedup = cfg.repo_changes_dedup;
|
||
assert!(!dedup.enabled);
|
||
assert!(dedup.include_inline_fallback);
|
||
assert_eq!(dedup.max_inline_bytes, 1024);
|
||
assert!(!dedup.dedup_untracked);
|
||
assert!(!dedup.dedup_binary);
|
||
assert_eq!(dedup.untracked_max_bytes, 2048);
|
||
assert_eq!(dedup.untracked_exclude_globs, vec!["*.zip", "tmp/**"]);
|
||
}
|
||
#[test]
|
||
fn parses_model_context_window() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.my-custom-model]
|
||
model = "custom-llm"
|
||
base_url = "https://api.example.com/v1"
|
||
context_window = 256000
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let model = resolved.get("my-custom-model").expect("model should exist");
|
||
assert_eq!(model.info.context_window, NonZeroU64::new(256_000).unwrap());
|
||
}
|
||
#[test]
|
||
fn sampling_config_context_window_from_entry_or_default() {
|
||
let model = test_model_entry("any-model", "https://byok.example/v1", None, None, None);
|
||
let config = sampling_config_for_model(&model, resolve_credentials(&model, None), None);
|
||
assert_eq!(config.context_window, 200_000);
|
||
let mut model = test_model_entry("any-model", "https://byok.example/v1", None, None, None);
|
||
model.info.context_window = NonZeroU64::new(256_000).unwrap();
|
||
let config = sampling_config_for_model(&model, resolve_credentials(&model, None), None);
|
||
assert_eq!(config.context_window, 256_000);
|
||
}
|
||
#[test]
|
||
fn parses_model_api_backend_responses() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.my-responses-model]
|
||
model = "kigi-4.5"
|
||
base_url = "https://api.example.com/v1"
|
||
context_window = 200000
|
||
api_backend = "responses"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let model = resolved
|
||
.get("my-responses-model")
|
||
.expect("model should exist");
|
||
assert_eq!(model.info.api_backend, ApiBackend::Responses);
|
||
}
|
||
#[test]
|
||
fn parses_model_api_backend_chat_completions() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.my-chat-model]
|
||
model = "kigi-4.5"
|
||
base_url = "https://api.example.com/v1"
|
||
context_window = 200000
|
||
api_backend = "chat_completions"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let model = resolved.get("my-chat-model").expect("model should exist");
|
||
assert_eq!(model.info.api_backend, ApiBackend::ChatCompletions);
|
||
}
|
||
/// Messages backend (Anthropic) auto-defaults supports_reasoning_effort=true.
|
||
/// Without this, `--reasoning-effort` is silently dropped in
|
||
/// kigi-shell/src/agent/models.rs:857 for any BYOK Claude config.
|
||
#[test]
|
||
fn model_messages_backend_auto_defaults_supports_reasoning_effort() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.my-claude]
|
||
model = "kigi-4.5"
|
||
base_url = "https://messages.example.com"
|
||
context_window = 200000
|
||
api_backend = "messages"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let model = resolved.get("my-claude").expect("model should exist");
|
||
assert!(
|
||
model.info.supports_reasoning_effort,
|
||
"Messages backend should auto-default supports_reasoning_effort=true",
|
||
);
|
||
}
|
||
/// An explicit `supports_reasoning_effort = false` in config must override
|
||
/// the Messages auto-default — config wins.
|
||
#[test]
|
||
fn model_messages_backend_respects_explicit_supports_reasoning_effort_false() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.my-claude]
|
||
model = "kigi-4.5"
|
||
base_url = "https://messages.example.com"
|
||
context_window = 200000
|
||
api_backend = "messages"
|
||
supports_reasoning_effort = false
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let model = resolved.get("my-claude").expect("model should exist");
|
||
assert!(
|
||
!model.info.supports_reasoning_effort,
|
||
"explicit supports_reasoning_effort=false in config must override the Messages auto-default",
|
||
);
|
||
}
|
||
/// Non-Messages backends keep their existing default (false) since adaptive
|
||
/// thinking is Anthropic-specific and other providers vary per upstream model.
|
||
#[test]
|
||
fn model_chat_completions_backend_does_not_auto_default_supports_reasoning_effort() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.my-openai]
|
||
model = "kigi-4.5"
|
||
base_url = "https://api.example.com/v1"
|
||
context_window = 200000
|
||
api_backend = "chat_completions"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let model = resolved.get("my-openai").expect("model should exist");
|
||
assert!(
|
||
!model.info.supports_reasoning_effort,
|
||
"ChatCompletions backend must not auto-default supports_reasoning_effort=true",
|
||
);
|
||
}
|
||
#[test]
|
||
fn model_api_backend_defaults_to_chat_completions() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.my-model]
|
||
model = "kigi-4.5"
|
||
base_url = "https://api.example.com/v1"
|
||
context_window = 200000
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let model = resolved.get("my-model").expect("model should exist");
|
||
assert_eq!(model.info.api_backend, ApiBackend::ChatCompletions);
|
||
}
|
||
#[test]
|
||
fn sampling_config_uses_model_api_backend() {
|
||
let mut model =
|
||
test_model_entry("test-model", "https://api.example.com/v1", None, None, None);
|
||
model.info.api_backend = ApiBackend::Responses;
|
||
let sampling_config =
|
||
sampling_config_for_model(&model, resolve_credentials(&model, None), None);
|
||
assert_eq!(sampling_config.api_backend, ApiBackend::Responses);
|
||
}
|
||
#[test]
|
||
fn parses_model_use_concise_true() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.my-concise-model]
|
||
model = "my-concise-model"
|
||
base_url = "https://api.example.com/v1"
|
||
context_window = 200000
|
||
use_concise = true
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let model = resolved
|
||
.get("my-concise-model")
|
||
.expect("model should exist");
|
||
assert!(model.info.use_concise);
|
||
}
|
||
#[test]
|
||
fn model_use_concise_defaults_to_false() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.my-model]
|
||
model = "my-model"
|
||
base_url = "https://api.example.com/v1"
|
||
context_window = 200000
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let model = resolved.get("my-model").expect("model should exist");
|
||
assert!(!model.info.use_concise);
|
||
}
|
||
#[test]
|
||
fn model_info_from_config_propagates_use_concise() {
|
||
let entry = ModelEntryConfig {
|
||
id: None,
|
||
model: "test".to_string(),
|
||
base_url: "https://test.api/v1".to_string(),
|
||
name: None,
|
||
description: None,
|
||
max_completion_tokens: None,
|
||
temperature: None,
|
||
top_p: None,
|
||
api_key: None,
|
||
env_key: None,
|
||
api_backend: ApiBackend::default(),
|
||
auth_scheme: None,
|
||
extra_headers: IndexMap::new(),
|
||
context_window: NonZeroU64::new(200_000).unwrap(),
|
||
auto_compact_threshold_percent: None,
|
||
system_prompt_label: None,
|
||
api_base_url: None,
|
||
use_concise: true,
|
||
agent_type: default_agent_type(),
|
||
inference_idle_timeout_secs: None,
|
||
max_retries: None,
|
||
hidden: false,
|
||
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(),
|
||
};
|
||
let info = ModelInfo::from_config(&entry);
|
||
assert!(info.use_concise);
|
||
}
|
||
#[test]
|
||
fn deprecated_toolset_use_concise_is_ignored_in_model_config() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[toolset]
|
||
use_concise = true
|
||
|
||
[model.my-model]
|
||
model = "my-model"
|
||
base_url = "https://api.example.com/v1"
|
||
context_window = 200000
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let model = resolved.get("my-model").expect("model should exist");
|
||
assert!(
|
||
!model.info.use_concise,
|
||
"old [toolset] use_concise should not affect per-model use_concise"
|
||
);
|
||
}
|
||
#[test]
|
||
fn agent_selection_config_defaults_to_none() {
|
||
let cfg = Config::default();
|
||
assert!(cfg.agent.name.is_none());
|
||
assert!(cfg.agent.definition.is_none());
|
||
}
|
||
#[test]
|
||
fn parses_agent_selection_name() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[agent]
|
||
name = "my-custom-agent"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
assert_eq!(cfg.agent.name.as_deref(), Some("my-custom-agent"));
|
||
assert!(cfg.agent.definition.is_none());
|
||
}
|
||
#[test]
|
||
fn parses_agent_selection_definition_path() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[agent]
|
||
definition = "/path/to/my-agent.md"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
assert!(cfg.agent.name.is_none());
|
||
assert_eq!(
|
||
cfg.agent.definition.as_deref(),
|
||
Some(std::path::Path::new("/path/to/my-agent.md"))
|
||
);
|
||
}
|
||
#[test]
|
||
fn parses_agent_selection_both_name_and_definition() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[agent]
|
||
name = "fallback-agent"
|
||
definition = "/path/to/primary-agent.md"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
assert_eq!(cfg.agent.name.as_deref(), Some("fallback-agent"));
|
||
assert_eq!(
|
||
cfg.agent.definition.as_deref(),
|
||
Some(std::path::Path::new("/path/to/primary-agent.md"))
|
||
);
|
||
}
|
||
#[test]
|
||
fn agent_selection_not_specified_uses_defaults() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[toolset.bash]
|
||
timeout_secs = 123
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
assert!(cfg.agent.name.is_none());
|
||
assert!(cfg.agent.definition.is_none());
|
||
}
|
||
#[test]
|
||
fn parses_model_with_agent_type() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.my-agent-model]
|
||
model = "my-agent-model"
|
||
base_url = "https://api.example.com/v1"
|
||
context_window = 200000
|
||
agent_type = "codex"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let model = resolved.get("my-agent-model").expect("model should exist");
|
||
assert_eq!(model.info.agent_type, "codex");
|
||
}
|
||
#[test]
|
||
fn model_agent_type_defaults_to_kigi() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.my-model]
|
||
model = "my-model"
|
||
base_url = "https://api.example.com/v1"
|
||
context_window = 200000
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let model = resolved.get("my-model").expect("model should exist");
|
||
assert_eq!(model.info.agent_type, DEFAULT_AGENT_TYPE);
|
||
}
|
||
#[test]
|
||
fn model_info_from_config_propagates_agent_type() {
|
||
let entry = ModelEntryConfig {
|
||
id: None,
|
||
model: "test".to_string(),
|
||
base_url: "https://test.api/v1".to_string(),
|
||
name: None,
|
||
description: None,
|
||
max_completion_tokens: None,
|
||
temperature: None,
|
||
top_p: None,
|
||
api_key: None,
|
||
env_key: None,
|
||
api_backend: ApiBackend::default(),
|
||
auth_scheme: None,
|
||
extra_headers: IndexMap::new(),
|
||
context_window: NonZeroU64::new(200_000).unwrap(),
|
||
auto_compact_threshold_percent: None,
|
||
system_prompt_label: None,
|
||
api_base_url: None,
|
||
use_concise: false,
|
||
agent_type: "codex".to_string(),
|
||
inference_idle_timeout_secs: None,
|
||
max_retries: None,
|
||
hidden: false,
|
||
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(),
|
||
};
|
||
let info = ModelInfo::from_config(&entry);
|
||
assert_eq!(info.agent_type, "codex");
|
||
}
|
||
#[test]
|
||
fn acp_model_meta_includes_agent_type_when_present() {
|
||
let mut models = IndexMap::new();
|
||
let mut entry = test_model_entry("test-model", "https://test.api/v1", None, None, None);
|
||
entry.info.name = Some("Test Model".to_string());
|
||
entry.info.context_window = NonZeroU64::new(256_000).unwrap();
|
||
entry.info.agent_type = "codex".to_string();
|
||
models.insert("test-model".to_string(), entry);
|
||
let acp_models = to_acp_model_info(&models);
|
||
let acp_model = acp_models.values().next().expect("should have one model");
|
||
let meta = acp_model.meta.as_ref().expect("meta should be present");
|
||
assert_eq!(meta["agentType"], "codex");
|
||
assert_eq!(meta["totalContextTokens"], 256_000);
|
||
}
|
||
#[test]
|
||
fn acp_model_meta_always_includes_agent_type() {
|
||
let mut models = IndexMap::new();
|
||
let mut entry = test_model_entry("plain-model", "https://test.api/v1", None, None, None);
|
||
entry.info.name = Some("Plain Model".to_string());
|
||
entry.info.context_window = NonZeroU64::new(256_000).unwrap();
|
||
models.insert("plain-model".to_string(), entry);
|
||
let acp_models = to_acp_model_info(&models);
|
||
let acp_model = acp_models.values().next().expect("should have one model");
|
||
let meta = acp_model.meta.as_ref().expect("meta should be present");
|
||
assert_eq!(meta["totalContextTokens"], 256_000);
|
||
assert_eq!(
|
||
meta["agentType"], DEFAULT_AGENT_TYPE,
|
||
"agentType should always be in meta, defaulting to DEFAULT_AGENT_TYPE"
|
||
);
|
||
}
|
||
#[test]
|
||
fn acp_model_meta_emits_reasoning_effort_when_supported() {
|
||
let mut models = IndexMap::new();
|
||
let mut entry = test_model_entry("m", "https://test.api/v1", None, None, None);
|
||
entry.info.supports_reasoning_effort = true;
|
||
entry.info.reasoning_effort = Some(ReasoningEffort::High);
|
||
models.insert("m".to_string(), entry);
|
||
let meta = to_acp_model_info(&models)
|
||
.values()
|
||
.next()
|
||
.unwrap()
|
||
.meta
|
||
.clone()
|
||
.unwrap();
|
||
assert_eq!(meta["supportsReasoningEffort"], true);
|
||
assert_eq!(meta["reasoningEffort"], "high");
|
||
}
|
||
#[test]
|
||
fn acp_model_meta_supports_without_default_effort() {
|
||
let mut models = IndexMap::new();
|
||
let mut entry = test_model_entry("m", "https://test.api/v1", None, None, None);
|
||
entry.info.supports_reasoning_effort = true;
|
||
models.insert("m".to_string(), entry);
|
||
let meta = to_acp_model_info(&models)
|
||
.values()
|
||
.next()
|
||
.unwrap()
|
||
.meta
|
||
.clone()
|
||
.unwrap();
|
||
assert_eq!(meta["supportsReasoningEffort"], true);
|
||
assert!(meta.get("reasoningEffort").is_none());
|
||
}
|
||
#[test]
|
||
fn acp_model_meta_emits_reasoning_efforts_and_derives_legacy() {
|
||
let mut models = IndexMap::new();
|
||
let mut entry = test_model_entry("m", "https://test.api/v1", None, None, None);
|
||
entry.info.reasoning_efforts = vec![
|
||
ReasoningEffortOption {
|
||
id: "deep".to_string(),
|
||
value: ReasoningEffort::Xhigh,
|
||
label: "Deep".to_string(),
|
||
description: None,
|
||
default: false,
|
||
},
|
||
ReasoningEffortOption {
|
||
id: "high".to_string(),
|
||
value: ReasoningEffort::High,
|
||
label: "High".to_string(),
|
||
description: None,
|
||
default: true,
|
||
},
|
||
];
|
||
entry.info.derive_reasoning_effort_fields();
|
||
models.insert("m".to_string(), entry);
|
||
let meta = to_acp_model_info(&models)
|
||
.values()
|
||
.next()
|
||
.unwrap()
|
||
.meta
|
||
.clone()
|
||
.unwrap();
|
||
assert_eq!(meta[REASONING_EFFORTS_META_KEY][0]["id"], "deep");
|
||
assert_eq!(meta[REASONING_EFFORTS_META_KEY][0]["value"], "xhigh");
|
||
assert_eq!(meta["supportsReasoningEffort"], true);
|
||
assert_eq!(meta["reasoningEffort"], "high");
|
||
}
|
||
#[test]
|
||
fn acp_model_meta_omits_reasoning_efforts_when_list_empty() {
|
||
let mut models = IndexMap::new();
|
||
let mut entry = test_model_entry("m", "https://test.api/v1", None, None, None);
|
||
entry.info.supports_reasoning_effort = true;
|
||
entry.info.reasoning_effort = Some(ReasoningEffort::Medium);
|
||
models.insert("m".to_string(), entry);
|
||
let meta = to_acp_model_info(&models)
|
||
.values()
|
||
.next()
|
||
.unwrap()
|
||
.meta
|
||
.clone()
|
||
.unwrap();
|
||
assert!(meta.get(REASONING_EFFORTS_META_KEY).is_none());
|
||
assert_eq!(meta["supportsReasoningEffort"], true);
|
||
assert_eq!(meta["reasoningEffort"], "medium");
|
||
}
|
||
#[test]
|
||
fn acp_model_meta_keeps_explicit_scalar_when_list_present() {
|
||
let mut models = IndexMap::new();
|
||
let mut entry = test_model_entry("m", "https://test.api/v1", None, None, None);
|
||
entry.info.reasoning_effort = Some(ReasoningEffort::Low);
|
||
entry.info.reasoning_efforts = vec![ReasoningEffortOption {
|
||
id: "high".to_string(),
|
||
value: ReasoningEffort::High,
|
||
label: "High".to_string(),
|
||
description: None,
|
||
default: true,
|
||
}];
|
||
entry.info.derive_reasoning_effort_fields();
|
||
models.insert("m".to_string(), entry);
|
||
let meta = to_acp_model_info(&models)
|
||
.values()
|
||
.next()
|
||
.unwrap()
|
||
.meta
|
||
.clone()
|
||
.unwrap();
|
||
assert_eq!(meta["supportsReasoningEffort"], true);
|
||
assert_eq!(meta["reasoningEffort"], "low");
|
||
}
|
||
#[test]
|
||
fn acp_model_meta_derives_first_option_when_no_default() {
|
||
let mut models = IndexMap::new();
|
||
let mut entry = test_model_entry("m", "https://test.api/v1", None, None, None);
|
||
entry.info.reasoning_efforts = vec![
|
||
ReasoningEffortOption {
|
||
id: "balanced".to_string(),
|
||
value: ReasoningEffort::Medium,
|
||
label: "Balanced".to_string(),
|
||
description: None,
|
||
default: false,
|
||
},
|
||
ReasoningEffortOption {
|
||
id: "deep".to_string(),
|
||
value: ReasoningEffort::Xhigh,
|
||
label: "Deep".to_string(),
|
||
description: None,
|
||
default: false,
|
||
},
|
||
];
|
||
entry.info.derive_reasoning_effort_fields();
|
||
models.insert("m".to_string(), entry);
|
||
let meta = to_acp_model_info(&models)
|
||
.values()
|
||
.next()
|
||
.unwrap()
|
||
.meta
|
||
.clone()
|
||
.unwrap();
|
||
assert_eq!(meta["supportsReasoningEffort"], true);
|
||
assert_eq!(meta["reasoningEffort"], "medium");
|
||
}
|
||
#[test]
|
||
fn acp_model_meta_omits_reasoning_when_unsupported() {
|
||
let mut models = IndexMap::new();
|
||
let mut entry = test_model_entry("m", "https://test.api/v1", None, None, None);
|
||
entry.info.reasoning_effort = Some(ReasoningEffort::High);
|
||
models.insert("m".to_string(), entry);
|
||
let meta = to_acp_model_info(&models)
|
||
.values()
|
||
.next()
|
||
.unwrap()
|
||
.meta
|
||
.clone();
|
||
if let Some(meta) = meta {
|
||
assert!(meta.get("supportsReasoningEffort").is_none());
|
||
assert!(meta.get("reasoningEffort").is_none());
|
||
}
|
||
}
|
||
#[test]
|
||
fn acp_model_meta_always_has_context_window() {
|
||
let mut models = IndexMap::new();
|
||
let mut entry = test_model_entry("unknown-model", "https://test.api/v1", None, None, None);
|
||
entry.info.name = Some("Unknown Model".to_string());
|
||
models.insert("unknown-model".to_string(), entry);
|
||
let acp_models = to_acp_model_info(&models);
|
||
let meta = acp_models.values().next().unwrap().meta.as_ref().unwrap();
|
||
assert_eq!(meta["totalContextTokens"], 200_000);
|
||
}
|
||
#[test]
|
||
fn hidden_model_excluded_from_acp_but_kept_in_catalog() {
|
||
use crate::agent::models::{available_models, resolve_model_catalog};
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.visible-model]
|
||
model = "visible-model"
|
||
base_url = "https://byok.example/v1"
|
||
context_window = 200000
|
||
|
||
[model.hidden-model]
|
||
model = "hidden-model"
|
||
base_url = "https://byok.example/v1"
|
||
context_window = 200000
|
||
hidden = true
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).unwrap();
|
||
let catalog = resolve_model_catalog(&cfg, None, &Default::default());
|
||
let available = available_models(&catalog, true);
|
||
assert!(
|
||
catalog.contains_key("visible-model"),
|
||
"visible model missing from catalog"
|
||
);
|
||
assert!(
|
||
catalog.contains_key("hidden-model"),
|
||
"hidden model missing from catalog"
|
||
);
|
||
assert!(
|
||
available.values().any(|m| m.name == "visible-model"),
|
||
"visible model missing from ACP"
|
||
);
|
||
assert!(
|
||
!available.values().any(|m| m.name == "hidden-model"),
|
||
"hidden model should NOT appear in ACP"
|
||
);
|
||
}
|
||
#[test]
|
||
fn disabled_models_removed_from_catalog() {
|
||
use crate::agent::models::resolve_model_catalog;
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[models]
|
||
disabled_models = ["to-disable"]
|
||
[model.to-disable]
|
||
model = "to-disable"
|
||
base_url = "https://byok.example/v1"
|
||
context_window = 200000
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let catalog = resolve_model_catalog(
|
||
&Config::new_from_toml_cfg(&raw).unwrap(),
|
||
None,
|
||
&Default::default(),
|
||
);
|
||
assert!(!catalog.contains_key("to-disable"));
|
||
}
|
||
#[test]
|
||
fn hidden_models_kept_in_catalog_but_not_in_acp() {
|
||
use crate::agent::models::{available_models, resolve_model_catalog};
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[models]
|
||
hidden_models = ["to-hide"]
|
||
[model.to-hide]
|
||
model = "to-hide"
|
||
base_url = "https://byok.example/v1"
|
||
context_window = 200000
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let catalog = resolve_model_catalog(
|
||
&Config::new_from_toml_cfg(&raw).unwrap(),
|
||
None,
|
||
&Default::default(),
|
||
);
|
||
let available = available_models(&catalog, true);
|
||
assert!(catalog.contains_key("to-hide"));
|
||
assert!(catalog["to-hide"].info.hidden);
|
||
assert!(!available.values().any(|m| m.name == "to-hide"));
|
||
}
|
||
#[test]
|
||
fn allowed_models_marks_selectable_by_wildcard_key_or_model() {
|
||
use crate::agent::models::resolve_model_catalog;
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[models]
|
||
allowed_models = ["keep-*", "explicit-key", "explicit-model-id"]
|
||
[model.to-drop]
|
||
model = "to-drop"
|
||
base_url = "https://byok.example/v1"
|
||
context_window = 256000
|
||
[model.keep-one]
|
||
model = "keep-one"
|
||
base_url = "https://byok.example/v1"
|
||
context_window = 256000
|
||
[model.explicit-key]
|
||
model = "explicit-model-id"
|
||
base_url = "https://byok.example/v1"
|
||
context_window = 256000
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let catalog = resolve_model_catalog(
|
||
&Config::new_from_toml_cfg(&raw).unwrap(),
|
||
None,
|
||
&Default::default(),
|
||
);
|
||
assert!(catalog["keep-one"].info.user_selectable, "wildcard match");
|
||
assert!(
|
||
catalog["explicit-key"].info.user_selectable,
|
||
"matched by catalog key or model id"
|
||
);
|
||
assert!(
|
||
!catalog["to-drop"].info.user_selectable,
|
||
"kept but not selectable"
|
||
);
|
||
}
|
||
#[test]
|
||
fn allowed_models_empty_is_unrestricted() {
|
||
use crate::agent::models::resolve_model_catalog;
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[models]
|
||
allowed_models = []
|
||
[model.foo]
|
||
model = "foo"
|
||
base_url = "https://byok.example/v1"
|
||
context_window = 256000
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let catalog = resolve_model_catalog(
|
||
&Config::new_from_toml_cfg(&raw).unwrap(),
|
||
None,
|
||
&Default::default(),
|
||
);
|
||
assert!(
|
||
catalog["foo"].info.user_selectable,
|
||
"empty allowed_models must not restrict"
|
||
);
|
||
}
|
||
#[test]
|
||
fn invalid_glob_is_rejected_by_validation() {
|
||
use crate::agent::models::ModelGlobSet;
|
||
assert!(ModelGlobSet::compile(Some(&vec!["kigi[".to_string()])).is_err());
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[models]
|
||
allowed_models = ["kigi["]
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let err = Config::new_from_toml_cfg(&raw)
|
||
.unwrap()
|
||
.validate_model_filters()
|
||
.unwrap_err();
|
||
assert!(
|
||
err.contains("allowed_models"),
|
||
"error should name the offending field: {err}"
|
||
);
|
||
}
|
||
#[test]
|
||
fn supported_in_api_false_hides_from_api_key_users() {
|
||
use crate::agent::models::{available_models, resolve_model_catalog};
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.oauth-only-model]
|
||
model = "oauth-only-model"
|
||
base_url = "https://byok.example/v1"
|
||
context_window = 200000
|
||
supported_in_api = false
|
||
|
||
[model.public-model]
|
||
model = "public-model"
|
||
base_url = "https://byok.example/v1"
|
||
context_window = 200000
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw).unwrap();
|
||
let catalog = resolve_model_catalog(&cfg, None, &Default::default());
|
||
assert!(catalog.contains_key("oauth-only-model"));
|
||
assert!(catalog.contains_key("public-model"));
|
||
let api_available = available_models(&catalog, false);
|
||
assert!(!api_available.values().any(|m| m.name == "oauth-only-model"));
|
||
assert!(api_available.values().any(|m| m.name == "public-model"));
|
||
let oauth_available = available_models(&catalog, true);
|
||
assert!(
|
||
oauth_available
|
||
.values()
|
||
.any(|m| m.name == "oauth-only-model")
|
||
);
|
||
assert!(oauth_available.values().any(|m| m.name == "public-model"));
|
||
}
|
||
#[test]
|
||
fn inference_idle_timeout_secs_round_trip() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.slow-model]
|
||
model = "kigi-4.5"
|
||
base_url = "https://byok.example/v1"
|
||
context_window = 200000
|
||
inference_idle_timeout_secs = 600
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let model = resolved.get("slow-model").expect("model should exist");
|
||
assert_eq!(model.info.inference_idle_timeout_secs, Some(600));
|
||
}
|
||
#[test]
|
||
fn inference_idle_timeout_secs_absent_defaults_to_none() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.default-model]
|
||
model = "kigi-fast"
|
||
base_url = "https://byok.example/v1"
|
||
context_window = 200000
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let model = resolved.get("default-model").expect("model should exist");
|
||
assert_eq!(model.info.inference_idle_timeout_secs, None);
|
||
}
|
||
#[test]
|
||
fn inference_idle_timeout_propagates_to_model_info() {
|
||
let entry = ModelEntryConfig {
|
||
id: None,
|
||
model: "test".to_string(),
|
||
base_url: "https://test.api/v1".to_string(),
|
||
name: None,
|
||
description: None,
|
||
max_completion_tokens: None,
|
||
temperature: None,
|
||
top_p: None,
|
||
api_key: None,
|
||
env_key: None,
|
||
api_backend: ApiBackend::default(),
|
||
auth_scheme: None,
|
||
extra_headers: IndexMap::new(),
|
||
context_window: NonZeroU64::new(200_000).unwrap(),
|
||
auto_compact_threshold_percent: None,
|
||
system_prompt_label: None,
|
||
api_base_url: None,
|
||
use_concise: false,
|
||
agent_type: default_agent_type(),
|
||
inference_idle_timeout_secs: Some(120),
|
||
max_retries: None,
|
||
hidden: false,
|
||
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(),
|
||
};
|
||
let info = ModelInfo::from_config(&entry);
|
||
assert_eq!(info.inference_idle_timeout_secs, Some(120));
|
||
}
|
||
/// The `[auth]` alias and the explicit `[kimi_code_config]` table both
|
||
/// deserialize (the auth block currently carries no per-deployment
|
||
/// options; the alias machinery is retained for future knobs).
|
||
#[test]
|
||
fn auth_alias_and_kimi_code_config_tables_parse() {
|
||
for body in ["[auth]\n", "[kimi_code_config]\n"] {
|
||
let raw: toml::Value = toml::from_str(body).unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||
assert_eq!(cfg.kimi_code_config.auth_scope(), "oauth/kimi-code");
|
||
}
|
||
}
|
||
fn resolve_models_from_toml(
|
||
toml_str: &str,
|
||
prefetched: Option<IndexMap<String, ModelEntry>>,
|
||
) -> (Config, IndexMap<String, ModelEntry>) {
|
||
let raw: toml::Value = toml::from_str(toml_str).expect("test TOML should parse");
|
||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, prefetched, &Default::default());
|
||
(cfg, resolved)
|
||
}
|
||
fn resolve_sampling(model: &ModelEntry, session_key: Option<&str>) -> SamplerConfig {
|
||
let credentials = resolve_credentials(model, session_key);
|
||
sampling_config_for_model(model, credentials, None)
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn e2e_user_overrides_default_model_key_with_custom_endpoint() {
|
||
let dm = crate::models::default_model();
|
||
let (_, models) = resolve_models_from_toml(
|
||
&format!(
|
||
r#"
|
||
[model."{dm}"]
|
||
model = "{dm}"
|
||
base_url = "https://inference.example.com/v1"
|
||
context_window = 200000
|
||
env_key = "ENTERPRISE_AUTH_TOKEN"
|
||
"#,
|
||
),
|
||
None,
|
||
);
|
||
let model = models.get(dm).expect("model should exist");
|
||
assert_eq!(model.info.base_url, "https://inference.example.com/v1");
|
||
assert_eq!(
|
||
model.env_key.as_ref().and_then(|k| k.primary()),
|
||
Some("ENTERPRISE_AUTH_TOKEN")
|
||
);
|
||
unsafe { std::env::set_var("ENTERPRISE_AUTH_TOKEN", "enterprise-secret-key") };
|
||
let sampling = resolve_sampling(model, None);
|
||
assert_eq!(
|
||
sampling.api_key.as_deref(),
|
||
Some("enterprise-secret-key"),
|
||
"should use the user's env_key, not fall through to session/external"
|
||
);
|
||
assert_eq!(
|
||
sampling.base_url, "https://inference.example.com/v1",
|
||
"should route to the user's custom endpoint, not the BYOK endpoint"
|
||
);
|
||
unsafe { std::env::remove_var("ENTERPRISE_AUTH_TOKEN") };
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn e2e_config_toml_model_overrides_default() {
|
||
let dm = crate::models::default_model();
|
||
let (_, models) = resolve_models_from_toml(
|
||
&format!(
|
||
r#"
|
||
[model."{dm}"]
|
||
base_url = "https://inference.example.com/v1"
|
||
"#,
|
||
),
|
||
None,
|
||
);
|
||
let model = models.get(dm).expect("model should exist");
|
||
let sampling = resolve_sampling(model, Some("session-tok"));
|
||
assert_eq!(sampling.base_url, "https://inference.example.com/v1");
|
||
unsafe { std::env::set_var("XAI_API_KEY", "xai-key") };
|
||
let sampling = resolve_sampling(model, None);
|
||
assert_eq!(sampling.base_url, "https://inference.example.com/v1");
|
||
unsafe { std::env::remove_var("XAI_API_KEY") };
|
||
let sampling = resolve_sampling(model, None);
|
||
assert_eq!(sampling.base_url, "https://inference.example.com/v1");
|
||
}
|
||
#[test]
|
||
fn e2e_user_overrides_default_model_with_api_key() {
|
||
let dm = crate::models::default_model();
|
||
let (_, models) = resolve_models_from_toml(
|
||
&format!(
|
||
r#"
|
||
[model."{dm}"]
|
||
model = "{dm}"
|
||
base_url = "https://my-proxy.example.com/v1"
|
||
context_window = 200000
|
||
api_key = "my-custom-api-key"
|
||
"#,
|
||
),
|
||
None,
|
||
);
|
||
let model = models.get(dm).expect("model should exist");
|
||
assert_eq!(model.info.base_url, "https://my-proxy.example.com/v1");
|
||
assert_eq!(model.api_key.as_deref(), Some("my-custom-api-key"));
|
||
assert!(model.env_key.is_none());
|
||
let sampling = resolve_sampling(model, Some("session-token"));
|
||
assert_eq!(
|
||
sampling.api_key.as_deref(),
|
||
Some("my-custom-api-key"),
|
||
"model's own api_key must beat session token"
|
||
);
|
||
assert_eq!(
|
||
sampling.base_url, "https://my-proxy.example.com/v1",
|
||
"should route to user's custom endpoint"
|
||
);
|
||
}
|
||
#[test]
|
||
fn parsed_config_has_models_config() {
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[models]
|
||
default = "my-enterprise-model"
|
||
session_summary = "title-model"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||
assert_eq!(cfg.models.default.as_deref(), Some("my-enterprise-model"));
|
||
assert_eq!(cfg.models.session_summary.as_deref(), Some("title-model"));
|
||
}
|
||
#[test]
|
||
fn config_models_default_is_not_overwritten_by_default_models_json() {
|
||
let config_default = Some("custom-byok-model");
|
||
let remote_settings_default = Some("remote-settings-model");
|
||
let resolved = resolve_string_flag(
|
||
None,
|
||
"KIGI_DEFAULT_MODEL_TEST_NONEXISTENT",
|
||
config_default,
|
||
remote_settings_default,
|
||
);
|
||
let resolved = resolved.expect("should resolve to a value");
|
||
assert_eq!(resolved.value, "custom-byok-model");
|
||
assert_eq!(
|
||
resolved.source,
|
||
ConfigSource::Config,
|
||
"[models] default from config.toml must beat remote settings and compiled-in defaults"
|
||
);
|
||
}
|
||
#[test]
|
||
fn config_models_default_custom_model_is_in_resolved_model_list() {
|
||
let (_, models) = resolve_models_from_toml(
|
||
r#"
|
||
[model.acme-kigi]
|
||
model = "kigi-4.5"
|
||
base_url = "https://inference.example.com/v1"
|
||
context_window = 256000
|
||
env_key = "ENTERPRISE_AUTH_TOKEN"
|
||
"#,
|
||
None,
|
||
);
|
||
assert!(
|
||
models.contains_key("acme-kigi"),
|
||
"user-defined model must be in the resolved model list"
|
||
);
|
||
let model = models.get("acme-kigi").unwrap();
|
||
assert_eq!(model.info.model, "kigi-4.5");
|
||
assert_eq!(model.info.base_url, "https://inference.example.com/v1");
|
||
}
|
||
#[test]
|
||
fn e2e_default_model_with_session_routes_to_proxy() {
|
||
let (_, models) = resolve_models_from_toml("", None);
|
||
let model = models
|
||
.get(BUNDLED_DEFAULT_KEY)
|
||
.expect("default model should exist");
|
||
let sampling = resolve_sampling(model, Some("session-token-123"));
|
||
assert_eq!(sampling.api_key.as_deref(), Some("session-token-123"));
|
||
assert_eq!(
|
||
sampling.base_url, "https://api.kimi.com/coding/v1",
|
||
"session auth should route to the subscription endpoint"
|
||
);
|
||
assert_eq!(
|
||
sampling.model, "kimi-for-coding",
|
||
"wire slug, not catalog key"
|
||
);
|
||
}
|
||
/// F2 acceptance seam: with ONLY a moonshot API key configured (no
|
||
/// subscription login), the bundled open-platform entry resolves usable
|
||
/// credentials routed at the moonshot base — nothing platform-specific is
|
||
/// left for the sampler (F3).
|
||
#[test]
|
||
#[serial]
|
||
fn e2e_moonshot_env_key_routes_to_moonshot_base() {
|
||
let (_, models) = resolve_models_from_toml("", None);
|
||
let model = models
|
||
.get("moonshot-ai/kimi-k2-turbo-preview")
|
||
.expect("bundled moonshot fallback entry should exist");
|
||
unsafe { std::env::set_var("KIGI_MOONSHOT_API_KEY", "sk-moonshot-generic") };
|
||
let sampling = resolve_sampling(model, None);
|
||
assert_eq!(sampling.api_key.as_deref(), Some("sk-moonshot-generic"));
|
||
assert_eq!(
|
||
sampling.base_url, "https://api.moonshot.ai/v1",
|
||
"moonshot key must route to the open-platform base"
|
||
);
|
||
assert_eq!(sampling.model, "kimi-k2-turbo-preview");
|
||
unsafe { std::env::remove_var("KIGI_MOONSHOT_API_KEY") };
|
||
}
|
||
#[test]
|
||
fn e2e_user_config_overrides_prefetched_model() {
|
||
let dm = crate::models::default_model();
|
||
let mut prefetched = IndexMap::new();
|
||
prefetched.insert(
|
||
dm.to_string(),
|
||
test_model_entry(dm, "https://api.kimi.com/coding/v1", None, None, None),
|
||
);
|
||
let (_, models) = resolve_models_from_toml(
|
||
&format!(
|
||
r#"
|
||
[model."{dm}"]
|
||
model = "{dm}"
|
||
base_url = "https://my-proxy.example.com/v1"
|
||
context_window = 200000
|
||
api_key = "my-api-key"
|
||
"#,
|
||
),
|
||
Some(prefetched),
|
||
);
|
||
let model = models.get(dm).unwrap();
|
||
assert_eq!(
|
||
model.info.base_url, "https://my-proxy.example.com/v1",
|
||
"user TOML should override prefetched model"
|
||
);
|
||
let sampling = resolve_sampling(model, Some("session-token"));
|
||
assert_eq!(
|
||
sampling.api_key.as_deref(),
|
||
Some("my-api-key"),
|
||
"model's own api_key should win over session token"
|
||
);
|
||
assert_eq!(sampling.base_url, "https://my-proxy.example.com/v1");
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn e2e_credential_priority_model_key_beats_session_beats_env() {
|
||
let model_with_key = test_model_entry(
|
||
"test",
|
||
"https://custom.api/v1",
|
||
Some("model-key"),
|
||
None,
|
||
None,
|
||
);
|
||
unsafe { std::env::set_var("XAI_API_KEY", "env-key") };
|
||
let sampling = resolve_sampling(&model_with_key, Some("session-key"));
|
||
assert_eq!(
|
||
sampling.api_key.as_deref(),
|
||
Some("model-key"),
|
||
"model's own api_key must beat session and env key"
|
||
);
|
||
assert_eq!(
|
||
sampling.base_url, "https://custom.api/v1",
|
||
"model's own base_url must be used"
|
||
);
|
||
let model_no_key = test_model_entry(
|
||
"test",
|
||
"https://proxy.api/v1",
|
||
None,
|
||
None,
|
||
Some("https://byok.example/v1"),
|
||
);
|
||
let sampling = resolve_sampling(&model_no_key, Some("session-key"));
|
||
assert_eq!(
|
||
sampling.api_key.as_deref(),
|
||
Some("session-key"),
|
||
"session token should beat env key when model has no own credentials"
|
||
);
|
||
assert_eq!(
|
||
sampling.base_url, "https://proxy.api/v1",
|
||
"session auth should use base_url, not api_base_url"
|
||
);
|
||
let sampling = resolve_sampling(&model_no_key, None);
|
||
assert_eq!(
|
||
sampling.api_key.as_deref(),
|
||
Some("env-key"),
|
||
"env key should be used when no session and no model credentials"
|
||
);
|
||
assert_eq!(
|
||
sampling.base_url, "https://byok.example/v1",
|
||
"env key should route to api_base_url"
|
||
);
|
||
unsafe { std::env::remove_var("XAI_API_KEY") };
|
||
let sampling = resolve_sampling(&model_no_key, None);
|
||
assert!(
|
||
sampling.api_key.is_none(),
|
||
"no credentials available → api_key should be None"
|
||
);
|
||
}
|
||
#[test]
|
||
fn e2e_duplicate_model_field_both_entries_survive() {
|
||
let dm = crate::models::default_model();
|
||
let (_, models) = resolve_models_from_toml(
|
||
&format!(
|
||
r#"
|
||
[model.acme-kimi]
|
||
model = "{dm}"
|
||
base_url = "https://inference.example.com/v1"
|
||
context_window = 200000
|
||
api_key = "enterprise-key"
|
||
"#,
|
||
),
|
||
None,
|
||
);
|
||
assert!(
|
||
models.contains_key(BUNDLED_DEFAULT_KEY),
|
||
"default entry should still exist"
|
||
);
|
||
assert!(
|
||
models.contains_key("acme-kimi"),
|
||
"user entry with different key should also exist"
|
||
);
|
||
let default = models.get(BUNDLED_DEFAULT_KEY).unwrap();
|
||
let user = models.get("acme-kimi").unwrap();
|
||
assert_eq!(default.info.model, user.info.model, "same model field");
|
||
assert_ne!(
|
||
default.info.base_url, user.info.base_url,
|
||
"different base_urls"
|
||
);
|
||
let sampling = resolve_sampling(user, None);
|
||
assert_eq!(sampling.api_key.as_deref(), Some("enterprise-key"));
|
||
assert_eq!(sampling.base_url, "https://inference.example.com/v1");
|
||
let sampling = resolve_sampling(default, Some("session-key"));
|
||
assert_eq!(sampling.api_key.as_deref(), Some("session-key"));
|
||
assert_eq!(sampling.base_url, "https://api.kimi.com/coding/v1",);
|
||
}
|
||
#[test]
|
||
fn e2e_enterprise_custom_endpoint_skips_xai_defaults() {
|
||
let mut cfg = Config::default();
|
||
cfg.endpoints.models_base_url = Some("https://enterprise.acme.com/v1".to_owned());
|
||
let mut prefetched = IndexMap::new();
|
||
prefetched.insert(
|
||
"acme-model".to_string(),
|
||
test_model_entry(
|
||
"acme-model",
|
||
"https://enterprise.acme.com/v1",
|
||
None,
|
||
None,
|
||
None,
|
||
),
|
||
);
|
||
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
|
||
assert!(
|
||
resolved.contains_key("acme-model"),
|
||
"enterprise model should be present"
|
||
);
|
||
assert!(
|
||
!resolved.contains_key(crate::models::default_model()),
|
||
"xAI default must not leak into enterprise model list"
|
||
);
|
||
assert_eq!(resolved.len(), 1, "only the prefetched enterprise model");
|
||
}
|
||
#[test]
|
||
fn e2e_default_endpoint_still_injects_defaults() {
|
||
let cfg = Config::default();
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
assert!(
|
||
resolved.contains_key(BUNDLED_DEFAULT_KEY),
|
||
"default model should be present when using default endpoint"
|
||
);
|
||
}
|
||
#[test]
|
||
fn e2e_acp_model_info_no_dedup_on_model_field() {
|
||
let mut models = IndexMap::new();
|
||
models.insert(
|
||
"default-kigi".to_string(),
|
||
test_model_entry(
|
||
crate::models::default_model(),
|
||
"https://api.kimi.com/coding/v1",
|
||
None,
|
||
None,
|
||
Some("https://byok.example/v1"),
|
||
),
|
||
);
|
||
models.insert(
|
||
"acme-kigi".to_string(),
|
||
test_model_entry(
|
||
crate::models::default_model(),
|
||
"https://inference.example.com/v1",
|
||
Some("enterprise-key"),
|
||
None,
|
||
None,
|
||
),
|
||
);
|
||
let acp_models = to_acp_model_info(&models);
|
||
assert_eq!(
|
||
acp_models.len(),
|
||
2,
|
||
"both entries should survive in ACP model list"
|
||
);
|
||
assert!(
|
||
acp_models.contains_key(&acp::ModelId::new("default-kigi")),
|
||
"default entry should be addressable by map key"
|
||
);
|
||
assert!(
|
||
acp_models.contains_key(&acp::ModelId::new("acme-kigi")),
|
||
"user entry should be addressable by map key"
|
||
);
|
||
}
|
||
#[test]
|
||
fn e2e_enterprise_endpoints_plus_partial_model_override() {
|
||
let (_, models) = resolve_models_from_toml(
|
||
&format!(
|
||
r#"
|
||
[endpoints]
|
||
coding_api_base_url = "https://enterprise-proxy.acme.com/v1"
|
||
|
||
[model."{BUNDLED_DEFAULT_KEY}"]
|
||
api_key = "acme-api-key"
|
||
"#,
|
||
),
|
||
None,
|
||
);
|
||
let model = models.get(BUNDLED_DEFAULT_KEY).expect("model should exist");
|
||
assert_eq!(
|
||
model.info.base_url, "https://enterprise-proxy.acme.com/v1",
|
||
"base_url must inherit from [endpoints], not stale default"
|
||
);
|
||
assert_eq!(model.api_key.as_deref(), Some("acme-api-key"));
|
||
let sampling = resolve_sampling(model, Some("session-token"));
|
||
assert_eq!(
|
||
sampling.api_key.as_deref(),
|
||
Some("acme-api-key"),
|
||
"model's own api_key must beat session token"
|
||
);
|
||
assert_eq!(
|
||
sampling.base_url, "https://enterprise-proxy.acme.com/v1",
|
||
"sampling must route to enterprise proxy"
|
||
);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn e2e_enterprise_endpoints_only_no_model_override() {
|
||
// Hermetic: the moonshot-base assertion below races serial tests
|
||
// that legitimately point KIGI_MOONSHOT_CN_BASE_URL at wiremock —
|
||
// this test was non-serial and read the env mid-guard.
|
||
let _cn = EnvGuard::unset(kigi_models::MOONSHOT_CN_BASE_URL_ENV);
|
||
let (_, models) = resolve_models_from_toml(
|
||
r#"
|
||
[endpoints]
|
||
coding_api_base_url = "https://enterprise-proxy.acme.com/v1"
|
||
"#,
|
||
None,
|
||
);
|
||
let model = models.get(BUNDLED_DEFAULT_KEY).expect("model should exist");
|
||
assert_eq!(
|
||
model.info.base_url, "https://enterprise-proxy.acme.com/v1",
|
||
"default model should use enterprise coding_api_base_url"
|
||
);
|
||
// The open-platform fallback entries keep their fixed moonshot bases;
|
||
// only the subscription entry follows the proxy override.
|
||
let moonshot = models
|
||
.get("moonshot-cn/kimi-k2-turbo-preview")
|
||
.expect("bundled moonshot entry should exist");
|
||
assert_eq!(moonshot.info.base_url, "https://api.moonshot.cn/v1");
|
||
}
|
||
/// Unset every env var that `EndpointsConfig::default()` reads for endpoints,
|
||
/// so the cli-chat-proxy resolver tests below are deterministic regardless of
|
||
/// the ambient environment. Gated behind `#[serial]`.
|
||
fn unset_endpoint_env_vars() {
|
||
for k in [
|
||
"KIGI_CODE_BASE_URL",
|
||
kigi_env::CODE_BASE_URL_ENV,
|
||
"KIGI_API_BASE_URL",
|
||
"KIGI_FEEDBACK_BASE_URL",
|
||
"KIGI_TRACE_UPLOAD_URL",
|
||
"KIGI_MANAGED_CONFIG_URL",
|
||
"KIGI_MODELS_BASE_URL",
|
||
"KIGI_MODELS_LIST_URL",
|
||
] {
|
||
unsafe { std::env::remove_var(k) };
|
||
}
|
||
}
|
||
/// PRD §9: there is no built-in BYOK endpoint default — `api_base_url`
|
||
/// stays unset unless explicitly configured (config key, env var, CLI
|
||
/// flag, or requirements pin).
|
||
#[test]
|
||
#[serial]
|
||
fn api_base_url_has_no_default() {
|
||
unset_endpoint_env_vars();
|
||
assert_eq!(EndpointsConfig::default().api_base_url, None);
|
||
}
|
||
|
||
/// INVARIANT: auxiliary-service resolvers resolve to the cli-chat-proxy, never
|
||
/// `api_base_url` — overriding ONLY inference keeps every aux endpoint on
|
||
/// the proxy; explicit per-service overrides win verbatim.
|
||
#[test]
|
||
#[serial]
|
||
fn aux_endpoints_resolve_to_proxy_never_inference() {
|
||
unset_endpoint_env_vars();
|
||
let inference = "https://inference.acme-corp.example/xai/v1";
|
||
let cfg = EndpointsConfig {
|
||
api_base_url: Some(inference.to_string()),
|
||
coding_api_base_url: None,
|
||
..Default::default()
|
||
};
|
||
let proxy = kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url;
|
||
assert_eq!(cfg.proxy_url(), proxy);
|
||
assert_eq!(cfg.resolve_inference_base_url(), proxy);
|
||
assert_eq!(cfg.resolve_models_list_url(), format!("{proxy}/models"));
|
||
assert_eq!(
|
||
cfg.resolve_managed_config_url(),
|
||
format!("{proxy}/deployment/config")
|
||
);
|
||
assert_eq!(cfg.resolve_feedback_base_url(), proxy);
|
||
assert_eq!(cfg.api_base_url.as_deref(), Some(inference));
|
||
let overridden = EndpointsConfig {
|
||
coding_api_base_url: Some("https://proxy.enterprise.example/v1".to_string()),
|
||
managed_config_url: Some(
|
||
"https://control.enterprise.example/deployment/config".to_string(),
|
||
),
|
||
feedback_base_url: Some("https://feedback.enterprise.example".to_string()),
|
||
..Default::default()
|
||
};
|
||
assert_eq!(
|
||
overridden.proxy_url(),
|
||
"https://proxy.enterprise.example/v1"
|
||
);
|
||
assert_eq!(
|
||
overridden.resolve_managed_config_url(),
|
||
"https://control.enterprise.example/deployment/config"
|
||
);
|
||
assert_eq!(
|
||
overridden.resolve_feedback_base_url(),
|
||
"https://feedback.enterprise.example"
|
||
);
|
||
}
|
||
/// REGRESSION: the managed-config URL never follows `api_base_url`
|
||
/// through the full loader `Config::new_from_toml_cfg` — a distinct construction
|
||
/// path from `from_config_value`, so the deployment key never reaches the
|
||
/// inference host on either.
|
||
#[test]
|
||
#[serial]
|
||
fn loader_managed_config_url_never_follows_inference_endpoint() {
|
||
unset_endpoint_env_vars();
|
||
let cfg = Config::new_from_toml_cfg(
|
||
&toml::from_str(
|
||
r#"[endpoints]
|
||
api_base_url = "https://inference.acme-corp.example/xai/v1""#,
|
||
)
|
||
.unwrap(),
|
||
)
|
||
.expect("config should parse");
|
||
assert!(cfg.endpoints.coding_api_base_url.is_none());
|
||
assert_eq!(
|
||
cfg.endpoints.resolve_managed_config_url(),
|
||
format!(
|
||
"{}/deployment/config",
|
||
kigi_env::PRODUCTION_ENDPOINTS.coding_api_base_url
|
||
)
|
||
);
|
||
assert!(
|
||
!cfg.endpoints
|
||
.resolve_managed_config_url()
|
||
.contains("inference.acme-corp.example"),
|
||
"deployment key would be sent to the inference host"
|
||
);
|
||
}
|
||
#[test]
|
||
fn e2e_user_override_explicit_base_url_wins_over_endpoints() {
|
||
let dm = crate::models::default_model();
|
||
let (_, models) = resolve_models_from_toml(
|
||
&format!(
|
||
r#"
|
||
[endpoints]
|
||
coding_api_base_url = "https://enterprise-proxy.acme.com/v1"
|
||
|
||
[model."{dm}"]
|
||
base_url = "https://my-special-proxy.example.com/v1"
|
||
"#,
|
||
),
|
||
None,
|
||
);
|
||
let model = models.get(dm).expect("model should exist");
|
||
assert_eq!(
|
||
model.info.base_url, "https://my-special-proxy.example.com/v1",
|
||
"explicit base_url in [model.*] must win over [endpoints]"
|
||
);
|
||
}
|
||
#[test]
|
||
fn e2e_models_endpoint_serde_alias_parses_as_models_list_url() {
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[endpoints]
|
||
models_endpoint = "https://old-style.acme.com/v1/models"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||
assert_eq!(
|
||
cfg.endpoints.models_list_url.as_deref(),
|
||
Some("https://old-style.acme.com/v1/models"),
|
||
"models_endpoint alias should parse into models_list_url"
|
||
);
|
||
assert!(cfg.endpoints.has_custom_endpoint());
|
||
}
|
||
#[test]
|
||
fn e2e_config_models_parsed_directly_not_via_deep_merge() {
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.custom-model]
|
||
model = "my-custom-llm"
|
||
api_key = "custom-key"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||
assert!(cfg.config_models.contains_key("custom-model"));
|
||
let model_override = cfg.config_models.get("custom-model").unwrap();
|
||
assert_eq!(model_override.model.as_deref(), Some("my-custom-llm"));
|
||
assert_eq!(model_override.api_key.as_deref(), Some("custom-key"));
|
||
assert!(
|
||
model_override.base_url.is_none(),
|
||
"base_url should be None when user didn't set it"
|
||
);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_feedback_defaults_to_true_when_unset() {
|
||
unsafe { std::env::remove_var("KIGI_FEEDBACK_ENABLED") };
|
||
unsafe { std::env::remove_var("KIGI_TELEMETRY_ENABLED") };
|
||
let cfg = Config::default();
|
||
let r = cfg.resolve_feedback();
|
||
assert!(r.value, "feedback should be true by default");
|
||
assert_eq!(r.source, ConfigSource::Default);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_session_recap_defaults_to_true_when_unset() {
|
||
unsafe { std::env::remove_var("KIGI_SESSION_RECAP") };
|
||
let cfg = Config::default();
|
||
let r = cfg.resolve_session_recap();
|
||
assert!(r.value, "session_recap should be true by default");
|
||
assert_eq!(r.source, ConfigSource::Default);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_session_recap_config_off_overrides_default() {
|
||
unsafe { std::env::remove_var("KIGI_SESSION_RECAP") };
|
||
let cfg = Config {
|
||
features: Features {
|
||
session_recap: Some(false),
|
||
..Default::default()
|
||
},
|
||
..Default::default()
|
||
};
|
||
let r = cfg.resolve_session_recap();
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_session_recap_env_off_overrides_default() {
|
||
unsafe { std::env::set_var("KIGI_SESSION_RECAP", "0") };
|
||
let cfg = Config::default();
|
||
let r = cfg.resolve_session_recap();
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
unsafe { std::env::remove_var("KIGI_SESSION_RECAP") };
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_session_recap_remote_off_overrides_default() {
|
||
unsafe { std::env::remove_var("KIGI_SESSION_RECAP") };
|
||
let cfg = Config {
|
||
remote_settings: Some(crate::util::config::RemoteSettings {
|
||
session_recap: Some(false),
|
||
..Default::default()
|
||
}),
|
||
..Default::default()
|
||
};
|
||
let r = cfg.resolve_session_recap();
|
||
assert!(
|
||
!r.value,
|
||
"remote settings/remote false must kill-switch default on"
|
||
);
|
||
assert_eq!(r.source, ConfigSource::Remote);
|
||
}
|
||
/// Precedence: env > config.toml > remote settings > default(false). One test
|
||
/// covers the full ladder so we do not maintain a matrix of flag cases.
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_two_pass_compaction_precedence() {
|
||
unsafe { std::env::remove_var("KIGI_TWO_PASS_COMPACTION") };
|
||
let default_cfg = Config::default();
|
||
let r = default_cfg.resolve_two_pass_compaction();
|
||
assert!(!r.value, "default is opt-in off");
|
||
assert_eq!(r.source, ConfigSource::Default);
|
||
let remote_on = Config {
|
||
remote_settings: Some(crate::util::config::RemoteSettings {
|
||
two_pass_compaction_enabled: Some(true),
|
||
..Default::default()
|
||
}),
|
||
..Default::default()
|
||
};
|
||
let r = remote_on.resolve_two_pass_compaction();
|
||
assert!(r.value);
|
||
assert_eq!(r.source, ConfigSource::Remote);
|
||
let config_over_remote = Config {
|
||
features: Features {
|
||
two_pass_compaction: Some(true),
|
||
..Default::default()
|
||
},
|
||
remote_settings: Some(crate::util::config::RemoteSettings {
|
||
two_pass_compaction_enabled: Some(false),
|
||
..Default::default()
|
||
}),
|
||
..Default::default()
|
||
};
|
||
let r = config_over_remote.resolve_two_pass_compaction();
|
||
assert!(r.value);
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
unsafe { std::env::set_var("KIGI_TWO_PASS_COMPACTION", "0") };
|
||
let r = config_over_remote.resolve_two_pass_compaction();
|
||
assert!(!r.value, "env wins over config + remote");
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
unsafe { std::env::remove_var("KIGI_TWO_PASS_COMPACTION") };
|
||
}
|
||
/// Gate precedence: env > `[doom_loop_recovery]` > remote settings >
|
||
/// default(off), with the remote layer merged PER-FIELD from the nested
|
||
/// `doom_loop_recovery` object. One test covers the full ladder (the
|
||
/// `resolve_two_pass_compaction_precedence` pattern).
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_doom_loop_recovery_precedence() {
|
||
use crate::util::config::DoomLoopRecoverySettings;
|
||
unsafe { std::env::remove_var("KIGI_DOOM_LOOP_RECOVERY") };
|
||
let default_cfg = Config::default();
|
||
assert!(
|
||
default_cfg.resolve_doom_loop_recovery().is_none(),
|
||
"default is opt-in off"
|
||
);
|
||
let remote_on = Config {
|
||
remote_settings: Some(crate::util::config::RemoteSettings {
|
||
doom_loop_recovery: Some(DoomLoopRecoverySettings {
|
||
enabled: Some(true),
|
||
max_threshold: Some(16),
|
||
max_retries: Some(1),
|
||
}),
|
||
..Default::default()
|
||
}),
|
||
..Default::default()
|
||
};
|
||
let p = remote_on.resolve_doom_loop_recovery().expect("remote on");
|
||
assert_eq!(p.max_threshold, 16);
|
||
assert_eq!(p.max_retries, 1);
|
||
let partial_remote = Config {
|
||
doom_loop_recovery: DoomLoopRecoverySettings {
|
||
enabled: Some(true),
|
||
..Default::default()
|
||
},
|
||
remote_settings: Some(crate::util::config::RemoteSettings {
|
||
doom_loop_recovery: Some(DoomLoopRecoverySettings {
|
||
max_threshold: Some(16),
|
||
..Default::default()
|
||
}),
|
||
..Default::default()
|
||
}),
|
||
..Default::default()
|
||
};
|
||
let p = partial_remote
|
||
.resolve_doom_loop_recovery()
|
||
.expect("gate from TOML despite remote object omitting enabled");
|
||
assert_eq!(p.max_threshold, 16, "remote tunable applies");
|
||
assert_eq!(p.max_retries, 2, "unset field falls to the default");
|
||
let config_over_remote = Config {
|
||
doom_loop_recovery: DoomLoopRecoverySettings {
|
||
enabled: Some(true),
|
||
max_threshold: Some(4),
|
||
max_retries: Some(3),
|
||
},
|
||
remote_settings: Some(crate::util::config::RemoteSettings {
|
||
doom_loop_recovery: Some(DoomLoopRecoverySettings {
|
||
enabled: Some(false),
|
||
max_threshold: Some(16),
|
||
max_retries: Some(1),
|
||
}),
|
||
..Default::default()
|
||
}),
|
||
..Default::default()
|
||
};
|
||
let p = config_over_remote
|
||
.resolve_doom_loop_recovery()
|
||
.expect("config on beats remote kill-switch");
|
||
assert_eq!(p.max_threshold, 4);
|
||
assert_eq!(p.max_retries, 3);
|
||
unsafe { std::env::set_var("KIGI_DOOM_LOOP_RECOVERY", "0") };
|
||
assert!(
|
||
config_over_remote.resolve_doom_loop_recovery().is_none(),
|
||
"env wins over config + remote"
|
||
);
|
||
unsafe { std::env::remove_var("KIGI_DOOM_LOOP_RECOVERY") };
|
||
}
|
||
/// The `[doom_loop_recovery]` TOML section deserializes through the
|
||
/// standard config path (no bespoke parser).
|
||
#[test]
|
||
#[serial]
|
||
fn doom_loop_recovery_section_parses_from_toml() {
|
||
unsafe { std::env::remove_var("KIGI_DOOM_LOOP_RECOVERY") };
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[doom_loop_recovery]
|
||
enabled = true
|
||
max_threshold = 12
|
||
max_retries = 1
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw).unwrap();
|
||
assert_eq!(cfg.doom_loop_recovery.enabled, Some(true));
|
||
let p = cfg.resolve_doom_loop_recovery().expect("enabled via toml");
|
||
assert_eq!(p.max_threshold, 12);
|
||
assert_eq!(p.max_retries, 1);
|
||
}
|
||
/// Out-of-range tunables clamp instead of being honored or dropped.
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_doom_loop_recovery_clamps_tunables() {
|
||
use crate::util::config::DoomLoopRecoverySettings;
|
||
unsafe { std::env::remove_var("KIGI_DOOM_LOOP_RECOVERY") };
|
||
let cfg = Config {
|
||
doom_loop_recovery: DoomLoopRecoverySettings {
|
||
enabled: Some(true),
|
||
max_threshold: Some(1_000),
|
||
max_retries: Some(99),
|
||
},
|
||
..Default::default()
|
||
};
|
||
let p = cfg.resolve_doom_loop_recovery().expect("enabled");
|
||
assert_eq!(p.max_threshold, 64);
|
||
assert_eq!(p.max_retries, 5);
|
||
let cfg = Config {
|
||
doom_loop_recovery: DoomLoopRecoverySettings {
|
||
enabled: Some(true),
|
||
max_threshold: Some(0),
|
||
max_retries: Some(0),
|
||
},
|
||
..Default::default()
|
||
};
|
||
let p = cfg.resolve_doom_loop_recovery().expect("enabled");
|
||
assert_eq!(p.max_threshold, 2);
|
||
assert_eq!(p.max_retries, 0, "0 retries is valid (observe-only)");
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_feedback_env_overrides_all() {
|
||
unsafe { std::env::set_var("KIGI_FEEDBACK_ENABLED", "true") };
|
||
let mut cfg = Config::default();
|
||
cfg.features.feedback = Some(false);
|
||
cfg.remote_settings = Some(crate::util::config::RemoteSettings {
|
||
feedback_enabled: Some(false),
|
||
..Default::default()
|
||
});
|
||
let r = cfg.resolve_feedback();
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
assert!(r.value);
|
||
unsafe { std::env::remove_var("KIGI_FEEDBACK_ENABLED") };
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_feedback_config_overrides_remote_settings() {
|
||
unsafe { std::env::remove_var("KIGI_FEEDBACK_ENABLED") };
|
||
let mut cfg = Config::default();
|
||
cfg.features.feedback = Some(true);
|
||
cfg.remote_settings = Some(crate::util::config::RemoteSettings {
|
||
feedback_enabled: Some(false),
|
||
..Default::default()
|
||
});
|
||
let r = cfg.resolve_feedback();
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
assert!(r.value);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_feedback_remote_settings_used_when_no_local() {
|
||
unsafe { std::env::remove_var("KIGI_FEEDBACK_ENABLED") };
|
||
let cfg = Config {
|
||
remote_settings: Some(crate::util::config::RemoteSettings {
|
||
feedback_enabled: Some(true),
|
||
..Default::default()
|
||
}),
|
||
..Default::default()
|
||
};
|
||
let r = cfg.resolve_feedback();
|
||
assert_eq!(r.source, ConfigSource::Remote);
|
||
assert!(r.value);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_defaults_to_true_when_unset() {
|
||
unsafe { std::env::remove_var("KIGI_GOAL") };
|
||
let cfg = Config::default();
|
||
let r = cfg.resolve_goal();
|
||
assert!(r.value, "goal should be on by default");
|
||
assert_eq!(r.source, ConfigSource::Default);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_env_overrides_config() {
|
||
unsafe { std::env::set_var("KIGI_GOAL", "1") };
|
||
let mut cfg = Config::default();
|
||
cfg.goal.enabled = Some(false);
|
||
cfg.remote_settings = Some(crate::util::config::RemoteSettings {
|
||
goal_enabled: Some(false),
|
||
..Default::default()
|
||
});
|
||
let r = cfg.resolve_goal();
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
assert!(r.value);
|
||
unsafe { std::env::remove_var("KIGI_GOAL") };
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_config_overrides_remote_settings() {
|
||
unsafe { std::env::remove_var("KIGI_GOAL") };
|
||
let mut cfg = Config::default();
|
||
cfg.goal.enabled = Some(true);
|
||
cfg.remote_settings = Some(crate::util::config::RemoteSettings {
|
||
goal_enabled: Some(false),
|
||
..Default::default()
|
||
});
|
||
let r = cfg.resolve_goal();
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
assert!(r.value);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_remote_settings_used_when_no_local() {
|
||
unsafe { std::env::remove_var("KIGI_GOAL") };
|
||
let cfg = Config {
|
||
remote_settings: Some(crate::util::config::RemoteSettings {
|
||
goal_enabled: Some(true),
|
||
..Default::default()
|
||
}),
|
||
..Default::default()
|
||
};
|
||
let r = cfg.resolve_goal();
|
||
assert_eq!(r.source, ConfigSource::Remote);
|
||
assert!(r.value);
|
||
}
|
||
/// The remote settings `goal_enabled: false` kill-switch must still win over
|
||
/// the default-on fallback.
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_remote_settings_kill_switch_overrides_default_on() {
|
||
unsafe { std::env::remove_var("KIGI_GOAL") };
|
||
let cfg = Config {
|
||
remote_settings: Some(crate::util::config::RemoteSettings {
|
||
goal_enabled: Some(false),
|
||
..Default::default()
|
||
}),
|
||
..Default::default()
|
||
};
|
||
let r = cfg.resolve_goal();
|
||
assert_eq!(r.source, ConfigSource::Remote);
|
||
assert!(!r.value);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_ask_user_question_defaults_to_true_when_unset() {
|
||
unsafe { std::env::remove_var("KIGI_ASK_USER_QUESTION") };
|
||
let cfg = Config::default();
|
||
let r = cfg.resolve_ask_user_question();
|
||
assert!(r.value, "ask_user_question should be on by default");
|
||
assert_eq!(r.source, ConfigSource::Default);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_ask_user_question_remote_settings_enables() {
|
||
unsafe { std::env::remove_var("KIGI_ASK_USER_QUESTION") };
|
||
let cfg = Config {
|
||
remote_settings: Some(crate::util::config::RemoteSettings {
|
||
ask_user_question_enabled: Some(true),
|
||
..Default::default()
|
||
}),
|
||
..Default::default()
|
||
};
|
||
let r = cfg.resolve_ask_user_question();
|
||
assert_eq!(r.source, ConfigSource::Remote);
|
||
assert!(r.value);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_ask_user_question_env_overrides_remote_settings() {
|
||
unsafe { std::env::set_var("KIGI_ASK_USER_QUESTION", "1") };
|
||
let cfg = Config {
|
||
remote_settings: Some(crate::util::config::RemoteSettings {
|
||
ask_user_question_enabled: Some(false),
|
||
..Default::default()
|
||
}),
|
||
..Default::default()
|
||
};
|
||
let r = cfg.resolve_ask_user_question();
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
assert!(r.value);
|
||
unsafe { std::env::remove_var("KIGI_ASK_USER_QUESTION") };
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_ask_user_question_config_overrides_remote_settings() {
|
||
unsafe { std::env::remove_var("KIGI_ASK_USER_QUESTION") };
|
||
let mut cfg = Config::default();
|
||
cfg.features.ask_user_question = Some(true);
|
||
cfg.remote_settings = Some(crate::util::config::RemoteSettings {
|
||
ask_user_question_enabled: Some(false),
|
||
..Default::default()
|
||
});
|
||
let r = cfg.resolve_ask_user_question();
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
assert!(r.value);
|
||
}
|
||
/// remote settings `ask_user_question_enabled: false` is a kill-switch: it must
|
||
/// win over the default-on fallback.
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_ask_user_question_remote_settings_kill_switch_overrides_default_on() {
|
||
unsafe { std::env::remove_var("KIGI_ASK_USER_QUESTION") };
|
||
let cfg = Config {
|
||
remote_settings: Some(crate::util::config::RemoteSettings {
|
||
ask_user_question_enabled: Some(false),
|
||
..Default::default()
|
||
}),
|
||
..Default::default()
|
||
};
|
||
let r = cfg.resolve_ask_user_question();
|
||
assert_eq!(r.source, ConfigSource::Remote);
|
||
assert!(!r.value);
|
||
}
|
||
/// Clear every env var the goal/companion resolvers read so tests
|
||
/// start from a known baseline regardless of run order.
|
||
fn clear_goal_envs() {
|
||
unsafe {
|
||
std::env::remove_var("KIGI_GOAL");
|
||
std::env::remove_var("KIGI_GOAL_CLASSIFIER");
|
||
std::env::remove_var("KIGI_GOAL_PLANNER");
|
||
std::env::remove_var("KIGI_GOAL_SUMMARY");
|
||
std::env::remove_var("KIGI_GOAL_VERIFIER_N");
|
||
std::env::remove_var("KIGI_GOAL_CLASSIFIER_MAX");
|
||
std::env::remove_var("KIGI_GOAL_STRATEGIST_EVERY");
|
||
std::env::remove_var("KIGI_GOAL_REVERIFY_AFTER");
|
||
}
|
||
}
|
||
fn cfg_with_goal(goal: bool) -> Config {
|
||
Config {
|
||
goal: GoalConfig {
|
||
enabled: Some(goal),
|
||
..Default::default()
|
||
},
|
||
..Default::default()
|
||
}
|
||
}
|
||
fn cfg_with_goal_and_remote(goal: bool, remote: crate::util::config::RemoteSettings) -> Config {
|
||
Config {
|
||
goal: GoalConfig {
|
||
enabled: Some(goal),
|
||
..Default::default()
|
||
},
|
||
remote_settings: Some(remote),
|
||
..Default::default()
|
||
}
|
||
}
|
||
fn remote_classifier(v: bool) -> crate::util::config::RemoteSettings {
|
||
crate::util::config::RemoteSettings {
|
||
goal_classifier_enabled: Some(v),
|
||
..Default::default()
|
||
}
|
||
}
|
||
fn remote_planner(v: bool) -> crate::util::config::RemoteSettings {
|
||
crate::util::config::RemoteSettings {
|
||
goal_planner_enabled: Some(v),
|
||
..Default::default()
|
||
}
|
||
}
|
||
fn remote_summary(v: bool) -> crate::util::config::RemoteSettings {
|
||
crate::util::config::RemoteSettings {
|
||
goal_summary_enabled: Some(v),
|
||
..Default::default()
|
||
}
|
||
}
|
||
fn cfg_with_goal_config(goal: GoalConfig) -> Config {
|
||
Config {
|
||
goal,
|
||
..Default::default()
|
||
}
|
||
}
|
||
fn cfg_with_goal_config_and_remote(
|
||
goal: GoalConfig,
|
||
remote: crate::util::config::RemoteSettings,
|
||
) -> Config {
|
||
Config {
|
||
goal,
|
||
remote_settings: Some(remote),
|
||
..Default::default()
|
||
}
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_classifier_default_tracks_goal_enabled() {
|
||
clear_goal_envs();
|
||
assert!(
|
||
!cfg_with_goal(false)
|
||
.resolve_goal_classifier_enabled(false)
|
||
.value
|
||
);
|
||
let on = cfg_with_goal(true).resolve_goal_classifier_enabled(true);
|
||
assert!(on.value);
|
||
assert_eq!(on.source, ConfigSource::Default);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_classifier_remote_forces_either_way() {
|
||
clear_goal_envs();
|
||
let off = cfg_with_goal_and_remote(true, remote_classifier(false))
|
||
.resolve_goal_classifier_enabled(true);
|
||
assert!(!off.value);
|
||
assert_eq!(off.source, ConfigSource::Remote);
|
||
let on = cfg_with_goal_and_remote(false, remote_classifier(true))
|
||
.resolve_goal_classifier_enabled(false);
|
||
assert!(on.value);
|
||
assert_eq!(on.source, ConfigSource::Remote);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_classifier_env_overrides_default_and_remote() {
|
||
clear_goal_envs();
|
||
unsafe { std::env::set_var("KIGI_GOAL_CLASSIFIER", "0") };
|
||
let r = cfg_with_goal_and_remote(true, remote_classifier(true))
|
||
.resolve_goal_classifier_enabled(true);
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
unsafe { std::env::set_var("KIGI_GOAL_CLASSIFIER", "1") };
|
||
let r = cfg_with_goal_and_remote(false, remote_classifier(false))
|
||
.resolve_goal_classifier_enabled(false);
|
||
assert!(r.value);
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_planner_default_tracks_goal_enabled() {
|
||
clear_goal_envs();
|
||
assert!(
|
||
!cfg_with_goal(false)
|
||
.resolve_goal_planner_enabled(false)
|
||
.value
|
||
);
|
||
let on = cfg_with_goal(true).resolve_goal_planner_enabled(true);
|
||
assert!(on.value);
|
||
assert_eq!(on.source, ConfigSource::Default);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_planner_remote_forces_either_way() {
|
||
clear_goal_envs();
|
||
let off = cfg_with_goal_and_remote(true, remote_planner(false))
|
||
.resolve_goal_planner_enabled(true);
|
||
assert!(!off.value);
|
||
assert_eq!(off.source, ConfigSource::Remote);
|
||
let on = cfg_with_goal_and_remote(false, remote_planner(true))
|
||
.resolve_goal_planner_enabled(false);
|
||
assert!(on.value);
|
||
assert_eq!(on.source, ConfigSource::Remote);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_planner_env_overrides_default_and_remote() {
|
||
clear_goal_envs();
|
||
unsafe { std::env::set_var("KIGI_GOAL_PLANNER", "0") };
|
||
let r =
|
||
cfg_with_goal_and_remote(true, remote_planner(true)).resolve_goal_planner_enabled(true);
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
unsafe { std::env::set_var("KIGI_GOAL_PLANNER", "1") };
|
||
let r = cfg_with_goal_and_remote(false, remote_planner(false))
|
||
.resolve_goal_planner_enabled(false);
|
||
assert!(r.value);
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_summary_default_tracks_goal_enabled() {
|
||
clear_goal_envs();
|
||
assert!(
|
||
!cfg_with_goal(false)
|
||
.resolve_goal_summary_enabled(false)
|
||
.value
|
||
);
|
||
let on = cfg_with_goal(true).resolve_goal_summary_enabled(true);
|
||
assert!(on.value);
|
||
assert_eq!(on.source, ConfigSource::Default);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_summary_remote_forces_either_way() {
|
||
clear_goal_envs();
|
||
let off = cfg_with_goal_and_remote(true, remote_summary(false))
|
||
.resolve_goal_summary_enabled(true);
|
||
assert!(!off.value);
|
||
assert_eq!(off.source, ConfigSource::Remote);
|
||
let on = cfg_with_goal_and_remote(false, remote_summary(true))
|
||
.resolve_goal_summary_enabled(false);
|
||
assert!(on.value);
|
||
assert_eq!(on.source, ConfigSource::Remote);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_summary_env_overrides_default_and_remote() {
|
||
clear_goal_envs();
|
||
unsafe { std::env::set_var("KIGI_GOAL_SUMMARY", "0") };
|
||
let r =
|
||
cfg_with_goal_and_remote(true, remote_summary(true)).resolve_goal_summary_enabled(true);
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_classifier_config_honored_when_env_unset() {
|
||
clear_goal_envs();
|
||
let r = cfg_with_goal_config(GoalConfig {
|
||
classifier_enabled: Some(true),
|
||
..Default::default()
|
||
})
|
||
.resolve_goal_classifier_enabled(false);
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
assert!(r.value);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_classifier_env_beats_config() {
|
||
clear_goal_envs();
|
||
unsafe { std::env::set_var("KIGI_GOAL_CLASSIFIER", "0") };
|
||
let r = cfg_with_goal_config(GoalConfig {
|
||
classifier_enabled: Some(true),
|
||
..Default::default()
|
||
})
|
||
.resolve_goal_classifier_enabled(false);
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
assert!(!r.value);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_classifier_config_beats_remote() {
|
||
clear_goal_envs();
|
||
let r = cfg_with_goal_config_and_remote(
|
||
GoalConfig {
|
||
classifier_enabled: Some(true),
|
||
..Default::default()
|
||
},
|
||
remote_classifier(false),
|
||
)
|
||
.resolve_goal_classifier_enabled(false);
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
assert!(r.value);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_classifier_config_beats_default() {
|
||
clear_goal_envs();
|
||
let r = cfg_with_goal_config(GoalConfig {
|
||
enabled: Some(true),
|
||
classifier_enabled: Some(false),
|
||
..Default::default()
|
||
})
|
||
.resolve_goal_classifier_enabled(false);
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
assert!(!r.value);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_planner_config_honored_when_env_unset() {
|
||
clear_goal_envs();
|
||
let r = cfg_with_goal_config(GoalConfig {
|
||
planner_enabled: Some(true),
|
||
..Default::default()
|
||
})
|
||
.resolve_goal_planner_enabled(false);
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
assert!(r.value);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_planner_env_beats_config() {
|
||
clear_goal_envs();
|
||
unsafe { std::env::set_var("KIGI_GOAL_PLANNER", "0") };
|
||
let r = cfg_with_goal_config(GoalConfig {
|
||
planner_enabled: Some(true),
|
||
..Default::default()
|
||
})
|
||
.resolve_goal_planner_enabled(false);
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
assert!(!r.value);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_planner_config_beats_remote() {
|
||
clear_goal_envs();
|
||
let r = cfg_with_goal_config_and_remote(
|
||
GoalConfig {
|
||
planner_enabled: Some(true),
|
||
..Default::default()
|
||
},
|
||
remote_planner(false),
|
||
)
|
||
.resolve_goal_planner_enabled(false);
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
assert!(r.value);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_planner_config_beats_default() {
|
||
clear_goal_envs();
|
||
let r = cfg_with_goal_config(GoalConfig {
|
||
enabled: Some(true),
|
||
planner_enabled: Some(false),
|
||
..Default::default()
|
||
})
|
||
.resolve_goal_planner_enabled(false);
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
assert!(!r.value);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_summary_config_honored_when_env_unset() {
|
||
clear_goal_envs();
|
||
let r = cfg_with_goal_config(GoalConfig {
|
||
summary_enabled: Some(true),
|
||
..Default::default()
|
||
})
|
||
.resolve_goal_summary_enabled(false);
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
assert!(r.value);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_summary_env_beats_config() {
|
||
clear_goal_envs();
|
||
unsafe { std::env::set_var("KIGI_GOAL_SUMMARY", "0") };
|
||
let r = cfg_with_goal_config(GoalConfig {
|
||
summary_enabled: Some(true),
|
||
..Default::default()
|
||
})
|
||
.resolve_goal_summary_enabled(false);
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
assert!(!r.value);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_summary_config_beats_remote() {
|
||
clear_goal_envs();
|
||
let r = cfg_with_goal_config_and_remote(
|
||
GoalConfig {
|
||
summary_enabled: Some(true),
|
||
..Default::default()
|
||
},
|
||
remote_summary(false),
|
||
)
|
||
.resolve_goal_summary_enabled(false);
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
assert!(r.value);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_goal_summary_config_beats_default() {
|
||
clear_goal_envs();
|
||
let r = cfg_with_goal_config(GoalConfig {
|
||
enabled: Some(true),
|
||
summary_enabled: Some(false),
|
||
..Default::default()
|
||
})
|
||
.resolve_goal_summary_enabled(false);
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
assert!(!r.value);
|
||
clear_goal_envs();
|
||
}
|
||
#[test]
|
||
fn goal_keys_round_trip_from_toml() {
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[goal]
|
||
enabled = true
|
||
classifier_enabled = true
|
||
planner_enabled = false
|
||
summary_enabled = true
|
||
verifier_count = 4
|
||
classifier_max_runs = 7
|
||
strategist_every = 3
|
||
reverify_after = 6
|
||
"#,
|
||
)
|
||
.expect("test TOML should parse");
|
||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||
assert_eq!(cfg.goal.enabled, Some(true));
|
||
assert_eq!(cfg.goal.classifier_enabled, Some(true));
|
||
assert_eq!(cfg.goal.planner_enabled, Some(false));
|
||
assert_eq!(cfg.goal.summary_enabled, Some(true));
|
||
assert_eq!(cfg.goal.verifier_count, Some(4));
|
||
assert_eq!(cfg.goal.classifier_max_runs, Some(7));
|
||
assert_eq!(cfg.goal.strategist_every, Some(3));
|
||
assert_eq!(cfg.goal.reverify_after, Some(6));
|
||
let empty = Config::new_from_toml_cfg(&toml::from_str("").unwrap()).unwrap();
|
||
assert_eq!(empty.goal.classifier_enabled, None);
|
||
assert_eq!(empty.goal.verifier_count, None);
|
||
}
|
||
const GOAL_USE_CURRENT_ENV: &str = "KIGI_GOAL_USE_CURRENT_MODEL_ONLY";
|
||
fn clear_goal_model_env() {
|
||
unsafe { std::env::remove_var(GOAL_USE_CURRENT_ENV) };
|
||
}
|
||
fn planner_pair() -> crate::util::config::GoalRoleModel {
|
||
crate::util::config::GoalRoleModel {
|
||
model: "kigi-4".to_string(),
|
||
agent_type: "general-purpose".to_string(),
|
||
}
|
||
}
|
||
fn strategist_pair() -> crate::util::config::GoalRoleModel {
|
||
crate::util::config::GoalRoleModel {
|
||
model: "kigi-4.5".to_string(),
|
||
agent_type: "cursor".to_string(),
|
||
}
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn goal_use_current_model_only_env_true() {
|
||
clear_goal_model_env();
|
||
unsafe { std::env::set_var(GOAL_USE_CURRENT_ENV, "1") };
|
||
let r = Config::default().resolve_goal_use_current_model_only();
|
||
assert!(r.value);
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
clear_goal_model_env();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn goal_use_current_model_only_config_true() {
|
||
clear_goal_model_env();
|
||
let cfg = cfg_with_goal_config(GoalConfig {
|
||
use_current_model_only: Some(true),
|
||
..Default::default()
|
||
});
|
||
let r = cfg.resolve_goal_use_current_model_only();
|
||
assert!(r.value);
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
clear_goal_model_env();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn goal_use_current_model_only_default_false() {
|
||
clear_goal_model_env();
|
||
let r = Config::default().resolve_goal_use_current_model_only();
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Default);
|
||
clear_goal_model_env();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn goal_use_current_model_only_env_overrides_config_false() {
|
||
clear_goal_model_env();
|
||
unsafe { std::env::set_var(GOAL_USE_CURRENT_ENV, "1") };
|
||
let cfg = cfg_with_goal_config(GoalConfig {
|
||
use_current_model_only: Some(false),
|
||
..Default::default()
|
||
});
|
||
let r = cfg.resolve_goal_use_current_model_only();
|
||
assert!(r.value);
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
clear_goal_model_env();
|
||
}
|
||
fn remote_planner_model(
|
||
p: crate::util::config::GoalRoleModel,
|
||
) -> crate::util::config::RemoteSettings {
|
||
crate::util::config::RemoteSettings {
|
||
goal_planner_model: Some(p),
|
||
..Default::default()
|
||
}
|
||
}
|
||
fn remote_strategist_model(
|
||
p: crate::util::config::GoalRoleModel,
|
||
) -> crate::util::config::RemoteSettings {
|
||
crate::util::config::RemoteSettings {
|
||
goal_strategist_model: Some(p),
|
||
..Default::default()
|
||
}
|
||
}
|
||
#[test]
|
||
fn resolve_goal_planner_model_kill_switch_inherits() {
|
||
let cfg = cfg_with_goal_config_and_remote(
|
||
GoalConfig::default(),
|
||
remote_planner_model(planner_pair()),
|
||
);
|
||
let r = cfg.resolve_goal_planner_model(true);
|
||
assert_eq!(r.value, GoalRoleModelChoice::InheritCurrent);
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
}
|
||
#[test]
|
||
fn resolve_goal_planner_model_remote_pair_explicit() {
|
||
let cfg = cfg_with_goal_config_and_remote(
|
||
GoalConfig::default(),
|
||
remote_planner_model(planner_pair()),
|
||
);
|
||
let r = cfg.resolve_goal_planner_model(false);
|
||
assert_eq!(r.value, GoalRoleModelChoice::Explicit(planner_pair()));
|
||
assert_eq!(r.source, ConfigSource::Remote);
|
||
}
|
||
#[test]
|
||
fn resolve_goal_planner_model_config_overrides_remote() {
|
||
let cfg = cfg_with_goal_config_and_remote(
|
||
GoalConfig {
|
||
planner_model: Some(planner_pair()),
|
||
..Default::default()
|
||
},
|
||
remote_planner_model(strategist_pair()),
|
||
);
|
||
let r = cfg.resolve_goal_planner_model(false);
|
||
assert_eq!(r.value, GoalRoleModelChoice::Explicit(planner_pair()));
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
}
|
||
#[test]
|
||
fn resolve_goal_planner_model_default_inherits() {
|
||
let r = Config::default().resolve_goal_planner_model(false);
|
||
assert_eq!(r.value, GoalRoleModelChoice::InheritCurrent);
|
||
assert_eq!(r.source, ConfigSource::Default);
|
||
}
|
||
#[test]
|
||
fn resolve_goal_planner_model_remote_present_but_field_absent_inherits() {
|
||
let cfg = cfg_with_goal_config_and_remote(
|
||
GoalConfig::default(),
|
||
remote_strategist_model(strategist_pair()),
|
||
);
|
||
let r = cfg.resolve_goal_planner_model(false);
|
||
assert_eq!(r.value, GoalRoleModelChoice::InheritCurrent);
|
||
assert_eq!(r.source, ConfigSource::Default);
|
||
}
|
||
#[test]
|
||
fn resolve_goal_strategist_model_remote_pair_explicit() {
|
||
let cfg = cfg_with_goal_config_and_remote(
|
||
GoalConfig::default(),
|
||
remote_strategist_model(strategist_pair()),
|
||
);
|
||
let r = cfg.resolve_goal_strategist_model(false);
|
||
assert_eq!(r.value, GoalRoleModelChoice::Explicit(strategist_pair()));
|
||
assert_eq!(r.source, ConfigSource::Remote);
|
||
}
|
||
#[test]
|
||
fn resolve_goal_strategist_model_config_overrides_remote() {
|
||
let cfg = cfg_with_goal_config_and_remote(
|
||
GoalConfig {
|
||
strategist_model: Some(strategist_pair()),
|
||
..Default::default()
|
||
},
|
||
remote_strategist_model(planner_pair()),
|
||
);
|
||
let r = cfg.resolve_goal_strategist_model(false);
|
||
assert_eq!(r.value, GoalRoleModelChoice::Explicit(strategist_pair()));
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
}
|
||
#[test]
|
||
fn resolve_goal_skeptic_models_kill_switch_inherits() {
|
||
let cfg = cfg_with_goal_config(GoalConfig {
|
||
skeptic_models: vec![planner_pair(), strategist_pair()],
|
||
..Default::default()
|
||
});
|
||
let r = cfg.resolve_goal_skeptic_models(true);
|
||
assert!(r.value.is_empty(), "kill-switch ⇒ all skeptics inherit");
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
}
|
||
#[test]
|
||
fn resolve_goal_skeptic_models_remote_pool_explicit() {
|
||
let remote = crate::util::config::RemoteSettings {
|
||
goal_skeptic_models: vec![planner_pair(), strategist_pair()],
|
||
..Default::default()
|
||
};
|
||
let r = cfg_with_goal_config_and_remote(GoalConfig::default(), remote)
|
||
.resolve_goal_skeptic_models(false);
|
||
assert_eq!(
|
||
r.value,
|
||
vec![
|
||
GoalRoleModelChoice::Explicit(planner_pair()),
|
||
GoalRoleModelChoice::Explicit(strategist_pair()),
|
||
]
|
||
);
|
||
assert_eq!(r.source, ConfigSource::Remote);
|
||
}
|
||
#[test]
|
||
fn resolve_goal_skeptic_models_config_pool_overrides_remote_pool() {
|
||
let remote = crate::util::config::RemoteSettings {
|
||
goal_skeptic_models: vec![strategist_pair(), strategist_pair()],
|
||
..Default::default()
|
||
};
|
||
let cfg = cfg_with_goal_config_and_remote(
|
||
GoalConfig {
|
||
skeptic_models: vec![planner_pair(), strategist_pair()],
|
||
..Default::default()
|
||
},
|
||
remote,
|
||
);
|
||
let r = cfg.resolve_goal_skeptic_models(false);
|
||
assert_eq!(
|
||
r.value,
|
||
vec![
|
||
GoalRoleModelChoice::Explicit(planner_pair()),
|
||
GoalRoleModelChoice::Explicit(strategist_pair()),
|
||
]
|
||
);
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
}
|
||
#[test]
|
||
fn resolve_goal_skeptic_models_no_pool_inherits() {
|
||
let r = Config::default().resolve_goal_skeptic_models(false);
|
||
assert!(r.value.is_empty());
|
||
assert_eq!(r.source, ConfigSource::Default);
|
||
}
|
||
/// `[goal]` model pins parse from both the inline-table and `[[...]]` array forms.
|
||
#[test]
|
||
fn goal_model_pins_parse_from_toml() {
|
||
let toml_str = r#"
|
||
[goal]
|
||
enabled = true
|
||
planner_model = { model = "kigi", agent_type = "kigi-plan" }
|
||
|
||
[goal.strategist_model]
|
||
model = "kigi-composer-2.5-fast"
|
||
agent_type = "cursor"
|
||
|
||
[[goal.skeptic_models]]
|
||
model = "kigi"
|
||
agent_type = "kigi-plan"
|
||
|
||
[[goal.skeptic_models]]
|
||
model = "kigi-composer-2.5-fast"
|
||
agent_type = "cursor"
|
||
"#;
|
||
let raw: toml::Value = toml::from_str(toml_str).unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw).unwrap();
|
||
assert_eq!(cfg.goal.planner_model.as_ref().unwrap().model, "kigi");
|
||
assert_eq!(
|
||
cfg.goal.strategist_model.as_ref().unwrap().agent_type,
|
||
"cursor"
|
||
);
|
||
assert_eq!(cfg.goal.skeptic_models.len(), 2);
|
||
assert_eq!(cfg.goal.skeptic_models[0].model, "kigi");
|
||
assert_eq!(
|
||
cfg.resolve_goal_planner_model(false).source,
|
||
ConfigSource::Config
|
||
);
|
||
}
|
||
/// A malformed pin must drop to `None`, not fail the whole parse (which
|
||
/// would silently wipe every other setting).
|
||
#[test]
|
||
fn goal_model_pin_malformed_is_dropped_not_fatal() {
|
||
let toml_str = r#"
|
||
[goal]
|
||
enabled = true
|
||
classifier_max_runs = 6
|
||
planner_model = { agent_type = "kigi-plan" }
|
||
"#;
|
||
let raw: toml::Value = toml::from_str(toml_str).unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw)
|
||
.expect("malformed planner_model must not fail the whole parse");
|
||
assert!(cfg.goal.planner_model.is_none());
|
||
assert_eq!(cfg.goal.classifier_max_runs, Some(6));
|
||
}
|
||
#[test]
|
||
fn goal_skeptic_models_drop_malformed_entry_keep_rest() {
|
||
let toml_str = r#"
|
||
[goal]
|
||
enabled = true
|
||
|
||
[[goal.skeptic_models]]
|
||
model = "kigi"
|
||
agent_type = "kigi-plan"
|
||
|
||
[[goal.skeptic_models]]
|
||
agent_type = "cursor"
|
||
|
||
[[goal.skeptic_models]]
|
||
model = "kigi-composer-2.5-fast"
|
||
agent_type = "cursor"
|
||
"#;
|
||
let raw: toml::Value = toml::from_str(toml_str).unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw).unwrap();
|
||
assert_eq!(cfg.goal.skeptic_models.len(), 2);
|
||
assert_eq!(cfg.goal.skeptic_models[0].model, "kigi");
|
||
assert_eq!(cfg.goal.skeptic_models[1].model, "kigi-composer-2.5-fast");
|
||
}
|
||
/// Acceptance test: a full managed-config `[goal]` block resolves end-to-end,
|
||
/// every value sourced from config (not remote/default).
|
||
#[test]
|
||
#[serial]
|
||
fn full_goal_managed_config_resolves_end_to_end() {
|
||
clear_goal_envs();
|
||
clear_goal_model_env();
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[goal]
|
||
enabled = true
|
||
classifier_enabled = true
|
||
planner_enabled = true
|
||
verifier_count = 3
|
||
classifier_max_runs = 6
|
||
planner_model = { model = "kigi", agent_type = "kigi-plan" }
|
||
strategist_model = { model = "kigi-composer-2.5-fast", agent_type = "cursor" }
|
||
|
||
[[goal.skeptic_models]]
|
||
model = "kigi"
|
||
agent_type = "kigi-plan"
|
||
|
||
[[goal.skeptic_models]]
|
||
model = "kigi-composer-2.5-fast"
|
||
agent_type = "cursor"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw).expect("[goal] config must parse");
|
||
let kigi = crate::util::config::GoalRoleModel {
|
||
model: "kigi".into(),
|
||
agent_type: "kigi-plan".into(),
|
||
};
|
||
let composer = crate::util::config::GoalRoleModel {
|
||
model: "kigi-composer-2.5-fast".into(),
|
||
agent_type: "cursor".into(),
|
||
};
|
||
let goal_enabled = cfg.resolve_goal().value;
|
||
assert!(goal_enabled);
|
||
assert!(cfg.resolve_goal_classifier_enabled(goal_enabled).value);
|
||
assert!(cfg.resolve_goal_planner_enabled(goal_enabled).value);
|
||
assert_eq!(cfg.resolve_goal_verifier_count().value, 3);
|
||
assert_eq!(cfg.resolve_goal_classifier_max_runs().value, 6);
|
||
let use_current = cfg.resolve_goal_use_current_model_only().value;
|
||
assert!(!use_current);
|
||
let planner = cfg.resolve_goal_planner_model(use_current);
|
||
assert_eq!(planner.value, GoalRoleModelChoice::Explicit(kigi.clone()));
|
||
assert_eq!(planner.source, ConfigSource::Config);
|
||
assert_eq!(
|
||
cfg.resolve_goal_strategist_model(use_current).value,
|
||
GoalRoleModelChoice::Explicit(composer.clone())
|
||
);
|
||
assert_eq!(
|
||
cfg.resolve_goal_skeptic_models(use_current).value,
|
||
vec![
|
||
GoalRoleModelChoice::Explicit(kigi),
|
||
GoalRoleModelChoice::Explicit(composer),
|
||
]
|
||
);
|
||
clear_goal_envs();
|
||
clear_goal_model_env();
|
||
}
|
||
/// Run the production scan (`deserialize_collecting_unrecognized`) on a
|
||
/// TOML string, mirroring the [model] removal + default-merge in
|
||
/// `new_from_toml_cfg`.
|
||
fn unused_keys_from_toml(toml_str: &str) -> Vec<String> {
|
||
let raw: toml::Value = toml::from_str(toml_str).unwrap();
|
||
let raw_without_models = {
|
||
let mut r = raw.clone();
|
||
if let toml::Value::Table(ref mut t) = r {
|
||
t.remove("model");
|
||
}
|
||
r
|
||
};
|
||
let mut base = toml::Value::try_from(Config::default()).unwrap();
|
||
if let toml::Value::Table(ref mut t) = base {
|
||
t.remove("model");
|
||
}
|
||
crate::config::deep_merge_toml(&mut base, &raw_without_models);
|
||
let (_config, unused) =
|
||
Config::deserialize_collecting_unrecognized(base, &raw_without_models)
|
||
.expect("config should deserialize");
|
||
unused
|
||
}
|
||
#[test]
|
||
fn config_warns_on_section_typo() {
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[endpoint]
|
||
deployment_key = "xai-token-test"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let config = Config::new_from_toml_cfg(&raw).expect("should parse");
|
||
assert!(config.endpoints.deployment_key.is_none());
|
||
let unused = unused_keys_from_toml(
|
||
r#"
|
||
[endpoint]
|
||
deployment_key = "xai-token-test"
|
||
"#,
|
||
);
|
||
assert!(unused.iter().any(|k| k == "endpoint"), "got: {unused:?}");
|
||
}
|
||
#[test]
|
||
fn config_warns_on_field_typos() {
|
||
let unused = unused_keys_from_toml(
|
||
r#"
|
||
[endpoints]
|
||
deplomyent_key = "test"
|
||
[ui]
|
||
yoloo = true
|
||
[features]
|
||
telmetry = true
|
||
"#,
|
||
);
|
||
assert!(
|
||
unused.iter().any(|k| k == "endpoints.deplomyent_key"),
|
||
"got: {unused:?}"
|
||
);
|
||
assert!(unused.iter().any(|k| k == "ui.yoloo"), "got: {unused:?}");
|
||
assert!(
|
||
unused.iter().any(|k| k == "features.telmetry"),
|
||
"got: {unused:?}"
|
||
);
|
||
}
|
||
#[test]
|
||
fn config_accepts_all_known_sections() {
|
||
let unused = unused_keys_from_toml(
|
||
r#"
|
||
disabled_mcp_servers = ["old-server"]
|
||
[cli]
|
||
auto_update = false
|
||
[features]
|
||
feedback = true
|
||
[endpoints]
|
||
deployment_key = "test"
|
||
management_api_key = "mgmt-key"
|
||
gcs_service_account_key = "gcs-key"
|
||
[models]
|
||
default = "kigi-3"
|
||
[ui]
|
||
yolo = true
|
||
theme = "dark"
|
||
approval_mode = "ask"
|
||
[session]
|
||
auto_compact_threshold_percent = 85
|
||
[agent]
|
||
name = "custom"
|
||
[skills]
|
||
paths = ["~/skills"]
|
||
[plugins]
|
||
paths = ["~/plugins"]
|
||
[subagents]
|
||
enabled = true
|
||
[memory]
|
||
enabled = true
|
||
[compaction]
|
||
[compaction.pruning]
|
||
enabled = true
|
||
[harness]
|
||
block_for_upload = true
|
||
[repo_changes_dedup]
|
||
enabled = false
|
||
[remote]
|
||
secret = "value"
|
||
[worktree_pool]
|
||
pool_size = 4
|
||
[mcp_servers.test]
|
||
url = "https://mcp.test.com"
|
||
[toolset.bash]
|
||
timeout_secs = 120
|
||
persistent_shell = true
|
||
[shortcuts]
|
||
ctrl_k = "search"
|
||
[kimi_code_config]
|
||
[storage]
|
||
cleanup_ttl_days = 7
|
||
[permission]
|
||
[[permission.rules]]
|
||
action = "allow"
|
||
tool = "bash"
|
||
[tools]
|
||
respect_gitignore = false
|
||
[desktop]
|
||
some_key = "value"
|
||
"#,
|
||
);
|
||
assert!(
|
||
unused.is_empty(),
|
||
"false positive on valid config: {unused:?}"
|
||
);
|
||
}
|
||
#[test]
|
||
fn config_accepts_compact_permission_section() {
|
||
let unused = unused_keys_from_toml(
|
||
r#"
|
||
[permission]
|
||
allow = ["Read(//tmp/**)"]
|
||
deny = ["Bash(rm *)"]
|
||
ask = ["WebFetch"]
|
||
"#,
|
||
);
|
||
assert!(
|
||
unused.is_empty(),
|
||
"false positive on [permission] keys: {unused:?}"
|
||
);
|
||
}
|
||
/// `prompt_policy` is not consumed from any TOML permission section (the
|
||
/// verbose loader keeps only `rules`; prompt policy comes from .claude
|
||
/// settings `defaultMode`), so it must warn rather than be a silent no-op.
|
||
#[test]
|
||
fn permission_prompt_policy_warns_as_unconsumed() {
|
||
let unused = unused_keys_from_toml(
|
||
r#"
|
||
[permission]
|
||
deny = ["Bash(rm *)"]
|
||
prompt_policy = "deny"
|
||
"#,
|
||
);
|
||
assert_eq!(
|
||
unused,
|
||
vec!["permission.prompt_policy".to_string()],
|
||
"an unconsumed key in a security section must be flagged"
|
||
);
|
||
}
|
||
/// A typo'd `[permission]` sub-key must still warn — silently dropping a
|
||
/// misspelled security rule would leave the user believing it's in force.
|
||
#[test]
|
||
fn permission_unknown_subkey_still_warns() {
|
||
let unused = unused_keys_from_toml(
|
||
r#"
|
||
[permission]
|
||
denny = ["Bash(rm *)"]
|
||
ask = ["WebFetch"]
|
||
"#,
|
||
);
|
||
assert_eq!(
|
||
unused,
|
||
vec!["permission.denny".to_string()],
|
||
"exactly the typo'd sub-key must be flagged"
|
||
);
|
||
}
|
||
/// Permission *values* are opaque: a malformed `[[permission.rules]]`
|
||
/// entry neither warns nor fails Config load — the out-of-band loaders
|
||
/// parse it tolerantly and warn per item.
|
||
#[test]
|
||
fn malformed_permission_rules_do_not_fail_config_load() {
|
||
let toml_str = r#"
|
||
[[permission.rules]]
|
||
pattern = 5
|
||
"#;
|
||
let raw: toml::Value = toml::from_str(toml_str).unwrap();
|
||
Config::new_from_toml_cfg(&raw)
|
||
.expect("malformed rule values are the permission loaders' concern");
|
||
let unused = unused_keys_from_toml(toml_str);
|
||
assert!(unused.is_empty(), "got: {unused:?}");
|
||
}
|
||
/// A non-table `[permission]` value still fails Config load (pre-existing
|
||
/// behavior): a fundamentally broken security section should be loud.
|
||
#[test]
|
||
fn non_table_permission_value_fails_config_load() {
|
||
let raw: toml::Value = toml::from_str(r#"permission = "foo""#).unwrap();
|
||
assert!(
|
||
Config::new_from_toml_cfg(&raw).is_err(),
|
||
"non-table [permission] must fail loudly"
|
||
);
|
||
}
|
||
/// Exempting `[permission]` and friends must not swallow warnings for
|
||
/// genuinely unknown keys.
|
||
#[test]
|
||
fn unknown_key_still_warns_next_to_exempt_sections() {
|
||
let unused = unused_keys_from_toml(
|
||
r#"
|
||
[permission]
|
||
deny = ["Bash(rm *)"]
|
||
[ui]
|
||
yollo = true
|
||
"#,
|
||
);
|
||
assert_eq!(
|
||
unused,
|
||
vec!["ui.yollo".to_string()],
|
||
"exactly the typo'd key must be flagged"
|
||
);
|
||
}
|
||
fn empty_config() -> toml::Value {
|
||
toml::Value::Table(toml::map::Map::new())
|
||
}
|
||
fn clear_runtime_env_vars() {
|
||
unsafe {
|
||
std::env::remove_var("KIGI_SUBAGENTS");
|
||
std::env::remove_var("KIGI_RESPECT_GITIGNORE");
|
||
std::env::remove_var("KIGI_SESSION_SUMMARY_MODEL");
|
||
std::env::remove_var("KIGI_CURSOR_SKILLS_ENABLED");
|
||
std::env::remove_var("KIGI_CURSOR_RULES_ENABLED");
|
||
std::env::remove_var("KIGI_CURSOR_AGENTS_ENABLED");
|
||
std::env::remove_var("KIGI_CLAUDE_SKILLS_ENABLED");
|
||
std::env::remove_var("KIGI_CLAUDE_RULES_ENABLED");
|
||
std::env::remove_var("KIGI_CLAUDE_AGENTS_ENABLED");
|
||
}
|
||
}
|
||
fn isolate_compat_env() -> Vec<EnvGuard> {
|
||
COMPAT_CELLS
|
||
.into_iter()
|
||
.map(|cell| EnvGuard::unset(cell.env_var()))
|
||
.collect()
|
||
}
|
||
fn parse_compat(source: &str) -> CompatConfigToml {
|
||
let raw: toml::Value = toml::from_str(source).unwrap();
|
||
raw.get("compat").unwrap().clone().try_into().unwrap()
|
||
}
|
||
fn assert_session_one_disabled(config: CompatConfig, expected: CompatVendor) {
|
||
for cell in COMPAT_CELLS {
|
||
if cell.surface() == CompatSurface::Sessions {
|
||
assert_eq!(
|
||
config.value(cell),
|
||
cell.vendor() != expected,
|
||
"{}.sessions",
|
||
cell.vendor().as_str()
|
||
);
|
||
}
|
||
}
|
||
}
|
||
fn remote_settings_with(
|
||
key: CompatRemoteKey,
|
||
value: bool,
|
||
) -> crate::util::config::RemoteSettings {
|
||
let mut remote = crate::util::config::RemoteSettings::default();
|
||
match key {
|
||
CompatRemoteKey::CursorSkills => remote.cursor_skills_enabled = Some(value),
|
||
CompatRemoteKey::CursorRules => remote.cursor_rules_enabled = Some(value),
|
||
CompatRemoteKey::CursorAgents => remote.cursor_agents_enabled = Some(value),
|
||
CompatRemoteKey::CursorMcps => remote.cursor_mcps_enabled = Some(value),
|
||
CompatRemoteKey::CursorHooks => remote.cursor_hooks_enabled = Some(value),
|
||
CompatRemoteKey::CursorSessions => {
|
||
remote.cursor_sessions_enabled = Some(value);
|
||
}
|
||
CompatRemoteKey::ClaudeSkills => remote.claude_skills_enabled = Some(value),
|
||
CompatRemoteKey::ClaudeRules => remote.claude_rules_enabled = Some(value),
|
||
CompatRemoteKey::ClaudeAgents => remote.claude_agents_enabled = Some(value),
|
||
CompatRemoteKey::ClaudeMcps => remote.claude_mcps_enabled = Some(value),
|
||
CompatRemoteKey::ClaudeHooks => remote.claude_hooks_enabled = Some(value),
|
||
CompatRemoteKey::ClaudeSessions => {
|
||
remote.claude_sessions_enabled = Some(value);
|
||
}
|
||
CompatRemoteKey::CodexSessions => remote.codex_sessions_enabled = Some(value),
|
||
}
|
||
remote
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_compat_defaults_match_registry() {
|
||
let _env = isolate_compat_env();
|
||
assert_eq!(
|
||
resolve_compat_config(&CompatConfigToml::default(), None),
|
||
CompatConfig::default()
|
||
);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_compat_toml_sessions_disable_independently() {
|
||
let _env = isolate_compat_env();
|
||
for (vendor, section) in [
|
||
(CompatVendor::Cursor, "cursor"),
|
||
(CompatVendor::Claude, "claude"),
|
||
(CompatVendor::Codex, "codex"),
|
||
] {
|
||
let config = parse_compat(&format!("[compat.{section}]\nsessions = false"));
|
||
assert_session_one_disabled(resolve_compat_config(&config, None), vendor);
|
||
}
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_raw_compat_sessions_fails_closed_per_vendor() {
|
||
let _env = isolate_compat_env();
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[compat.cursor]
|
||
sessions = "malformed"
|
||
[compat.claude]
|
||
sessions = false
|
||
[compat.codex]
|
||
hooks = "unrelated malformed field"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let resolved = resolve_compat_sessions_from_raw(Ok(&raw), None);
|
||
assert!(!resolved.cursor.sessions);
|
||
assert!(!resolved.claude.sessions);
|
||
assert!(resolved.codex.sessions);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_raw_compat_sessions_keeps_absent_and_valid_cells_independent() {
|
||
let _env = isolate_compat_env();
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[compat.cursor]
|
||
sessions = false
|
||
hooks = "malformed but irrelevant"
|
||
[compat.claude]
|
||
sessions = true
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let remote = crate::util::config::RemoteSettings {
|
||
codex_sessions_enabled: Some(false),
|
||
..Default::default()
|
||
};
|
||
let resolved = resolve_compat_sessions_from_raw(Ok(&raw), Some(&remote));
|
||
assert!(!resolved.cursor.sessions);
|
||
assert!(resolved.claude.sessions);
|
||
assert!(!resolved.codex.sessions);
|
||
}
|
||
#[test]
|
||
fn compat_config_cell_is_tolerant_and_fail_closed_per_cell() {
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[compat.cursor]
|
||
skills = false
|
||
rules = "malformed"
|
||
[compat.claude]
|
||
hooks = true
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cell = |vendor, surface| {
|
||
COMPAT_CELLS
|
||
.into_iter()
|
||
.find(|cell| cell.vendor() == vendor && cell.surface() == surface)
|
||
.unwrap()
|
||
};
|
||
assert_eq!(
|
||
compat_config_cell(Ok(&raw), cell(CompatVendor::Cursor, CompatSurface::Skills)),
|
||
Ok(Some(false))
|
||
);
|
||
assert_eq!(
|
||
compat_config_cell(Ok(&raw), cell(CompatVendor::Cursor, CompatSurface::Rules)),
|
||
Err(CompatConfigCellError::Malformed)
|
||
);
|
||
assert_eq!(
|
||
compat_config_cell(Ok(&raw), cell(CompatVendor::Claude, CompatSurface::Hooks)),
|
||
Ok(Some(true))
|
||
);
|
||
assert_eq!(
|
||
compat_config_cell(Ok(&raw), cell(CompatVendor::Codex, CompatSurface::Sessions)),
|
||
Ok(None)
|
||
);
|
||
assert_eq!(
|
||
compat_config_cell(Err(()), cell(CompatVendor::Claude, CompatSurface::Sessions)),
|
||
Err(CompatConfigCellError::Unavailable)
|
||
);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_raw_compat_sessions_load_failure_fails_closed() {
|
||
let _env = isolate_compat_env();
|
||
let resolved = resolve_compat_sessions_from_raw(Err(()), None);
|
||
assert!(!resolved.cursor.sessions);
|
||
assert!(!resolved.claude.sessions);
|
||
assert!(!resolved.codex.sessions);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_raw_compat_sessions_load_failure_allows_env_override() {
|
||
let _env = isolate_compat_env();
|
||
let _codex = EnvGuard::set("KIGI_CODEX_SESSIONS_ENABLED", "true");
|
||
let resolved = resolve_compat_sessions_from_raw(Err(()), None);
|
||
assert!(!resolved.cursor.sessions);
|
||
assert!(!resolved.claude.sessions);
|
||
assert!(resolved.codex.sessions);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_raw_compat_sessions_valid_empty_uses_remote_and_defaults() {
|
||
let _env = isolate_compat_env();
|
||
let raw = toml::Value::Table(Default::default());
|
||
let remote = crate::util::config::RemoteSettings {
|
||
claude_sessions_enabled: Some(false),
|
||
..Default::default()
|
||
};
|
||
let resolved = resolve_compat_sessions_from_raw(Ok(&raw), Some(&remote));
|
||
assert!(resolved.cursor.sessions);
|
||
assert!(!resolved.claude.sessions);
|
||
assert!(resolved.codex.sessions);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn remote_keys_are_one_hot_and_false_overrides_default() {
|
||
let _env = isolate_compat_env();
|
||
for key in COMPAT_CELLS
|
||
.into_iter()
|
||
.filter_map(|cell| cell.remote_key())
|
||
{
|
||
let remote = remote_settings_with(key, false);
|
||
for cell in COMPAT_CELLS {
|
||
assert_eq!(
|
||
remote_compat_value(Some(&remote), cell.remote_key()),
|
||
(cell.remote_key() == Some(key)).then_some(false),
|
||
"{key:?} mapped to {}.{}",
|
||
cell.vendor().as_str(),
|
||
cell.surface().as_str()
|
||
);
|
||
}
|
||
}
|
||
let remote = remote_settings_with(CompatRemoteKey::CursorSkills, false);
|
||
assert!(CompatConfig::default().cursor.skills);
|
||
assert!(
|
||
!resolve_compat_config(&CompatConfigToml::default(), Some(&remote))
|
||
.cursor
|
||
.skills
|
||
);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_compat_env_sessions_disable_independently() {
|
||
let _env = isolate_compat_env();
|
||
for (vendor, env_var) in [
|
||
(CompatVendor::Cursor, "KIGI_CURSOR_SESSIONS_ENABLED"),
|
||
(CompatVendor::Claude, "KIGI_CLAUDE_SESSIONS_ENABLED"),
|
||
(CompatVendor::Codex, "KIGI_CODEX_SESSIONS_ENABLED"),
|
||
] {
|
||
let _disabled = EnvGuard::set(env_var, "false");
|
||
assert_session_one_disabled(
|
||
resolve_compat_config(&CompatConfigToml::default(), None),
|
||
vendor,
|
||
);
|
||
}
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_compat_precedence_and_reserved_codex_hook() {
|
||
let _env = isolate_compat_env();
|
||
let config =
|
||
parse_compat("[compat.cursor]\nsessions = false\n[compat.codex]\nhooks = false");
|
||
let remote = crate::util::config::RemoteSettings {
|
||
cursor_sessions_enabled: Some(true),
|
||
..Default::default()
|
||
};
|
||
let resolved = resolve_compat_config(&config, Some(&remote));
|
||
assert!(!resolved.cursor.sessions);
|
||
assert!(!resolved.codex.hooks);
|
||
assert!(resolved.cursor.hooks);
|
||
assert!(resolved.claude.hooks);
|
||
let _session = EnvGuard::set("KIGI_CURSOR_SESSIONS_ENABLED", "true");
|
||
let _hook = EnvGuard::set("KIGI_CODEX_HOOKS_ENABLED", "true");
|
||
let resolved = resolve_compat_config(&config, Some(&remote));
|
||
assert!(resolved.cursor.sessions);
|
||
assert!(resolved.codex.hooks);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_runtime_fields_compat_asymmetric_sources() {
|
||
let _env = isolate_compat_env();
|
||
let _cursor = EnvGuard::set("KIGI_CURSOR_SESSIONS_ENABLED", "false");
|
||
let raw: toml::Value =
|
||
toml::from_str("[compat.cursor]\nsessions = true\n[compat.claude]\nsessions = false")
|
||
.unwrap();
|
||
let remote = crate::util::config::RemoteSettings {
|
||
cursor_sessions_enabled: Some(true),
|
||
claude_sessions_enabled: Some(true),
|
||
codex_sessions_enabled: Some(false),
|
||
..Default::default()
|
||
};
|
||
let mut config = Config::new_from_toml_cfg(&raw).unwrap();
|
||
config.resolve_runtime_fields(&RuntimeResolutionContext {
|
||
raw_config: &raw,
|
||
remote_settings: Some(&remote),
|
||
cwd: None,
|
||
is_headless: false,
|
||
cli_subagents: None,
|
||
cli_session_summary_model: None,
|
||
cli_experimental_memory: false,
|
||
cli_no_memory: false,
|
||
disable_web_search: false,
|
||
todo_gate: false,
|
||
laziness_debug_log: None,
|
||
storage_mode: None,
|
||
});
|
||
assert!(!config.compat_resolved.cursor.sessions);
|
||
assert!(!config.compat_resolved.claude.sessions);
|
||
assert!(!config.compat_resolved.codex.sessions);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_runtime_fields_interactive_defaults() {
|
||
clear_runtime_env_vars();
|
||
let raw = empty_config();
|
||
let mut cfg = Config::new_from_toml_cfg(&raw).unwrap();
|
||
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
|
||
raw_config: &raw,
|
||
remote_settings: None,
|
||
cwd: None,
|
||
is_headless: false,
|
||
cli_subagents: None,
|
||
cli_session_summary_model: None,
|
||
cli_experimental_memory: false,
|
||
cli_no_memory: false,
|
||
disable_web_search: false,
|
||
todo_gate: false,
|
||
laziness_debug_log: None,
|
||
storage_mode: None,
|
||
});
|
||
assert!(cfg.subagents_enabled);
|
||
assert!(!cfg.respect_gitignore);
|
||
assert_eq!(
|
||
cfg.session_summary_model,
|
||
Some(crate::models::default_session_summary_model().to_owned())
|
||
);
|
||
assert!(!cfg.path_not_found_hints);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_runtime_fields_subagents_from_config() {
|
||
clear_runtime_env_vars();
|
||
let raw: toml::Value = toml::from_str("[subagents]\nenabled = true").unwrap();
|
||
let mut cfg = Config::new_from_toml_cfg(&raw).unwrap();
|
||
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
|
||
raw_config: &raw,
|
||
remote_settings: None,
|
||
cwd: None,
|
||
is_headless: false,
|
||
cli_subagents: None,
|
||
cli_session_summary_model: None,
|
||
cli_experimental_memory: false,
|
||
cli_no_memory: false,
|
||
disable_web_search: false,
|
||
todo_gate: false,
|
||
laziness_debug_log: None,
|
||
storage_mode: None,
|
||
});
|
||
assert!(cfg.subagents_enabled);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_runtime_fields_cli_subagents_override() {
|
||
clear_runtime_env_vars();
|
||
let raw = empty_config();
|
||
let mut cfg = Config::new_from_toml_cfg(&raw).unwrap();
|
||
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
|
||
raw_config: &raw,
|
||
remote_settings: None,
|
||
cwd: None,
|
||
is_headless: false,
|
||
cli_subagents: Some(true),
|
||
cli_session_summary_model: None,
|
||
cli_experimental_memory: false,
|
||
cli_no_memory: false,
|
||
disable_web_search: false,
|
||
todo_gate: false,
|
||
laziness_debug_log: None,
|
||
storage_mode: None,
|
||
});
|
||
assert!(cfg.subagents_enabled);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_runtime_fields_gitignore_from_env() {
|
||
clear_runtime_env_vars();
|
||
unsafe { std::env::set_var("KIGI_RESPECT_GITIGNORE", "0") };
|
||
let raw = empty_config();
|
||
let mut cfg = Config::new_from_toml_cfg(&raw).unwrap();
|
||
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
|
||
raw_config: &raw,
|
||
remote_settings: None,
|
||
cwd: None,
|
||
is_headless: false,
|
||
cli_subagents: None,
|
||
cli_session_summary_model: None,
|
||
cli_experimental_memory: false,
|
||
cli_no_memory: false,
|
||
disable_web_search: false,
|
||
todo_gate: false,
|
||
laziness_debug_log: None,
|
||
storage_mode: None,
|
||
});
|
||
assert!(!cfg.respect_gitignore);
|
||
clear_runtime_env_vars();
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_runtime_fields_model_overrides_from_cli() {
|
||
clear_runtime_env_vars();
|
||
let raw = empty_config();
|
||
let mut cfg = Config::new_from_toml_cfg(&raw).unwrap();
|
||
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
|
||
raw_config: &raw,
|
||
remote_settings: None,
|
||
cwd: None,
|
||
is_headless: false,
|
||
cli_subagents: None,
|
||
cli_session_summary_model: Some("custom-ss"),
|
||
cli_experimental_memory: false,
|
||
cli_no_memory: false,
|
||
disable_web_search: false,
|
||
todo_gate: false,
|
||
laziness_debug_log: None,
|
||
storage_mode: None,
|
||
});
|
||
assert_eq!(cfg.session_summary_model, Some("custom-ss".to_owned()));
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_runtime_fields_path_hints_from_remote() {
|
||
clear_runtime_env_vars();
|
||
let raw = empty_config();
|
||
let remote = crate::util::config::RemoteSettings {
|
||
path_not_found_hints: Some(true),
|
||
..Default::default()
|
||
};
|
||
let mut cfg = Config::new_from_toml_cfg(&raw).unwrap();
|
||
cfg.resolve_runtime_fields(&RuntimeResolutionContext {
|
||
raw_config: &raw,
|
||
remote_settings: Some(&remote),
|
||
cwd: None,
|
||
is_headless: false,
|
||
cli_subagents: None,
|
||
cli_session_summary_model: None,
|
||
cli_experimental_memory: false,
|
||
cli_no_memory: false,
|
||
disable_web_search: false,
|
||
todo_gate: false,
|
||
laziness_debug_log: None,
|
||
storage_mode: None,
|
||
});
|
||
assert!(cfg.path_not_found_hints);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn resolve_runtime_fields_idempotent() {
|
||
clear_runtime_env_vars();
|
||
let raw: toml::Value = toml::from_str("[subagents]\nenabled = true").unwrap();
|
||
let mut cfg = Config::new_from_toml_cfg(&raw).unwrap();
|
||
let ctx = RuntimeResolutionContext {
|
||
raw_config: &raw,
|
||
remote_settings: None,
|
||
cwd: None,
|
||
is_headless: false,
|
||
cli_subagents: None,
|
||
cli_session_summary_model: None,
|
||
cli_experimental_memory: false,
|
||
cli_no_memory: false,
|
||
disable_web_search: false,
|
||
todo_gate: false,
|
||
laziness_debug_log: None,
|
||
storage_mode: None,
|
||
};
|
||
cfg.resolve_runtime_fields(&ctx);
|
||
let first_subagents = cfg.subagents_enabled;
|
||
let first_gitignore = cfg.respect_gitignore;
|
||
let first_ss = cfg.session_summary_model.clone();
|
||
cfg.resolve_runtime_fields(&ctx);
|
||
assert_eq!(cfg.subagents_enabled, first_subagents);
|
||
assert_eq!(cfg.respect_gitignore, first_gitignore);
|
||
assert_eq!(cfg.session_summary_model, first_ss);
|
||
}
|
||
#[test]
|
||
fn version_overrides_apply_into_typed_config() {
|
||
let mut value: toml::Value = toml::from_str(
|
||
r#"
|
||
[models]
|
||
default = "kigi"
|
||
|
||
[[version_overrides]]
|
||
minimum_version = "1.8.0"
|
||
[version_overrides.models]
|
||
default = "kigi-4.5"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let v = semver::Version::parse("1.8.0").unwrap();
|
||
kigi_config::apply_version_overrides(&mut value, &v).unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&value).unwrap();
|
||
assert_eq!(cfg.models.default.as_deref(), Some("kigi-4.5"));
|
||
}
|
||
/// Reproduce the enterprise managed config bug: [model.kigi-build] sets
|
||
/// context_window=500k for model="kigi-4.5", but
|
||
/// [models].default="kigi-4.5" resolves to the bare
|
||
/// prefetched entry (256k) because Layer 3 only overrides key
|
||
/// "kigi-build", not key "kigi-4.5".
|
||
///
|
||
/// After the Layer 4 slug propagation fix, both keys should have 500k.
|
||
#[test]
|
||
fn slug_propagation_enterprise_managed_config_key_mismatch() {
|
||
let default_cw = DEFAULT_CONTEXT_WINDOW;
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[models]
|
||
default = "kigi-4.5"
|
||
|
||
[model.kigi-build]
|
||
model = "kigi-4.5"
|
||
context_window = 500000
|
||
base_url = "https://inference.example.com/v1"
|
||
api_backend = "responses"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||
let mut prefetched = IndexMap::new();
|
||
let mut entry = test_model_entry(
|
||
"kigi-4.5",
|
||
"https://inference.example.com/v1",
|
||
None,
|
||
None,
|
||
None,
|
||
);
|
||
entry.info.context_window = NonZeroU64::new(default_cw).unwrap();
|
||
prefetched.insert("kigi-4.5".to_owned(), entry);
|
||
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
|
||
let by_key = resolved
|
||
.get("kigi-build")
|
||
.expect("kigi-build key must exist");
|
||
assert_eq!(by_key.info.context_window.get(), 500_000);
|
||
assert_eq!(by_key.info.model, "kigi-4.5");
|
||
let by_latest = resolved.get("kigi-4.5").expect("kigi-4.5 key must exist");
|
||
assert_eq!(
|
||
by_latest.info.context_window.get(),
|
||
500_000,
|
||
"BUG: prefetched 'kigi-4.5' should inherit 500k from \
|
||
sibling 'kigi-build' (same model slug), not stay at {default_cw}"
|
||
);
|
||
}
|
||
/// Slug propagation should carry over api_backend but NOT agent_type.
|
||
#[test]
|
||
fn slug_propagation_inherits_api_backend_but_not_agent_type() {
|
||
let default_cw = DEFAULT_CONTEXT_WINDOW;
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.kigi-build]
|
||
model = "kigi-4.5"
|
||
context_window = 500000
|
||
base_url = "https://test.example.com/v1"
|
||
api_backend = "responses"
|
||
agent_type = "kigi"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||
let mut prefetched = IndexMap::new();
|
||
let mut entry =
|
||
test_model_entry("kigi-4.5", "https://test.example.com/v1", None, None, None);
|
||
entry.info.context_window = NonZeroU64::new(default_cw).unwrap();
|
||
entry.info.agent_type = default_agent_type();
|
||
entry.info.api_backend = ApiBackend::default();
|
||
prefetched.insert("kigi-4.5".to_owned(), entry);
|
||
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
|
||
let latest = resolved.get("kigi-4.5").unwrap();
|
||
assert_eq!(
|
||
latest.info.agent_type,
|
||
default_agent_type(),
|
||
"agent_type must NOT be inherited from sibling — each entry owns its own harness"
|
||
);
|
||
assert_eq!(
|
||
latest.info.api_backend,
|
||
ApiBackend::Responses,
|
||
"api_backend should be inherited from sibling"
|
||
);
|
||
}
|
||
/// When the prefetched entry has an explicitly-set context_window
|
||
/// (not the 256k default), slug propagation must NOT overwrite it.
|
||
#[test]
|
||
fn slug_propagation_does_not_overwrite_explicit_context_window() {
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.kigi-build]
|
||
model = "kigi-4.5"
|
||
context_window = 500000
|
||
base_url = "https://test.example.com/v1"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||
let mut prefetched = IndexMap::new();
|
||
let mut entry =
|
||
test_model_entry("kigi-4.5", "https://test.example.com/v1", None, None, None);
|
||
entry.info.context_window = NonZeroU64::new(65_536).unwrap();
|
||
prefetched.insert("kigi-4.5".to_owned(), entry);
|
||
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
|
||
let latest = resolved.get("kigi-4.5").unwrap();
|
||
assert_eq!(
|
||
latest.info.context_window.get(),
|
||
65_536,
|
||
"explicitly-set context_window must not be overwritten by slug propagation"
|
||
);
|
||
}
|
||
/// When no sibling has a real context_window, slug propagation is a no-op.
|
||
#[test]
|
||
fn slug_propagation_noop_when_no_donor() {
|
||
let default_cw = DEFAULT_CONTEXT_WINDOW;
|
||
let cfg = Config::default();
|
||
let mut prefetched = IndexMap::new();
|
||
let mut entry = test_model_entry(
|
||
"some-unknown-model",
|
||
"https://test.example.com/v1",
|
||
None,
|
||
None,
|
||
None,
|
||
);
|
||
entry.info.context_window = NonZeroU64::new(default_cw).unwrap();
|
||
prefetched.insert("some-unknown-model".to_owned(), entry);
|
||
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
|
||
let model = resolved.get("some-unknown-model").unwrap();
|
||
assert_eq!(
|
||
model.info.context_window.get(),
|
||
default_cw,
|
||
"no donor exists, context_window should stay at parser default"
|
||
);
|
||
}
|
||
/// Build a minimal `ModelEntry` for testing resolve_model_list.
|
||
fn prefetch_model_entry(
|
||
slug: &str,
|
||
context_window: u64,
|
||
api_backend: ApiBackend,
|
||
) -> ModelEntry {
|
||
ModelEntry {
|
||
info: ModelInfo {
|
||
user_selectable: true,
|
||
id: None,
|
||
model: slug.to_owned(),
|
||
base_url: "https://test.example.com/v1".to_owned(),
|
||
name: Some(slug.to_owned()),
|
||
description: None,
|
||
max_completion_tokens: None,
|
||
temperature: None,
|
||
top_p: None,
|
||
api_backend,
|
||
auth_scheme: Default::default(),
|
||
extra_headers: IndexMap::new(),
|
||
context_window: NonZeroU64::new(context_window).unwrap(),
|
||
use_concise: false,
|
||
agent_type: default_agent_type(),
|
||
inference_idle_timeout_secs: None,
|
||
max_retries: None,
|
||
hidden: false,
|
||
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(),
|
||
auto_compact_threshold_percent: None,
|
||
system_prompt_label: None,
|
||
},
|
||
api_key: None,
|
||
env_key: None,
|
||
api_base_url: None,
|
||
}
|
||
}
|
||
#[test]
|
||
fn global_extra_headers_apply_to_model_without_override() {
|
||
let (_, models) = resolve_models_from_toml(
|
||
r#"
|
||
[models]
|
||
extra_headers = { "X-Request-Tags" = "team=example,env=prod" }
|
||
"#,
|
||
None,
|
||
);
|
||
let model = models
|
||
.get(BUNDLED_DEFAULT_KEY)
|
||
.expect("default model should exist");
|
||
assert_eq!(
|
||
model
|
||
.info
|
||
.extra_headers
|
||
.get("X-Request-Tags")
|
||
.map(String::as_str),
|
||
Some("team=example,env=prod"),
|
||
"global [models].extra_headers must apply to a model with no per-model override"
|
||
);
|
||
}
|
||
#[test]
|
||
fn per_model_extra_headers_override_global_per_key() {
|
||
let dm = crate::models::default_model();
|
||
let (_, models) = resolve_models_from_toml(
|
||
&format!(
|
||
r#"
|
||
[models]
|
||
extra_headers = {{ "X-Request-Tags" = "team=example,env=staging", "X-Team" = "platform" }}
|
||
|
||
[model."{dm}"]
|
||
extra_headers = {{ "X-Request-Tags" = "team=example,env=prod" }}
|
||
"#,
|
||
),
|
||
None,
|
||
);
|
||
let model = models.get(dm).expect("default model should exist");
|
||
assert_eq!(
|
||
model
|
||
.info
|
||
.extra_headers
|
||
.get("X-Request-Tags")
|
||
.map(String::as_str),
|
||
Some("team=example,env=prod"),
|
||
"per-model extra_headers must override the global value for that key"
|
||
);
|
||
assert_eq!(
|
||
model.info.extra_headers.get("X-Team").map(String::as_str),
|
||
Some("platform"),
|
||
"a global-only key must still be inherited when a model overrides a different key"
|
||
);
|
||
}
|
||
#[test]
|
||
fn per_model_extra_headers_override_global_case_insensitively() {
|
||
let dm = crate::models::default_model();
|
||
let (_, models) = resolve_models_from_toml(
|
||
&format!(
|
||
r#"
|
||
[models]
|
||
extra_headers = {{ "X-Request-Tags" = "global" }}
|
||
|
||
[model."{dm}"]
|
||
extra_headers = {{ "x-request-tags" = "permodel" }}
|
||
"#,
|
||
),
|
||
None,
|
||
);
|
||
let model = models.get(dm).expect("default model should exist");
|
||
let cost_tags: Vec<&str> = model
|
||
.info
|
||
.extra_headers
|
||
.iter()
|
||
.filter(|(k, _)| k.eq_ignore_ascii_case("x-request-tags"))
|
||
.map(|(_, v)| v.as_str())
|
||
.collect();
|
||
assert_eq!(
|
||
cost_tags,
|
||
vec!["permodel"],
|
||
"per-model value must win case-insensitively, with no global case-variant duplicate"
|
||
);
|
||
assert!(
|
||
!model.info.extra_headers.contains_key("X-Request-Tags"),
|
||
"global \"X-Request-Tags\" must not co-exist with per-model \"x-request-tags\""
|
||
);
|
||
}
|
||
#[test]
|
||
fn global_extra_headers_apply_to_prefetched_model() {
|
||
let mut cfg = Config::default();
|
||
cfg.models.extra_headers.insert(
|
||
"X-Request-Tags".to_owned(),
|
||
"team=example,env=prod".to_owned(),
|
||
);
|
||
let entry = prefetch_model_entry("remote-only-model", 200_000, ApiBackend::default());
|
||
let mut prefetched = IndexMap::new();
|
||
prefetched.insert("remote-only-model".to_owned(), entry);
|
||
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
|
||
let model = resolved
|
||
.get("remote-only-model")
|
||
.expect("prefetched model should exist");
|
||
assert_eq!(
|
||
model
|
||
.info
|
||
.extra_headers
|
||
.get("X-Request-Tags")
|
||
.map(String::as_str),
|
||
Some("team=example,env=prod"),
|
||
"global [models].extra_headers must cover models from /v1/models"
|
||
);
|
||
}
|
||
#[test]
|
||
fn global_model_defaults_apply_to_model_without_override() {
|
||
let mut cfg = Config::default();
|
||
cfg.models.temperature = Some(0.5);
|
||
cfg.models.top_p = Some(0.25);
|
||
cfg.models.max_completion_tokens = Some(4096);
|
||
cfg.models.max_retries = Some(9);
|
||
cfg.models.inference_idle_timeout_secs = Some(600);
|
||
cfg.models.stream_tool_calls = Some(true);
|
||
let entry = prefetch_model_entry("remote-only-model", 200_000, ApiBackend::default());
|
||
let mut prefetched = IndexMap::new();
|
||
prefetched.insert("remote-only-model".to_owned(), entry);
|
||
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
|
||
let info = &resolved
|
||
.get("remote-only-model")
|
||
.expect("prefetched model should exist")
|
||
.info;
|
||
assert_eq!(info.temperature, Some(0.5));
|
||
assert_eq!(info.top_p, Some(0.25));
|
||
assert_eq!(info.max_completion_tokens, Some(4096));
|
||
assert_eq!(info.max_retries, Some(9));
|
||
assert_eq!(info.inference_idle_timeout_secs, Some(600));
|
||
assert_eq!(info.stream_tool_calls, Some(true));
|
||
}
|
||
#[test]
|
||
fn per_model_value_overrides_global_model_default() {
|
||
let mut cfg = Config::default();
|
||
cfg.models.max_retries = Some(9);
|
||
cfg.models.max_completion_tokens = Some(8192);
|
||
cfg.config_models.insert(
|
||
"remote-only-model".to_owned(),
|
||
ConfigModelOverride {
|
||
max_retries: Some(2),
|
||
..Default::default()
|
||
},
|
||
);
|
||
let entry = prefetch_model_entry("remote-only-model", 200_000, ApiBackend::default());
|
||
let mut prefetched = IndexMap::new();
|
||
prefetched.insert("remote-only-model".to_owned(), entry);
|
||
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
|
||
let model = resolved
|
||
.get("remote-only-model")
|
||
.expect("model should exist");
|
||
assert_eq!(
|
||
model.info.max_retries,
|
||
Some(2),
|
||
"per-model value must win over the [models] default"
|
||
);
|
||
assert_eq!(
|
||
model.info.max_completion_tokens,
|
||
Some(8192),
|
||
"a global-only default must still be inherited"
|
||
);
|
||
}
|
||
#[test]
|
||
fn global_model_defaults_do_not_override_prefetched_value() {
|
||
let mut cfg = Config::default();
|
||
cfg.models.max_retries = Some(9);
|
||
cfg.models.temperature = Some(0.5);
|
||
let mut entry = prefetch_model_entry("remote-only-model", 200_000, ApiBackend::default());
|
||
entry.info.max_retries = Some(3);
|
||
let mut prefetched = IndexMap::new();
|
||
prefetched.insert("remote-only-model".to_owned(), entry);
|
||
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
|
||
let model = resolved
|
||
.get("remote-only-model")
|
||
.expect("prefetched model should exist");
|
||
assert_eq!(
|
||
model.info.max_retries,
|
||
Some(3),
|
||
"a prefetched value must beat the [models] default (fallback semantics)"
|
||
);
|
||
assert_eq!(
|
||
model.info.temperature,
|
||
Some(0.5),
|
||
"a field the prefetch left unset must inherit the [models] default"
|
||
);
|
||
}
|
||
#[test]
|
||
fn config_model_reasoning_efforts_parses_inline_tables_and_bare_strings() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.custom]
|
||
model = "custom"
|
||
base_url = "https://api.example.com/v1"
|
||
context_window = 200000
|
||
reasoning_efforts = [
|
||
{ value = "high", label = "High", default = true },
|
||
{ id = "deep", value = "xhigh", label = "Deep", description = "Max" },
|
||
]
|
||
|
||
[model.shorthand]
|
||
model = "shorthand"
|
||
base_url = "https://api.example.com/v1"
|
||
context_window = 200000
|
||
reasoning_efforts = ["low", "high"]
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let custom = &resolved.get("custom").expect("custom model").info;
|
||
assert_eq!(custom.reasoning_efforts.len(), 2);
|
||
assert_eq!(custom.reasoning_efforts[0].label, "High");
|
||
assert!(custom.reasoning_efforts[0].default);
|
||
assert_eq!(custom.reasoning_efforts[1].id, "deep");
|
||
assert_eq!(custom.reasoning_efforts[1].value, ReasoningEffort::Xhigh);
|
||
let shorthand = &resolved.get("shorthand").expect("shorthand model").info;
|
||
let ids: Vec<_> = shorthand
|
||
.reasoning_efforts
|
||
.iter()
|
||
.map(|o| o.id.as_str())
|
||
.collect();
|
||
assert_eq!(ids, ["low", "high"]);
|
||
assert_eq!(shorthand.reasoning_efforts[0].label, "Low");
|
||
}
|
||
#[test]
|
||
fn resolve_model_list_config_reasoning_efforts_beats_remote() {
|
||
let raw_config: toml::Value = toml::from_str(
|
||
r#"
|
||
[model.kigi-x]
|
||
reasoning_efforts = ["low"]
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw_config).expect("config should parse");
|
||
let mut entry = prefetch_model_entry("kigi-x", 200_000, ApiBackend::default());
|
||
entry.info.reasoning_efforts = vec![ReasoningEffortOption {
|
||
id: "high".to_string(),
|
||
value: ReasoningEffort::High,
|
||
label: "High".to_string(),
|
||
description: None,
|
||
default: false,
|
||
}];
|
||
let mut prefetched = IndexMap::new();
|
||
prefetched.insert("kigi-x".to_owned(), entry);
|
||
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
|
||
let efforts = &resolved
|
||
.get("kigi-x")
|
||
.expect("kigi-x")
|
||
.info
|
||
.reasoning_efforts;
|
||
assert_eq!(efforts.len(), 1);
|
||
assert_eq!(
|
||
efforts[0].id, "low",
|
||
"config.toml list must override remote"
|
||
);
|
||
}
|
||
#[test]
|
||
fn resolve_model_list_inherits_context_window_from_default_when_prefetched_has_fallback() {
|
||
let cfg = Config::default();
|
||
let default_cw = DEFAULT_CONTEXT_WINDOW;
|
||
let entry = prefetch_model_entry(BUNDLED_DEFAULT_KEY, default_cw, ApiBackend::default());
|
||
let mut prefetched = IndexMap::new();
|
||
prefetched.insert(BUNDLED_DEFAULT_KEY.to_owned(), entry);
|
||
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
|
||
let entry = resolved.get(BUNDLED_DEFAULT_KEY).expect("model must exist");
|
||
assert_ne!(
|
||
entry.info.context_window.get(),
|
||
default_cw,
|
||
"context_window should have been inherited from hardcoded default, not left at DEFAULT_CONTEXT_WINDOW"
|
||
);
|
||
}
|
||
#[test]
|
||
fn resolve_model_list_does_not_override_explicitly_set_context_window() {
|
||
let cfg = Config::default();
|
||
let explicit_cw = 65_536;
|
||
let entry = prefetch_model_entry(BUNDLED_DEFAULT_KEY, explicit_cw, ApiBackend::default());
|
||
let mut prefetched = IndexMap::new();
|
||
prefetched.insert(BUNDLED_DEFAULT_KEY.to_owned(), entry);
|
||
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
|
||
let entry = resolved.get(BUNDLED_DEFAULT_KEY).expect("model must exist");
|
||
assert_eq!(
|
||
entry.info.context_window.get(),
|
||
explicit_cw,
|
||
"explicitly-set context_window must not be overwritten by default"
|
||
);
|
||
}
|
||
#[test]
|
||
fn resolve_model_list_inherits_agent_type_and_api_backend() {
|
||
let cfg = Config::default();
|
||
let default_cw = DEFAULT_CONTEXT_WINDOW;
|
||
let entry = prefetch_model_entry("kigi", default_cw, ApiBackend::default());
|
||
let mut prefetched = IndexMap::new();
|
||
prefetched.insert("kigi".to_owned(), entry);
|
||
let resolved = resolve_model_list(&cfg, Some(prefetched), &Default::default());
|
||
let entry = resolved.get("kigi").expect("model must exist");
|
||
let defaults = default_model_entries(&EndpointsConfig::default());
|
||
if let Some(default) = defaults.get("kigi") {
|
||
if default.info.agent_type != DEFAULT_AGENT_TYPE {
|
||
assert_eq!(
|
||
entry.info.agent_type, default.info.agent_type,
|
||
"agent_type should be inherited from default"
|
||
);
|
||
}
|
||
if default.info.api_backend != ApiBackend::default() {
|
||
assert_eq!(
|
||
entry.info.api_backend, default.info.api_backend,
|
||
"api_backend should be inherited from default"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
#[test]
|
||
fn resolve_model_list_prunes_bundled_entries_not_in_prefetch() {
|
||
let cfg = Config::default();
|
||
let mut defs = default_model_entries(&EndpointsConfig::default());
|
||
let mut p = IndexMap::new();
|
||
if let Some(e) = defs.shift_remove(BUNDLED_DEFAULT_KEY) {
|
||
p.insert(BUNDLED_DEFAULT_KEY.to_string(), e);
|
||
}
|
||
let resolved = resolve_model_list(&cfg, Some(p), &Default::default());
|
||
assert!(resolved.contains_key(BUNDLED_DEFAULT_KEY));
|
||
let no_p = resolve_model_list(&cfg, None, &Default::default());
|
||
assert!(no_p.contains_key(BUNDLED_DEFAULT_KEY));
|
||
}
|
||
#[test]
|
||
fn resolve_model_list_prefetch_visibility_matches_auth_and_server_list() {
|
||
let cfg = Config::default();
|
||
let mut defs = default_model_entries(&EndpointsConfig::default());
|
||
let mut p = IndexMap::new();
|
||
if let Some(e) = defs.shift_remove(BUNDLED_DEFAULT_KEY) {
|
||
p.insert(BUNDLED_DEFAULT_KEY.to_string(), e);
|
||
}
|
||
let resolved = resolve_model_list(&cfg, Some(p), &Default::default());
|
||
let sess: Vec<_> = resolved
|
||
.values()
|
||
.filter(|e| e.visible_for_auth(true))
|
||
.collect();
|
||
let api: Vec<_> = resolved
|
||
.values()
|
||
.filter(|e| e.visible_for_auth(false))
|
||
.collect();
|
||
assert_eq!(sess.len(), 1);
|
||
assert!(
|
||
api.is_empty(),
|
||
"the subscription entry (supported_in_api=false) must stay hidden from API-key users"
|
||
);
|
||
}
|
||
#[test]
|
||
fn resolve_model_list_keeps_prefetch_only_entries_and_prunes_defaults() {
|
||
let cfg = Config::default();
|
||
let mut p = IndexMap::new();
|
||
let e = prefetch_model_entry("secret-xyz", 200000, ApiBackend::default());
|
||
p.insert("secret-xyz".to_string(), e);
|
||
let resolved = resolve_model_list(&cfg, Some(p), &Default::default());
|
||
assert!(resolved.contains_key("secret-xyz"));
|
||
assert!(!resolved.contains_key(BUNDLED_DEFAULT_KEY));
|
||
}
|
||
#[test]
|
||
fn resolve_model_list_prefetch_replaces_bundled_entirely() {
|
||
let cfg = Config::default();
|
||
let mut p = IndexMap::new();
|
||
let e = prefetch_model_entry("kimi-fresh", 500_000, ApiBackend::Responses);
|
||
p.insert("kimi-fresh".to_string(), e);
|
||
let resolved = resolve_model_list(&cfg, Some(p), &Default::default());
|
||
assert!(resolved.contains_key("kimi-fresh"));
|
||
assert!(!resolved.contains_key(BUNDLED_DEFAULT_KEY));
|
||
}
|
||
#[test]
|
||
fn resolve_model_list_empty_prefetch_yields_empty_base() {
|
||
let cfg = Config::default();
|
||
let resolved = resolve_model_list(&cfg, Some(IndexMap::new()), &Default::default());
|
||
assert!(resolved.is_empty());
|
||
}
|
||
/// Regression: enterprise managed config aliases the bundled subscription
|
||
/// entry to their own endpoint with env_key. The bundled entry has
|
||
/// supported_in_api=false. The config overlay must be visible to API-key
|
||
/// users (env_key = BYOK).
|
||
#[test]
|
||
fn byok_config_overlay_visible_to_api_key_users() {
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[model."kimi-code/kimi-for-coding"]
|
||
model = "kimi-for-coding"
|
||
base_url = "https://inference.company.com/v1"
|
||
env_key = "COMPANY_TOKEN"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let entry = resolved
|
||
.get(BUNDLED_DEFAULT_KEY)
|
||
.expect("bundled default must exist");
|
||
assert!(
|
||
entry.visible_for_auth(false),
|
||
"BYOK config entry must be visible to API-key users — \
|
||
bundled supported_in_api=false must not leak into credentialed overlays"
|
||
);
|
||
}
|
||
/// Guard: config overlay WITHOUT credentials must NOT override the
|
||
/// bundled supported_in_api flag. Only BYOK triggers the override.
|
||
#[test]
|
||
fn plain_config_overlay_preserves_bundled_visibility() {
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[model."kimi-code/kimi-for-coding"]
|
||
context_window = 300000
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
let entry = resolved
|
||
.get(BUNDLED_DEFAULT_KEY)
|
||
.expect("bundled default must exist");
|
||
assert!(
|
||
!entry.visible_for_auth(false),
|
||
"non-BYOK config overlay must preserve bundled supported_in_api=false"
|
||
);
|
||
}
|
||
/// PRD F2: a `[platforms.<id>].api_key` from config.toml is stamped onto
|
||
/// that platform's catalog entries (in-memory only), making them usable
|
||
/// and API-key-visible — and only onto that platform.
|
||
#[test]
|
||
#[serial]
|
||
fn platforms_config_key_stamps_matching_open_platform_entries() {
|
||
let _cn = EnvGuard::unset(kigi_models::MOONSHOT_CN_API_KEY_ENV);
|
||
let _ai = EnvGuard::unset(kigi_models::MOONSHOT_AI_API_KEY_ENV);
|
||
let _gen = EnvGuard::unset(kigi_models::MOONSHOT_API_KEY_ENV);
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[platforms.moonshot-cn]
|
||
api_key = "sk-from-config"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||
let resolved = resolve_model_list(&cfg, None, &Default::default());
|
||
|
||
let cn = resolved
|
||
.get("moonshot-cn/kimi-k2-turbo-preview")
|
||
.expect("bundled moonshot-cn entry");
|
||
assert_eq!(cn.api_key.as_deref(), Some("sk-from-config"));
|
||
assert!(
|
||
cn.has_own_credentials(),
|
||
"config key must make the entry sampleable (F2 acceptance)"
|
||
);
|
||
assert!(
|
||
cn.visible_for_auth(false),
|
||
"credentialed open-platform entry must be visible to API-key users"
|
||
);
|
||
|
||
let ai = resolved
|
||
.get("moonshot-ai/kimi-k2-turbo-preview")
|
||
.expect("bundled moonshot-ai entry");
|
||
assert!(
|
||
ai.api_key.is_none(),
|
||
"the cn key must not leak onto the ai platform"
|
||
);
|
||
|
||
let code = resolved
|
||
.get("kimi-code/kimi-for-coding")
|
||
.expect("bundled subscription entry");
|
||
assert!(
|
||
code.api_key.is_none() && code.env_key.is_none(),
|
||
"the OAuth platform takes no API key"
|
||
);
|
||
}
|
||
/// A key that resolves ONLY via auth.json (the TUI-paste storage) must be
|
||
/// stamped onto the platform's catalog entries exactly like a config.toml
|
||
/// key — otherwise login validates the key but every completion goes out
|
||
/// keyless (401), and restart falls back to the login screen.
|
||
#[test]
|
||
#[serial]
|
||
fn auth_json_resolved_key_stamps_matching_open_platform_entries() {
|
||
let _cn = EnvGuard::unset(kigi_models::MOONSHOT_CN_API_KEY_ENV);
|
||
let _ai = EnvGuard::unset(kigi_models::MOONSHOT_AI_API_KEY_ENV);
|
||
let _gen = EnvGuard::unset(kigi_models::MOONSHOT_API_KEY_ENV);
|
||
let cfg = Config::default();
|
||
// The resolved snapshot as PlatformApiKeys::resolve would build it
|
||
// from an auth.json `moonshot-cn` scope (no env, no config.toml).
|
||
let keys =
|
||
crate::agent::models::PlatformApiKeys::test_keys(Some("sk-from-auth-json"), None);
|
||
let resolved = resolve_model_list(&cfg, None, &keys);
|
||
|
||
let cn = resolved
|
||
.get("moonshot-cn/kimi-k2-turbo-preview")
|
||
.expect("bundled moonshot-cn entry");
|
||
assert_eq!(
|
||
cn.api_key.as_deref(),
|
||
Some("sk-from-auth-json"),
|
||
"auth.json-resolved key must be stamped onto the entry"
|
||
);
|
||
assert!(
|
||
cn.visible_for_auth(false),
|
||
"credentialed open-platform entry must be visible to API-key users"
|
||
);
|
||
assert!(
|
||
crate::agent::auth_method::should_advertise_xai_api_key(resolved.values()),
|
||
"a stamped auth.json key alone must advertise the API-key auth \
|
||
method on restart (no login screen)"
|
||
);
|
||
let ai = resolved
|
||
.get("moonshot-ai/kimi-k2-turbo-preview")
|
||
.expect("bundled moonshot-ai entry");
|
||
assert!(
|
||
ai.api_key.is_none(),
|
||
"the cn key must not leak onto the ai platform"
|
||
);
|
||
}
|
||
/// When auth.json and config.toml disagree, the resolved snapshot
|
||
/// (auth.json) wins — the same precedence the login validator uses, so a
|
||
/// key rotated via the TUI can never lose to a stale config.toml key.
|
||
#[test]
|
||
#[serial]
|
||
fn auth_json_key_beats_stale_config_key_when_stamping() {
|
||
let _cn = EnvGuard::unset(kigi_models::MOONSHOT_CN_API_KEY_ENV);
|
||
let _ai = EnvGuard::unset(kigi_models::MOONSHOT_AI_API_KEY_ENV);
|
||
let _gen = EnvGuard::unset(kigi_models::MOONSHOT_API_KEY_ENV);
|
||
let raw: toml::Value = toml::from_str(
|
||
r#"
|
||
[platforms.moonshot-cn]
|
||
api_key = "sk-stale-config"
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let cfg = Config::new_from_toml_cfg(&raw).expect("config should parse");
|
||
let keys = crate::agent::models::PlatformApiKeys::test_keys(Some("sk-rotated"), None);
|
||
let resolved = resolve_model_list(&cfg, None, &keys);
|
||
let cn = resolved
|
||
.get("moonshot-cn/kimi-k2-turbo-preview")
|
||
.expect("bundled moonshot-cn entry");
|
||
assert_eq!(
|
||
cn.api_key.as_deref(),
|
||
Some("sk-rotated"),
|
||
"the resolved snapshot must beat the stale config.toml key"
|
||
);
|
||
}
|
||
/// F2 acceptance: with ONLY a moonshot key (env), the api-key auth method
|
||
/// is advertised (no login screen) because the catalog has a credentialed
|
||
/// entry.
|
||
#[test]
|
||
#[serial]
|
||
fn moonshot_env_key_advertises_api_key_auth_method() {
|
||
let _gen = EnvGuard::set(kigi_models::MOONSHOT_API_KEY_ENV, "sk-only-moonshot");
|
||
let cfg = Config::default();
|
||
let models = resolve_model_list(&cfg, None, &Default::default());
|
||
assert!(
|
||
crate::agent::auth_method::should_advertise_xai_api_key(models.values()),
|
||
"a moonshot env key alone must advertise the API-key auth method"
|
||
);
|
||
}
|
||
/// The login-screen writer persists the key into auth.json under the
|
||
/// platform-id scope — the exact scope `resolve_platform_api_key` reads
|
||
/// back — trimming whitespace and never leaking onto the other platform.
|
||
#[test]
|
||
fn save_platform_api_key_round_trips_through_resolver() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let home = dir.path();
|
||
|
||
save_platform_api_key_in(home, kigi_models::PlatformId::MoonshotCn, " sk-from-tui ")
|
||
.expect("write must succeed");
|
||
|
||
// Env unset in this resolve (injected getenv) → auth.json wins.
|
||
let platforms = PlatformsConfig::default();
|
||
let resolved = resolve_platform_api_key_with(
|
||
kigi_models::PlatformId::MoonshotCn,
|
||
&platforms,
|
||
|_| None,
|
||
|p| crate::auth::read_platform_api_key(home, p),
|
||
);
|
||
assert_eq!(
|
||
resolved.as_deref(),
|
||
Some("sk-from-tui"),
|
||
"trimmed key round-trips"
|
||
);
|
||
assert_eq!(
|
||
crate::auth::read_platform_api_key(home, kigi_models::PlatformId::MoonshotAi),
|
||
None,
|
||
"the cn key must not leak onto the ai platform"
|
||
);
|
||
}
|
||
/// Precedence: env var > auth.json scope > `[platforms.*]` config file.
|
||
#[test]
|
||
fn platform_key_precedence_env_then_auth_json_then_config() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let home = dir.path();
|
||
save_platform_api_key_in(home, kigi_models::PlatformId::MoonshotCn, "sk-auth-json")
|
||
.expect("write must succeed");
|
||
let platforms: PlatformsConfig =
|
||
toml::from_str("[moonshot-cn]\napi_key = \"sk-config\"\n").unwrap();
|
||
|
||
let stored = |p| crate::auth::read_platform_api_key(home, p);
|
||
// Env wins over both files.
|
||
let resolved = resolve_platform_api_key_with(
|
||
kigi_models::PlatformId::MoonshotCn,
|
||
&platforms,
|
||
|name| (name == kigi_models::MOONSHOT_CN_API_KEY_ENV).then(|| "sk-env".to_owned()),
|
||
stored,
|
||
);
|
||
assert_eq!(resolved.as_deref(), Some("sk-env"));
|
||
// auth.json wins over config.toml.
|
||
let resolved = resolve_platform_api_key_with(
|
||
kigi_models::PlatformId::MoonshotCn,
|
||
&platforms,
|
||
|_| None,
|
||
stored,
|
||
);
|
||
assert_eq!(resolved.as_deref(), Some("sk-auth-json"));
|
||
// config.toml is the last fallback.
|
||
let resolved = resolve_platform_api_key_with(
|
||
kigi_models::PlatformId::MoonshotCn,
|
||
&platforms,
|
||
|_| None,
|
||
|_| None,
|
||
);
|
||
assert_eq!(resolved.as_deref(), Some("sk-config"));
|
||
}
|
||
/// Writer guardrails: the OAuth platform takes no key and empty keys are
|
||
/// rejected — and a rejected write never creates auth.json.
|
||
#[test]
|
||
fn save_platform_api_key_rejects_invalid_inputs() {
|
||
let dir = tempfile::tempdir().unwrap();
|
||
let home = dir.path();
|
||
|
||
assert!(
|
||
save_platform_api_key_in(home, kigi_models::PlatformId::KimiCode, "sk-x").is_err(),
|
||
"kimi-code authenticates via OAuth and must reject an API key"
|
||
);
|
||
assert!(
|
||
save_platform_api_key_in(home, kigi_models::PlatformId::MoonshotCn, " ").is_err(),
|
||
"blank keys must be rejected"
|
||
);
|
||
assert!(
|
||
!home.join("auth.json").exists(),
|
||
"rejected writes must not create auth.json"
|
||
);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_liveness_watchers_default_is_true() {
|
||
unsafe { std::env::remove_var("KIGI_MCP_LIVENESS_WATCHERS") };
|
||
let r = resolve_mcp_liveness_watchers(None, None, None, None, None);
|
||
assert!(r.value, "default-on by spec");
|
||
assert_eq!(r.source, ConfigSource::Default);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_liveness_watchers_requirement_wins_over_everything() {
|
||
unsafe { std::env::set_var("KIGI_MCP_LIVENESS_WATCHERS", "true") };
|
||
let r = resolve_mcp_liveness_watchers(
|
||
Some(false),
|
||
Some(true),
|
||
Some(true),
|
||
Some(true),
|
||
Some(true),
|
||
);
|
||
unsafe { std::env::remove_var("KIGI_MCP_LIVENESS_WATCHERS") };
|
||
assert!(!r.value, "requirement overrides every other layer");
|
||
assert_eq!(r.source, ConfigSource::Requirement);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_liveness_watchers_cli_wins_over_env_and_below() {
|
||
unsafe { std::env::set_var("KIGI_MCP_LIVENESS_WATCHERS", "true") };
|
||
let r =
|
||
resolve_mcp_liveness_watchers(None, Some(false), Some(true), Some(true), Some(true));
|
||
unsafe { std::env::remove_var("KIGI_MCP_LIVENESS_WATCHERS") };
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Cli);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_liveness_watchers_env_wins_over_config_and_below() {
|
||
unsafe { std::env::set_var("KIGI_MCP_LIVENESS_WATCHERS", "false") };
|
||
let r = resolve_mcp_liveness_watchers(None, None, Some(true), Some(true), Some(true));
|
||
unsafe { std::env::remove_var("KIGI_MCP_LIVENESS_WATCHERS") };
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_liveness_watchers_config_wins_over_managed_and_feature_flag() {
|
||
unsafe { std::env::remove_var("KIGI_MCP_LIVENESS_WATCHERS") };
|
||
let r = resolve_mcp_liveness_watchers(None, None, Some(false), Some(true), Some(true));
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_liveness_watchers_managed_wins_over_feature_flag() {
|
||
unsafe { std::env::remove_var("KIGI_MCP_LIVENESS_WATCHERS") };
|
||
let r = resolve_mcp_liveness_watchers(None, None, None, Some(false), Some(true));
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::ManagedConfig);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_liveness_watchers_feature_flag_used_when_no_higher_layer() {
|
||
unsafe { std::env::remove_var("KIGI_MCP_LIVENESS_WATCHERS") };
|
||
let r = resolve_mcp_liveness_watchers(None, None, None, None, Some(false));
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Remote);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_auto_restart_default_is_true() {
|
||
unsafe { std::env::remove_var("KIGI_MCP_AUTO_RESTART") };
|
||
let r = resolve_mcp_auto_restart(None, None, None, None, None);
|
||
assert!(r.value, "recovery is on by default");
|
||
assert_eq!(r.source, ConfigSource::Default);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_auto_restart_requirement_wins_over_everything() {
|
||
unsafe { std::env::set_var("KIGI_MCP_AUTO_RESTART", "false") };
|
||
let r = resolve_mcp_auto_restart(
|
||
Some(true),
|
||
Some(false),
|
||
Some(false),
|
||
Some(false),
|
||
Some(false),
|
||
);
|
||
unsafe { std::env::remove_var("KIGI_MCP_AUTO_RESTART") };
|
||
assert!(r.value);
|
||
assert_eq!(r.source, ConfigSource::Requirement);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_auto_restart_env_wins_over_config_and_below() {
|
||
unsafe { std::env::set_var("KIGI_MCP_AUTO_RESTART", "true") };
|
||
let r = resolve_mcp_auto_restart(None, None, Some(false), Some(false), Some(false));
|
||
unsafe { std::env::remove_var("KIGI_MCP_AUTO_RESTART") };
|
||
assert!(r.value);
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_push_server_status_default_is_true() {
|
||
unsafe { std::env::remove_var("KIGI_MCP_PUSH_SERVER_STATUS") };
|
||
let r = resolve_mcp_push_server_status(None, None, None, None, None);
|
||
assert!(r.value, "default-on by spec");
|
||
assert_eq!(r.source, ConfigSource::Default);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_push_server_status_requirement_wins_over_everything() {
|
||
unsafe { std::env::set_var("KIGI_MCP_PUSH_SERVER_STATUS", "true") };
|
||
let r = resolve_mcp_push_server_status(
|
||
Some(false),
|
||
Some(true),
|
||
Some(true),
|
||
Some(true),
|
||
Some(true),
|
||
);
|
||
unsafe { std::env::remove_var("KIGI_MCP_PUSH_SERVER_STATUS") };
|
||
assert!(!r.value, "requirement overrides every other layer");
|
||
assert_eq!(r.source, ConfigSource::Requirement);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_push_server_status_cli_wins_over_env_and_below() {
|
||
unsafe { std::env::set_var("KIGI_MCP_PUSH_SERVER_STATUS", "true") };
|
||
let r =
|
||
resolve_mcp_push_server_status(None, Some(false), Some(true), Some(true), Some(true));
|
||
unsafe { std::env::remove_var("KIGI_MCP_PUSH_SERVER_STATUS") };
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Cli);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_push_server_status_env_wins_over_config_and_below() {
|
||
unsafe { std::env::set_var("KIGI_MCP_PUSH_SERVER_STATUS", "false") };
|
||
let r = resolve_mcp_push_server_status(None, None, Some(true), Some(true), Some(true));
|
||
unsafe { std::env::remove_var("KIGI_MCP_PUSH_SERVER_STATUS") };
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_push_server_status_config_wins_over_managed_and_feature_flag() {
|
||
unsafe { std::env::remove_var("KIGI_MCP_PUSH_SERVER_STATUS") };
|
||
let r = resolve_mcp_push_server_status(None, None, Some(false), Some(true), Some(true));
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_push_server_status_managed_wins_over_feature_flag() {
|
||
unsafe { std::env::remove_var("KIGI_MCP_PUSH_SERVER_STATUS") };
|
||
let r = resolve_mcp_push_server_status(None, None, None, Some(false), Some(true));
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::ManagedConfig);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_push_server_status_feature_flag_used_when_no_higher_layer() {
|
||
unsafe { std::env::remove_var("KIGI_MCP_PUSH_SERVER_STATUS") };
|
||
let r = resolve_mcp_push_server_status(None, None, None, None, Some(false));
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Remote);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_recursive_config_watch_default_is_true() {
|
||
unsafe { std::env::remove_var("KIGI_MCP_RECURSIVE_CONFIG_WATCH") };
|
||
let r = resolve_mcp_recursive_config_watch(None, None, None, None, None);
|
||
assert!(r.value, "default-on by spec");
|
||
assert_eq!(r.source, ConfigSource::Default);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_recursive_config_watch_requirement_wins_over_everything() {
|
||
unsafe { std::env::set_var("KIGI_MCP_RECURSIVE_CONFIG_WATCH", "true") };
|
||
let r = resolve_mcp_recursive_config_watch(
|
||
Some(false),
|
||
Some(true),
|
||
Some(true),
|
||
Some(true),
|
||
Some(true),
|
||
);
|
||
unsafe { std::env::remove_var("KIGI_MCP_RECURSIVE_CONFIG_WATCH") };
|
||
assert!(!r.value, "requirement overrides every other layer");
|
||
assert_eq!(r.source, ConfigSource::Requirement);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_recursive_config_watch_cli_wins_over_env_and_below() {
|
||
unsafe { std::env::set_var("KIGI_MCP_RECURSIVE_CONFIG_WATCH", "true") };
|
||
let r = resolve_mcp_recursive_config_watch(
|
||
None,
|
||
Some(false),
|
||
Some(true),
|
||
Some(true),
|
||
Some(true),
|
||
);
|
||
unsafe { std::env::remove_var("KIGI_MCP_RECURSIVE_CONFIG_WATCH") };
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Cli);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_recursive_config_watch_env_wins_over_config_and_below() {
|
||
unsafe { std::env::set_var("KIGI_MCP_RECURSIVE_CONFIG_WATCH", "false") };
|
||
let r = resolve_mcp_recursive_config_watch(None, None, Some(true), Some(true), Some(true));
|
||
unsafe { std::env::remove_var("KIGI_MCP_RECURSIVE_CONFIG_WATCH") };
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Env);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_recursive_config_watch_config_wins_over_managed_and_feature_flag() {
|
||
unsafe { std::env::remove_var("KIGI_MCP_RECURSIVE_CONFIG_WATCH") };
|
||
let r = resolve_mcp_recursive_config_watch(None, None, Some(false), Some(true), Some(true));
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Config);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_recursive_config_watch_managed_wins_over_feature_flag() {
|
||
unsafe { std::env::remove_var("KIGI_MCP_RECURSIVE_CONFIG_WATCH") };
|
||
let r = resolve_mcp_recursive_config_watch(None, None, None, Some(false), Some(true));
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::ManagedConfig);
|
||
}
|
||
#[test]
|
||
#[serial]
|
||
fn mcp_recursive_config_watch_feature_flag_used_when_no_higher_layer() {
|
||
unsafe { std::env::remove_var("KIGI_MCP_RECURSIVE_CONFIG_WATCH") };
|
||
let r = resolve_mcp_recursive_config_watch(None, None, None, None, Some(false));
|
||
assert!(!r.value);
|
||
assert_eq!(r.source, ConfigSource::Remote);
|
||
}
|
||
}
|