Files
Kigi-CLI/crates/codegen/kigi-shell/src/agent/models_fetch.rs
T
ZacharyZhang-NY c02b4b1ed7 feat(providers): add Kimi For Coding via static KIMI_API_KEY
Provider 16 (19th registry variant). Same endpoint + models + Kimi dialect
as the existing OAuth kimi-code platform (api.kimi.com/coding/v1 via the
KIGI_CODE_BASE_URL override), but authenticated with a static KIMI_API_KEY
instead of the device flow — for users who have a Kimi For Coding key rather
than an OAuth subscription. Bearer, OpenAI listing, ChatCompletions,
ChatCompat::Kimi, wire_serves_metadata=true (Kimi /models self-serves
context/thinking), restrict_to_enriched=false (clean 3-model catalog).
/coding/v1/models is auth-gated (401) so it doubles as the validator.

No collision: KIMI_API_KEY was previously unused (grep-verified), and the
house BYOK reads only KIGI_API_KEY/XAI_API_KEY/legacy. kimi-code (OAuth) and
kimi-coding (static key) are independently gated (OAuth-token vs key) and
their models get distinct managed keys (kimi-code/k3 vs kimi-coding/k3) — a
user with both simply sees each Kimi model twice; no dedup collision, no crash.

Review (6 areas): no blocking defects; confirmed the spec correctly mirrors
KIMI_CODE_SPEC (differing only in uses_oauth/api_key_envs/console_host/labels)
and the coexistence is benign. Strengthened the e2e's dialect assertion (Kimi
is the default ChatCompat, so it did not discriminate a parse failure) by
also asserting parse_managed_model_key attributes the key to KimiCoding.

Tests: e2e proves wire-served context (1_048_576 from the wire) with the
models.dev fetch SKIPPED (all-wire-metadata provider, .expect(0)), bare-id
round-trip under kimi-coding/, Kimi dialect; validation test rejects a 401
from /models. Registry at 19; picker 20 rows; snapshot already bundles
kimi-for-coding.
2026-07-21 18:10:23 -04:00

2830 lines
120 KiB
Rust

