diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index bae7f56..6eb9323 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -70,7 +70,9 @@ pub enum PlatformChatCompat { Kimi, DeepSeek, Passthrough, - Mistral, + /// Strict OpenAI-compatible validator (Mistral, Cerebras) — strips + /// `stream_options` and private fields. + StrictOpenAi, } /// How a platform's API key rides requests (listing, validation, inference). @@ -365,8 +367,9 @@ const MISTRAL_SPEC: PlatformSpec = PlatformSpec { wire_api: PlatformWireApi::ChatCompletions, listing: ListingDialect::OpenAi, // Mistral's strict validator 422s on `stream_options`, and its reasoning - // models return array content — the Mistral dialect handles both. - chat_compat: PlatformChatCompat::Mistral, + // models return array content — the StrictOpenAi dialect strips + // stream_options; the response deserializer handles arrays universally. + chat_compat: PlatformChatCompat::StrictOpenAi, key_header: PlatformKeyHeader::Bearer, // The listing carries embed/moderation/OCR entries; keep tool-calling // chat models only. @@ -496,6 +499,38 @@ const TOGETHER_SPEC: PlatformSpec = PlatformSpec { restrict_to_enriched: true, }; +/// Base-URL override for Cerebras (dev/test escape hatch). +pub const CEREBRAS_BASE_URL_ENV: &str = "KIGI_CEREBRAS_BASE_URL"; + +const CEREBRAS_SPEC: PlatformSpec = PlatformSpec { + id: "cerebras", + display_name: "Cerebras", + base_url: BaseUrlSource::EnvOr { + env: CEREBRAS_BASE_URL_ENV, + default: "https://api.cerebras.ai/v1", + }, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["CEREBRAS_API_KEY"], + vendor: "Cerebras", + console_host: Some("cloud.cerebras.ai"), + login_label: Some("Cerebras (API key)"), + models_dev_id: Some("cerebras"), + // /models is minimal (id only, no context) → enrichment supplies context + // + effort menus. The catalog is all chat LLMs (no embedding/tts + // pollution), so keep every live model and enrich the known ones. + wire_serves_metadata: false, + wire_api: PlatformWireApi::ChatCompletions, + listing: ListingDialect::OpenAi, + // Cerebras uses strict additionalProperties:false validation (400s on + // out-of-schema fields like store/thinking); strip stream_options. + chat_compat: PlatformChatCompat::StrictOpenAi, + key_header: PlatformKeyHeader::Bearer, + key_validation_path: None, + strip_listing_id_prefix: None, + 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)] @@ -524,12 +559,14 @@ pub enum PlatformId { OpenRouter, /// Together AI platform API (API key, bare-array listing). Together, + /// Cerebras platform API (API key, OpenAI-compatible ChatCompletions). + Cerebras, } 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; 12] = [ + pub const ALL: [PlatformId; 13] = [ Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi, @@ -542,6 +579,7 @@ impl PlatformId { Self::Google, Self::OpenRouter, Self::Together, + Self::Cerebras, ]; /// The registry row backing this platform (single source of per-platform @@ -560,6 +598,7 @@ impl PlatformId { Self::Google => &GOOGLE_SPEC, Self::OpenRouter => &OPENROUTER_SPEC, Self::Together => &TOGETHER_SPEC, + Self::Cerebras => &CEREBRAS_SPEC, } } @@ -1310,9 +1349,10 @@ mod tests { PlatformId::Google => 9, PlatformId::OpenRouter => 10, PlatformId::Together => 11, + PlatformId::Cerebras => 12, } } - const VARIANT_COUNT: usize = 12; // update together with `ordinal` + const VARIANT_COUNT: usize = 13; // update together with `ordinal` let mut seen: Vec = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect(); seen.sort_unstable(); seen.dedup(); diff --git a/crates/codegen/kigi-sampler/src/kimi_compat.rs b/crates/codegen/kigi-sampler/src/kimi_compat.rs index 4ad39dd..d7a71ed 100644 --- a/crates/codegen/kigi-sampler/src/kimi_compat.rs +++ b/crates/codegen/kigi-sampler/src/kimi_compat.rs @@ -47,7 +47,7 @@ pub(crate) fn adapt_chat_completions_body_for( kigi_sampling_types::ChatCompat::Passthrough => { strip_kigi_private_message_fields(body); } - kigi_sampling_types::ChatCompat::Mistral => { + kigi_sampling_types::ChatCompat::StrictOpenAi => { strip_kigi_private_message_fields(body); strip_stream_options(body); } @@ -410,7 +410,7 @@ mod tests { } #[test] - fn mistral_dialect_strips_stream_options_and_private_fields() { + fn strict_openai_dialect_strips_stream_options_and_private_fields() { use kigi_sampling_types::ChatCompat; // Mistral 422s on stream_options (extra_forbidden) and doesn't know // kigi's private message fields; OpenAI-style reasoning_effort stays. @@ -424,7 +424,7 @@ mod tests { "reasoning_content": "internal", "model_id": "kigi/x" } ] }); - adapt_chat_completions_body_for(ChatCompat::Mistral, &mut body); + adapt_chat_completions_body_for(ChatCompat::StrictOpenAi, &mut body); assert_eq!( body.get("stream_options"), None, diff --git a/crates/codegen/kigi-sampling-types/src/types.rs b/crates/codegen/kigi-sampling-types/src/types.rs index 814fbde..7aaba35 100644 --- a/crates/codegen/kigi-sampling-types/src/types.rs +++ b/crates/codegen/kigi-sampling-types/src/types.rs @@ -1078,11 +1078,15 @@ pub enum ChatCompat { DeepSeek, /// Leave the body as-is (OpenAI-style `reasoning_effort` passes through). Passthrough, - /// Mistral wire: OpenAI-style `reasoning_effort` passes through, but the - /// strict Pydantic validator 422-rejects `stream_options` (the SDK's - /// request model has no such field), so it must be stripped. Also strips - /// the kigi-private message fields like Passthrough. - Mistral, + /// Strict OpenAI-compatible validators (Mistral, Cerebras) reject any + /// out-of-schema request field with a 4xx (`additionalProperties:false`). + /// kigi injects `stream_options.include_usage` on every streaming + /// request, which such validators reject, so it is stripped (streaming + /// usage falls back to token estimation). `reasoning_effort` passes + /// through; private message fields are stripped like Passthrough. + /// (Serde alias `mistral` keeps sessions persisted before the rename.) + #[serde(alias = "mistral")] + StrictOpenAi, } pub const REASONING_EFFORT_META_KEY: &str = "reasoningEffort"; @@ -1449,6 +1453,21 @@ mod tests { /// String content (the only shape non-Mistral providers send) stays the /// answer verbatim with no thinking — byte-identical to the pre-change /// deserialization. + /// The StrictOpenAi dialect kept the serde alias `mistral`, so sessions + /// persisted before the rename still deserialize. + #[test] + fn chat_compat_mistral_alias_deserializes_to_strict_openai() { + let v: ChatCompat = serde_json::from_str("\"mistral\"").unwrap(); + assert_eq!(v, ChatCompat::StrictOpenAi); + // New value round-trips as strict_open_ai. + let v: ChatCompat = serde_json::from_str("\"strict_open_ai\"").unwrap(); + assert_eq!(v, ChatCompat::StrictOpenAi); + assert_eq!( + serde_json::to_string(&ChatCompat::StrictOpenAi).unwrap(), + "\"strict_open_ai\"" + ); + } + #[test] fn chunk_delta_string_content_unchanged() { let delta: ChatChunkDelta = diff --git a/crates/codegen/kigi-shell/src/agent/auth_method.rs b/crates/codegen/kigi-shell/src/agent/auth_method.rs index a41addb..f0c2111 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -604,7 +604,8 @@ mod tests { "fireworks", "google", "openrouter", - "together" + "together", + "cerebras" ] ); assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID)); @@ -639,7 +640,8 @@ mod tests { "fireworks", "google", "openrouter", - "together" + "together", + "cerebras" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -667,7 +669,8 @@ mod tests { "fireworks", "google", "openrouter", - "together" + "together", + "cerebras" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -698,7 +701,8 @@ mod tests { "fireworks", "google", "openrouter", - "together" + "together", + "cerebras" ] ); assert_eq!(default_id(&built), None); diff --git a/crates/codegen/kigi-shell/src/agent/config.rs b/crates/codegen/kigi-shell/src/agent/config.rs index b432775..ec3be7b 100644 --- a/crates/codegen/kigi-shell/src/agent/config.rs +++ b/crates/codegen/kigi-shell/src/agent/config.rs @@ -4074,7 +4074,9 @@ pub fn sampling_config_for_model( kigi_models::PlatformChatCompat::Passthrough => { kigi_sampling_types::ChatCompat::Passthrough } - kigi_models::PlatformChatCompat::Mistral => kigi_sampling_types::ChatCompat::Mistral, + kigi_models::PlatformChatCompat::StrictOpenAi => { + kigi_sampling_types::ChatCompat::StrictOpenAi + } }) .unwrap_or_default(); SamplerConfig { diff --git a/crates/codegen/kigi-shell/src/agent/models_fetch.rs b/crates/codegen/kigi-shell/src/agent/models_fetch.rs index 15e5b81..2ceff13 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -1304,8 +1304,8 @@ mod tests { 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 must map to the Mistral dialect (stream_options strip)" + kigi_sampling_types::ChatCompat::StrictOpenAi, + "mistral entries use the StrictOpenAi dialect (stream_options strip)" ); } @@ -1695,6 +1695,116 @@ mod tests { ); } + /// Cerebras-cycle e2e: enrich WITHOUT restrict (the unpolluted-catalog + /// path). A minimal listing (bare ids, no context) → every live model is + /// kept; the enrichment-known one gains context + an effort menu, the + /// unknown one keeps the default context. Passthrough dialect. + #[tokio::test(flavor = "multi_thread")] + #[serial_test::serial] + async fn cerebras_enriches_without_restrict_keeping_all_models() { + let platform_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/models")) + .and(wiremock::matchers::header("Authorization", "Bearer cb-1")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + // Minimal Cerebras listing: ids only, no context. + serde_json::json!({ "data": [ + { "id": "gpt-oss-120b", "object": "model" }, + { "id": "brand-new-cerebras-model", "object": "model" } + ]}), + )) + .expect(1) + .mount(&platform_server) + .await; + let modelsdev_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/api.json")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "cerebras": { "models": { + "gpt-oss-120b": { + "limit": {"context": 131072, "output": 40960}, + "reasoning": true, + "reasoning_options": [ + {"type": "effort", "values": ["low", "medium", "high"]} + ], + "tool_call": true + } + }}}), + )) + .expect(1) + .mount(&modelsdev_server) + .await; + let cache_dir = tempfile::tempdir().unwrap(); + let _base = kigi_test_support::EnvGuard::set( + kigi_models::CEREBRAS_BASE_URL_ENV, + platform_server.uri(), + ); + let _mdev = kigi_test_support::EnvGuard::set( + crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV, + format!("{}/api.json", modelsdev_server.uri()), + ); + let _mdev_cache = kigi_test_support::EnvGuard::set( + crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV, + cache_dir.path(), + ); + let endpoints = crate::agent::config::EndpointsConfig::default(); + let keys = crate::agent::models::PlatformApiKeys::test_single( + kigi_models::PlatformId::Cerebras, + "cb-1", + ); + let result = tokio::task::spawn_blocking(move || { + fetch_platform_models_blocking(&endpoints, None, &keys) + }) + .await + .unwrap() + .expect("fetch must succeed"); + // restrict=false → BOTH the known and the unknown model are kept. + assert_eq!( + result + .models + .iter() + .map(|m| m.id.as_deref().unwrap_or_default()) + .collect::>(), + vec!["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!["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)" + ); + } + #[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 f19ad4e..e37e28d 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(), 13, "12 login rows + Quit, got {items:?}"); + assert_eq!(items.len(), 14, "13 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 {:?}", @@ -6978,7 +6978,14 @@ pub(crate) mod tests { label: "Together AI (API key)".into(), } ); - assert_eq!(items[12], PendingMenuItem::Quit); + assert_eq!( + items[12], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::Cerebras), + label: "Cerebras (API key)".into(), + } + ); + assert_eq!(items[13], 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 { @@ -6989,7 +6996,7 @@ pub(crate) mod tests { ); assert_eq!( pending_menu_items(&byok.methods, None).len(), - 13, + 14, "xai.api_key / cached_token must not add rows" ); }