diff --git a/crates/codegen/kigi-models/src/enrichment.rs b/crates/codegen/kigi-models/src/enrichment.rs index 2a63f06..7d2d46e 100644 --- a/crates/codegen/kigi-models/src/enrichment.rs +++ b/crates/codegen/kigi-models/src/enrichment.rs @@ -19,9 +19,8 @@ pub struct EnrichmentModel { /// Max context window in tokens (`limit.context`). #[serde(default, skip_serializing_if = "is_zero")] pub context: u64, - /// Max output tokens (`limit.output`). Not yet consumed by - /// [`enrich_wire_model`]; feeds `max_completion_tokens` when provider - /// cycles start mapping it. + /// Max output tokens (`limit.output`); fills a wire-unserved output cap + /// (Anthropic 400s when `max_tokens` exceeds the model's limit). #[serde(default, skip_serializing_if = "is_zero")] pub output: u64, /// Model supports reasoning/thinking. @@ -186,6 +185,9 @@ pub fn enrich_wire_model(wire: &mut crate::WireModel, meta: &EnrichmentModel) { if wire.context_length == 0 && meta.context > 0 { wire.context_length = meta.context; } + if wire.max_output_tokens == 0 && meta.output > 0 { + wire.max_output_tokens = meta.output; + } if meta.reasoning { wire.supports_reasoning = true; } @@ -235,6 +237,7 @@ mod tests { fn enrich_fills_gaps_and_never_overwrites_wire() { let meta = EnrichmentModel { context: 400_000, + output: 64_000, reasoning: true, efforts: vec!["low".into(), "high".into()], image_in: true, @@ -246,6 +249,7 @@ mod tests { serde_json::from_value(serde_json::json!({ "id": "gpt-test" })).unwrap(); enrich_wire_model(&mut bare, &meta); assert_eq!(bare.context_length, 400_000); + assert_eq!(bare.max_output_tokens, 64_000, "output cap filled"); assert!(bare.supports_reasoning); assert!(bare.supports_image_in); assert_eq!(bare.display_name.as_deref(), Some("GPT Test")); @@ -258,12 +262,14 @@ mod tests { let mut served: crate::WireModel = serde_json::from_value(serde_json::json!({ "id": "gpt-test", "context_length": 123, + "max_output_tokens": 77, "display_name": "Wire Name", "think_efforts": { "support": true, "valid_efforts": ["max"] } })) .unwrap(); enrich_wire_model(&mut served, &meta); assert_eq!(served.context_length, 123, "wire context wins"); + assert_eq!(served.max_output_tokens, 77, "wire output cap wins"); assert_eq!(served.display_name.as_deref(), Some("Wire Name")); assert_eq!( served.think_efforts.unwrap().valid_efforts, diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index decfd81..eaa17ba 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -51,6 +51,27 @@ pub enum PlatformWireApi { Messages, } +/// Shape + headers of a platform's model-listing endpoint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ListingDialect { + /// `GET {base}/models`, `Authorization: Bearer`, `{data:[{id,...}]}` + /// (the F4 wire contract; kimi extends it with think_efforts etc.). + OpenAi, + /// `GET {base}/models?limit=1000`, `x-api-key` + `anthropic-version` + /// headers, Anthropic's response shape (parsed by + /// [`parse_anthropic_listing`]). + Anthropic, +} + +/// How a platform's API key rides requests (listing, validation, inference). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlatformKeyHeader { + /// `Authorization: Bearer `. + Bearer, + /// `x-api-key: ` plus `anthropic-version` (Anthropic wire). + XApiKey, +} + /// Where a platform's base URL is resolved from. enum BaseUrlSource { /// The Kimi Code subscription base, owned by `kigi_env` @@ -102,6 +123,10 @@ struct PlatformSpec { wire_serves_metadata: bool, /// Inference dialect (mapped to the sampler backend by the shell). wire_api: PlatformWireApi, + /// Model-listing endpoint shape + headers. + listing: ListingDialect, + /// Key header style for listing/validation/inference. + key_header: PlatformKeyHeader, /// Restrict the live listing to models the enrichment catalog knows — /// for providers whose `/models` is polluted with non-chat entries /// (tts/embeddings/image). Availability still requires the LIVE listing; @@ -122,6 +147,8 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec { models_dev_id: Some("kimi-for-coding"), wire_serves_metadata: true, wire_api: PlatformWireApi::ChatCompletions, + listing: ListingDialect::OpenAi, + key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: false, }; @@ -141,6 +168,8 @@ const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec { models_dev_id: Some("moonshotai-cn"), wire_serves_metadata: true, wire_api: PlatformWireApi::ChatCompletions, + listing: ListingDialect::OpenAi, + key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: false, }; @@ -160,6 +189,8 @@ const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec { models_dev_id: Some("moonshotai"), wire_serves_metadata: true, wire_api: PlatformWireApi::ChatCompletions, + listing: ListingDialect::OpenAi, + key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: false, }; @@ -184,9 +215,37 @@ const OPENAI_SPEC: PlatformSpec = PlatformSpec { // and is polluted with tts/embeddings/image entries. wire_serves_metadata: false, wire_api: PlatformWireApi::Responses, + listing: ListingDialect::OpenAi, + key_header: PlatformKeyHeader::Bearer, restrict_to_enriched: true, }; +/// Base-URL override for Anthropic (dev/test escape hatch). +pub const ANTHROPIC_BASE_URL_ENV: &str = "KIGI_ANTHROPIC_BASE_URL"; + +const ANTHROPIC_SPEC: PlatformSpec = PlatformSpec { + id: "anthropic", + display_name: "Anthropic", + base_url: BaseUrlSource::EnvOr { + env: ANTHROPIC_BASE_URL_ENV, + default: "https://api.anthropic.com/v1", + }, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["ANTHROPIC_API_KEY"], + vendor: "Anthropic", + console_host: Some("console.anthropic.com"), + login_label: Some("Anthropic (API key)"), + models_dev_id: Some("anthropic"), + // The 2026 /v1/models serves capabilities + max_input_tokens, but the + // adapter maps only what's present — enrichment fills gaps (wire wins). + wire_serves_metadata: false, + wire_api: PlatformWireApi::Messages, + listing: ListingDialect::Anthropic, + key_header: PlatformKeyHeader::XApiKey, + restrict_to_enriched: false, +}; + /// The platform registry. Platforms are compiled-in spec rows; there is no /// dynamic provider registration (PRD F2). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -199,16 +258,19 @@ pub enum PlatformId { MoonshotAi, /// OpenAI platform API (API key, Responses dialect). OpenAi, + /// Anthropic platform API (API key, Messages dialect). + Anthropic, } impl PlatformId { /// All platforms, in catalog precedence order: the subscription channel /// first so "default model = first list item" favors it when present. - pub const ALL: [PlatformId; 4] = [ + pub const ALL: [PlatformId; 5] = [ Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi, Self::OpenAi, + Self::Anthropic, ]; /// The registry row backing this platform (single source of per-platform @@ -219,6 +281,7 @@ impl PlatformId { Self::MoonshotCn => &MOONSHOT_CN_SPEC, Self::MoonshotAi => &MOONSHOT_AI_SPEC, Self::OpenAi => &OPENAI_SPEC, + Self::Anthropic => &ANTHROPIC_SPEC, } } @@ -308,6 +371,16 @@ impl PlatformId { pub fn restrict_to_enriched(self) -> bool { self.spec().restrict_to_enriched } + + /// Model-listing endpoint shape + headers. + pub fn listing(self) -> ListingDialect { + self.spec().listing + } + + /// Key header style for listing/validation/inference requests. + pub fn key_header(self) -> PlatformKeyHeader { + self.spec().key_header + } } /// Split a managed catalog key `{platform_id}/{model_id}` back into its @@ -370,6 +443,10 @@ pub struct WireModel { pub supports_video_in: bool, #[serde(default)] pub display_name: Option, + /// Max output tokens (`max_tokens` on the Anthropic listing; absent on + /// the Kimi/OpenAI-shape wires). 0 = unserved → enrichment may fill. + #[serde(default)] + pub max_output_tokens: u64, /// `"only"` marks always-thinking models (thinking cannot be disabled). /// Verified against the live `api.kimi.com/coding/v1/models` response. #[serde(default)] @@ -400,6 +477,126 @@ pub struct WireModelsResponse { pub data: Vec, } +// ── Anthropic listing adapter (ListingDialect::Anthropic) ─────────────────── + +#[derive(serde::Deserialize)] +struct AnthropicListing { + /// No default: a 200 body without `data` is a contract violation and + /// must error like the OpenAI-shape branch, not yield an empty catalog. + data: Vec, + #[serde(default)] + has_more: bool, +} + +#[derive(serde::Deserialize, Default)] +#[serde(default)] +struct AnthropicModel { + id: String, + display_name: Option, + max_input_tokens: u64, + /// The model's output cap (Anthropic REQUIRES `max_tokens` on + /// /v1/messages and 400s when it exceeds this). + max_tokens: u64, + capabilities: AnthropicCapabilities, +} + +#[derive(serde::Deserialize, Default)] +#[serde(default)] +struct AnthropicCapabilities { + effort: AnthropicEffort, + thinking: AnthropicSupported, + image_input: AnthropicSupported, +} + +#[derive(serde::Deserialize, Default)] +#[serde(default)] +struct AnthropicEffort { + supported: bool, + low: AnthropicSupported, + medium: AnthropicSupported, + high: AnthropicSupported, + xhigh: AnthropicSupported, + max: AnthropicSupported, +} + +#[derive(serde::Deserialize, Default)] +#[serde(default)] +struct AnthropicSupported { + supported: bool, +} + +/// Parse the 2026 Anthropic `GET /v1/models` response into the F4 +/// [`WireModel`] shape: `max_input_tokens` → context (0 stays 0 so +/// enrichment can fill it), `capabilities.thinking/image_input` → flags, +/// `capabilities.effort.{level}.supported` → `think_efforts` in canonical +/// order. `has_more: true` (impossible under `?limit=1000` for Anthropic's +/// catalog size) warns rather than silently truncating. +pub fn parse_anthropic_listing(json: &str) -> Result, serde_json::Error> { + let listing: AnthropicListing = serde_json::from_str(json)?; + if listing.has_more { + tracing::warn!( + fetched = listing.data.len(), + "anthropic /models reports more pages beyond limit=1000; \ + listing may be incomplete" + ); + } + Ok(listing + .data + .into_iter() + .filter(|m| { + let keep = !m.id.is_empty(); + if !keep { + tracing::warn!("anthropic listing entry without id; dropping"); + } + keep + }) + .map(|m| { + let e = &m.capabilities.effort; + let valid_efforts: Vec = [ + ("low", e.low.supported), + ("medium", e.medium.supported), + ("high", e.high.supported), + ("xhigh", e.xhigh.supported), + ("max", e.max.supported), + ] + .into_iter() + .filter(|(_, supported)| *supported) + .map(|(level, _)| level.to_string()) + .collect(); + // The wire has no default marker; the provider's implicit + // default applies until the user picks a level. An explicit + // `supported: false` becomes a DECLINE sentinel (support=false) + // — distinguishable from "wire silent", so enrichment can never + // inject a menu the server rejects (pre-4.6 models 400 on + // adaptive thinking). + let think_efforts = if e.supported && !valid_efforts.is_empty() { + Some(WireThinkEfforts { + support: true, + valid_efforts, + default_effort: None, + }) + } else { + Some(WireThinkEfforts { + support: false, + valid_efforts: Vec::new(), + default_effort: None, + }) + }; + WireModel { + id: m.id, + context_length: m.max_input_tokens, + supports_reasoning: m.capabilities.thinking.supported, + supports_image_in: m.capabilities.image_input.supported, + supports_video_in: false, + display_name: m.display_name, + max_output_tokens: m.max_tokens, + supports_thinking_type: None, + think_efforts, + } + }) + .collect()) +} + impl WireModel { /// Capability derivation ported verbatim from kimi-cli /// `auth/platforms.py::ModelInfo.capabilities`: @@ -619,6 +816,100 @@ mod tests { assert_eq!(caps, sorted); } + /// The Anthropic listing adapter maps the documented 2026 response shape + /// (platform.claude.com/docs/en/api/models-list) onto WireModel: effort + /// capability levels become think_efforts in canonical order, a zero + /// max_input_tokens stays zero (enrichment fills it), unknown fields + /// tolerated, effort.supported=false yields no menu. + #[test] + fn anthropic_listing_maps_documented_shape() { + let json = serde_json::json!({ + "data": [ + { + "id": "claude-opus-4-6", + "display_name": "Claude Opus 4.6", + "created_at": "2026-02-04T00:00:00Z", + "type": "model", + "max_input_tokens": 1_000_000, + "max_tokens": 128_000, + "created_at_is_ignored": true, + "capabilities": { + "batch": { "supported": true }, + "effort": { + "supported": true, + "low": { "supported": true }, + "medium": { "supported": true }, + "high": { "supported": true }, + "xhigh": { "supported": true }, + "max": { "supported": true } + }, + "thinking": { + "supported": true, + "types": { + "adaptive": { "supported": true }, + "enabled": { "supported": true } + } + }, + "image_input": { "supported": true }, + "structured_outputs": { "supported": true } + } + }, + { + "id": "claude-legacy", + "max_input_tokens": 0, + "capabilities": { + "effort": { "supported": false }, + "thinking": { "supported": false }, + "image_input": { "supported": false } + } + } + ], + "first_id": "claude-opus-4-6", + "last_id": "claude-legacy", + "has_more": false + }) + .to_string(); + let models = parse_anthropic_listing(&json).expect("documented shape parses"); + assert_eq!(models.len(), 2); + let opus = &models[0]; + assert_eq!(opus.id, "claude-opus-4-6"); + assert_eq!(opus.context_length, 1_000_000); + assert_eq!( + opus.max_output_tokens, 128_000, + "wire max_tokens is the output cap (Anthropic 400s above it)" + ); + assert!(opus.supports_reasoning && opus.supports_image_in); + assert_eq!(opus.display_name.as_deref(), Some("Claude Opus 4.6")); + let efforts = opus.think_efforts.as_ref().expect("effort menu"); + assert_eq!( + efforts.valid_efforts, + ["low", "medium", "high", "xhigh", "max"], + "levels in canonical order from per-level supported flags" + ); + assert_eq!(efforts.default_effort, None); + let legacy = &models[1]; + assert_eq!(legacy.context_length, 0, "zero stays zero for enrichment"); + let decline = legacy + .think_efforts + .as_ref() + .expect("explicit wire decline is a sentinel, not absence"); + assert!( + !decline.support && decline.valid_efforts.is_empty(), + "effort.supported=false must block enrichment menu injection" + ); + assert!(!legacy.supports_reasoning); + + // A 200 body without `data` is a contract violation, not an empty + // catalog; entries without an id are dropped with a warning. + assert!(parse_anthropic_listing("{}").is_err()); + let ghosts = serde_json::json!({ "data": [ {}, { "id": "real" } ] }).to_string(); + let models = parse_anthropic_listing(&ghosts).unwrap(); + assert_eq!( + models.iter().map(|m| m.id.as_str()).collect::>(), + vec!["real"] + ); + } + #[test] fn platform_ids_round_trip() { for p in PlatformId::ALL { @@ -639,9 +930,10 @@ mod tests { PlatformId::MoonshotCn => 1, PlatformId::MoonshotAi => 2, PlatformId::OpenAi => 3, + PlatformId::Anthropic => 4, } } - const VARIANT_COUNT: usize = 4; // update together with `ordinal` + const VARIANT_COUNT: usize = 5; // update together with `ordinal` let mut seen: Vec = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect(); seen.sort_unstable(); seen.dedup(); @@ -804,6 +1096,7 @@ mod tests { supports_image_in: false, supports_video_in: false, display_name: None, + max_output_tokens: 0, supports_thinking_type: None, think_efforts: None, }, @@ -814,6 +1107,7 @@ mod tests { supports_image_in: false, supports_video_in: false, display_name: None, + max_output_tokens: 0, supports_thinking_type: None, think_efforts: None, }, diff --git a/crates/codegen/kigi-sampler/src/client.rs b/crates/codegen/kigi-sampler/src/client.rs index 9d59fee..78de0f0 100644 --- a/crates/codegen/kigi-sampler/src/client.rs +++ b/crates/codegen/kigi-sampler/src/client.rs @@ -371,6 +371,14 @@ impl SamplingClient { ) })?; headers.insert(HeaderName::from_static("x-api-key"), header_value); + if config.api_backend == kigi_sampling_types::ApiBackend::Messages { + // The real Anthropic Messages wire rejects requests + // without this; compatible endpoints ignore it. + headers.insert( + HeaderName::from_static("anthropic-version"), + HeaderValue::from_static(kigi_sampling_types::ANTHROPIC_VERSION), + ); + } } AuthScheme::Bearer => { let bearer = format!("Bearer {}", api_key); @@ -1890,6 +1898,41 @@ mod tests { } } + /// The real Anthropic Messages wire rejects requests without + /// `anthropic-version`; the XApiKey+Messages client must carry it in its + /// default headers, and Bearer/ChatCompletions clients must NOT. + #[test] + fn x_api_key_messages_client_sends_anthropic_version() { + let mut config = minimal_config(); + config.auth_scheme = AuthScheme::XApiKey; + config.api_backend = ApiBackend::Messages; + let client = SamplingClient::new(config).expect("client builds"); + assert_eq!( + client + .default_headers + .get("anthropic-version") + .and_then(|v| v.to_str().ok()), + Some(kigi_sampling_types::ANTHROPIC_VERSION) + ); + assert!(client.default_headers.get("x-api-key").is_some()); + + let bearer = SamplingClient::new(minimal_config()).expect("client builds"); + assert!( + bearer.default_headers.get("anthropic-version").is_none(), + "non-anthropic clients must not grow the header" + ); + let mut x_api_chat = minimal_config(); + x_api_chat.auth_scheme = AuthScheme::XApiKey; + let x_api_chat = SamplingClient::new(x_api_chat).expect("client builds"); + assert!( + x_api_chat + .default_headers + .get("anthropic-version") + .is_none(), + "XApiKey without the Messages backend must not grow the header" + ); + } + /// Verify the serialized shape of StreamingChatRequest matches the /// expected wire format: all ChatCompletionRequest fields flattened at /// top level, plus `stream: true` and `stream_options.include_usage: true`. diff --git a/crates/codegen/kigi-sampling-types/src/types.rs b/crates/codegen/kigi-sampling-types/src/types.rs index 2151802..0e002b9 100644 --- a/crates/codegen/kigi-sampling-types/src/types.rs +++ b/crates/codegen/kigi-sampling-types/src/types.rs @@ -934,6 +934,10 @@ pub fn normalize_effort_echo(value: &mut Value) { } } +/// The `anthropic-version` header value kigi speaks on Anthropic-style +/// wires (Messages inference and the /v1/models listing). +pub const ANTHROPIC_VERSION: &str = "2023-06-01"; + pub const REASONING_EFFORT_META_KEY: &str = "reasoningEffort"; pub const SUPPORTS_REASONING_EFFORT_META_KEY: &str = "supportsReasoningEffort"; diff --git a/crates/codegen/kigi-shell/src/agent/auth_method.rs b/crates/codegen/kigi-shell/src/agent/auth_method.rs index 821e9d6..17c94c9 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -379,8 +379,9 @@ pub fn missing_platform_key_error(platform: kigi_models::PlatformId) -> String { /// missing-key message. A present key is validated with /// `GET {platform_base}/models` (the same endpoint the catalog fetch uses): /// 401 → "invalid API key"; any other non-success status or network error -/// surfaces as-is. SECURITY: the key is only ever sent as the bearer header — -/// it must never appear in errors or logs. +/// surfaces as-is. SECURITY: the key is only ever sent as the platform's +/// key header (Bearer or x-api-key) — it must never appear in errors or +/// logs. pub(crate) async fn authenticate_platform_api_key( platform: kigi_models::PlatformId, key: Option<&str>, @@ -394,9 +395,16 @@ pub(crate) async fn authenticate_platform_api_key( return Err(auth_err(missing_platform_key_error(platform))); }; let url = format!("{}/models", platform.base_url().trim_end_matches('/')); - let response = crate::http::shared_client() - .get(&url) - .header("Authorization", format!("Bearer {key}")) + let request = match platform.key_header() { + kigi_models::PlatformKeyHeader::Bearer => crate::http::shared_client() + .get(&url) + .header("Authorization", format!("Bearer {key}")), + kigi_models::PlatformKeyHeader::XApiKey => crate::http::shared_client() + .get(&url) + .header("x-api-key", key) + .header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION), + }; + let response = request .send() .await .map_err(|e| auth_err(format!("Couldn't reach {}: {e}", platform.as_str())))?; @@ -584,7 +592,8 @@ mod tests { KIMI_CODE_METHOD_ID, MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID, - "openai" + "openai", + "anthropic" ] ); assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID)); @@ -611,7 +620,8 @@ mod tests { KIMI_CODE_METHOD_ID, MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID, - "openai" + "openai", + "anthropic" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -631,7 +641,8 @@ mod tests { KIMI_CODE_METHOD_ID, MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID, - "openai" + "openai", + "anthropic" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -654,7 +665,8 @@ mod tests { KIMI_CODE_METHOD_ID, MOONSHOT_CN_METHOD_ID, MOONSHOT_AI_METHOD_ID, - "openai" + "openai", + "anthropic" ] ); assert_eq!(default_id(&built), None); diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index be53bb7..39d3224 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -266,12 +266,25 @@ fn fetch_one_platform_models( enrichment: &kigi_models::enrichment::EnrichmentCatalog, ) -> Result<(Vec, Option), BackendError> { let client = crate::http::shared_blocking_client(); - let url = platform_models_url(platform, endpoints); + 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 response = client - .get(&url) - .header("Authorization", format!("Bearer {}", bearer)) - .send()?; + let request = match platform.key_header() { + kigi_models::PlatformKeyHeader::Bearer => client + .get(&url) + .header("Authorization", format!("Bearer {}", bearer)), + kigi_models::PlatformKeyHeader::XApiKey => client + .get(&url) + .header("x-api-key", bearer) + .header("anthropic-version", kigi_sampling_types::ANTHROPIC_VERSION), + }; + let response = request.send()?; if !response.status().is_success() { let status = response.status().as_u16(); let body = response.text().unwrap_or_default(); @@ -282,9 +295,22 @@ fn fetch_one_platform_models( .get("etag") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); - let listing: kigi_models::WireModelsResponse = response.json()?; - let total = listing.data.len(); - let mut filtered = kigi_models::filter_allowed_models(platform, listing.data); + let data = match platform.listing() { + kigi_models::ListingDialect::OpenAi => { + response.json::()?.data + } + 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}"), + } + })? + } + }; + let total = data.len(); + let mut filtered = kigi_models::filter_allowed_models(platform, data); if filtered.len() != total { tracing::info!( platform = platform.as_str(), @@ -432,19 +458,27 @@ pub(crate) fn platform_wire_model_to_entry( 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, - max_completion_tokens: 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: None, + auth_scheme, reasoning_effort: think_efforts .and_then(|t| t.default_effort.as_deref()) .and_then(|s| s.parse().ok()), @@ -836,6 +870,158 @@ mod tests { ); } + /// Anthropic-cycle e2e (mock wire): the Anthropic listing dialect — + /// x-api-key + anthropic-version headers, ?limit=1000 — maps + /// wire-served metadata (max_input_tokens, per-level effort + /// capabilities) onto Messages-backed XApiKey entries, and enrichment + /// fills a zero max_input_tokens without touching wire-served values. + #[tokio::test(flavor = "multi_thread")] + #[serial_test::serial] + async fn anthropic_listing_maps_wire_metadata_and_enrichment_fills_gaps() { + let platform_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/models")) + .and(wiremock::matchers::query_param("limit", "1000")) + .and(wiremock::matchers::header("x-api-key", "sk-ant")) + .and(wiremock::matchers::header( + "anthropic-version", + "2023-06-01", + )) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "data": [ + { + "id": "claude-opus-4-8", + "display_name": "Claude Opus 4.8", + "type": "model", + "max_input_tokens": 1_000_000, + "capabilities": { + "effort": { + "supported": true, + "low": {"supported": true}, + "medium": {"supported": true}, + "high": {"supported": true}, + "xhigh": {"supported": true}, + "max": {"supported": true} + }, + "thinking": {"supported": true}, + "image_input": {"supported": true} + } + }, + { + "id": "claude-gap-test", + "type": "model", + "max_input_tokens": 0, + "capabilities": { + "effort": {"supported": false}, + "thinking": {"supported": true}, + "image_input": {"supported": false} + } + } + ], "has_more": false }), + )) + .expect(1) + .mount(&platform_server) + .await; + let modelsdev_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/api.json")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "anthropic": { "models": { + "claude-gap-test": { + "limit": {"context": 200000, "output": 64000}, + "tool_call": true, + "reasoning": true, + "reasoning_options": [ + {"type": "effort", "values": ["low", "high"]} + ] + }, + "claude-opus-4-8": { + "limit": {"context": 555}, + "tool_call": true + } + }}}), + )) + .expect(1) + .mount(&modelsdev_server) + .await; + let cache_dir = tempfile::tempdir().unwrap(); + let _base = kigi_test_support::EnvGuard::set( + kigi_models::ANTHROPIC_BASE_URL_ENV, + platform_server.uri(), + ); + let _mdev = kigi_test_support::EnvGuard::set( + crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV, + format!("{}/api.json", modelsdev_server.uri()), + ); + let _mdev_cache = kigi_test_support::EnvGuard::set( + crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV, + cache_dir.path(), + ); + + let endpoints = crate::agent::config::EndpointsConfig::default(); + let keys = crate::agent::models::PlatformApiKeys::test_single( + kigi_models::PlatformId::Anthropic, + "sk-ant", + ); + let result = tokio::task::spawn_blocking(move || { + fetch_platform_models_blocking(&endpoints, None, &keys) + }) + .await + .unwrap() + .expect("fetch must succeed"); + + assert_eq!(result.models.len(), 2); + let opus = &result.models[0]; + assert_eq!(opus.id.as_deref(), Some("anthropic/claude-opus-4-8")); + assert_eq!( + opus.context_window.get(), + 1_000_000, + "wire max_input_tokens must WIN over enrichment (555)" + ); + assert_eq!(opus.api_backend, crate::sampling::ApiBackend::Messages); + assert_eq!( + opus.auth_scheme, + Some(kigi_sampler::AuthScheme::XApiKey), + "anthropic entries must ride x-api-key at inference" + ); + assert_eq!( + opus.reasoning_efforts + .iter() + .map(|o| o.id.as_str()) + .collect::>(), + vec!["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" + ); + } + #[test] fn get_env_keys_parses_strings_and_rejects_non_strings() { use crate::agent::config::EnvKeys; diff --git a/crates/codegen/kigi-tui/src/app/app_view.rs b/crates/codegen/kigi-tui/src/app/app_view.rs index c42f85a..a0bf179 100644 --- a/crates/codegen/kigi-tui/src/app/app_view.rs +++ b/crates/codegen/kigi-tui/src/app/app_view.rs @@ -6894,7 +6894,7 @@ pub(crate) mod tests { #[test] fn pending_menu_items_lists_interactive_methods_plus_quit() { let items = pending_menu_items(&fresh_user_auth_methods(), None); - assert_eq!(items.len(), 5, "4 login rows + Quit, got {items:?}"); + assert_eq!(items.len(), 6, "5 login rows + Quit, got {items:?}"); assert!( matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"), "row 0 must be the OAuth login, got {:?}", @@ -6922,7 +6922,14 @@ pub(crate) mod tests { }, "new registry rows must appear in the picker with zero TUI changes" ); - assert_eq!(items[4], PendingMenuItem::Quit); + assert_eq!( + items[4], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::Anthropic), + label: "Anthropic (API key)".into(), + } + ); + assert_eq!(items[5], PendingMenuItem::Quit); // The non-interactive methods must never appear as rows. let byok = kigi_shell::agent::auth_method::build_auth_methods( kigi_shell::agent::auth_method::AuthMethodsBuildInputs { @@ -6933,7 +6940,7 @@ pub(crate) mod tests { ); assert_eq!( pending_menu_items(&byok.methods, None).len(), - 5, + 6, "xai.api_key / cached_token must not add rows" ); }