diff --git a/crates/codegen/kigi-models/src/lib.rs b/crates/codegen/kigi-models/src/lib.rs index d32f22d..6aff086 100644 --- a/crates/codegen/kigi-models/src/lib.rs +++ b/crates/codegen/kigi-models/src/lib.rs @@ -346,6 +346,34 @@ const MISTRAL_SPEC: PlatformSpec = PlatformSpec { restrict_to_enriched: true, }; +/// Base-URL override for Fireworks (dev/test escape hatch). +pub const FIREWORKS_BASE_URL_ENV: &str = "KIGI_FIREWORKS_BASE_URL"; + +const FIREWORKS_SPEC: PlatformSpec = PlatformSpec { + id: "fireworks", + display_name: "Fireworks AI", + base_url: BaseUrlSource::EnvOr { + env: FIREWORKS_BASE_URL_ENV, + // Inference plane (note the /inference/v1 path, not /v1). + default: "https://api.fireworks.ai/inference/v1", + }, + uses_oauth: false, + allowed_model_prefixes: None, + api_key_envs: &["FIREWORKS_API_KEY"], + vendor: "Fireworks", + console_host: Some("fireworks.ai"), + login_label: Some("Fireworks AI (API key)"), + models_dev_id: Some("fireworks-ai"), + wire_serves_metadata: false, + wire_api: PlatformWireApi::ChatCompletions, + listing: ListingDialect::OpenAi, + chat_compat: PlatformChatCompat::Passthrough, + key_header: PlatformKeyHeader::Bearer, + // The inference /models listing can include embedding/non-chat models; + // keep tool-calling enrichment-known models only. + restrict_to_enriched: true, +}; + /// 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)] @@ -366,12 +394,14 @@ pub enum PlatformId { Groq, /// Mistral platform API (API key, OpenAI-compatible ChatCompletions). Mistral, + /// Fireworks AI platform API (API key, OpenAI-compatible ChatCompletions). + Fireworks, } 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; 8] = [ + pub const ALL: [PlatformId; 9] = [ Self::KimiCode, Self::MoonshotCn, Self::MoonshotAi, @@ -380,6 +410,7 @@ impl PlatformId { Self::DeepSeek, Self::Groq, Self::Mistral, + Self::Fireworks, ]; /// The registry row backing this platform (single source of per-platform @@ -394,6 +425,7 @@ impl PlatformId { Self::DeepSeek => &DEEPSEEK_SPEC, Self::Groq => &GROQ_SPEC, Self::Mistral => &MISTRAL_SPEC, + Self::Fireworks => &FIREWORKS_SPEC, } } @@ -1052,9 +1084,10 @@ mod tests { PlatformId::DeepSeek => 5, PlatformId::Groq => 6, PlatformId::Mistral => 7, + PlatformId::Fireworks => 8, } } - const VARIANT_COUNT: usize = 8; // update together with `ordinal` + const VARIANT_COUNT: usize = 9; // 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-shell/src/agent/auth_method.rs b/crates/codegen/kigi-shell/src/agent/auth_method.rs index 9a7bdb7..f1a4a37 100644 --- a/crates/codegen/kigi-shell/src/agent/auth_method.rs +++ b/crates/codegen/kigi-shell/src/agent/auth_method.rs @@ -596,7 +596,8 @@ mod tests { "anthropic", "deepseek", "groq", - "mistral" + "mistral", + "fireworks" ] ); assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID)); @@ -627,7 +628,8 @@ mod tests { "anthropic", "deepseek", "groq", - "mistral" + "mistral", + "fireworks" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -651,7 +653,8 @@ mod tests { "anthropic", "deepseek", "groq", - "mistral" + "mistral", + "fireworks" ] ); assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID)); @@ -678,7 +681,8 @@ mod tests { "anthropic", "deepseek", "groq", - "mistral" + "mistral", + "fireworks" ] ); 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 035597a..fb8d3f1 100644 --- a/crates/codegen/kigi-shell/src/agent/models_fetch.rs +++ b/crates/codegen/kigi-shell/src/agent/models_fetch.rs @@ -1290,6 +1290,111 @@ mod tests { ); } + /// Fireworks-cycle e2e (Groq pattern): embedding pollution restricted + /// away, Passthrough dialect, and Fireworks' deeply-slashed native ids + /// (`accounts/fireworks/models/…`) round-trip through the managed key + /// (`fireworks/accounts/fireworks/models/…`, first-slash split). + #[tokio::test(flavor = "multi_thread")] + #[serial_test::serial] + async fn fireworks_listing_restricts_maps_dialect_and_keeps_slashed_ids() { + let platform_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/models")) + .and(wiremock::matchers::header("Authorization", "Bearer fw-1")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "data": [ + { "id": "accounts/fireworks/models/glm-5p2", "object": "model" }, + { "id": "nomic-ai/nomic-embed-text-v1.5", "object": "model" } + ]}), + )) + .expect(1) + .mount(&platform_server) + .await; + let modelsdev_server = wiremock::MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/api.json")) + .respond_with(wiremock::ResponseTemplate::new(200).set_body_json( + serde_json::json!({ "fireworks-ai": { "models": { + "accounts/fireworks/models/glm-5p2": { + "limit": {"context": 1048575, "output": 65536}, + "tool_call": true + }, + "nomic-ai/nomic-embed-text-v1.5": { "limit": {"context": 8192} } + }}}), + )) + .expect(1) + .mount(&modelsdev_server) + .await; + let cache_dir = tempfile::tempdir().unwrap(); + let _base = kigi_test_support::EnvGuard::set( + kigi_models::FIREWORKS_BASE_URL_ENV, + platform_server.uri(), + ); + let _mdev = kigi_test_support::EnvGuard::set( + crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV, + format!("{}/api.json", modelsdev_server.uri()), + ); + let _mdev_cache = kigi_test_support::EnvGuard::set( + crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV, + cache_dir.path(), + ); + let endpoints = crate::agent::config::EndpointsConfig::default(); + let keys = crate::agent::models::PlatformApiKeys::test_single( + kigi_models::PlatformId::Fireworks, + "fw-1", + ); + let result = tokio::task::spawn_blocking(move || { + fetch_platform_models_blocking(&endpoints, None, &keys) + }) + .await + .unwrap() + .expect("fetch must succeed"); + assert_eq!( + result + .models + .iter() + .map(|m| m.id.as_deref().unwrap_or_default()) + .collect::>(), + vec!["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" + ); + } + #[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 0936cac..a7ae0b1 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(), 9, "8 login rows + Quit, got {items:?}"); + assert_eq!(items.len(), 10, "9 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 {:?}", @@ -6950,7 +6950,14 @@ pub(crate) mod tests { label: "Mistral (API key)".into(), } ); - assert_eq!(items[8], PendingMenuItem::Quit); + assert_eq!( + items[8], + PendingMenuItem::ApiKey { + target: PlatformLogin(kigi_shell::models::PlatformId::Fireworks), + label: "Fireworks AI (API key)".into(), + } + ); + assert_eq!(items[9], 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 { @@ -6961,7 +6968,7 @@ pub(crate) mod tests { ); assert_eq!( pending_menu_items(&byok.methods, None).len(), - 9, + 10, "xai.api_key / cached_token must not add rows" ); }