//! Model catalog fetch (PRD F4).
//!
//! Fetches the model catalog with `GET {base}/models` per enabled platform
//! (the subscription platform via the OAuth session, the open platforms via
//! their API keys), plus the custom-endpoint OpenAI-compatible listing path.
//!
//! This is the network surface relocated out of the deleted xAI-proxy
//! backend client (`remote/`); it talks only to the configured platform
//! model endpoints (plus the models.dev metadata refresh when an enabled
//! platform needs enrichment — see `enrichment_fetch`), never to a proxy
//! backend.
use crate::auth::KimiAuth;
use indexmap::IndexMap;
use serde::Deserialize;
/// Errors from a model-catalog fetch.
#[derive(Debug, thiserror::Error)]
pub(crate) enum BackendError {
#[error("Network error: {0}")]
Network(#[from] reqwest::Error),
#[error("Request failed: {status} - {body}")]
RequestFailed { status: u16, body: String },
#[error("Auth error: {0}")]
Auth(String),
}
pub(crate) const DEFAULT_CONTEXT_WINDOW: u64 = 256_000;
#[derive(Debug, Deserialize)]
struct ModelsResponse {
data: Vec<serde_json::Value>,
}
/// The models-fetch origin key for this endpoints/auth shape. Used as the
/// models disk-cache origin: cached entries embed absolute `base_url`s from
/// the backend(s) that served them, so a catalog fetched against one fetch
/// plan (env override, different set of platform credentials, a test's mock
/// server) must be a cache miss for any other. Encodes URLs and enabled
/// platform NAMES only — never credential values.
pub(crate) fn models_fetch_origin(
endpoints: &crate::agent::config::EndpointsConfig,
fetch_auth: crate::agent::models::ModelFetchAuth,
has_oauth: bool,
platform_keys: &crate::agent::models::PlatformApiKeys,
) -> String {
match fetch_auth {
crate::agent::models::ModelFetchAuth::CustomEndpoint => endpoints.resolve_models_list_url(),
crate::agent::models::ModelFetchAuth::Platforms => {
let parts: Vec<String> = enabled_platforms(has_oauth, platform_keys)
.into_iter()
.map(|p| format!("{}={}", p.as_str(), platform_models_url(p, endpoints)))
.collect();
format!("platforms[{}]", parts.join(";"))
}
}
}
/// The platforms with usable credentials, in registry order (kimi-code first
/// so "default model = first list item" favors the subscription).
fn enabled_platforms(
has_oauth: bool,
platform_keys: &crate::agent::models::PlatformApiKeys,
) -> Vec<kigi_models::PlatformId> {
kigi_models::PlatformId::ALL
.into_iter()
.filter(|p| {
if p.uses_oauth() {
has_oauth
} else {
platform_keys.key_for(*p).is_some()
}
})
.collect()
}
/// `{base}/models` for one platform. The subscription platform resolves its
/// base through the endpoints config (`coding_api_base_url` override,
/// else `KIGI_CODE_BASE_URL` / production default via kigi-env); the open
/// platforms use their fixed bases.
fn platform_models_url(
platform: kigi_models::PlatformId,
endpoints: &crate::agent::config::EndpointsConfig,
) -> String {
let base = if platform.uses_oauth() {
endpoints.proxy_url()
} else {
platform.base_url()
};
format!("{}/models", base.trim_end_matches('/'))
}
/// Fetch result: model entries + optional etag from the subscription platform.
pub struct FetchModelsResult {
pub models: Vec<crate::agent::config::ModelEntryConfig>,
pub etag: Option<String>,
/// The OAuth platform answered 401. The async layer forces a token
/// refresh and retries once (port of kimi-cli `refresh_managed_models`).
pub oauth_unauthorized: bool,
}
/// Fetch the model catalog (PRD F4).
///
/// - Custom endpoint mode (`KIGI_MODELS_BASE_URL` / `models_list_url`): a
/// single OpenAI-compatible listing fetched with the BYOK key or session
/// bearer, parsed leniently ([`parse_remote_model_value`]).
/// - Otherwise, the fixed platform registry: `GET {base}/models` with
/// `Authorization: Bearer <oauth-token or api-key>` per enabled platform,
/// parsed per the F4 wire contract with capability derivation and the
/// `kimi-k` prefix filter for the open platforms.
///
/// Succeeds when at least one platform delivers; per-platform failures are
/// logged (status codes only, never credentials).
pub(crate) fn fetch_models_blocking(
endpoints: &crate::agent::config::EndpointsConfig,
auth: Option<&KimiAuth>,
fetch_auth: crate::agent::models::ModelFetchAuth,
platform_keys: &crate::agent::models::PlatformApiKeys,
) -> Result<FetchModelsResult, BackendError> {
match fetch_auth {
crate::agent::models::ModelFetchAuth::CustomEndpoint => {
fetch_custom_endpoint_models_blocking(endpoints, auth)
}
crate::agent::models::ModelFetchAuth::Platforms => {
fetch_platform_models_blocking(endpoints, auth, platform_keys)
}
}
}
fn fetch_custom_endpoint_models_blocking(
endpoints: &crate::agent::config::EndpointsConfig,
auth: Option<&KimiAuth>,
) -> Result<FetchModelsResult, BackendError> {
let client = crate::http::shared_blocking_client();
let url = endpoints.resolve_models_list_url();
let inference_base_url = endpoints.resolve_inference_base_url();
tracing::info!("Fetching models from custom endpoint {}", url);
let api_key = crate::agent::auth_method::read_xai_api_key_env()
.or_else(|_| {
auth.map(|a| a.key.clone())
.ok_or(std::env::VarError::NotPresent)
})
.map_err(|_| {
BackendError::Auth("No API key for custom models endpoint. Set KIGI_API_KEY.".into())
})?;
let request = client
.get(&url)
.header("Authorization", format!("Bearer {}", api_key));
let response = request.send()?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().unwrap_or_default();
tracing::warn!("Failed to fetch models: {} - {}", status, body);
return Err(BackendError::RequestFailed { status, body });
}
let etag = response
.headers()
.get("etag")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let models_response: ModelsResponse = response.json()?;
tracing::info!("Fetched {} models from {}", models_response.data.len(), url);
let mut models = Vec::with_capacity(models_response.data.len());
for (idx, value) in models_response.data.into_iter().enumerate() {
match parse_remote_model_value(&value, &inference_base_url) {
Some(model) => models.push(model),
None => {
tracing::warn!(
"Skipping model at index {}: missing required field ('model' or 'context_window') or invalid types",
idx
)
}
}
}
Ok(FetchModelsResult {
models,
etag,
oauth_unauthorized: false,
})
}
/// Registry fetch across all platforms with usable credentials.
fn fetch_platform_models_blocking(
endpoints: &crate::agent::config::EndpointsConfig,
auth: Option<&KimiAuth>,
platform_keys: &crate::agent::models::PlatformApiKeys,
) -> Result<FetchModelsResult, BackendError> {
let enabled = enabled_platforms(auth.is_some(), platform_keys);
if enabled.is_empty() {
return Err(BackendError::Auth(
"No platform credentials: log in with `kigi login`, paste a platform API key in \
the login screen (stored in ~/.kigi/auth.json), or set a platform env var such \
as KIGI_MOONSHOT_API_KEY."
.into(),
));
}
let mut models = Vec::new();
let mut etag = None;
let mut oauth_unauthorized = false;
let mut successes = 0usize;
let mut last_error: Option<BackendError> = None;
// Loaded once per fetch pass; zero IO while every enabled platform
// serves its own metadata (kimi/moonshot today).
let enrichment = crate::agent::enrichment_fetch::load_enrichment_catalog(&enabled);
for platform in &enabled {
let bearer = if platform.uses_oauth() {
auth.map(|a| a.key.clone())
.expect("enabled_platforms gated on auth presence")
} else {
platform_keys
.key_for(*platform)
.expect("enabled_platforms gated on key presence")
.to_owned()
};
match fetch_one_platform_models(*platform, endpoints, &bearer, &enrichment) {
Ok((platform_models, platform_etag)) => {
tracing::info!(
platform = platform.as_str(),
count = platform_models.len(),
"platform models fetch succeeded"
);
successes += 1;
if platform.uses_oauth() {
etag = platform_etag;
}
models.extend(platform_models);
}
Err(e) => {
if platform.uses_oauth()
&& matches!(&e, BackendError::RequestFailed { status: 401, .. })
{
oauth_unauthorized = true;
}
tracing::warn!(
platform = platform.as_str(),
error = %e,
"platform models fetch failed"
);
last_error = Some(e);
}
}
}
if successes == 0 {
// All enabled platforms failed. When the failure includes an OAuth
// 401, return `Ok` with the flag set (and no models) so the async
// layer can force a token refresh and retry — an `Err` would drop
// the signal. Non-401 failures propagate as the last error.
if oauth_unauthorized {
return Ok(FetchModelsResult {
models: Vec::new(),
etag: None,
oauth_unauthorized: true,
});
}
return Err(last_error.unwrap_or_else(|| {
BackendError::Auth("no platform models fetch was attempted".into())
}));
}
Ok(FetchModelsResult {
models,
etag,
oauth_unauthorized,
})
}
/// `GET {base}/models` for one platform (PRD F4 wire contract):
/// `Authorization: Bearer <token>` → `{data:[{id, context_length,
/// supports_reasoning, supports_image_in, supports_video_in, display_name?}]}`.
/// Applies the platform's `kimi-k` prefix filter and capability derivation,
/// and keys each entry `{platform_id}/{model_id}`.
fn fetch_one_platform_models(
platform: kigi_models::PlatformId,
endpoints: &crate::agent::config::EndpointsConfig,
bearer: &str,
enrichment: &kigi_models::enrichment::EnrichmentCatalog,
) -> Result<(Vec<crate::agent::config::ModelEntryConfig>, Option<String>), BackendError> {
let client = crate::http::shared_blocking_client();
let url = match platform.listing() {
kigi_models::ListingDialect::OpenAi => platform_models_url(platform, endpoints),
// Anthropic paginates (default 20); limit=1000 is the documented max
// and far above the catalog size (the adapter warns on has_more).
kigi_models::ListingDialect::Anthropic => {
format!("{}?limit=1000", platform_models_url(platform, endpoints))
}
};
tracing::info!(platform = platform.as_str(), url = %url, "fetching platform models");
let request = match platform.key_header() {
kigi_models::PlatformKeyHeader::Bearer => client
.get(&url)
.header("Authorization", format!("Bearer {}", bearer)),
kigi_models::PlatformKeyHeader::XApiKey => client
.get(&url)
.header("x-api-key", bearer)
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION),
};
let response = request.send()?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().unwrap_or_default();
return Err(BackendError::RequestFailed { status, body });
}
let etag = response
.headers()
.get("etag")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let data = match platform.listing() {
kigi_models::ListingDialect::OpenAi => {
// Tolerant of both the {data:[...]} envelope and a bare array
// (Together AI serves the bare form).
let body = response.text()?;
kigi_models::parse_openai_listing(&body).map_err(|e| BackendError::RequestFailed {
status: 200,
body: format!("openai listing parse failed: {e}"),
})?
}
kigi_models::ListingDialect::Anthropic => {
let body = response.text()?;
kigi_models::parse_anthropic_listing(&body).map_err(|e| {
BackendError::RequestFailed {
status: 200,
body: format!("anthropic listing parse failed: {e}"),
}
})?
}
};
// Canonicalize listing ids before filtering/enrichment/keying. Google's
// OpenAI-compat `/models` returns `models/`-prefixed ids while its chat
// endpoint and the models.dev snapshot use the bare id — without this the
// enrichment lookup misses and `restrict_to_enriched` would empty the
// catalog. No-op for platforms with no configured prefix.
let mut data = data;
if let Some(prefix) = platform.strip_listing_id_prefix() {
for wire in &mut data {
if let Some(bare) = wire.id.strip_prefix(prefix) {
wire.id = bare.to_string();
}
}
}
let total = data.len();
let mut filtered = kigi_models::filter_allowed_models(platform, data);
if filtered.len() != total {
tracing::info!(
platform = platform.as_str(),
total,
kept = filtered.len(),
"applied platform model-prefix filter"
);
}
// Polluted listings (tts/embeddings/image entries) are restricted to
// models the enrichment catalog knows. FAIL-SAFE: if enrichment has no
// data for this provider at all (refresh broken AND snapshot gap), keep
// the full listing with a warning — a noisy picker beats an empty one.
if platform.restrict_to_enriched()
&& let Some(dev_id) = platform.models_dev_id()
{
let provider_known = enrichment.get(dev_id).is_some_and(|m| !m.is_empty());
if provider_known {
let before = filtered.len();
let mut dropped: Vec<String> = Vec::new();
// Keep only tool-calling chat models: membership alone would
// admit models.dev-known embeddings/moderation entries, which
// would 400 on every agentic request (EnrichmentModel.tool_call
// exists exactly for this cut).
filtered.retain(|wire| {
let keep = kigi_models::enrichment::lookup(enrichment, dev_id, &wire.id)
.is_some_and(|meta| meta.tool_call);
if !keep {
dropped.push(wire.id.clone());
}
keep
});
if filtered.len() != before {
tracing::info!(
platform = platform.as_str(),
before,
kept = filtered.len(),
"restricted listing to tool-calling enrichment-known models"
);
// A launch-day model missing from enrichment lands here for
// up to models.dev lag + cache TTL — keep the ids traceable.
tracing::debug!(
platform = platform.as_str(),
dropped = ?dropped,
"listing ids dropped by the enrichment restriction"
);
}
} else {
tracing::warn!(
platform = platform.as_str(),
"no enrichment data for provider; keeping full listing"
);
}
}
let base_url = if platform.uses_oauth() {
endpoints.proxy_url()
} else {
platform.base_url()
};
let models = filtered
.into_iter()
.map(|mut wire| {
// Metadata-poor listings (bare ids) get context window / thinking
// levels from the models.dev catalog; wire-served platforms skip
// this entirely and wire values always win (enrich_wire_model).
if !platform.wire_serves_metadata()
&& let Some(dev_id) = platform.models_dev_id()
{
match kigi_models::enrichment::lookup(enrichment, dev_id, &wire.id) {
Some(meta) => kigi_models::enrichment::enrich_wire_model(&mut wire, meta),
None => tracing::debug!(
platform = platform.as_str(), model = %wire.id,
"no enrichment entry; defaults will apply"
),
}
}
platform_wire_model_to_entry(platform, wire, &base_url)
})
.collect();
Ok((models, etag))
}
/// Map one F4 wire model to a catalog entry config.
///
/// SECURITY: the entry carries only env-var NAMES (`env_key`) for the open
/// platforms — never key values — because raw fetched entries are persisted
/// to the models disk cache. Config-file keys are stamped in-memory later by
/// `resolve_model_list`'s platform-credentials layer.
/// Map a live `think_efforts` block to catalog effort options. The wire
/// token stays the option id/label (`"max"` → label `"Max"`) so the UI
/// mirrors the server's vocabulary, while the canonical value maps through
/// the [`kigi_sampling_types::ReasoningEffort`] parser (`"max"` → `Max`
/// since the Xhigh/Max split). Unknown tokens are dropped with a warning
/// rather than inventing a level.
fn think_efforts_to_options(
think: &kigi_models::WireThinkEfforts,
) -> Vec<kigi_sampling_types::ReasoningEffortOption> {
think
.valid_efforts
.iter()
.filter_map(|token| {
let value = match token.parse::<kigi_sampling_types::ReasoningEffort>() {
Ok(v) => v,
Err(error) => {
tracing::warn!(%token, %error, "unknown think_efforts token; dropping");
return None;
}
};
let mut label: String = token.clone();
if let Some(first) = label.get_mut(0..1) {
first.make_ascii_uppercase();
}
Some(kigi_sampling_types::ReasoningEffortOption {
id: token.clone(),
value,
label,
description: None,
default: think.default_effort.as_deref() == Some(token.as_str()),
})
})
.collect()
}
pub(crate) fn platform_wire_model_to_entry(
platform: kigi_models::PlatformId,
wire: kigi_models::WireModel,
base_url: &str,
) -> crate::agent::config::ModelEntryConfig {
let capabilities = wire.capabilities();
// Selectable thinking levels (live wire `think_efforts`, e.g. K3's
// low/high/max). `support: false` or absence both mean "no levels".
let think_efforts = wire.think_efforts.as_ref().filter(|t| t.support);
let context_window = std::num::NonZeroU64::new(wire.context_length).unwrap_or_else(|| {
tracing::debug!(
model = %wire.id,
default = DEFAULT_CONTEXT_WINDOW,
"platform model missing context_length; using default"
);
std::num::NonZeroU64::new(DEFAULT_CONTEXT_WINDOW).expect("non-zero")
});
let env_key = (!platform.uses_oauth())
.then(|| crate::agent::config::EnvKeys::new(platform.api_key_env_names().iter().copied()));
let api_backend = match platform.wire_api() {
kigi_models::PlatformWireApi::ChatCompletions => {
crate::sampling::ApiBackend::ChatCompletions
}
kigi_models::PlatformWireApi::Responses => crate::sampling::ApiBackend::Responses,
kigi_models::PlatformWireApi::Messages => crate::sampling::ApiBackend::Messages,
};
let auth_scheme = match platform.key_header() {
kigi_models::PlatformKeyHeader::Bearer => None,
kigi_models::PlatformKeyHeader::XApiKey => Some(kigi_sampler::AuthScheme::XApiKey),
};
crate::agent::config::ModelEntryConfig {
id: Some(platform.managed_model_key(&wire.id)),
name: Some(wire.display_name.clone().unwrap_or_else(|| wire.id.clone())),
model: wire.id,
base_url: base_url.to_owned(),
description: None,
// The wire/enrichment output cap; the sampler otherwise defaults to
// 128K, which Anthropic rejects on smaller-cap models (400 on every
// request for e.g. a 64K haiku).
max_completion_tokens: (wire.max_output_tokens > 0)
.then(|| u32::try_from(wire.max_output_tokens).unwrap_or(u32::MAX)),
temperature: None,
top_p: None,
api_key: None,
env_key,
api_backend,
auth_scheme,
reasoning_effort: think_efforts
.and_then(|t| t.default_effort.as_deref())
.and_then(|s| s.parse().ok()),
supports_reasoning_effort: think_efforts.is_some(),
reasoning_efforts: think_efforts
.map(think_efforts_to_options)
.unwrap_or_default(),
capabilities,
extra_headers: IndexMap::new(),
context_window,
auto_compact_threshold_percent: None,
system_prompt_label: None,
api_base_url: None,
use_concise: false,
agent_type: crate::agent::config::default_agent_type(),
inference_idle_timeout_secs: None,
max_retries: None,
hidden: false,
// Subscription models require the OAuth session; open-platform
// models are usable by API-key users.
supported_in_api: !platform.uses_oauth(),
supports_backend_search: false,
compactions_remaining: None,
compaction_at_tokens: None,
show_model_fingerprint: false,
stream_tool_calls: None,
laziness_detector: Default::default(),
}
}
/// Parse a single model entry from the /models response.
/// Used by both initial model fetch and session-resume metadata refresh.
pub fn parse_remote_model_value(
value: &serde_json::Value,
default_base_url: &str,
) -> Option<crate::agent::config::ModelEntryConfig> {
let obj = value.as_object()?;
let meta = obj.get("_meta").and_then(|v| v.as_object());
let id = get_string(obj, "id");
let model = get_string(obj, "model")
.or_else(|| get_string(obj, "modelId"))
.or_else(|| id.clone())
.or_else(|| meta.and_then(|m| get_string(m, "model")))
.or_else(|| meta.and_then(|m| get_string(m, "modelId")))?;
let base_url = get_string(obj, "baseUrl")
.or_else(|| get_string(obj, "base_url"))
.unwrap_or_else(|| default_base_url.to_owned());
let name = get_string(obj, "name").or_else(|| Some(model.clone()));
let context_window = get_u64(obj, "contextWindow")
.or_else(|| get_u64(obj, "context_window"))
.or_else(|| meta.and_then(|m| get_u64(m, "contextWindow")))
.or_else(|| meta.and_then(|m| get_u64(m, "totalContextTokens")))
.unwrap_or(DEFAULT_CONTEXT_WINDOW);
let context_window = std::num::NonZeroU64::new(context_window)?;
let agent_type = get_string(obj, "systemPromptType")
.or_else(|| get_string(obj, "system_prompt_type"))
.or_else(|| get_string(obj, "agent_type"))
.or_else(|| get_string(obj, "agentType"))
.or_else(|| meta.and_then(|m| get_string(m, "agentType")))
.or_else(|| meta.and_then(|m| get_string(m, "agent_type")))
.unwrap_or_else(crate::agent::config::default_agent_type);
let api_backend = get_string(obj, "apiBackend")
.or_else(|| get_string(obj, "api_backend"))
.and_then(|s| match s.as_str() {
"responses" => Some(crate::sampling::ApiBackend::Responses),
"chat_completions" => Some(crate::sampling::ApiBackend::ChatCompletions),
"messages" => Some(crate::sampling::ApiBackend::Messages),
_ => None,
})
.unwrap_or_default();
Some(crate::agent::config::ModelEntryConfig {
id,
model,
base_url,
name,
description: get_string(obj, "description"),
max_completion_tokens: get_u64(obj, "maxCompletionTokens")
.or_else(|| get_u64(obj, "max_completion_tokens"))
.and_then(|v| u32::try_from(v).ok()),
temperature: get_f64(obj, "temperature").map(|v| v as f32),
top_p: get_f64(obj, "topP").or_else(|| get_f64(obj, "top_p")).map(|v| v as f32),
api_key: get_string(obj, "apiKey").or_else(|| get_string(obj, "api_key")),
env_key: get_env_keys(obj, "envKey").or_else(|| get_env_keys(obj, "env_key")),
api_backend,
context_window,
auto_compact_threshold_percent: get_u64(obj, "autoCompactThresholdPercent")
.or_else(|| get_u64(obj, "auto_compact_threshold_percent"))
.and_then(|v| u8::try_from(v).ok()),
system_prompt_label: get_string(obj, "systemPromptLabel")
.or_else(|| get_string(obj, "system_prompt_label"))
.filter(|s| !s.trim().is_empty()),
extra_headers: get_string_map(obj, "extraHeaders"),
api_base_url: get_string(obj, "apiBaseUrl")
.or_else(|| get_string(obj, "api_base_url")),
use_concise: obj
.get("useConcise")
.or_else(|| obj.get("use_concise"))
.and_then(|v| v.as_bool())
.unwrap_or(false),
agent_type,
inference_idle_timeout_secs: get_u64(obj, "inferenceIdleTimeoutSecs")
.or_else(|| get_u64(obj, "inference_idle_timeout_secs")),
max_retries: get_u64(obj, "maxRetries")
.or_else(|| get_u64(obj, "max_retries"))
.and_then(|v| u32::try_from(v).ok()),
hidden: obj
.get("hidden")
.or_else(|| meta.and_then(|m| m.get("hidden")))
.and_then(|v| v.as_bool())
.unwrap_or(false),
supported_in_api: obj
.get("supportedInApi")
.or_else(|| obj.get("supported_in_api"))
.or_else(|| meta.and_then(|m| m.get("supportedInApi")))
.and_then(|v| v.as_bool())
.unwrap_or(true),
auth_scheme: None,
reasoning_effort: get_string(obj, "reasoningEffort")
.or_else(|| get_string(obj, "reasoning_effort"))
.or_else(|| meta.and_then(|m| get_string(m, "reasoningEffort")))
.and_then(|s| s.parse().ok()),
supports_reasoning_effort: obj
.get("supportsReasoningEffort")
.or_else(|| obj.get("supports_reasoning_effort"))
.or_else(|| meta.and_then(|m| m.get("supportsReasoningEffort")))
.and_then(|v| v.as_bool())
.unwrap_or(false),
reasoning_efforts: obj
.get("reasoningEfforts")
.or_else(|| obj.get("reasoning_efforts"))
.or_else(|| meta.and_then(|m| m.get("reasoningEfforts")))
.and_then(|v| v.as_array())
.map(|arr| kigi_sampling_types::parse_reasoning_effort_options(arr))
.unwrap_or_default(),
capabilities: obj
.get("capabilities")
.and_then(|v| {
serde_json::from_value::<Vec<kigi_models::ModelCapability>>(v.clone()).ok()
})
.unwrap_or_default(),
supports_backend_search: obj
.get("supportsBackendSearch")
.or_else(|| obj.get("supports_backend_search"))
.or_else(|| meta.and_then(|m| m.get("supportsBackendSearch")))
.and_then(|v| v.as_bool())
.unwrap_or(false),
compactions_remaining: obj
.get("compactionsRemaining")
.or_else(|| obj.get("compactions_remaining"))
.or_else(|| meta.and_then(|m| m.get("compactionsRemaining")))
.and_then(parse_compactions_remaining)
.or_else(|| {
obj
.get("sendCompactionsRemaining")
.or_else(|| obj.get("send_compactions_remaining"))
.or_else(|| meta.and_then(|m| m.get("sendCompactionsRemaining")))
.and_then(|v| v.as_bool())
.map(kigi_sampling_types::CompactionsRemaining::Dynamic)
}),
compaction_at_tokens: obj
.get("compactionAtTokens")
.or_else(|| obj.get("compaction_at_tokens"))
.or_else(|| meta.and_then(|m| m.get("compactionAtTokens")))
.and_then(parse_compaction_at_tokens),
show_model_fingerprint: obj
.get("showModelFingerprint")
.or_else(|| obj.get("show_model_fingerprint"))
.or_else(|| meta.and_then(|m| m.get("showModelFingerprint")))
.and_then(|v| v.as_bool())
.unwrap_or(false),
stream_tool_calls: obj
.get("streamToolCalls")
.or_else(|| obj.get("stream_tool_calls"))
.and_then(|v| v.as_bool()),
laziness_detector: get_object(obj, "lazinessDetector")
.or_else(|| get_object(obj, "laziness_detector"))
.or_else(|| meta.and_then(|m| get_object(m, "lazinessDetector")))
.and_then(|v| match serde_json::from_value::<
crate::agent::config::LazinessDetectorPerModelConfig,
>(v.clone()) {
Ok(cfg) => Some(cfg),
Err(e) => {
tracing::warn!(
error = % e,
"Failed to deserialize laziness_detector block from remote model; falling back to default"
);
None
}
})
.unwrap_or_default(),
})
}
fn get_string(obj: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<String> {
obj.get(key).and_then(|v| v.as_str()).map(|s| s.to_string())
}
/// Parse `env_key` / `envKey` as a single string or a string array.
fn get_env_keys(
obj: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Option<crate::agent::config::EnvKeys> {
let v = obj.get(key)?;
if let Some(s) = v.as_str() {
return Some(crate::agent::config::EnvKeys::single(s));
}
if let Some(arr) = v.as_array() {
let mut names = Vec::with_capacity(arr.len());
for item in arr {
let Some(s) = item.as_str() else {
tracing::warn!(
key,
"env_key array has a non-string element; ignoring env_key"
);
return None;
};
if !s.is_empty() {
names.push(s.to_owned());
}
}
if names.is_empty() {
return None;
}
return Some(crate::agent::config::EnvKeys::new(names));
}
None
}
fn parse_compaction_at_tokens(
v: &serde_json::Value,
) -> Option<kigi_sampling_types::CompactionAtTokens> {
use kigi_sampling_types::CompactionAtTokens;
v.as_bool()
.map(CompactionAtTokens::Enabled)
.or_else(|| v.as_u64().map(CompactionAtTokens::Fixed))
}
fn parse_compactions_remaining(
v: &serde_json::Value,
) -> Option<kigi_sampling_types::CompactionsRemaining> {
use kigi_sampling_types::CompactionsRemaining;
v.as_bool().map(CompactionsRemaining::Dynamic).or_else(|| {
v.as_u64()
.and_then(|n| u8::try_from(n).ok())
.map(CompactionsRemaining::Fixed)
})
}
fn get_u64(obj: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<u64> {
obj.get(key).and_then(|v| v.as_u64())
}
fn get_f64(obj: &serde_json::Map<String, serde_json::Value>, key: &str) -> Option<f64> {
obj.get(key).and_then(|v| v.as_f64())
}
fn get_object<'a>(
obj: &'a serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Option<&'a serde_json::Value> {
obj.get(key).filter(|v| v.is_object())
}
fn get_string_map(
obj: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> IndexMap<String, String> {
obj.get(key)
.and_then(|v| v.as_object())
.map(|map| {
map.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
/// OpenAI-cycle e2e (mock wire): a polluted bare-id `/models` listing +
/// a models.dev refresh produce a catalog with ONLY chat models, enriched
/// context windows / efforts, and the Responses backend — the full
/// "live list + documented metadata" contract.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn openai_listing_is_enriched_filtered_and_responses_backed() {
let platform_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/models"))
.and(wiremock::matchers::header("Authorization", "Bearer sk-oai"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "data": [
{ "id": "gpt-5-test", "object": "model", "owned_by": "openai" },
{ "id": "whisper-1", "object": "model", "owned_by": "openai" },
{ "id": "text-embedding-tiny", "object": "model" }
]}),
))
.expect(1)
.mount(&platform_server)
.await;
let modelsdev_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "openai": { "models": {
"gpt-5-test": {
"name": "GPT-5 Test",
"reasoning": true,
"reasoning_options": [
{"type": "effort", "values": ["low", "medium", "high"]}
],
"limit": {"context": 400000, "output": 128000},
"modalities": {"input": ["text", "image"]},
"tool_call": true
},
// models.dev KNOWS embeddings models — membership alone
// must not admit them; the tool_call cut does.
"text-embedding-tiny": {
"limit": {"context": 8191}
}
}}}),
))
.expect(1)
.mount(&modelsdev_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
let _base = kigi_test_support::EnvGuard::set(
kigi_models::OPENAI_BASE_URL_ENV,
platform_server.uri(),
);
let _mdev = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
format!("{}/api.json", modelsdev_server.uri()),
);
let _mdev_cache = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
cache_dir.path(),
);
let endpoints = crate::agent::config::EndpointsConfig::default();
let keys = crate::agent::models::PlatformApiKeys::test_single(
kigi_models::PlatformId::OpenAi,
"sk-oai",
);
let result = tokio::task::spawn_blocking(move || {
fetch_platform_models_blocking(&endpoints, None, &keys)
})
.await
.unwrap()
.expect("fetch must succeed");
assert_eq!(
result
.models
.iter()
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec!["openai/gpt-5-test"],
"pollution must be filtered: whisper (enrichment-unknown) AND \
text-embedding-tiny (enrichment-known but not tool-calling)"
);
let entry = &result.models[0];
assert_eq!(
entry.context_window.get(),
400_000,
"context window must come from enrichment (wire had none)"
);
assert_eq!(
entry.api_backend,
crate::sampling::ApiBackend::Responses,
"OpenAI entries must use the Responses backend"
);
assert_eq!(entry.name.as_deref(), Some("GPT-5 Test"));
assert!(entry.supports_reasoning_effort, "efforts must be filled");
assert_eq!(
entry
.reasoning_efforts
.iter()
.map(|o| o.id.as_str())
.collect::<Vec<_>>(),
vec!["low", "medium", "high"]
);
assert!(
entry
.capabilities
.contains(&kigi_models::ModelCapability::Thinking),
"enrichment reasoning flag must derive the thinking capability"
);
assert_eq!(
entry.env_key,
Some(crate::agent::config::EnvKeys::single("OPENAI_API_KEY")),
"entries carry the env NAME (never key values)"
);
assert!(
cache_dir.path().join("models_dev_cache.json").exists(),
"the refresh must be cached in the overridden dir"
);
}
/// Anthropic-cycle e2e (mock wire): the Anthropic listing dialect —
/// x-api-key + anthropic-version headers, ?limit=1000 — maps
/// wire-served metadata (max_input_tokens, per-level effort
/// capabilities) onto Messages-backed XApiKey entries, and enrichment
/// fills a zero max_input_tokens without touching wire-served values.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn anthropic_listing_maps_wire_metadata_and_enrichment_fills_gaps() {
let platform_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/models"))
.and(wiremock::matchers::query_param("limit", "1000"))
.and(wiremock::matchers::header("x-api-key", "sk-ant"))
.and(wiremock::matchers::header(
"anthropic-version",
"2023-06-01",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "data": [
{
"id": "claude-opus-4-8",
"display_name": "Claude Opus 4.8",
"type": "model",
"max_input_tokens": 1_000_000,
"capabilities": {
"effort": {
"supported": true,
"low": {"supported": true},
"medium": {"supported": true},
"high": {"supported": true},
"xhigh": {"supported": true},
"max": {"supported": true}
},
"thinking": {"supported": true},
"image_input": {"supported": true}
}
},
{
"id": "claude-gap-test",
"type": "model",
"max_input_tokens": 0,
"capabilities": {
"effort": {"supported": false},
"thinking": {"supported": true},
"image_input": {"supported": false}
}
}
], "has_more": false }),
))
.expect(1)
.mount(&platform_server)
.await;
let modelsdev_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "anthropic": { "models": {
"claude-gap-test": {
"limit": {"context": 200000, "output": 64000},
"tool_call": true,
"reasoning": true,
"reasoning_options": [
{"type": "effort", "values": ["low", "high"]}
]
},
"claude-opus-4-8": {
"limit": {"context": 555},
"tool_call": true
}
}}}),
))
.expect(1)
.mount(&modelsdev_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
let _base = kigi_test_support::EnvGuard::set(
kigi_models::ANTHROPIC_BASE_URL_ENV,
platform_server.uri(),
);
let _mdev = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
format!("{}/api.json", modelsdev_server.uri()),
);
let _mdev_cache = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
cache_dir.path(),
);
let endpoints = crate::agent::config::EndpointsConfig::default();
let keys = crate::agent::models::PlatformApiKeys::test_single(
kigi_models::PlatformId::Anthropic,
"sk-ant",
);
let result = tokio::task::spawn_blocking(move || {
fetch_platform_models_blocking(&endpoints, None, &keys)
})
.await
.unwrap()
.expect("fetch must succeed");
assert_eq!(result.models.len(), 2);
let opus = &result.models[0];
assert_eq!(opus.id.as_deref(), Some("anthropic/claude-opus-4-8"));
assert_eq!(
opus.context_window.get(),
1_000_000,
"wire max_input_tokens must WIN over enrichment (555)"
);
assert_eq!(opus.api_backend, crate::sampling::ApiBackend::Messages);
assert_eq!(
opus.auth_scheme,
Some(kigi_sampler::AuthScheme::XApiKey),
"anthropic entries must ride x-api-key at inference"
);
assert_eq!(
opus.reasoning_efforts
.iter()
.map(|o| o.id.as_str())
.collect::<Vec<_>>(),
vec!["low", "medium", "high", "xhigh", "max"],
"wire effort capabilities become the menu"
);
let gap = &result.models[1];
assert_eq!(
gap.context_window.get(),
200_000,
"a zero wire context must be filled by enrichment"
);
assert_eq!(
gap.max_completion_tokens,
Some(64_000),
"the enrichment output cap must reach max_completion_tokens"
);
assert!(
gap.reasoning_efforts.is_empty() && !gap.supports_reasoning_effort,
"the wire's explicit effort decline must block enrichment's menu \
(pre-4.6 models 400 on adaptive thinking); efforts={:?} supports={}",
gap.reasoning_efforts,
gap.supports_reasoning_effort,
);
let opus = &result.models[0];
assert_eq!(
opus.max_completion_tokens, None,
"no wire/enrichment cap on this fixture entry — sampler default applies"
);
assert!(
gap.capabilities
.contains(&kigi_models::ModelCapability::Thinking),
"wire thinking capability must survive"
);
}
/// DeepSeek-cycle e2e: bare OpenAI-shape listing + enrichment efforts
/// (high/max) produce ChatCompletions entries whose sampler config
/// speaks the DeepSeek thinking dialect.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn deepseek_listing_enriches_and_maps_dialect() {
let platform_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/models"))
.and(wiremock::matchers::header("Authorization", "Bearer sk-ds"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "data": [
{ "id": "deepseek-v4-pro", "object": "model", "owned_by": "deepseek" }
]}),
))
.expect(1)
.mount(&platform_server)
.await;
let modelsdev_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "deepseek": { "models": { "deepseek-v4-pro": {
"reasoning": true,
"reasoning_options": [
{"type": "toggle"},
{"type": "effort", "values": ["high", "max"]}
],
"limit": {"context": 1000000, "output": 384000},
"tool_call": true
}}}}),
))
.expect(1)
.mount(&modelsdev_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
let _base = kigi_test_support::EnvGuard::set(
kigi_models::DEEPSEEK_BASE_URL_ENV,
platform_server.uri(),
);
let _mdev = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
format!("{}/api.json", modelsdev_server.uri()),
);
let _mdev_cache = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
cache_dir.path(),
);
let endpoints = crate::agent::config::EndpointsConfig::default();
let keys = crate::agent::models::PlatformApiKeys::test_single(
kigi_models::PlatformId::DeepSeek,
"sk-ds",
);
let result = tokio::task::spawn_blocking(move || {
fetch_platform_models_blocking(&endpoints, None, &keys)
})
.await
.unwrap()
.expect("fetch must succeed");
assert_eq!(result.models.len(), 1);
let entry = &result.models[0];
assert_eq!(entry.id.as_deref(), Some("deepseek/deepseek-v4-pro"));
assert_eq!(entry.context_window.get(), 1_000_000);
assert_eq!(entry.max_completion_tokens, Some(384_000));
assert_eq!(
entry.api_backend,
crate::sampling::ApiBackend::ChatCompletions
);
assert_eq!(
entry
.reasoning_efforts
.iter()
.map(|o| o.id.as_str())
.collect::<Vec<_>>(),
vec!["high", "max"]
);
// The managed id maps to the DeepSeek chat dialect; a BYOK entry
// (no managed key) keeps the historical Kimi adaptation.
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
let creds = crate::agent::config::ResolvedCredentials {
api_key: Some("sk-ds".into()),
base_url: entry.base_url.clone(),
auth_type: kigi_chat_state::AuthType::ApiKey,
auth_scheme: Default::default(),
};
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
assert_eq!(cfg.chat_compat, kigi_sampling_types::ChatCompat::DeepSeek);
let mut byok = entry.clone();
byok.id = Some("my-custom".into());
let byok_entry = crate::agent::config::ModelEntry::from_config_entry(&byok);
let creds = crate::agent::config::ResolvedCredentials {
api_key: Some("sk-x".into()),
base_url: byok.base_url.clone(),
auth_type: kigi_chat_state::AuthType::ApiKey,
auth_scheme: Default::default(),
};
let cfg = crate::agent::config::sampling_config_for_model(&byok_entry, creds, None);
assert_eq!(cfg.chat_compat, kigi_sampling_types::ChatCompat::Kimi);
}
/// Groq-cycle e2e: pure pattern — polluted listing restricted to
/// tool-calling enrichment models, Passthrough dialect (OpenAI-style
/// reasoning_effort untouched on this wire).
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn groq_listing_restricts_and_maps_passthrough_dialect() {
let platform_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/models"))
.and(wiremock::matchers::header("Authorization", "Bearer gsk-1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "data": [
{ "id": "llama-3.3-70b-versatile", "object": "model" },
{ "id": "whisper-large-v3", "object": "model" }
]}),
))
.expect(1)
.mount(&platform_server)
.await;
let modelsdev_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "groq": { "models": {
"llama-3.3-70b-versatile": {
"limit": {"context": 131072, "output": 32768},
"tool_call": true
},
"whisper-large-v3": { "limit": {"context": 448} }
}}}),
))
.expect(1)
.mount(&modelsdev_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
let _base =
kigi_test_support::EnvGuard::set(kigi_models::GROQ_BASE_URL_ENV, platform_server.uri());
let _mdev = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
format!("{}/api.json", modelsdev_server.uri()),
);
let _mdev_cache = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
cache_dir.path(),
);
let endpoints = crate::agent::config::EndpointsConfig::default();
let keys = crate::agent::models::PlatformApiKeys::test_single(
kigi_models::PlatformId::Groq,
"gsk-1",
);
let result = tokio::task::spawn_blocking(move || {
fetch_platform_models_blocking(&endpoints, None, &keys)
})
.await
.unwrap()
.expect("fetch must succeed");
assert_eq!(
result
.models
.iter()
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec!["groq/llama-3.3-70b-versatile"],
"whisper (enrichment-known, not tool-calling) must be dropped"
);
let entry = &result.models[0];
assert_eq!(entry.context_window.get(), 131_072);
assert_eq!(entry.max_completion_tokens, Some(32_768));
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
let creds = crate::agent::config::ResolvedCredentials {
api_key: Some("gsk-1".into()),
base_url: entry.base_url.clone(),
auth_type: kigi_chat_state::AuthType::ApiKey,
auth_scheme: Default::default(),
};
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
assert_eq!(
cfg.chat_compat,
kigi_sampling_types::ChatCompat::Passthrough,
"groq entries must leave OpenAI-style bodies untouched"
);
}
/// Mistral-cycle e2e: embed pollution restricted away, and the Mistral
/// dialect (strips stream_options, handles reasoning arrays) is mapped.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn mistral_listing_restricts_and_maps_mistral_dialect() {
let platform_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/models"))
.and(wiremock::matchers::header("Authorization", "Bearer msk-1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "data": [
{ "id": "devstral-latest", "object": "model" },
{ "id": "mistral-embed", "object": "model" }
]}),
))
.expect(1)
.mount(&platform_server)
.await;
let modelsdev_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "mistral": { "models": {
"devstral-latest": {
"limit": {"context": 262144, "output": 65536},
"tool_call": true
},
"mistral-embed": { "limit": {"context": 8000} }
}}}),
))
.expect(1)
.mount(&modelsdev_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
let _base = kigi_test_support::EnvGuard::set(
kigi_models::MISTRAL_BASE_URL_ENV,
platform_server.uri(),
);
let _mdev = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
format!("{}/api.json", modelsdev_server.uri()),
);
let _mdev_cache = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
cache_dir.path(),
);
let endpoints = crate::agent::config::EndpointsConfig::default();
let keys = crate::agent::models::PlatformApiKeys::test_single(
kigi_models::PlatformId::Mistral,
"msk-1",
);
let result = tokio::task::spawn_blocking(move || {
fetch_platform_models_blocking(&endpoints, None, &keys)
})
.await
.unwrap()
.expect("fetch must succeed");
assert_eq!(
result
.models
.iter()
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec!["mistral/devstral-latest"],
"embed (enrichment-known, not tool-calling) must be dropped"
);
let entry = &result.models[0];
assert_eq!(entry.context_window.get(), 262_144);
assert_eq!(entry.max_completion_tokens, Some(65_536));
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
let creds = crate::agent::config::ResolvedCredentials {
api_key: Some("msk-1".into()),
base_url: entry.base_url.clone(),
auth_type: kigi_chat_state::AuthType::ApiKey,
auth_scheme: Default::default(),
};
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
assert_eq!(
cfg.chat_compat,
kigi_sampling_types::ChatCompat::StrictOpenAi,
"mistral entries use the StrictOpenAi dialect (stream_options strip)"
);
}
/// Fireworks-cycle e2e (Groq pattern): embedding pollution restricted
/// away, Passthrough dialect, and Fireworks' deeply-slashed native ids
/// (`accounts/fireworks/models/…`) round-trip through the managed key
/// (`fireworks/accounts/fireworks/models/…`, first-slash split).
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn fireworks_listing_restricts_maps_dialect_and_keeps_slashed_ids() {
let platform_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/models"))
.and(wiremock::matchers::header("Authorization", "Bearer fw-1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "data": [
{ "id": "accounts/fireworks/models/glm-5p2", "object": "model" },
{ "id": "nomic-ai/nomic-embed-text-v1.5", "object": "model" }
]}),
))
.expect(1)
.mount(&platform_server)
.await;
let modelsdev_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "fireworks-ai": { "models": {
"accounts/fireworks/models/glm-5p2": {
"limit": {"context": 1048575, "output": 65536},
"tool_call": true
},
"nomic-ai/nomic-embed-text-v1.5": { "limit": {"context": 8192} }
}}}),
))
.expect(1)
.mount(&modelsdev_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
let _base = kigi_test_support::EnvGuard::set(
kigi_models::FIREWORKS_BASE_URL_ENV,
platform_server.uri(),
);
let _mdev = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
format!("{}/api.json", modelsdev_server.uri()),
);
let _mdev_cache = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
cache_dir.path(),
);
let endpoints = crate::agent::config::EndpointsConfig::default();
let keys = crate::agent::models::PlatformApiKeys::test_single(
kigi_models::PlatformId::Fireworks,
"fw-1",
);
let result = tokio::task::spawn_blocking(move || {
fetch_platform_models_blocking(&endpoints, None, &keys)
})
.await
.unwrap()
.expect("fetch must succeed");
assert_eq!(
result
.models
.iter()
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec!["fireworks/accounts/fireworks/models/glm-5p2"],
"embedding model (enrichment-known, not tool-calling) must be dropped; \
the slashed native id survives in the managed key"
);
let entry = &result.models[0];
assert_eq!(entry.context_window.get(), 1_048_575);
assert_eq!(entry.max_completion_tokens, Some(65_536));
// The NATIVE slashed id rides the inference wire (`model` field);
// the `fireworks/` managed-key prefix is internal routing only. A
// regression here would 404 every Fireworks request.
assert_eq!(
entry.model, "accounts/fireworks/models/glm-5p2",
"wire model must be the native id, not the managed key"
);
// The managed key parses back to (Fireworks, native-slashed-id).
assert_eq!(
kigi_models::parse_managed_model_key(entry.id.as_deref().unwrap()),
Some((
kigi_models::PlatformId::Fireworks,
"accounts/fireworks/models/glm-5p2"
))
);
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
let creds = crate::agent::config::ResolvedCredentials {
api_key: Some("fw-1".into()),
base_url: entry.base_url.clone(),
auth_type: kigi_chat_state::AuthType::ApiKey,
auth_scheme: Default::default(),
};
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
assert_eq!(
cfg.chat_compat,
kigi_sampling_types::ChatCompat::Passthrough
);
assert_eq!(
cfg.model, "accounts/fireworks/models/glm-5p2",
"the sampler wire model is the native slashed id end-to-end"
);
}
/// Google/Gemini-cycle e2e: the OpenAI-compat listing returns
/// `models/`-PREFIXED ids (Google's real shape), which the Google spec's
/// `strip_listing_id_prefix` canonicalizes to the bare form the models.dev
/// snapshot + chat endpoint use — WITHOUT the strip, restrict_to_enriched
/// would silently drop every Gemini model. Embedding pollution is
/// restricted away; Passthrough dialect; bare id on the wire.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn google_compat_listing_strips_prefix_restricts_and_maps_passthrough() {
let platform_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/models"))
.and(wiremock::matchers::header("Authorization", "Bearer gk-1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "data": [
// Real Gemini compat shape: `models/`-prefixed ids.
{ "id": "models/gemini-2.5-pro", "object": "model" },
{ "id": "models/gemini-embedding-001", "object": "model" }
]}),
))
.expect(1)
.mount(&platform_server)
.await;
let modelsdev_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "google": { "models": {
"gemini-2.5-pro": {
"limit": {"context": 1048576, "output": 65536},
"tool_call": true
},
"gemini-embedding-001": { "limit": {"context": 2048} }
}}}),
))
.expect(1)
.mount(&modelsdev_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
let _base = kigi_test_support::EnvGuard::set(
kigi_models::GOOGLE_BASE_URL_ENV,
platform_server.uri(),
);
let _mdev = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
format!("{}/api.json", modelsdev_server.uri()),
);
let _mdev_cache = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
cache_dir.path(),
);
let endpoints = crate::agent::config::EndpointsConfig::default();
let keys = crate::agent::models::PlatformApiKeys::test_single(
kigi_models::PlatformId::Google,
"gk-1",
);
let result = tokio::task::spawn_blocking(move || {
fetch_platform_models_blocking(&endpoints, None, &keys)
})
.await
.unwrap()
.expect("fetch must succeed");
assert_eq!(
result
.models
.iter()
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec!["google/gemini-2.5-pro"],
"prefix stripped → matches bare snapshot → kept as the bare \
managed key; embedding (not tool-calling) dropped"
);
let entry = &result.models[0];
assert_eq!(
entry.context_window.get(),
1_048_576,
"enrichment matched the bare id"
);
assert_eq!(entry.max_completion_tokens, Some(65_536));
assert_eq!(
entry.model, "gemini-2.5-pro",
"the BARE Gemini id rides the wire (chat rejects the models/ prefix)"
);
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
let creds = crate::agent::config::ResolvedCredentials {
api_key: Some("gk-1".into()),
base_url: entry.base_url.clone(),
auth_type: kigi_chat_state::AuthType::ApiKey,
auth_scheme: Default::default(),
};
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
assert_eq!(
cfg.chat_compat,
kigi_sampling_types::ChatCompat::Passthrough
);
}
/// OpenRouter-cycle e2e: wire_serves_metadata=true — the listing itself
/// carries `context_length`, so context comes from the WIRE with NO
/// enrichment fetch and NO restriction (all models kept). The models.dev
/// refresh is disabled to prove it is never consulted. Slashed ids
/// round-trip; Passthrough dialect.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn openrouter_wire_metadata_needs_no_enrichment_and_keeps_all() {
let platform_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/models"))
.and(wiremock::matchers::header("Authorization", "Bearer or-1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "data": [
{ "id": "anthropic/claude-opus-4.8", "context_length": 1000000,
"supported_parameters": ["reasoning_effort", "tools"] },
{ "id": "openai/gpt-5.5", "context_length": 400000,
"supported_parameters": ["tools"] }
]}),
))
.expect(1)
.mount(&platform_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
let _base = kigi_test_support::EnvGuard::set(
kigi_models::OPENROUTER_BASE_URL_ENV,
platform_server.uri(),
);
// Kill switch: proves enrichment is never fetched for a wire-served
// platform (any attempt would need this URL).
let _mdev = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
"0",
);
let _mdev_cache = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
cache_dir.path(),
);
let endpoints = crate::agent::config::EndpointsConfig::default();
let keys = crate::agent::models::PlatformApiKeys::test_single(
kigi_models::PlatformId::OpenRouter,
"or-1",
);
let result = tokio::task::spawn_blocking(move || {
fetch_platform_models_blocking(&endpoints, None, &keys)
})
.await
.unwrap()
.expect("fetch must succeed");
assert_eq!(
result
.models
.iter()
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec![
"openrouter/anthropic/claude-opus-4.8",
"openrouter/openai/gpt-5.5"
],
"no restriction — all listed models kept; slashed ids in the key"
);
let opus = &result.models[0];
assert_eq!(
opus.context_window.get(),
1_000_000,
"context window comes from the wire listing, not enrichment"
);
assert_eq!(
opus.model, "anthropic/claude-opus-4.8",
"the native slashed id rides the wire"
);
assert_eq!(
kigi_models::parse_managed_model_key(opus.id.as_deref().unwrap()),
Some((
kigi_models::PlatformId::OpenRouter,
"anthropic/claude-opus-4.8"
))
);
let model_entry = crate::agent::config::ModelEntry::from_config_entry(opus);
let creds = crate::agent::config::ResolvedCredentials {
api_key: Some("or-1".into()),
base_url: opus.base_url.clone(),
auth_type: kigi_chat_state::AuthType::ApiKey,
auth_scheme: Default::default(),
};
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
assert_eq!(
cfg.chat_compat,
kigi_sampling_types::ChatCompat::Passthrough
);
}
/// Together-cycle e2e: the listing is a BARE JSON ARRAY (no {data:[]}
/// envelope) — parse_openai_listing tolerates it. models.dev enrichment
/// (matching org/Model keys) supplies context + the tool-calling
/// restriction drops non-chat models; Passthrough dialect; slashed id.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn together_bare_array_listing_enriches_restricts_and_maps_passthrough() {
let platform_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/models"))
.and(wiremock::matchers::header("Authorization", "Bearer tg-1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
// BARE ARRAY, not {object:list,data:[]}.
serde_json::json!([
{ "id": "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8", "object": "model",
"type": "chat", "context_length": 262144 },
{ "id": "togethercomputer/m2-bert-80M-8k-retrieval", "object": "model",
"type": "embedding" }
]),
))
.expect(1)
.mount(&platform_server)
.await;
let modelsdev_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "togetherai": { "models": {
"Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8": {
"limit": {"context": 262144, "output": 32768},
"tool_call": true
},
"togethercomputer/m2-bert-80M-8k-retrieval": { "limit": {"context": 8192} }
}}}),
))
.expect(1)
.mount(&modelsdev_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
let _base = kigi_test_support::EnvGuard::set(
kigi_models::TOGETHER_BASE_URL_ENV,
platform_server.uri(),
);
let _mdev = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
format!("{}/api.json", modelsdev_server.uri()),
);
let _mdev_cache = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
cache_dir.path(),
);
let endpoints = crate::agent::config::EndpointsConfig::default();
let keys = crate::agent::models::PlatformApiKeys::test_single(
kigi_models::PlatformId::Together,
"tg-1",
);
let result = tokio::task::spawn_blocking(move || {
fetch_platform_models_blocking(&endpoints, None, &keys)
})
.await
.unwrap()
.expect("fetch must succeed");
assert_eq!(
result
.models
.iter()
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec!["together/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8"],
"bare array parsed; embedding (not tool-calling) dropped; slashed id in key"
);
let entry = &result.models[0];
assert_eq!(entry.context_window.get(), 262_144);
assert_eq!(entry.max_completion_tokens, Some(32_768));
assert_eq!(
entry.model, "Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8",
"the native slashed id rides the wire"
);
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
let creds = crate::agent::config::ResolvedCredentials {
api_key: Some("tg-1".into()),
base_url: entry.base_url.clone(),
auth_type: kigi_chat_state::AuthType::ApiKey,
auth_scheme: Default::default(),
};
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
assert_eq!(
cfg.chat_compat,
kigi_sampling_types::ChatCompat::Passthrough
);
}
/// Cerebras-cycle e2e: enrich WITHOUT restrict (the unpolluted-catalog
/// path). A minimal listing (bare ids, no context) → every live model is
/// kept; the enrichment-known one gains context + an effort menu, the
/// unknown one keeps the default context. Passthrough dialect.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn cerebras_enriches_without_restrict_keeping_all_models() {
let platform_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/models"))
.and(wiremock::matchers::header("Authorization", "Bearer cb-1"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
// Minimal Cerebras listing: ids only, no context.
serde_json::json!({ "data": [
{ "id": "gpt-oss-120b", "object": "model" },
{ "id": "brand-new-cerebras-model", "object": "model" }
]}),
))
.expect(1)
.mount(&platform_server)
.await;
let modelsdev_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "cerebras": { "models": {
"gpt-oss-120b": {
"limit": {"context": 131072, "output": 40960},
"reasoning": true,
"reasoning_options": [
{"type": "effort", "values": ["low", "medium", "high"]}
],
"tool_call": true
}
}}}),
))
.expect(1)
.mount(&modelsdev_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
let _base = kigi_test_support::EnvGuard::set(
kigi_models::CEREBRAS_BASE_URL_ENV,
platform_server.uri(),
);
let _mdev = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
format!("{}/api.json", modelsdev_server.uri()),
);
let _mdev_cache = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
cache_dir.path(),
);
let endpoints = crate::agent::config::EndpointsConfig::default();
let keys = crate::agent::models::PlatformApiKeys::test_single(
kigi_models::PlatformId::Cerebras,
"cb-1",
);
let result = tokio::task::spawn_blocking(move || {
fetch_platform_models_blocking(&endpoints, None, &keys)
})
.await
.unwrap()
.expect("fetch must succeed");
// restrict=false → BOTH the known and the unknown model are kept.
assert_eq!(
result
.models
.iter()
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec!["cerebras/gpt-oss-120b", "cerebras/brand-new-cerebras-model"],
"enrich-without-restrict keeps every live model"
);
let known = &result.models[0];
assert_eq!(known.context_window.get(), 131_072, "known model enriched");
assert_eq!(known.max_completion_tokens, Some(40_960));
assert!(
known.supports_reasoning_effort,
"enrichment effort menu → selectable levels"
);
assert_eq!(
known
.reasoning_efforts
.iter()
.map(|o| o.id.as_str())
.collect::<Vec<_>>(),
vec!["low", "medium", "high"],
"effort menu comes from enrichment"
);
let unknown = &result.models[1];
assert_eq!(
unknown.context_window.get(),
DEFAULT_CONTEXT_WINDOW,
"an enrichment-unknown model keeps the default context (not dropped)"
);
let model_entry = crate::agent::config::ModelEntry::from_config_entry(known);
let creds = crate::agent::config::ResolvedCredentials {
api_key: Some("cb-1".into()),
base_url: known.base_url.clone(),
auth_type: kigi_chat_state::AuthType::ApiKey,
auth_scheme: Default::default(),
};
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
assert_eq!(
cfg.chat_compat,
kigi_sampling_types::ChatCompat::StrictOpenAi,
"Cerebras uses the StrictOpenAi dialect (strict validator strips stream_options)"
);
}
/// NVIDIA-cycle e2e: polluted listing (embedding/image models) restricted
/// to tool-calling enrichment-known chat models; slashed org/model ids;
/// StrictOpenAi dialect (strips stream_options — some NIM models reject it).
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn nvidia_restricts_slashed_ids_and_maps_strict_dialect() {
let platform_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/models"))
.and(wiremock::matchers::header(
"Authorization",
"Bearer nvapi-1",
))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "data": [
{ "id": "meta/llama-3.3-70b-instruct", "object": "model" },
{ "id": "baai/bge-m3", "object": "model" }
]}),
))
.expect(1)
.mount(&platform_server)
.await;
let modelsdev_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "nvidia": { "models": {
"meta/llama-3.3-70b-instruct": {
"limit": {"context": 128000, "output": 32768},
"tool_call": true
},
"baai/bge-m3": { "limit": {"context": 8192} }
}}}),
))
.expect(1)
.mount(&modelsdev_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
let _base = kigi_test_support::EnvGuard::set(
kigi_models::NVIDIA_BASE_URL_ENV,
platform_server.uri(),
);
let _mdev = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
format!("{}/api.json", modelsdev_server.uri()),
);
let _mdev_cache = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
cache_dir.path(),
);
let endpoints = crate::agent::config::EndpointsConfig::default();
let keys = crate::agent::models::PlatformApiKeys::test_single(
kigi_models::PlatformId::Nvidia,
"nvapi-1",
);
let result = tokio::task::spawn_blocking(move || {
fetch_platform_models_blocking(&endpoints, None, &keys)
})
.await
.unwrap()
.expect("fetch must succeed");
assert_eq!(
result
.models
.iter()
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec!["nvidia/meta/llama-3.3-70b-instruct"],
"bge-m3 embedding (not tool-calling) dropped; slashed org/model id kept"
);
let entry = &result.models[0];
assert_eq!(entry.context_window.get(), 128_000);
assert_eq!(entry.max_completion_tokens, Some(32_768));
assert_eq!(
entry.model, "meta/llama-3.3-70b-instruct",
"the native slashed id rides the wire"
);
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
let creds = crate::agent::config::ResolvedCredentials {
api_key: Some("nvapi-1".into()),
base_url: entry.base_url.clone(),
auth_type: kigi_chat_state::AuthType::ApiKey,
auth_scheme: Default::default(),
};
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
assert_eq!(
cfg.chat_compat,
kigi_sampling_types::ChatCompat::StrictOpenAi,
"NVIDIA uses StrictOpenAi (strips stream_options for NIM models that reject it)"
);
}
/// Vercel-cycle e2e: the gateway lists creator/model ids (matching the
/// models.dev "vercel" keys); enrichment supplies context (the wire uses
/// `context_window`, not the WireModel `context_length`); the restriction
/// drops non-chat types; slashed id; Passthrough dialect.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn vercel_gateway_enriches_restricts_and_maps_passthrough() {
let platform_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/models"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
// Vercel serves context under `context_window` (ignored by
// WireModel) — enrichment supplies the real context.
serde_json::json!({ "data": [
// A DISTINCT (wrong) context_window that WireModel ignores —
// so asserting the enrichment value below proves the source.
{ "id": "openai/gpt-5.5", "object": "model",
"type": "language", "context_window": 999 },
{ "id": "voyage/rerank-2.5", "object": "model", "type": "embedding" }
]}),
))
.expect(1)
.mount(&platform_server)
.await;
let modelsdev_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "vercel": { "models": {
"openai/gpt-5.5": {
"limit": {"context": 400000, "output": 128000},
"tool_call": true
},
"voyage/rerank-2.5": { "limit": {"context": 32000} }
}}}),
))
.expect(1)
.mount(&modelsdev_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
let _base = kigi_test_support::EnvGuard::set(
kigi_models::VERCEL_BASE_URL_ENV,
platform_server.uri(),
);
let _mdev = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
format!("{}/api.json", modelsdev_server.uri()),
);
let _mdev_cache = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
cache_dir.path(),
);
let endpoints = crate::agent::config::EndpointsConfig::default();
let keys = crate::agent::models::PlatformApiKeys::test_single(
kigi_models::PlatformId::Vercel,
"vg-1",
);
let result = tokio::task::spawn_blocking(move || {
fetch_platform_models_blocking(&endpoints, None, &keys)
})
.await
.unwrap()
.expect("fetch must succeed");
assert_eq!(
result
.models
.iter()
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec!["vercel-ai-gateway/openai/gpt-5.5"],
"rerank (not tool-calling) dropped; creator/model id kept under the platform key"
);
let entry = &result.models[0];
assert_eq!(
entry.context_window.get(),
400_000,
"context comes from enrichment (wire used context_window, not context_length)"
);
assert_eq!(entry.max_completion_tokens, Some(128_000));
assert_eq!(
entry.model, "openai/gpt-5.5",
"the creator/model id rides the wire"
);
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
let creds = crate::agent::config::ResolvedCredentials {
api_key: Some("vg-1".into()),
base_url: entry.base_url.clone(),
auth_type: kigi_chat_state::AuthType::ApiKey,
auth_scheme: Default::default(),
};
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
assert_eq!(
cfg.chat_compat,
kigi_sampling_types::ChatCompat::Passthrough
);
}
/// xAI-cycle e2e: /v1/models is minimal (bare grok ids, no context), so
/// enrichment supplies context/limits; the restriction drops the
/// non-tool-calling grok-imagine-* generators; bare id round-trips under
/// the `xai/` key; Passthrough dialect.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn xai_enriches_restricts_and_maps_passthrough() {
let platform_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/models"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
// xAI /v1/models is minimal: id/object only, NO context field —
// so a non-zero context below can only come from enrichment.
serde_json::json!({ "data": [
{ "id": "grok-4.5", "object": "model" },
{ "id": "grok-imagine-image", "object": "model" }
]}),
))
.expect(1)
.mount(&platform_server)
.await;
let modelsdev_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "xai": { "models": {
"grok-4.5": {
"limit": {"context": 500000, "output": 128000},
"tool_call": true
},
// present in enrichment too, but not tool-calling → dropped.
"grok-imagine-image": { "limit": {"context": 8000} }
}}}),
))
.expect(1)
.mount(&modelsdev_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
let _base =
kigi_test_support::EnvGuard::set(kigi_models::XAI_BASE_URL_ENV, platform_server.uri());
let _mdev = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
format!("{}/api.json", modelsdev_server.uri()),
);
let _mdev_cache = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
cache_dir.path(),
);
let endpoints = crate::agent::config::EndpointsConfig::default();
let keys = crate::agent::models::PlatformApiKeys::test_single(
kigi_models::PlatformId::Xai,
"xai-1",
);
let result = tokio::task::spawn_blocking(move || {
fetch_platform_models_blocking(&endpoints, None, &keys)
})
.await
.unwrap()
.expect("fetch must succeed");
assert_eq!(
result
.models
.iter()
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec!["xai/grok-4.5"],
"grok-imagine-image (not tool-calling) dropped; bare id kept under the xai key"
);
let entry = &result.models[0];
assert_eq!(
entry.context_window.get(),
500_000,
"context comes from enrichment (the wire listing carries none)"
);
assert_eq!(entry.max_completion_tokens, Some(128_000));
assert_eq!(entry.model, "grok-4.5", "the bare grok id rides the wire");
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
let creds = crate::agent::config::ResolvedCredentials {
api_key: Some("xai-1".into()),
base_url: entry.base_url.clone(),
auth_type: kigi_chat_state::AuthType::ApiKey,
auth_scheme: Default::default(),
};
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
assert_eq!(
cfg.chat_compat,
kigi_sampling_types::ChatCompat::Passthrough
);
}
/// Qwen-Token-Plan e2e: DashScope compatible-mode /models is auth-gated and
/// minimal (ids only), so enrichment supplies context; the restriction drops
/// the non-tool-calling qwen-image / wan generators the token plan lists;
/// bare id round-trips under the platform key; Passthrough dialect.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn qwen_token_plan_enriches_restricts_and_maps_passthrough() {
let platform_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/models"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
// DashScope /models is minimal: id/object only, NO context.
serde_json::json!({ "data": [
{ "id": "qwen3.7-max", "object": "model" },
{ "id": "qwen-image-2.0", "object": "model" }
]}),
))
.expect(1)
.mount(&platform_server)
.await;
let modelsdev_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "alibaba-token-plan": { "models": {
"qwen3.7-max": {
"limit": {"context": 1000000, "output": 32768},
"tool_call": true
},
// present in enrichment too, but not tool-calling → dropped.
"qwen-image-2.0": { "limit": {"context": 8192} }
}}}),
))
.expect(1)
.mount(&modelsdev_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
let _base = kigi_test_support::EnvGuard::set(
kigi_models::QWEN_TOKEN_PLAN_BASE_URL_ENV,
platform_server.uri(),
);
let _mdev = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
format!("{}/api.json", modelsdev_server.uri()),
);
let _mdev_cache = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
cache_dir.path(),
);
let endpoints = crate::agent::config::EndpointsConfig::default();
let keys = crate::agent::models::PlatformApiKeys::test_single(
kigi_models::PlatformId::QwenTokenPlan,
"qtp-1",
);
let result = tokio::task::spawn_blocking(move || {
fetch_platform_models_blocking(&endpoints, None, &keys)
})
.await
.unwrap()
.expect("fetch must succeed");
assert_eq!(
result
.models
.iter()
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec!["qwen-token-plan/qwen3.7-max"],
"qwen-image (not tool-calling) dropped; bare id kept under the platform key"
);
let entry = &result.models[0];
assert_eq!(
entry.context_window.get(),
1_000_000,
"context comes from enrichment (the wire listing carries none)"
);
assert_eq!(entry.max_completion_tokens, Some(32_768));
assert_eq!(entry.model, "qwen3.7-max", "the bare id rides the wire");
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
let creds = crate::agent::config::ResolvedCredentials {
api_key: Some("qtp-1".into()),
base_url: entry.base_url.clone(),
auth_type: kigi_chat_state::AuthType::ApiKey,
auth_scheme: Default::default(),
};
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
assert_eq!(
cfg.chat_compat,
kigi_sampling_types::ChatCompat::Passthrough
);
}
/// Kimi-For-Coding static-key e2e: same endpoint + Kimi dialect as the OAuth
/// kimi-code platform, keyed by KIMI_API_KEY. Kimi's /models serves its own
/// metadata (wire_serves_metadata), so context comes from the WIRE and the
/// models.dev fetch is skipped entirely (all enabled platforms self-serve);
/// no restriction; Kimi dialect.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
async fn kimi_coding_static_key_uses_wire_metadata_and_kimi_dialect() {
let platform_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/models"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "data": [
{ "id": "k3", "object": "model", "context_length": 1_048_576,
"supports_reasoning": true }
]}),
))
.expect(1)
.mount(&platform_server)
.await;
// Point models.dev at an ALWAYS-500 server: enrichment must NOT be
// fetched for an all-wire-metadata provider, so this is never hit.
let modelsdev_server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
.and(wiremock::matchers::path("/api.json"))
.respond_with(wiremock::ResponseTemplate::new(500))
.expect(0)
.mount(&modelsdev_server)
.await;
let cache_dir = tempfile::tempdir().unwrap();
let _base =
kigi_test_support::EnvGuard::set(kigi_env::CODE_BASE_URL_ENV, platform_server.uri());
let _mdev = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
format!("{}/api.json", modelsdev_server.uri()),
);
let _mdev_cache = kigi_test_support::EnvGuard::set(
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
cache_dir.path(),
);
let endpoints = crate::agent::config::EndpointsConfig::default();
let keys = crate::agent::models::PlatformApiKeys::test_single(
kigi_models::PlatformId::KimiCoding,
"kc-1",
);
let result = tokio::task::spawn_blocking(move || {
fetch_platform_models_blocking(&endpoints, None, &keys)
})
.await
.unwrap()
.expect("fetch must succeed");
assert_eq!(
result
.models
.iter()
.map(|m| m.id.as_deref().unwrap_or_default())
.collect::<Vec<_>>(),
vec!["kimi-coding/k3"],
"wire model kept under the platform key (no restriction)"
);
let entry = &result.models[0];
assert_eq!(
entry.context_window.get(),
1_048_576,
"context comes from the WIRE (wire_serves_metadata); enrichment was skipped"
);
assert_eq!(entry.model, "k3");
// The managed key must parse back to KimiCoding — so the Kimi dialect
// below is a real attribution, not the default-dialect fallback that a
// failed parse would also yield.
assert_eq!(
kigi_models::parse_managed_model_key(entry.id.as_deref().unwrap()),
Some((kigi_models::PlatformId::KimiCoding, "k3")),
);
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
let creds = crate::agent::config::ResolvedCredentials {
api_key: Some("kc-1".into()),
base_url: entry.base_url.clone(),
auth_type: kigi_chat_state::AuthType::ApiKey,
auth_scheme: Default::default(),
};
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
assert_eq!(cfg.chat_compat, kigi_sampling_types::ChatCompat::Kimi);
}
#[test]
fn get_env_keys_parses_strings_and_rejects_non_strings() {
use crate::agent::config::EnvKeys;
let parse = |v: serde_json::Value| {
let obj = serde_json::json!({ "env_key" : v });
get_env_keys(obj.as_object().unwrap(), "env_key")
};
assert_eq!(parse(serde_json::json!("A")), Some(EnvKeys::single("A")));
assert_eq!(
parse(serde_json::json!(["A", "B"])),
Some(EnvKeys::new(["A", "B"]))
);
assert_eq!(parse(serde_json::json!(["A", 123])), None);
assert_eq!(parse(serde_json::json!([])), None);
}
#[test]
fn parse_openai_format_uses_id_field() {
let value = serde_json::json!(
{ "id" : "kigi-3", "object" : "model", "owned_by" : "xai", "context_window" :
131072 }
);
let result = parse_remote_model_value(&value, "https://byok.example/v1").unwrap();
assert_eq!(result.model, "kigi-3");
assert_eq!(result.base_url, "https://byok.example/v1");
assert_eq!(result.name.as_deref(), Some("kigi-3"));
}
#[test]
fn parse_model_field_takes_priority_over_id() {
let value = serde_json::json!(
{ "id" : "display-key", "model" : "actual-model-id", "name" : "Display Name",
"context_window" : 131072 }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert_eq!(result.model, "actual-model-id");
assert_eq!(result.name.as_deref(), Some("Display Name"));
}
/// Live-wire regression: the K3 `/models` entry (api.kimi.com, 2026-07)
/// must land in the catalog with selectable low/high/max efforts and a
/// max default — this is what feeds `/model <m> [effort]` and `/effort`.
#[test]
fn platform_entry_maps_live_k3_think_efforts() {
use kigi_sampling_types::ReasoningEffort;
let wire: kigi_models::WireModel = serde_json::from_value(serde_json::json!({
"id": "k3",
"display_name": "K3",
"context_length": 1_048_576,
"supports_reasoning": true,
"supports_image_in": true,
"supports_video_in": true,
"supports_thinking_type": "only",
"think_efforts": {
"support": true,
"valid_efforts": ["low", "high", "max"],
"default_effort": "max"
}
}))
.unwrap();
let entry = platform_wire_model_to_entry(
kigi_models::PlatformId::KimiCode,
wire,
"https://api.kimi.com/coding/v1",
);
assert!(entry.supports_reasoning_effort);
// The wire token "max" is canonical Max since the Xhigh/Max split;
// kimi_compat still spells it "max" on the inference wire.
assert_eq!(entry.reasoning_effort, Some(ReasoningEffort::Max));
let ids: Vec<&str> = entry
.reasoning_efforts
.iter()
.map(|o| o.id.as_str())
.collect();
assert_eq!(
ids,
["low", "high", "max"],
"wire tokens stay the option ids"
);
assert_eq!(
entry
.reasoning_efforts
.iter()
.map(|o| o.value)
.collect::<Vec<_>>(),
[
ReasoningEffort::Low,
ReasoningEffort::High,
ReasoningEffort::Max
],
);
let max = entry
.reasoning_efforts
.iter()
.find(|o| o.id == "max")
.unwrap();
assert!(max.default, "max is the server default for K3");
assert_eq!(max.label, "Max");
// K2.7-style entries (no think_efforts) stay effort-less.
let plain: kigi_models::WireModel = serde_json::from_value(serde_json::json!({
"id": "kimi-for-coding",
"context_length": 262_144,
"supports_reasoning": true,
"supports_thinking_type": "only"
}))
.unwrap();
let entry = platform_wire_model_to_entry(
kigi_models::PlatformId::KimiCode,
plain,
"https://api.kimi.com/coding/v1",
);
assert!(!entry.supports_reasoning_effort);
assert!(entry.reasoning_efforts.is_empty());
assert!(entry.reasoning_effort.is_none());
}
#[test]
fn parse_reads_reasoning_effort_fields() {
use kigi_sampling_types::ReasoningEffort;
let value = serde_json::json!(
{ "model" : "kigi-4.5", "context_window" : 1_000_000,
"supports_reasoning_effort" : true, "reasoning_effort" : "high" }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert!(result.supports_reasoning_effort);
assert_eq!(result.reasoning_effort, Some(ReasoningEffort::High));
let value = serde_json::json!(
{ "model" : "kigi-4.5", "contextWindow" : 1_000_000,
"supportsReasoningEffort" : true, "reasoningEffort" : "xhigh" }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert!(result.supports_reasoning_effort);
assert_eq!(result.reasoning_effort, Some(ReasoningEffort::Xhigh));
let value = serde_json::json!({ "model" : "x", "context_window" : 256_000 });
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert!(!result.supports_reasoning_effort);
assert!(result.reasoning_effort.is_none());
}
#[test]
fn parse_reads_reasoning_efforts_list() {
use kigi_sampling_types::ReasoningEffort;
let value = serde_json::json!(
{ "model" : "kigi-4.5", "context_window" : 1_000_000, "reasoning_efforts" :
[{ "id" : "deep", "value" : "xhigh", "label" : "Deep" }, { "value" :
"quantum" }, "low",] }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert_eq!(result.reasoning_efforts.len(), 2);
assert_eq!(result.reasoning_efforts[0].id, "deep");
assert_eq!(result.reasoning_efforts[0].value, ReasoningEffort::Xhigh);
assert_eq!(result.reasoning_efforts[1].value, ReasoningEffort::Low);
for value in [
serde_json::json!(
{ "model" : "m", "context_window" : 256_000, "reasoningEfforts" : [{
"value" : "high" }] }
),
serde_json::json!(
{ "model" : "m", "context_window" : 256_000, "_meta" : {
"reasoningEfforts" : [{ "value" : "high" }] } }
),
] {
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert_eq!(result.reasoning_efforts.len(), 1);
assert_eq!(result.reasoning_efforts[0].value, ReasoningEffort::High);
}
let value = serde_json::json!({ "model" : "x", "context_window" : 256_000 });
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert!(result.reasoning_efforts.is_empty());
}
#[test]
fn parse_reads_meta_fallback_fields() {
let value = serde_json::json!(
{ "_meta" : { "model" : "meta-model-id", "contextWindow" : 131072,
"agentType" : "concise" } }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert_eq!(result.model, "meta-model-id");
assert_eq!(
result.context_window,
std::num::NonZeroU64::new(131072).unwrap()
);
assert_eq!(result.agent_type, "concise");
}
#[test]
fn parse_remote_model_value_no_laziness_detector_block_yields_default() {
let value = serde_json::json!(
{ "model" : "kigi-4", "context_window" : 256_000, }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert_eq!(
result.laziness_detector,
crate::agent::config::LazinessDetectorPerModelConfig::default()
);
}
#[test]
fn parse_remote_model_value_parses_camelcase_key() {
let value = serde_json::json!(
{ "model" : "kigi-4", "context_window" : 256_000, "lazinessDetector" : {
"enabled" : true, "max_nudges_per_session" : 2, "idle_threshold_ms" : 12_000,
"min_confidence" : 0.75, }, }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
let expected = crate::agent::config::LazinessDetectorPerModelConfig {
enabled: true,
max_nudges_per_session: 2,
idle_threshold_ms: Some(12_000),
min_confidence: Some(0.75),
include_reasoning: None,
};
assert_eq!(result.laziness_detector, expected);
}
#[test]
fn parse_remote_model_value_parses_snake_case_laziness_detector() {
let value = serde_json::json!(
{ "model" : "kigi-4", "context_window" : 256_000, "laziness_detector" : {
"enabled" : true, "max_nudges_per_session" : 3, "idle_threshold_ms" : 8_000,
"min_confidence" : 0.6, }, }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
let expected = crate::agent::config::LazinessDetectorPerModelConfig {
enabled: true,
max_nudges_per_session: 3,
idle_threshold_ms: Some(8_000),
min_confidence: Some(0.6),
include_reasoning: None,
};
assert_eq!(result.laziness_detector, expected);
}
#[test]
fn parse_remote_model_value_parses_meta_laziness_detector() {
let value = serde_json::json!(
{ "model" : "kigi-4", "context_window" : 256_000, "_meta" : {
"lazinessDetector" : { "enabled" : true, "max_nudges_per_session" : 1,
"idle_threshold_ms" : 15_000, "min_confidence" : 0.9, }, }, }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
let expected = crate::agent::config::LazinessDetectorPerModelConfig {
enabled: true,
max_nudges_per_session: 1,
idle_threshold_ms: Some(15_000),
min_confidence: Some(0.9),
include_reasoning: None,
};
assert_eq!(result.laziness_detector, expected);
}
#[test]
fn parse_remote_model_value_partial_block_uses_field_defaults() {
let value = serde_json::json!(
{ "model" : "kigi-4", "context_window" : 256_000, "lazinessDetector" : {
"enabled" : true, }, }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
let expected = crate::agent::config::LazinessDetectorPerModelConfig {
enabled: true,
max_nudges_per_session: 0,
idle_threshold_ms: None,
min_confidence: None,
include_reasoning: None,
};
assert_eq!(result.laziness_detector, expected);
}
#[test]
fn parse_remote_model_value_malformed_block_falls_back_to_default() {
let value = serde_json::json!(
{ "model" : "kigi-4", "context_window" : 256_000, "lazinessDetector" : {
"enabled" : true, "max_nudges_per_session" : "abc", }, }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert_eq!(
result.laziness_detector,
crate::agent::config::LazinessDetectorPerModelConfig::default()
);
}
#[test]
fn parse_remote_model_value_non_object_value_falls_back_to_default() {
let value = serde_json::json!(
{ "model" : "kigi-4", "context_window" : 256_000, "lazinessDetector" :
"not-an-object", }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert_eq!(
result.laziness_detector,
crate::agent::config::LazinessDetectorPerModelConfig::default()
);
}
#[test]
fn parse_remote_model_value_top_level_camelcase_wins_over_snake_case() {
let value = serde_json::json!(
{ "model" : "kigi-4", "context_window" : 256_000, "lazinessDetector" : {
"enabled" : true, "max_nudges_per_session" : 7, }, "laziness_detector" : {
"enabled" : false, "max_nudges_per_session" : 99, }, }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
let expected = crate::agent::config::LazinessDetectorPerModelConfig {
enabled: true,
max_nudges_per_session: 7,
idle_threshold_ms: None,
min_confidence: None,
include_reasoning: None,
};
assert_eq!(result.laziness_detector, expected);
}
/// `include_reasoning: false` parses cleanly under the per-model
/// `lazinessDetector` block (camelCase wrapper, snake_case inner —
/// matching the existing field-naming convention used for the
/// sibling `min_confidence`, `idle_threshold_ms`, etc.).
#[test]
fn parse_remote_model_value_parses_include_reasoning_under_camelcase_wrapper() {
let value = serde_json::json!(
{ "model" : "kigi-4", "context_window" : 256_000, "lazinessDetector" : {
"enabled" : true, "include_reasoning" : false, }, }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert_eq!(result.laziness_detector.include_reasoning, Some(false));
}
#[test]
fn parse_remote_model_value_parses_include_reasoning_under_snake_case_wrapper() {
let value = serde_json::json!(
{ "model" : "kigi-4", "context_window" : 256_000, "laziness_detector" : {
"enabled" : true, "include_reasoning" : true, }, }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert_eq!(result.laziness_detector.include_reasoning, Some(true));
}
#[test]
fn parse_remote_model_value_omitted_include_reasoning_defaults_to_none() {
let value = serde_json::json!(
{ "model" : "kigi-4", "context_window" : 256_000, "lazinessDetector" : {
"enabled" : true, "max_nudges_per_session" : 2, }, }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert_eq!(
result.laziness_detector.include_reasoning, None,
"absent include_reasoning defers to harness default via None",
);
}
#[test]
fn parse_remote_model_value_top_level_wins_over_meta() {
let value = serde_json::json!(
{ "model" : "kigi-4", "context_window" : 256_000, "lazinessDetector" : {
"enabled" : true, "max_nudges_per_session" : 5, }, "_meta" : {
"lazinessDetector" : { "enabled" : false, "max_nudges_per_session" : 99, },
}, }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
let expected = crate::agent::config::LazinessDetectorPerModelConfig {
enabled: true,
max_nudges_per_session: 5,
idle_threshold_ms: None,
min_confidence: None,
include_reasoning: None,
};
assert_eq!(result.laziness_detector, expected);
}
#[test]
fn parse_reads_show_model_fingerprint_field() {
let value = serde_json::json!(
{ "model" : "kigi", "context_window" : 256_000,
"show_model_fingerprint" : true }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert!(result.show_model_fingerprint);
let value = serde_json::json!(
{ "model" : "kigi", "contextWindow" : 256_000, "showModelFingerprint" :
true }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert!(result.show_model_fingerprint);
let value = serde_json::json!(
{ "model" : "kigi", "context_window" : 256_000, "_meta" : {
"showModelFingerprint" : true } }
);
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert!(result.show_model_fingerprint);
let value = serde_json::json!({ "model" : "x", "context_window" : 256_000 });
let result = parse_remote_model_value(&value, "https://default.url").unwrap();
assert!(!result.show_model_fingerprint);
}
#[test]
fn get_object_returns_none_for_non_object_values() {
let value = serde_json::json!(
{ "string" : "hello", "number" : 42, "bool" : true, "array" : [1, 2, 3],
"null" : null, }
);
let obj = value.as_object().unwrap();
assert!(get_object(obj, "string").is_none());
assert!(get_object(obj, "number").is_none());
assert!(get_object(obj, "bool").is_none());
assert!(get_object(obj, "array").is_none());
assert!(get_object(obj, "null").is_none());
assert!(get_object(obj, "missing").is_none());
}
#[test]
fn get_object_returns_some_for_actual_object() {
let value = serde_json::json!({ "nested" : { "a" : 1, "b" : "two" }, });
let obj = value.as_object().unwrap();
let nested = get_object(obj, "nested").expect("nested key should resolve to object");
assert!(nested.is_object());
assert_eq!(nested["a"], serde_json::json!(1));
assert_eq!(nested["b"], serde_json::json!("two"));
}
fn endpoints(
proxy: &str,
models_base_url: Option<&str>,
models_list_url: Option<&str>,
) -> crate::agent::config::EndpointsConfig {
crate::agent::config::EndpointsConfig {
coding_api_base_url: Some(proxy.to_owned()),
models_base_url: models_base_url.map(|s| s.to_owned()),
models_list_url: models_list_url.map(|s| s.to_owned()),
..Default::default()
}
}
#[test]
fn inference_url_defaults_to_proxy() {
let ep = endpoints("https://proxy.kigi.com/v1", None, None);
assert_eq!(ep.resolve_inference_base_url(), "https://proxy.kigi.com/v1");
}
#[test]
fn inference_url_uses_models_base_url() {
let ep = endpoints(
"https://proxy.kigi.com/v1",
Some("https://enterprise.acme.com/v1"),
None,
);
assert_eq!(
ep.resolve_inference_base_url(),
"https://enterprise.acme.com/v1"
);
}
#[test]
fn inference_url_base_url_wins_over_proxy() {
let ep = endpoints(
"https://proxy.kigi.com/v1",
Some("https://inference.acme.com/v1"),
Some("https://registry.acme.com/api/models"),
);
assert_eq!(
ep.resolve_inference_base_url(),
"https://inference.acme.com/v1"
);
}
#[test]
fn list_url_defaults_to_proxy_models() {
let ep = endpoints("https://proxy.kigi.com/v1", None, None);
assert_eq!(
ep.resolve_models_list_url(),
"https://proxy.kigi.com/v1/models"
);
}
#[test]
fn list_url_derived_from_base_url() {
let ep = endpoints(
"https://proxy.kigi.com/v1",
Some("https://byok.example/v1"),
None,
);
assert_eq!(
ep.resolve_models_list_url(),
"https://byok.example/v1/models"
);
}
#[test]
fn list_url_explicit_overrides_derivation() {
let ep = endpoints(
"https://proxy.kigi.com/v1",
Some("https://inference.acme.com/v1"),
Some("https://registry.acme.com/api/list-models"),
);
assert_eq!(
ep.resolve_models_list_url(),
"https://registry.acme.com/api/list-models"
);
}
/// INVARIANT: each platform's `/models` URL matches its registry base —
/// kimi-code → the subscription proxy (config override respected, else the
/// kigi-env default), moonshot platforms → their fixed bases — and the
/// cache-origin key encodes the enabled fetch plan without any secrets.
#[test]
#[serial_test::serial]
fn platform_models_urls_and_fetch_origin() {
use crate::agent::config::EndpointsConfig;
use crate::agent::models::{ModelFetchAuth, PlatformApiKeys};
for k in [
"KIGI_CODE_BASE_URL",
"KIGI_CODE_BASE_URL",
"KIGI_MODELS_LIST_URL",
] {
unsafe { std::env::remove_var(k) };
}
let cfg = EndpointsConfig::from_config_value(&toml::Value::Table(Default::default()));
assert_eq!(
platform_models_url(kigi_models::PlatformId::KimiCode, &cfg),
"https://api.kimi.com/coding/v1/models"
);
assert_eq!(
platform_models_url(kigi_models::PlatformId::MoonshotCn, &cfg),
"https://api.moonshot.cn/v1/models"
);
assert_eq!(
platform_models_url(kigi_models::PlatformId::MoonshotAi, &cfg),
"https://api.moonshot.ai/v1/models"
);
assert_eq!(
platform_models_url(kigi_models::PlatformId::OpenAi, &cfg),
"https://api.openai.com/v1/models"
);
// Proxy override re-points the subscription platform only.
let proxied = EndpointsConfig::from_config_value(
&toml::from_str(
r#"[endpoints]
coding_api_base_url = "https://proxy.acme.example/v1""#,
)
.unwrap(),
);
assert_eq!(
platform_models_url(kigi_models::PlatformId::KimiCode, &proxied),
"https://proxy.acme.example/v1/models"
);
assert_eq!(
platform_models_url(kigi_models::PlatformId::MoonshotCn, &proxied),
"https://api.moonshot.cn/v1/models"
);
// Origin key: OAuth-only plan lists kimi-code only; adding a moonshot
// key changes the plan (→ cache miss); the key VALUE never appears.
let oauth_only = models_fetch_origin(
&cfg,
ModelFetchAuth::Platforms,
true,
&PlatformApiKeys::default(),
);
assert_eq!(
oauth_only,
"platforms[kimi-code=https://api.kimi.com/coding/v1/models]"
);
let with_cn = models_fetch_origin(
&cfg,
ModelFetchAuth::Platforms,
true,
&crate::agent::models::PlatformApiKeys::test_keys(Some("sk-secret-cn"), None),
);
assert_ne!(
oauth_only, with_cn,
"enabling a platform must change the origin"
);
assert!(with_cn.contains("moonshot-cn=https://api.moonshot.cn/v1/models"));
assert!(
!with_cn.contains("sk-secret-cn"),
"origin key must never embed credential values"
);
// Custom endpoint mode → the explicit list URL verbatim.
let custom = EndpointsConfig::from_config_value(
&toml::from_str(
r#"[endpoints]
models_base_url = "https://models.acme.com/v1""#,
)
.unwrap(),
);
assert_eq!(
models_fetch_origin(
&custom,
ModelFetchAuth::CustomEndpoint,
false,
&PlatformApiKeys::default(),
),
"https://models.acme.com/v1/models"
);
}
}