The hardcoded Codex catalog advertised `ultra` for gpt-5.6-sol and gpt-5.6-terra, so selecting it sent `reasoning.effort = "ultra"` and the Responses endpoint answered 400 with its real menu, which ends at `max`. The tier was kigi's invention: the enum documented it as codex-only, the catalog was its only producer, and the upstream model snapshot never listed it. Removing the variant as well as the catalog entries means `/effort ultra` is rejected at the TUI instead of parsing and failing on the wire; a persisted `ultra` degrades to the model default through `lenient_reasoning_effort_opt`, as designed for vocabulary changes.
3835 lines
164 KiB
Rust
3835 lines
164 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.
|
|
//!
|
|
//! 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;
|
|
|
|
#[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>,
|
|
}
|
|
|
|
/// Session bearer tokens for the GENERIC device-code OAuth platforms
|
|
/// (`platform.oauth().is_some()` — xai-grok today), resolved per provider from
|
|
/// its own scope-keyed `AuthManager` (refreshed on expiry) before the blocking
|
|
/// fetch. kimi-code is NOT here — it still rides the single `auth: &KimiAuth`.
|
|
///
|
|
/// SECURITY: values are access tokens — never logged, never persisted here.
|
|
pub(crate) type OAuthSessionTokens = std::collections::BTreeMap<kigi_models::PlatformId, String>;
|
|
|
|
/// Resolve session bearers for every generic device-code OAuth platform whose
|
|
/// own `AuthManager` holds a usable (refreshed-on-expiry) session. Each
|
|
/// platform resolves from ITS OWN scope (`oauth/xai`, …) — independent of the
|
|
/// Kimi session. Providers without a stored session are simply absent. Only a
|
|
/// non-secret count is logged.
|
|
pub(crate) async fn resolve_generic_oauth_tokens(
|
|
kigi_home: &std::path::Path,
|
|
) -> OAuthSessionTokens {
|
|
let mut tokens = OAuthSessionTokens::new();
|
|
for platform in kigi_models::PlatformId::ALL {
|
|
let Some(oauth) = platform.oauth() else {
|
|
continue;
|
|
};
|
|
let manager = std::sync::Arc::new(crate::auth::AuthManager::new_oauth_provider(
|
|
kigi_home, oauth,
|
|
));
|
|
manager.configure_refresher();
|
|
if let Ok(auth) = manager.auth().await {
|
|
tokens.insert(platform, auth.key);
|
|
}
|
|
}
|
|
if !tokens.is_empty() {
|
|
tracing::info!(
|
|
count = tokens.len(),
|
|
"resolved generic oauth platform session tokens"
|
|
);
|
|
}
|
|
tokens
|
|
}
|
|
|
|
/// The generic device-code OAuth platforms with a STORED session scope in
|
|
/// auth.json — the cheap sync companion to [`resolve_generic_oauth_tokens`]
|
|
/// (no `AuthManager`, no refresh, no secrets read beyond scope presence).
|
|
///
|
|
/// Every place that reasons about the FETCH PLAN without resolving bearers
|
|
/// (the prefetch arming gate, `on_auth_changed`'s wipe guard, cache-origin
|
|
/// computation) must consult this, or a subscription-OAuth-only user (e.g.
|
|
/// claude-pro-max with no Kimi session and no API key) is treated as
|
|
/// credential-less: catalog wiped/never fetched, session stuck on the
|
|
/// bundled Kimi table with an empty picker.
|
|
pub(crate) fn stored_oauth_platforms(kigi_home: &std::path::Path) -> Vec<kigi_models::PlatformId> {
|
|
let Ok(store) = crate::auth::read_auth_json(&kigi_home.join("auth.json")) else {
|
|
return Vec::new();
|
|
};
|
|
kigi_models::PlatformId::ALL
|
|
.into_iter()
|
|
.filter(|p| p.oauth().is_some_and(|o| store.contains_key(o.scope_key)))
|
|
.collect()
|
|
}
|
|
|
|
/// Presence-only stand-in for [`OAuthSessionTokens`] in fetch-plan/origin
|
|
/// computations. Values are EMPTY strings: cache origins encode enabled
|
|
/// platform NAMES and URLs only ([`models_fetch_origin`]), and this map must
|
|
/// never reach a request builder — real bearers come from
|
|
/// [`resolve_generic_oauth_tokens`] on the fetch path itself.
|
|
pub(crate) fn stored_oauth_token_stubs(kigi_home: &std::path::Path) -> OAuthSessionTokens {
|
|
stored_oauth_platforms(kigi_home)
|
|
.into_iter()
|
|
.map(|p| (p, String::new()))
|
|
.collect()
|
|
}
|
|
/// 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,
|
|
oauth_tokens: &OAuthSessionTokens,
|
|
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, oauth_tokens, 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).
|
|
///
|
|
/// - kimi-code (`uses_oauth`, no `OAuthConfig`) is gated on the single Kimi
|
|
/// session (`has_oauth`);
|
|
/// - a generic device-code OAuth platform (`oauth().is_some()`, e.g. xai-grok)
|
|
/// is gated on ITS OWN resolved session token (`oauth_tokens`);
|
|
/// - an API-key platform is gated on a stored key.
|
|
fn enabled_platforms(
|
|
has_oauth: bool,
|
|
oauth_tokens: &OAuthSessionTokens,
|
|
platform_keys: &crate::agent::models::PlatformApiKeys,
|
|
) -> Vec<kigi_models::PlatformId> {
|
|
kigi_models::PlatformId::ALL
|
|
.into_iter()
|
|
.filter(|p| {
|
|
if p.oauth().is_some() {
|
|
oauth_tokens.contains_key(p)
|
|
} else if p.uses_oauth() {
|
|
has_oauth
|
|
} else {
|
|
platform_keys.key_for(*p).is_some()
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
/// The inference/listing base for one platform's `/models` fetch:
|
|
/// - kimi-code (`uses_oauth`, no `OAuthConfig`) → the Kimi subscription base
|
|
/// via the endpoints config (`proxy_url`);
|
|
/// - every other platform — API-key AND generic OAuth (xai-grok) — → its own
|
|
/// registry `base_url()` (e.g. api.x.ai/v1).
|
|
fn platform_fetch_base(
|
|
platform: kigi_models::PlatformId,
|
|
endpoints: &crate::agent::config::EndpointsConfig,
|
|
) -> String {
|
|
if platform.uses_oauth() && platform.oauth().is_none() {
|
|
endpoints.proxy_url()
|
|
} else {
|
|
platform.base_url()
|
|
}
|
|
}
|
|
/// `{base}/models` for one platform (see [`platform_fetch_base`]).
|
|
fn platform_models_url(
|
|
platform: kigi_models::PlatformId,
|
|
endpoints: &crate::agent::config::EndpointsConfig,
|
|
) -> String {
|
|
format!(
|
|
"{}/models",
|
|
platform_fetch_base(platform, endpoints).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>,
|
|
oauth_tokens: &OAuthSessionTokens,
|
|
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, oauth_tokens, 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>,
|
|
oauth_tokens: &OAuthSessionTokens,
|
|
platform_keys: &crate::agent::models::PlatformApiKeys,
|
|
) -> Result<FetchModelsResult, BackendError> {
|
|
let enabled = enabled_platforms(auth.is_some(), oauth_tokens, 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.oauth().is_some() {
|
|
// Generic device-code OAuth platform (xai-grok): its OWN resolved
|
|
// session token — never the Kimi session.
|
|
oauth_tokens
|
|
.get(platform)
|
|
.expect("enabled_platforms gated on generic-oauth token presence")
|
|
.clone()
|
|
} else 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;
|
|
// The catalog etag tracks the Kimi subscription listing only
|
|
// (kimi-code: uses_oauth with no generic OAuthConfig).
|
|
if platform.uses_oauth() && platform.oauth().is_none() {
|
|
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> {
|
|
// A platform that serves NO live `/models` listing (openai-codex) delivers a
|
|
// HARDCODED catalog: short-circuit BEFORE any HTTP, mapping the compiled-in
|
|
// `WireModel`s through the SAME `platform_wire_model_to_entry` output a live
|
|
// listing produces (context window + per-model reasoning efforts). The
|
|
// bearer is unused here (login gates availability; no request is made).
|
|
if let Some(wire_models) = platform.hardcoded_catalog() {
|
|
let base_url = platform_fetch_base(platform, endpoints);
|
|
let models = wire_models
|
|
.into_iter()
|
|
.map(|wire| platform_wire_model_to_entry(platform, wire, &base_url))
|
|
.collect();
|
|
return Ok((models, None));
|
|
}
|
|
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 => {
|
|
let mut req = client
|
|
.get(&url)
|
|
.header("Authorization", format!("Bearer {}", bearer));
|
|
// An Anthropic listing reached with a Bearer key is the OAuth
|
|
// channel (claude-pro-max): the /v1/models endpoint requires
|
|
// anthropic-version, and the OAuth bearer needs the oauth beta.
|
|
// The OpenAI-listing Bearer platforms (xai-grok, api-key OpenAI
|
|
// rows) add neither, so their requests stay byte-identical.
|
|
if platform.listing() == kigi_models::ListingDialect::Anthropic {
|
|
req = req
|
|
.header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION)
|
|
.header("anthropic-beta", kigi_sampling_types::ANTHROPIC_OAUTH_BETA);
|
|
}
|
|
// GitHub Copilot /models needs the VS Code editor identity + the
|
|
// Copilot API version. github-copilot-GATED, so every other Bearer
|
|
// OpenAI-listing platform (xai-grok, api-key OpenAI rows) is
|
|
// byte-identical.
|
|
if platform.sends_copilot_editor_headers() {
|
|
req = req
|
|
.header("User-Agent", kigi_sampling_types::COPILOT_USER_AGENT)
|
|
.header(
|
|
"Editor-Version",
|
|
kigi_sampling_types::COPILOT_EDITOR_VERSION,
|
|
)
|
|
.header(
|
|
"Editor-Plugin-Version",
|
|
kigi_sampling_types::COPILOT_EDITOR_PLUGIN_VERSION,
|
|
)
|
|
.header(
|
|
"Copilot-Integration-Id",
|
|
kigi_sampling_types::COPILOT_INTEGRATION_ID,
|
|
)
|
|
.header(
|
|
"X-GitHub-Api-Version",
|
|
kigi_sampling_types::COPILOT_API_VERSION,
|
|
);
|
|
}
|
|
req
|
|
}
|
|
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() {
|
|
// GitHub Copilot serves an OpenAI-shape listing with extra availability
|
|
// fields (model_picker_enabled/policy/tool_calls). Parse + filter it
|
|
// with the Copilot-specific adapter (keep only selectable, tool-calling,
|
|
// openai-completions-served ids). Platform-gated; every other OpenAi
|
|
// listing takes the plain path.
|
|
kigi_models::ListingDialect::OpenAi if platform.sends_copilot_editor_headers() => {
|
|
let body = response.text()?;
|
|
kigi_models::parse_github_copilot_listing(&body).map_err(|e| {
|
|
BackendError::RequestFailed {
|
|
status: 200,
|
|
body: format!("copilot listing parse failed: {e}"),
|
|
}
|
|
})?
|
|
}
|
|
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 = platform_fetch_base(platform, endpoints);
|
|
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 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()
|
|
}
|
|
|
|
/// 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.
|
|
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,
|
|
// `supported_in_api: false` hides a model unless the PRIMARY session is
|
|
// an OAuth session (`ModelInfo::visible_for_auth`). Only `kimi-code`
|
|
// rides that primary session, so only it may be gated on it. Every
|
|
// other OAuth platform (claude-pro-max, openai-codex, github-copilot,
|
|
// xai-grok) carries its OWN pooled credential, and its models only
|
|
// enter the catalog once THAT provider is signed in — gating them on
|
|
// the Kimi session would hide every model from a user who signed in
|
|
// with only a Claude/ChatGPT/Copilot/Grok subscription.
|
|
supported_in_api: platform != kigi_models::PlatformId::KimiCode,
|
|
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::*;
|
|
|
|
/// Whether a freshly-fetched platform entry shows up in the picker for a
|
|
/// user whose PRIMARY session is NOT an OAuth session (`is_session_auth ==
|
|
/// false`): an API-key user, or — the case that made this a ship-blocker —
|
|
/// someone who signed in with ONLY a Claude Pro/Max, ChatGPT, Copilot, or
|
|
/// Grok subscription. `ModelInfo::visible_for_auth` is the picker's real
|
|
/// predicate (`agent/models.rs` → `available()`).
|
|
fn visible_to_non_primary_session_user(entry: &crate::agent::config::ModelEntryConfig) -> bool {
|
|
crate::agent::config::ModelEntry::from_config_entry(entry).visible_for_auth(false)
|
|
}
|
|
|
|
/// 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, &Default::default(), &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, &Default::default(), &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"
|
|
);
|
|
}
|
|
|
|
/// Claude Pro/Max OAuth fetch e2e (mock wire): `GET /v1/models?limit=1000`
|
|
/// gated on the OAuth `Authorization: Bearer` + the oauth `anthropic-beta`
|
|
/// (the OAuth listing contract) → anthropic listing → enriched from
|
|
/// models.dev "anthropic" → keyed `claude-pro-max/<id>` on the Messages
|
|
/// backend. The token is drawn from the claude-pro-max OAuth-session map,
|
|
/// NOT an x-api-key.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[serial_test::serial]
|
|
async fn claude_pro_max_oauth_listing_is_bearer_gated_and_keyed() {
|
|
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"))
|
|
// OAuth listing: Bearer token + the oauth beta + anthropic-version.
|
|
// NO x-api-key header (that is the API-key `anthropic` path).
|
|
.and(wiremock::matchers::header(
|
|
"Authorization",
|
|
"Bearer sk-ant-oat-session",
|
|
))
|
|
// The oauth beta is comma-joined; wiremock's exact `header` matcher
|
|
// splits on commas, so assert both tokens via the multi-valued form.
|
|
.and(wiremock::matchers::headers(
|
|
"anthropic-beta",
|
|
vec!["claude-code-20250219", "oauth-2025-04-20"],
|
|
))
|
|
.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"
|
|
}
|
|
], "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-opus-4-8": {
|
|
"limit": {"context": 1000000, "output": 128000},
|
|
"tool_call": true
|
|
}
|
|
}}}),
|
|
))
|
|
.expect(1)
|
|
.mount(&modelsdev_server)
|
|
.await;
|
|
let cache_dir = tempfile::tempdir().unwrap();
|
|
let _base = kigi_test_support::EnvGuard::set(
|
|
kigi_models::CLAUDE_OAUTH_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();
|
|
// The listing bearer comes from the claude-pro-max OAuth-session map,
|
|
// never a Kimi session (auth=None) or an API key (keys empty).
|
|
let mut oauth_tokens = OAuthSessionTokens::new();
|
|
oauth_tokens.insert(
|
|
kigi_models::PlatformId::ClaudeProMax,
|
|
"sk-ant-oat-session".to_string(),
|
|
);
|
|
let keys = crate::agent::models::PlatformApiKeys::default();
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
fetch_platform_models_blocking(&endpoints, None, &oauth_tokens, &keys)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.expect("claude-pro-max oauth fetch must succeed");
|
|
|
|
assert_eq!(result.models.len(), 1);
|
|
let opus = &result.models[0];
|
|
assert_eq!(
|
|
opus.id.as_deref(),
|
|
Some("claude-pro-max/claude-opus-4-8"),
|
|
"the entry must key under the claude-pro-max platform"
|
|
);
|
|
assert_eq!(
|
|
opus.api_backend,
|
|
crate::sampling::ApiBackend::Messages,
|
|
"claude-pro-max speaks the Messages wire"
|
|
);
|
|
assert_eq!(
|
|
opus.auth_scheme, None,
|
|
"OAuth Bearer entries carry no XApiKey auth scheme"
|
|
);
|
|
assert_eq!(
|
|
opus.context_window.get(),
|
|
1_000_000,
|
|
"enrichment fills the context window from models.dev anthropic"
|
|
);
|
|
assert!(
|
|
opus.supported_in_api,
|
|
"claude-pro-max carries its OWN pooled credential — it must NOT be \
|
|
gated on the primary (Kimi) session"
|
|
);
|
|
assert!(
|
|
visible_to_non_primary_session_user(opus),
|
|
"a Claude-Pro/Max-only user has no primary OAuth session; their \
|
|
models must still appear in the picker"
|
|
);
|
|
}
|
|
|
|
/// GitHub Copilot OAuth fetch e2e (mock wire): `GET /models` gated on the
|
|
/// Bearer COPILOT token + the VS Code editor headers + X-GitHub-Api-Version
|
|
/// returns a mix (a good completions model, a claude-4.x messages model, a
|
|
/// gpt-5 responses-only model, and a disabled model). The catalog keeps ONLY
|
|
/// the completions-served enabled tool-calling model, keyed `github-copilot/
|
|
/// <id>` on the ChatCompletions backend, enriched from models.dev
|
|
/// "github-copilot". The bearer is drawn from the copilot OAuth-session map,
|
|
/// never a Kimi session or an API key.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[serial_test::serial]
|
|
async fn github_copilot_oauth_listing_filters_and_keys_completions_models() {
|
|
let platform_server = wiremock::MockServer::start().await;
|
|
wiremock::Mock::given(wiremock::matchers::method("GET"))
|
|
.and(wiremock::matchers::path("/models"))
|
|
// Bearer COPILOT token + the editor identity + the Copilot API
|
|
// version — the request MUST carry all of them or the mock 404s.
|
|
.and(wiremock::matchers::header(
|
|
"Authorization",
|
|
"Bearer copilot-session-tok",
|
|
))
|
|
.and(wiremock::matchers::header(
|
|
"User-Agent",
|
|
"GitHubCopilotChat/0.35.0",
|
|
))
|
|
.and(wiremock::matchers::header(
|
|
"Editor-Version",
|
|
"vscode/1.107.0",
|
|
))
|
|
.and(wiremock::matchers::header(
|
|
"Copilot-Integration-Id",
|
|
"vscode-chat",
|
|
))
|
|
.and(wiremock::matchers::header(
|
|
"X-GitHub-Api-Version",
|
|
"2026-06-01",
|
|
))
|
|
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
|
|
serde_json::json!({ "data": [
|
|
// KEPT: completions-served, selectable, tool-calling.
|
|
{ "id": "gpt-4.1", "name": "GPT-4.1", "model_picker_enabled": true,
|
|
"policy": {"state": "enabled"},
|
|
"capabilities": {"supports": {"tool_calls": true}} },
|
|
// DROPPED: claude-4.x → anthropic-messages wire (excluded).
|
|
{ "id": "claude-opus-4-8", "model_picker_enabled": true,
|
|
"capabilities": {"supports": {"tool_calls": true}} },
|
|
// DROPPED: gpt-5* → responses-only (excluded).
|
|
{ "id": "gpt-5.2", "model_picker_enabled": true,
|
|
"capabilities": {"supports": {"tool_calls": true}} },
|
|
// DROPPED: policy disabled.
|
|
{ "id": "gemini-3-flash-preview", "model_picker_enabled": true,
|
|
"policy": {"state": "disabled"} }
|
|
]}),
|
|
))
|
|
.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!({ "github-copilot": { "models": {
|
|
"gpt-4.1": {
|
|
"limit": {"context": 128000, "output": 16384},
|
|
"tool_call": true
|
|
}
|
|
}}}),
|
|
))
|
|
.expect(1)
|
|
.mount(&modelsdev_server)
|
|
.await;
|
|
let cache_dir = tempfile::tempdir().unwrap();
|
|
let _base = kigi_test_support::EnvGuard::set(
|
|
kigi_models::COPILOT_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();
|
|
// The listing bearer comes from the github-copilot OAuth-session map,
|
|
// never a Kimi session (auth=None) or an API key (keys empty).
|
|
let mut oauth_tokens = OAuthSessionTokens::new();
|
|
oauth_tokens.insert(
|
|
kigi_models::PlatformId::GithubCopilot,
|
|
"copilot-session-tok".to_string(),
|
|
);
|
|
let keys = crate::agent::models::PlatformApiKeys::default();
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
fetch_platform_models_blocking(&endpoints, None, &oauth_tokens, &keys)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.expect("github-copilot oauth fetch must succeed");
|
|
|
|
assert_eq!(
|
|
result
|
|
.models
|
|
.iter()
|
|
.map(|m| m.id.as_deref().unwrap_or_default())
|
|
.collect::<Vec<_>>(),
|
|
vec!["github-copilot/gpt-4.1"],
|
|
"only the completions-served, enabled, tool-calling model survives \
|
|
(claude-4.x, gpt-5, and the disabled model are dropped)"
|
|
);
|
|
let entry = &result.models[0];
|
|
assert_eq!(
|
|
entry.api_backend,
|
|
crate::sampling::ApiBackend::ChatCompletions,
|
|
"github-copilot speaks the ChatCompletions wire"
|
|
);
|
|
assert_eq!(
|
|
entry.auth_scheme, None,
|
|
"OAuth Bearer entries carry no XApiKey auth scheme"
|
|
);
|
|
assert_eq!(entry.name.as_deref(), Some("GPT-4.1"));
|
|
assert_eq!(
|
|
entry.context_window.get(),
|
|
128_000,
|
|
"context window comes from models.dev github-copilot enrichment"
|
|
);
|
|
assert!(
|
|
entry.supported_in_api,
|
|
"github-copilot carries its OWN pooled credential — it must NOT be \
|
|
gated on the primary (Kimi) session"
|
|
);
|
|
assert!(
|
|
visible_to_non_primary_session_user(entry),
|
|
"a Copilot-only user has no primary OAuth session; their models \
|
|
must still appear in the picker"
|
|
);
|
|
}
|
|
|
|
/// openai-codex fetch: the catalog is HARDCODED, so the fetch path
|
|
/// short-circuits BEFORE any HTTP — there is NO mock `/models` server, yet
|
|
/// the fetch returns exactly the 4 compiled-in models keyed
|
|
/// `openai-codex/<slug>` on the Responses backend, ctx 272000, each exposing
|
|
/// its exact reasoning efforts (incl. the codex-only `xhigh`/`max`).
|
|
/// A BOGUS base URL confirms no live `/models` request is attempted (it would
|
|
/// otherwise fail against an unroutable host).
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[serial_test::serial]
|
|
async fn openai_codex_catalog_is_hardcoded_with_no_http_fetch() {
|
|
// Unroutable base: if the fetch path tried a live `/models` request it
|
|
// would error here; the hardcoded short-circuit ignores it for fetching.
|
|
let _base = kigi_test_support::EnvGuard::set(
|
|
kigi_models::CODEX_BASE_URL_ENV,
|
|
"http://127.0.0.1:1/codex",
|
|
);
|
|
let endpoints = crate::agent::config::EndpointsConfig::default();
|
|
// The session token merely marks openai-codex "enabled"; it is unused by
|
|
// the hardcoded path (no request rides it).
|
|
let mut oauth_tokens = OAuthSessionTokens::new();
|
|
oauth_tokens.insert(
|
|
kigi_models::PlatformId::OpenaiCodex,
|
|
"codex-session-tok".to_string(),
|
|
);
|
|
let keys = crate::agent::models::PlatformApiKeys::default();
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
fetch_platform_models_blocking(&endpoints, None, &oauth_tokens, &keys)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.expect("openai-codex hardcoded catalog fetch must succeed with no HTTP");
|
|
|
|
assert_eq!(
|
|
result
|
|
.models
|
|
.iter()
|
|
.map(|m| m.id.as_deref().unwrap_or_default())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
"openai-codex/gpt-5.6-sol",
|
|
"openai-codex/gpt-5.6-terra",
|
|
"openai-codex/gpt-5.6-luna",
|
|
"openai-codex/gpt-5.5",
|
|
],
|
|
"exactly the 4 hardcoded models, keyed openai-codex/<slug>"
|
|
);
|
|
// Excluded models never appear.
|
|
for absent in [
|
|
"openai-codex/gpt-5.3-codex-spark",
|
|
"openai-codex/gpt-5.4",
|
|
"openai-codex/gpt-5.4-mini",
|
|
"openai-codex/codex-auto-review",
|
|
] {
|
|
assert!(
|
|
!result
|
|
.models
|
|
.iter()
|
|
.any(|m| m.id.as_deref() == Some(absent)),
|
|
"{absent} must be absent from the hardcoded catalog"
|
|
);
|
|
}
|
|
let sol = &result.models[0];
|
|
assert_eq!(
|
|
sol.api_backend,
|
|
crate::sampling::ApiBackend::Responses,
|
|
"openai-codex speaks the Responses wire"
|
|
);
|
|
assert_eq!(sol.context_window.get(), 272_000);
|
|
assert_eq!(sol.name.as_deref(), Some("GPT-5.6-Sol"));
|
|
assert!(
|
|
sol.supported_in_api,
|
|
"openai-codex carries its OWN pooled credential — it must NOT be \
|
|
gated on the primary (Kimi) session"
|
|
);
|
|
assert!(
|
|
visible_to_non_primary_session_user(sol),
|
|
"a ChatGPT/Codex-only user has no primary OAuth session; their \
|
|
models must still appear in the picker"
|
|
);
|
|
assert!(sol.supports_reasoning_effort);
|
|
assert_eq!(
|
|
sol.reasoning_efforts
|
|
.iter()
|
|
.map(|o| o.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["low", "medium", "high", "xhigh", "max"],
|
|
"sol exposes the full codex effort menu"
|
|
);
|
|
// gpt-5.5 tops out at xhigh (no max).
|
|
let five_five = result
|
|
.models
|
|
.iter()
|
|
.find(|m| m.id.as_deref() == Some("openai-codex/gpt-5.5"))
|
|
.unwrap();
|
|
assert_eq!(
|
|
five_five
|
|
.reasoning_efforts
|
|
.iter()
|
|
.map(|o| o.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["low", "medium", "high", "xhigh"]
|
|
);
|
|
}
|
|
|
|
/// 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, &Default::default(), &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, &Default::default(), &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, &Default::default(), &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::Mistral,
|
|
"mistral entries use the Mistral dialect (StrictOpenAi behavior \
|
|
plus the exactly-nine-alphanumeric tool-call id contract)"
|
|
);
|
|
}
|
|
|
|
/// 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, &Default::default(), &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, &Default::default(), &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, &Default::default(), &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, &Default::default(), &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, &Default::default(), &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, &Default::default(), &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, &Default::default(), &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, &Default::default(), &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, &Default::default(), &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, &Default::default(), &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);
|
|
}
|
|
|
|
/// Z.AI-cycle e2e: OpenAI-compatible GLM coding plan (per Pi, plain
|
|
/// completions — no thinking dialect). /models is auth-gated + minimal, so
|
|
/// enrichment supplies context; restrict drops any wire model absent from
|
|
/// the models.dev "zai-coding-plan" snapshot; bare id round-trips under the
|
|
/// zai key; Passthrough dialect.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[serial_test::serial]
|
|
async fn zai_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(
|
|
serde_json::json!({ "data": [
|
|
{ "id": "glm-5.2", "object": "model" },
|
|
// not in the enrichment snapshot → dropped by restrict.
|
|
{ "id": "glm-experimental-unlisted", "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!({ "zai-coding-plan": { "models": {
|
|
"glm-5.2": {
|
|
"limit": {"context": 200000, "output": 128000},
|
|
"tool_call": true
|
|
}
|
|
}}}),
|
|
))
|
|
.expect(1)
|
|
.mount(&modelsdev_server)
|
|
.await;
|
|
let cache_dir = tempfile::tempdir().unwrap();
|
|
let _base =
|
|
kigi_test_support::EnvGuard::set(kigi_models::ZAI_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::Zai,
|
|
"zai-1",
|
|
);
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.expect("fetch must succeed");
|
|
assert_eq!(
|
|
result
|
|
.models
|
|
.iter()
|
|
.map(|m| m.id.as_deref().unwrap_or_default())
|
|
.collect::<Vec<_>>(),
|
|
vec!["zai/glm-5.2"],
|
|
"the non-enriched wire model is dropped by restrict_to_enriched"
|
|
);
|
|
let entry = &result.models[0];
|
|
assert_eq!(
|
|
entry.context_window.get(),
|
|
200_000,
|
|
"context comes from enrichment (the wire listing carries none)"
|
|
);
|
|
assert_eq!(entry.max_completion_tokens, Some(128_000));
|
|
assert_eq!(entry.model, "glm-5.2");
|
|
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
|
|
let creds = crate::agent::config::ResolvedCredentials {
|
|
api_key: Some("zai-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
|
|
);
|
|
}
|
|
|
|
/// Xiaomi-cycle e2e: OpenAI-compatible MiMo (per Pi, plain completions).
|
|
/// /models is auth-gated + minimal; enrichment supplies context; restrict's
|
|
/// tool_call cut drops the mimo TTS models (tool_call=false) the token plan
|
|
/// lists; bare id round-trips under the xiaomi key; Passthrough.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[serial_test::serial]
|
|
async fn xiaomi_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(
|
|
serde_json::json!({ "data": [
|
|
{ "id": "mimo-v2.5-pro", "object": "model" },
|
|
// a TTS model (tool_call=false in enrichment) → dropped.
|
|
{ "id": "mimo-v2-tts", "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!({ "xiaomi": { "models": {
|
|
"mimo-v2.5-pro": {
|
|
"limit": {"context": 256000, "output": 32768},
|
|
"tool_call": true
|
|
},
|
|
"mimo-v2-tts": { "limit": {"context": 8192}, "tool_call": false }
|
|
}}}),
|
|
))
|
|
.expect(1)
|
|
.mount(&modelsdev_server)
|
|
.await;
|
|
let cache_dir = tempfile::tempdir().unwrap();
|
|
let _base = kigi_test_support::EnvGuard::set(
|
|
kigi_models::XIAOMI_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::Xiaomi,
|
|
"xm-1",
|
|
);
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.expect("fetch must succeed");
|
|
assert_eq!(
|
|
result
|
|
.models
|
|
.iter()
|
|
.map(|m| m.id.as_deref().unwrap_or_default())
|
|
.collect::<Vec<_>>(),
|
|
vec!["xiaomi/mimo-v2.5-pro"],
|
|
"the TTS model (tool_call=false) is dropped by restrict_to_enriched"
|
|
);
|
|
let entry = &result.models[0];
|
|
assert_eq!(
|
|
entry.context_window.get(),
|
|
256_000,
|
|
"context comes from enrichment (the wire listing carries none)"
|
|
);
|
|
assert_eq!(entry.max_completion_tokens, Some(32_768));
|
|
assert_eq!(entry.model, "mimo-v2.5-pro");
|
|
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
|
|
let creds = crate::agent::config::ResolvedCredentials {
|
|
api_key: Some("xm-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
|
|
);
|
|
}
|
|
|
|
/// MiniMax e2e: Pi drives MiniMax through its Anthropic-compatible surface,
|
|
/// so Kigi uses the Anthropic listing (x-api-key + anthropic-version,
|
|
/// ?limit=1000) + Messages wire. /anthropic/v1/models is minimal, so
|
|
/// enrichment supplies context; restrict=false keeps the clean MiniMax-M*
|
|
/// catalog; id round-trips under the minimax key.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[serial_test::serial]
|
|
async fn minimax_anthropic_listing_enriches_and_keys_under_platform() {
|
|
let platform_server = wiremock::MockServer::start().await;
|
|
wiremock::Mock::given(wiremock::matchers::method("GET"))
|
|
.and(wiremock::matchers::path("/models"))
|
|
// x-api-key auth (Anthropic key header) must be present.
|
|
.and(wiremock::matchers::header("x-api-key", "mm-1"))
|
|
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
|
|
// Anthropic listing envelope; minimal (id only) → enrichment fills.
|
|
serde_json::json!({ "data": [
|
|
{ "id": "MiniMax-M2.5", "type": "model" }
|
|
], "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!({ "minimax": { "models": {
|
|
"MiniMax-M2.5": {
|
|
"limit": {"context": 204800, "output": 131072},
|
|
"tool_call": true
|
|
}
|
|
}}}),
|
|
))
|
|
.expect(1)
|
|
.mount(&modelsdev_server)
|
|
.await;
|
|
let cache_dir = tempfile::tempdir().unwrap();
|
|
let _base = kigi_test_support::EnvGuard::set(
|
|
kigi_models::MINIMAX_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::Minimax,
|
|
"mm-1",
|
|
);
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
fetch_platform_models_blocking(&endpoints, None, &Default::default(), &keys)
|
|
})
|
|
.await
|
|
.unwrap()
|
|
.expect("fetch must succeed");
|
|
assert_eq!(
|
|
result
|
|
.models
|
|
.iter()
|
|
.map(|m| m.id.as_deref().unwrap_or_default())
|
|
.collect::<Vec<_>>(),
|
|
vec!["minimax/MiniMax-M2.5"],
|
|
"the Anthropic-listed model is keyed under the minimax platform"
|
|
);
|
|
let entry = &result.models[0];
|
|
assert_eq!(
|
|
entry.context_window.get(),
|
|
204_800,
|
|
"context comes from enrichment (the wire listing carries none)"
|
|
);
|
|
assert_eq!(entry.max_completion_tokens, Some(131_072));
|
|
assert_eq!(entry.model, "MiniMax-M2.5");
|
|
assert_eq!(
|
|
kigi_models::parse_managed_model_key(entry.id.as_deref().unwrap()),
|
|
Some((kigi_models::PlatformId::Minimax, "MiniMax-M2.5")),
|
|
);
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
/// xai-grok OAuth-cycle e2e (mock wire): the generic device-code OAuth
|
|
/// bearer (`grok-oauth-tok`, resolved from a stored `oauth/xai` session —
|
|
/// mocked here as the token map) fetches `GET {base}/models` against the
|
|
/// platform's OWN base (api.x.ai/v1 via its env), enriches from models.dev
|
|
/// "xai", restricts to tool-calling chat models, and keys each entry under
|
|
/// `xai-grok/<id>` with the Passthrough dialect — NOT the API-key `xai/`.
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[serial_test::serial]
|
|
async fn xai_grok_oauth_listing_is_enriched_restricted_and_keyed() {
|
|
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 grok-oauth-tok",
|
|
))
|
|
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
|
|
serde_json::json!({ "data": [
|
|
{ "id": "grok-4.5", "object": "model" },
|
|
// enrichment-known but NOT tool-calling → dropped by restrict.
|
|
{ "id": "grok-2-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": {
|
|
"name": "Grok 4.5",
|
|
"reasoning": true,
|
|
"limit": {"context": 256000, "output": 64000},
|
|
"tool_call": true
|
|
},
|
|
"grok-2-image": { "limit": {"context": 8192} }
|
|
}}}),
|
|
))
|
|
.expect(1)
|
|
.mount(&modelsdev_server)
|
|
.await;
|
|
let cache_dir = tempfile::tempdir().unwrap();
|
|
let _base = kigi_test_support::EnvGuard::set(
|
|
kigi_models::XAI_GROK_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::default();
|
|
let mut tokens = OAuthSessionTokens::new();
|
|
tokens.insert(kigi_models::PlatformId::XaiGrok, "grok-oauth-tok".into());
|
|
let result = tokio::task::spawn_blocking(move || {
|
|
fetch_platform_models_blocking(&endpoints, None, &tokens, &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/grok-4.5"],
|
|
"grok-2-image (enrichment-known, not tool-calling) must be dropped; \
|
|
the surviving model is keyed under xai-grok/ (not xai/)"
|
|
);
|
|
let entry = &result.models[0];
|
|
assert_eq!(entry.model, "grok-4.5");
|
|
assert_eq!(
|
|
entry.base_url,
|
|
platform_server.uri(),
|
|
"xai-grok fetches against its OWN base (its base env), not proxy_url"
|
|
);
|
|
assert_eq!(
|
|
entry.context_window.get(),
|
|
256_000,
|
|
"context window must come from models.dev \"xai\" enrichment"
|
|
);
|
|
assert_eq!(entry.max_completion_tokens, Some(64_000));
|
|
assert_eq!(
|
|
entry.api_backend,
|
|
crate::sampling::ApiBackend::ChatCompletions
|
|
);
|
|
assert_eq!(entry.name.as_deref(), Some("Grok 4.5"));
|
|
assert!(
|
|
entry.env_key.is_none(),
|
|
"an OAuth channel carries no api-key env"
|
|
);
|
|
assert!(
|
|
entry.supported_in_api,
|
|
"xai-grok carries its OWN pooled credential — it must NOT be gated \
|
|
on the primary (Kimi) session"
|
|
);
|
|
assert!(
|
|
visible_to_non_primary_session_user(entry),
|
|
"a Grok-only user has no primary OAuth session; their models must \
|
|
still appear in the picker"
|
|
);
|
|
// Passthrough dialect (identical to the API-key xai wire).
|
|
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
|
|
let creds = crate::agent::config::ResolvedCredentials {
|
|
api_key: Some("grok-oauth-tok".into()),
|
|
base_url: entry.base_url.clone(),
|
|
auth_type: kigi_chat_state::AuthType::SessionToken,
|
|
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
|
|
);
|
|
}
|
|
|
|
/// 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"
|
|
);
|
|
// xai-grok is uses_oauth but carries an OAuthConfig → its OWN base
|
|
// (api.x.ai/v1), NOT the Kimi subscription proxy.
|
|
assert_eq!(
|
|
platform_models_url(kigi_models::PlatformId::XaiGrok, &cfg),
|
|
"https://api.x.ai/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,
|
|
&Default::default(),
|
|
&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,
|
|
&Default::default(),
|
|
&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,
|
|
&Default::default(),
|
|
&PlatformApiKeys::default(),
|
|
),
|
|
"https://models.acme.com/v1/models"
|
|
);
|
|
}
|
|
|
|
/// `stored_oauth_platforms` maps auth.json `oauth/<provider>` scopes to
|
|
/// their platforms — presence only, no AuthManager, no refresh. Missing
|
|
/// or empty auth.json → empty (self-cleaning TempDir).
|
|
#[test]
|
|
fn stored_oauth_platforms_reads_scopes_from_auth_json() {
|
|
let home = tempfile::tempdir().expect("tempdir");
|
|
assert!(
|
|
stored_oauth_platforms(home.path()).is_empty(),
|
|
"no auth.json → no stored oauth platforms"
|
|
);
|
|
|
|
let mut store = std::collections::BTreeMap::new();
|
|
store.insert(
|
|
"oauth/claude-pro-max".to_string(),
|
|
crate::auth::KimiAuth::test_default(),
|
|
);
|
|
// A platform API-key scope must NOT count as a stored OAuth session.
|
|
store.insert(
|
|
"deepseek".to_string(),
|
|
crate::auth::KimiAuth::test_default(),
|
|
);
|
|
std::fs::write(
|
|
home.path().join("auth.json"),
|
|
serde_json::to_string(&store).expect("serialize store"),
|
|
)
|
|
.expect("write auth.json");
|
|
|
|
assert_eq!(
|
|
stored_oauth_platforms(home.path()),
|
|
vec![kigi_models::PlatformId::ClaudeProMax],
|
|
);
|
|
|
|
// The stub map carries the same platform set with EMPTY values.
|
|
let stubs = stored_oauth_token_stubs(home.path());
|
|
assert_eq!(stubs.len(), 1);
|
|
assert_eq!(stubs[&kigi_models::PlatformId::ClaudeProMax], "");
|
|
}
|
|
|
|
/// The origin computed from presence-only stubs equals the origin the
|
|
/// fetch path computes from REAL resolved tokens — the whole point of
|
|
/// the stubs (a claude-inclusive cached catalog must load at startup).
|
|
#[test]
|
|
fn stub_origin_matches_real_token_origin() {
|
|
use crate::agent::config::EndpointsConfig;
|
|
use crate::agent::models::{ModelFetchAuth, PlatformApiKeys};
|
|
let cfg = EndpointsConfig::default();
|
|
let mut real = OAuthSessionTokens::new();
|
|
real.insert(
|
|
kigi_models::PlatformId::ClaudeProMax,
|
|
"live-bearer".to_string(),
|
|
);
|
|
let mut stubs = OAuthSessionTokens::new();
|
|
stubs.insert(kigi_models::PlatformId::ClaudeProMax, String::new());
|
|
let with_real = models_fetch_origin(
|
|
&cfg,
|
|
ModelFetchAuth::Platforms,
|
|
false,
|
|
&real,
|
|
&PlatformApiKeys::default(),
|
|
);
|
|
let with_stubs = models_fetch_origin(
|
|
&cfg,
|
|
ModelFetchAuth::Platforms,
|
|
false,
|
|
&stubs,
|
|
&PlatformApiKeys::default(),
|
|
);
|
|
assert_eq!(with_real, with_stubs);
|
|
assert!(with_real.contains("claude-pro-max="));
|
|
assert!(
|
|
!with_real.contains("live-bearer"),
|
|
"origin never embeds tokens"
|
|
);
|
|
}
|
|
}
|