diff --git a/.env.example b/.env.example index a2a74f3e6..ad47bcf1a 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,9 @@ FIREWORKS_API_KEY= GEMINI_API_KEY= INCEPTION_API_KEY= KIMI_API_KEY= +MODAL_KIMI_K3_BASE_URL= +MODAL_TOKEN_ID= +MODAL_TOKEN_SECRET= MINIMAX_API_KEY= OPENAI_API_KEY= OPENROUTER_API_KEY= diff --git a/apps/fabro-web/public/images/providers/modal.svg b/apps/fabro-web/public/images/providers/modal.svg new file mode 100644 index 000000000..6499237e9 --- /dev/null +++ b/apps/fabro-web/public/images/providers/modal.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/public/administration/server-configuration.mdx b/docs/public/administration/server-configuration.mdx index 49a448138..b96f11bb1 100644 --- a/docs/public/administration/server-configuration.mdx +++ b/docs/public/administration/server-configuration.mdx @@ -387,8 +387,11 @@ fabro secret set GEMINI_API_KEY AI... | `INCEPTION_API_KEY` | Inception (Mercury) | | `POOLSIDE_API_KEY` | Poolside (Laguna) | | `OPENROUTER_API_KEY` | OpenRouter (when enabled) | +| `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` | Modal (when enabled) | | `FIREWORKS_API_KEY` | Fireworks AI (when enabled) | +Modal requires both vault tokens. Its provider definition resolves them into the `Modal-Key` and `Modal-Secret` request headers. + ### Sandbox and tools These optional server integrations are vault-only: diff --git a/docs/public/core-concepts/models.mdx b/docs/public/core-concepts/models.mdx index 1a6f094b3..25da3b32d 100644 --- a/docs/public/core-concepts/models.mdx +++ b/docs/public/core-concepts/models.mdx @@ -180,6 +180,23 @@ Fabro ships an [OpenRouter](/integrations/openrouter) provider definition with a enabled = true ``` +### Modal + +Fabro ships a [Modal](/integrations/modal) provider definition for Kimi K3, disabled by default. Modal assigns the endpoint URL and authenticates requests with a two-part proxy token: + +```toml title="settings.toml" +[llm.providers.modal] +enabled = true +base_url = "https://your-endpoint.modal.run/v1" +``` + +Store both token values in the Fabro server vault: + +```bash +fabro secret set MODAL_TOKEN_ID wk-... +fabro secret set MODAL_TOKEN_SECRET ws-... +``` + ### Amazon Bedrock Fabro ships an [Amazon Bedrock](/integrations/bedrock) provider definition with a curated multi-vendor catalog over Bedrock's Converse API, disabled by default. Enable it and authenticate with a Bedrock API key or AWS SigV4 credentials: diff --git a/docs/public/docs.json b/docs/public/docs.json index d6650e55a..e00f0d711 100644 --- a/docs/public/docs.json +++ b/docs/public/docs.json @@ -98,6 +98,7 @@ "integrations/bedrock", "integrations/poolside", "integrations/openrouter", + "integrations/modal", "integrations/fireworks", "integrations/slack", "integrations/brave-search" diff --git a/docs/public/integrations/modal.mdx b/docs/public/integrations/modal.mdx new file mode 100644 index 000000000..397b17ee4 --- /dev/null +++ b/docs/public/integrations/modal.mdx @@ -0,0 +1,162 @@ +--- +title: "Modal" +description: "Run Kimi K3 through Modal's OpenAI-compatible inference endpoints" +--- + +[Modal](https://modal.com/) serves Kimi K3 through an OpenAI-compatible Shared API and through dedicated Auto Endpoints. Fabro ships a disabled `modal` provider entry for Kimi K3. Enable it after Modal gives you an endpoint URL. + +## Prerequisites + +- A [Modal account](https://modal.com/signup) +- A [Kimi K3 Shared API or Auto Endpoint](https://modal.com/library/moonshot/kimi-k3) +- A Modal proxy-token pair + +## Create or select an endpoint + +Use the Kimi K3 Shared API from the Modal model library, or create a dedicated Auto Endpoint: + +```bash +modal endpoint create --model moonshotai/Kimi-K3 +``` + +Find the endpoint URL in the Modal dashboard or with `modal endpoint list`. Modal serves its OpenAI-compatible API under `/v1`. + +## Create a proxy token + +Modal endpoints are authenticated with two headers. Create a proxy-token pair: + +```bash +modal workspace proxy-tokens create +``` + +The command prints a token ID that starts with `wk-` and a secret that starts with `ws-`. Modal shows the secret only once, so save both values immediately. + +If your Modal workspace uses RBAC, allow the token in the endpoint's environment: + +```bash +modal workspace proxy-tokens allow wk-... main +``` + +## Enable the provider + +Add the provider override to the settings file used by the Fabro server. Include `/v1` in the endpoint URL and omit a trailing slash. + +```toml title="settings.toml" +_version = 1 + +[llm.providers.modal] +enabled = true +base_url = "https://your-endpoint.modal.run/v1" +``` + +The endpoint URL is not built into Fabro because Modal assigns it to your Shared API or Auto Endpoint. + +## Configure credentials + +Store both proxy-token values in the target Fabro server vault: + +```bash +fabro secret set MODAL_TOKEN_ID wk-... +fabro secret set MODAL_TOKEN_SECRET ws-... + +# For a non-default remote server: +fabro secret --server https://your-fabro.example set MODAL_TOKEN_ID wk-... +fabro secret --server https://your-fabro.example set MODAL_TOKEN_SECRET ws-... +``` + + + `fabro provider login --provider modal` is not supported in this release because that command accepts one credential value. Use the two `fabro secret set` commands above. + + +Modal does not use a bearer API key for these endpoints. Fabro sends the vault values as `Modal-Key` and `Modal-Secret` headers and does not send an `Authorization` header. + +## Included model + +| Fabro model slug | Modal API ID | Context | Input / cached input / output | Estimated speed | +| --- | --- | --- | --- | --- | +| `kimi-k3` | `moonshotai/Kimi-K3` | 1M tokens | $3.00 / $0.30 / $15.00 per MTok | 460 tok/s | + +The catalog marks Kimi K3 as supporting tools, vision, reasoning, and prompt caching. Modal's model ID is case-sensitive. + +## Use Kimi K3 + +```bash +fabro model list --provider modal +fabro model test --provider modal --model kimi-k3 +fabro run workflow.fabro --provider modal --model kimi-k3 +``` + +When targeting a non-default remote server, pass the same `--server` value: + +```bash +fabro model list --server https://your-fabro.example --provider modal +fabro model test --server https://your-fabro.example --provider modal --model kimi-k3 +``` + +In workflow stylesheets: + +```dot title="workflow.fabro" +digraph Example { + graph [ + model_stylesheet=" + * { model: modal/kimi-k3; } + " + ] + + start [shape=Mdiamond, label="Start"] + work [label="Work", prompt="Use Kimi K3 through Modal."] + exit [shape=Msquare, label="Exit"] + + start -> work -> exit +} +``` + +## Direct SDK environment credentials + +The built-in Modal provider reads its two headers from the Fabro vault. Direct SDK code that uses `EnvCredentialSource` must explicitly change those header sources to environment variables: + +```toml title="settings.toml" +[llm.providers.modal] +enabled = true +base_url = "https://your-endpoint.modal.run/v1" + +[llm.providers.modal.extra_headers] +"Modal-Key" = "{{ env.MODAL_TOKEN_ID }}" +"Modal-Secret" = "{{ env.MODAL_TOKEN_SECRET }}" +``` + +Then export both values before starting the process: + +```bash +export MODAL_TOKEN_ID=wk-... +export MODAL_TOKEN_SECRET=ws-... +``` + +## Costs + +Fabro estimates Shared API costs from Modal's published Kimi K3 prices. Completion and reasoning tokens use the output rate. Modal responses do not include an authoritative charge, so Fabro reports `cost_source = "estimated"`. + +Dedicated Auto Endpoints use Modal compute billing instead of the Shared API token prices. The Fabro estimate does not represent that compute bill. + +## Troubleshooting + +**"provider 'modal' uses openai_compatible adapter but does not configure base_url"** — Add the Modal endpoint URL under `[llm.providers.modal]`. Include `/v1`. + +**Modal is not configured** — Set both `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` in the target server vault. One value is not sufficient. + +**401 or 403** — Confirm that the token pair belongs to the correct Modal workspace and environment. If the workspace uses RBAC, allow the token in that environment. + +**404** — Confirm that the base URL is the endpoint URL followed by `/v1`, with no trailing slash. + +**Unknown model** — The built-in API ID is exactly `moonshotai/Kimi-K3`. Run `fabro model test --provider modal --model kimi-k3` to test the configured offering. + +## Further reading + + + + Shared API prices, model specifications, and Auto Endpoint setup. + + + Proxy-token headers and endpoint calling conventions. + + diff --git a/docs/public/reference/sdk.mdx b/docs/public/reference/sdk.mdx index 651d67bcc..2410438dc 100644 --- a/docs/public/reference/sdk.mdx +++ b/docs/public/reference/sdk.mdx @@ -372,9 +372,22 @@ For env-backed usage, `EnvCredentialSource` checks for API key environment varia | `INCEPTION_API_KEY` | Inception | | `POOLSIDE_API_KEY` | Poolside | | `OPENROUTER_API_KEY` | OpenRouter, when enabled in settings | +| `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET` | Modal, when enabled and configured as below | The first provider registered becomes the default. Provider base URLs come from the model catalog. For vault-backed usage inside Fabro, use `fabro_auth::VaultCredentialSource` instead. +The built-in Modal definition reads proxy-token headers from the vault. To use `EnvCredentialSource` directly, enable Modal, set its endpoint URL, and override both header sources: + +```toml +[llm.providers.modal] +enabled = true +base_url = "https://your-endpoint.modal.run/v1" + +[llm.providers.modal.extra_headers] +"Modal-Key" = "{{ env.MODAL_TOKEN_ID }}" +"Modal-Secret" = "{{ env.MODAL_TOKEN_SECRET }}" +``` + #### Creating manually ```rust diff --git a/lib/components/fabro-llm/src/client.rs b/lib/components/fabro-llm/src/client.rs index 30d5c05a0..c57dd4042 100644 --- a/lib/components/fabro-llm/src/client.rs +++ b/lib/components/fabro-llm/src/client.rs @@ -1169,6 +1169,72 @@ output_cost_per_mtok = 20.0 completion.assert_async().await; } + #[tokio::test] + async fn modal_routes_kimi_k3_with_proxy_headers_and_no_bearer_auth() { + let upstream = httpmock::MockServer::start_async().await; + let completion = upstream + .mock_async(|when, then| { + when.method(httpmock::Method::POST) + .path("/v1/chat/completions") + .header("Modal-Key", "wk-test") + .header("Modal-Secret", "ws-test") + .header_missing("Authorization") + .json_body_includes(r#"{"model":"moonshotai/Kimi-K3"}"#); + then.status(200) + .header("content-type", "application/json") + .json_body(serde_json::json!({ + "id": "chatcmpl-modal", + "model": "moonshotai/Kimi-K3", + "choices": [{ + "message": {"role": "assistant", "content": "OK"}, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2 + } + })); + }) + .await; + let catalog = catalog_with(&format!( + r#" +[providers.modal] +enabled = true +base_url = "{}/v1" +"#, + upstream.base_url() + )); + let modal = ProviderId::new("modal"); + let client = Client::from_credentials( + vec![ApiCredential { + provider: modal.clone(), + auth_header: None, + extra_headers: HashMap::from([ + ("Modal-Key".to_string(), "wk-test".to_string()), + ("Modal-Secret".to_string(), "ws-test".to_string()), + ]), + base_url: None, + codex_mode: false, + org_id: None, + project_id: None, + }], + catalog, + ) + .await + .unwrap(); + let mut request = test_request(); + request.model = "kimi-k3".to_string(); + request.provider = Some(modal.to_string()); + + let response = client.complete(&request).await.unwrap(); + + assert_eq!(response.text(), "OK"); + assert_eq!(response.model, "kimi-k3"); + assert_eq!(response.provider, "modal"); + completion.assert_async().await; + } + #[tokio::test] async fn complete_stamps_estimated_cost_from_catalog() { let mut client = Client::new(HashMap::new(), None, vec![]); diff --git a/lib/components/fabro-llm/tests/integration.rs b/lib/components/fabro-llm/tests/integration.rs index c8e3ed1bf..3234e09b2 100644 --- a/lib/components/fabro-llm/tests/integration.rs +++ b/lib/components/fabro-llm/tests/integration.rs @@ -3,6 +3,7 @@ reason = "Live provider integration tests read required API keys from process env." )] +use std::collections::HashMap; use std::sync::Arc; use fabro_auth::ApiCredential; @@ -641,6 +642,64 @@ async fn openrouter_kimi_k3_deep_tool_round_trip() { ); } +#[fabro_macros::e2e_test( + live("MODAL_KIMI_K3_BASE_URL"), + live("MODAL_TOKEN_ID"), + live("MODAL_TOKEN_SECRET") +)] +async fn modal_kimi_k3_deep_tool_round_trip() { + let Some(base_url) = fabro_test::require_env("MODAL_KIMI_K3_BASE_URL") else { + return; + }; + let Some(token_id) = fabro_test::require_env("MODAL_TOKEN_ID") else { + return; + }; + let Some(token_secret) = fabro_test::require_env("MODAL_TOKEN_SECRET") else { + return; + }; + let provider = ProviderId::new("modal"); + let mut settings = LlmCatalogSettings::default(); + settings + .providers + .insert(provider.to_string(), ProviderCatalogSettings { + enabled: Some(true), + base_url: Some(base_url), + ..ProviderCatalogSettings::default() + }); + let catalog = Arc::new( + Catalog::from_builtin_with_overrides(&settings) + .expect("enabled Modal catalog should build"), + ); + let credential = ApiCredential { + provider: provider.clone(), + auth_header: None, + extra_headers: HashMap::from([ + ("Modal-Key".to_string(), token_id), + ("Modal-Secret".to_string(), token_secret), + ]), + base_url: None, + codex_mode: false, + org_id: None, + project_id: None, + }; + let client = Arc::new( + Client::from_credentials(vec![credential], Arc::clone(&catalog)) + .await + .expect("Modal client should build from the catalog"), + ); + let model = catalog + .get_on_provider(&provider, "kimi-k3") + .expect("Modal Kimi K3 should be present"); + + let outcome = run_model_test(model, ModelTestMode::Deep, client).await; + assert_eq!( + outcome.status, + ModelTestStatus::Ok, + "Modal Kimi K3 deep test failed: {:?}", + outcome.error_message + ); +} + async fn run_multi_turn_cache_test( adapter: &dyn ProviderAdapter, model: &str, diff --git a/lib/foundation/fabro-auth/src/env_source.rs b/lib/foundation/fabro-auth/src/env_source.rs index 8a2d7cc53..18804f28a 100644 --- a/lib/foundation/fabro-auth/src/env_source.rs +++ b/lib/foundation/fabro-auth/src/env_source.rs @@ -195,6 +195,20 @@ reasoning_effort = "levels" )) } + fn modal_env_catalog() -> Catalog { + catalog_with( + r#" +[providers.modal] +enabled = true +base_url = "https://example--kimi-k3.modal.run/v1" + +[providers.modal.extra_headers] +"Modal-Key" = "{{ env.MODAL_TOKEN_ID }}" +"Modal-Secret" = "{{ env.MODAL_TOKEN_SECRET }}" +"#, + ) + } + #[tokio::test] async fn configured_providers_reads_injected_env() { let source = test_source(&[("ANTHROPIC_API_KEY", "anthropic-key")]); @@ -331,6 +345,38 @@ x-portkey-provider = "@bedrock-prod" ); } + #[tokio::test] + async fn env_source_resolves_modal_proxy_headers_when_explicitly_configured() { + let catalog = modal_env_catalog(); + let source = test_source(&[ + ("MODAL_TOKEN_ID", "wk-test"), + ("MODAL_TOKEN_SECRET", "ws-test"), + ]); + let modal = ProviderId::new("modal"); + + assert!(source.configured_providers(&catalog).await.contains(&modal)); + + let resolved = source.resolve(&catalog).await.unwrap(); + let credential = resolved + .credentials + .iter() + .find(|credential| credential.provider == modal) + .expect("Modal should resolve from the explicit environment header settings"); + + assert!(credential.auth_header.is_none()); + assert_eq!( + credential.extra_headers, + HashMap::from([ + ("Modal-Key".to_string(), "wk-test".to_string()), + ("Modal-Secret".to_string(), "ws-test".to_string()), + ]) + ); + assert_eq!( + credential.base_url.as_deref(), + Some("https://example--kimi-k3.modal.run/v1") + ); + } + #[tokio::test] async fn env_source_secrets_header_token_is_unavailable() { let catalog = portkey_catalog(r#"x-team-secret = "{{ secrets.gateway_team_secret }}""#); diff --git a/lib/foundation/fabro-auth/src/resolve.rs b/lib/foundation/fabro-auth/src/resolve.rs index 60a4576b7..a83fc7f9a 100644 --- a/lib/foundation/fabro-auth/src/resolve.rs +++ b/lib/foundation/fabro-auth/src/resolve.rs @@ -609,6 +609,16 @@ reasoning_effort = "levels" )) } + fn modal_catalog() -> Catalog { + catalog_with( + r#" +[providers.modal] +enabled = true +base_url = "https://example--kimi-k3.modal.run/v1" +"#, + ) + } + #[tokio::test] async fn resolve_openai_api_request_prefers_env_when_listed_first() { let dir = tempfile::tempdir().unwrap(); @@ -935,6 +945,83 @@ reasoning = false ); } + #[tokio::test] + async fn modal_resolves_both_vault_proxy_headers_without_authorization() { + let catalog = modal_catalog(); + let dir = tempfile::tempdir().unwrap(); + let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); + vault_set_token(&mut vault, "MODAL_TOKEN_ID", "wk-test").unwrap(); + vault_set_token(&mut vault, "MODAL_TOKEN_SECRET", "ws-test").unwrap(); + let resolver = test_resolver(vault, Arc::new(|_| None)); + let modal = ProviderId::new("modal"); + + { + let vault = resolver.vault.read().await; + assert!( + resolver + .configured_providers(&vault, &catalog) + .contains(&modal) + ); + } + + let resolved = resolver + .resolve(modal.clone(), CredentialUsage::ApiRequest, &catalog) + .await + .unwrap(); + let ResolvedCredential::Api(api) = resolved; + + assert_eq!(api.provider, modal); + assert!(api.auth_header.is_none()); + assert_eq!( + api.extra_headers, + HashMap::from([ + ("Modal-Key".to_string(), "wk-test".to_string()), + ("Modal-Secret".to_string(), "ws-test".to_string()), + ]) + ); + assert_eq!( + api.base_url.as_deref(), + Some("https://example--kimi-k3.modal.run/v1") + ); + } + + #[tokio::test] + async fn modal_requires_both_vault_proxy_tokens() { + for (present_name, present_value, missing_name) in [ + ("MODAL_TOKEN_ID", "wk-present", "MODAL_TOKEN_SECRET"), + ("MODAL_TOKEN_SECRET", "ws-present", "MODAL_TOKEN_ID"), + ] { + let catalog = modal_catalog(); + let dir = tempfile::tempdir().unwrap(); + let mut vault = Vault::load(dir.path().join("secrets.json")).unwrap(); + vault_set_token(&mut vault, present_name, present_value).unwrap(); + let resolver = test_resolver(vault, Arc::new(|_| None)); + let modal = ProviderId::new("modal"); + + { + let vault = resolver.vault.read().await; + assert!( + !resolver + .configured_providers(&vault, &catalog) + .contains(&modal) + ); + } + + let err = resolver + .resolve(modal.clone(), CredentialUsage::ApiRequest, &catalog) + .await + .unwrap_err(); + + assert!(matches!( + err, + ResolveError::Interpolation { ref provider, .. } if provider == &modal + )); + let message = err.to_string(); + assert!(message.contains(missing_name)); + assert!(!message.contains(present_value)); + } + } + #[tokio::test] async fn resolve_multi_segment_header_token() { let catalog = portkey_catalog(r#"authorization = "Bearer {{ secrets.TOKEN }}""#); diff --git a/lib/foundation/fabro-model/src/catalog.rs b/lib/foundation/fabro-model/src/catalog.rs index f012a62ff..aac7d8725 100644 --- a/lib/foundation/fabro-model/src/catalog.rs +++ b/lib/foundation/fabro-model/src/catalog.rs @@ -3490,6 +3490,196 @@ enabled = true ]); } + #[test] + fn builtin_modal_provider_is_opt_in() { + let modal = ProviderId::new("modal"); + let builtin = Catalog::builtin(); + + assert!(builtin.provider(&modal).is_none()); + assert!(builtin.list(Some(&modal)).is_empty()); + + let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( + r" +[providers.modal] +enabled = true +", + )) + .expect("enabled Modal override should build from the built-in provider settings"); + + let provider = catalog + .provider(&modal) + .expect("enabled Modal provider should be present"); + assert_eq!(provider.adapter, AdapterKind::OpenAiCompatible); + assert_eq!(provider.codec, CodecKind::OpenAiCompatible); + assert_eq!(provider.agent_profile, AgentProfileKind::Kimi); + assert_eq!(provider.billing_policy, BillingPolicy::OpenAi); + assert_eq!(provider.priority, 30); + assert!(provider.auth.is_none()); + assert!(provider.base_url.is_none()); + assert_eq!( + provider.api_key_url.as_deref(), + Some("https://modal.com/docs/guide/endpoints#proxy-tokens") + ); + assert_eq!( + provider.extra_headers, + HashMap::from([ + ( + "Modal-Key".to_string(), + "{{ secrets.MODAL_TOKEN_ID }}".to_string(), + ), + ( + "Modal-Secret".to_string(), + "{{ secrets.MODAL_TOKEN_SECRET }}".to_string(), + ), + ]) + ); + assert_eq!( + catalog + .default_for_provider(&modal) + .map(|model| model.id.as_str()), + Some("kimi-k3") + ); + assert_eq!( + catalog + .probe_for_provider(&modal) + .map(|model| model.id.as_str()), + Some("kimi-k3") + ); + + let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( + r#" +[providers.modal] +enabled = true +base_url = "https://example--kimi-k3.modal.run/v1" +"#, + )) + .expect("Modal base URL override should build"); + assert_eq!( + catalog + .provider(&modal) + .and_then(|provider| provider.base_url.as_deref()), + Some("https://example--kimi-k3.modal.run/v1") + ); + } + + #[test] + fn builtin_modal_includes_kimi_k3_when_enabled() { + let modal = ProviderId::new("modal"); + let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( + r" +[providers.modal] +enabled = true +", + )) + .expect("enabled Modal override should build from the built-in provider settings"); + + assert_eq!(catalog.list(Some(&modal)).len(), 1); + let model = catalog + .get_on_provider(&modal, "kimi-k3") + .expect("Modal Kimi K3 should be present"); + insta::assert_debug_snapshot!(model, @r#" + Model { + id: "kimi-k3", + provider: modal, + family: "kimi-k3", + display_name: "Kimi K3 (via Modal)", + limits: ModelLimits { + context_window: 1048576, + max_output: Some( + 131072, + ), + }, + training: None, + knowledge_cutoff: None, + features: ModelFeatures { + tools: true, + vision: true, + reasoning: true, + reasoning_effort: AlwaysAdaptive, + prompt_cache: true, + cache_control_breakpoints: false, + sampling_params: false, + }, + controls: ModelControls { + reasoning_effort: [ + Low, + High, + Max, + ], + }, + costs: ModelCosts { + input_cost_per_mtok: Some( + 3.0, + ), + output_cost_per_mtok: Some( + 15.0, + ), + cache_input_cost_per_mtok: Some( + 0.3, + ), + }, + estimated_output_tps: Some( + 460.0, + ), + aliases: [], + default: true, + small_default: false, + configured: false, + } + "#); + + let settings = catalog + .model_settings_on_provider(&modal, "kimi-k3") + .expect("Modal Kimi K3 settings should be present"); + assert_eq!(settings.api_id, "moonshotai/Kimi-K3"); + assert_eq!(settings.agent_profile, AgentProfileKind::Kimi); + assert_eq!(settings.billing_policy, BillingPolicy::OpenAi); + assert_eq!(settings.controls.reasoning_effort, vec![ + ReasoningEffort::Low, + ReasoningEffort::High, + ReasoningEffort::Max, + ]); + } + + #[test] + fn builtin_kimi_k3_slug_is_portable_across_direct_and_gateway_providers() { + let kimi = ProviderId::new("kimi"); + let modal = ProviderId::new("modal"); + let openrouter = ProviderId::new("openrouter"); + let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( + r" +[providers.modal] +enabled = true + +[providers.openrouter] +enabled = true +", + )) + .expect("enabled Modal and OpenRouter overrides should build"); + + for provider in [&kimi, &modal, &openrouter] { + let model = catalog + .get_on_provider(provider, "kimi-k3") + .unwrap_or_else(|| panic!("Kimi K3 should resolve on provider '{provider}'")); + assert_eq!(model.id, "kimi-k3", "{provider}"); + assert_eq!(&model.provider, provider, "{provider}"); + } + + let selected = catalog + .select( + "kimi-k3", + None, + &HashSet::from([kimi.clone(), modal.clone(), openrouter.clone()]), + ) + .expect("direct Kimi should win portable Kimi K3 selection"); + assert_eq!(selected.provider, kimi); + + let selected = catalog + .select("kimi-k3", None, &HashSet::from([modal.clone(), openrouter])) + .expect("Modal should win gateway-only Kimi K3 selection"); + assert_eq!(selected.provider, modal); + } + #[test] fn builtin_openrouter_includes_poolside_laguna_when_enabled() { let catalog = Catalog::from_builtin_with_overrides(&minimal_settings( diff --git a/lib/foundation/fabro-model/src/catalog/providers/modal.toml b/lib/foundation/fabro-model/src/catalog/providers/modal.toml new file mode 100644 index 000000000..6c48c13ee --- /dev/null +++ b/lib/foundation/fabro-model/src/catalog/providers/modal.toml @@ -0,0 +1,50 @@ +[providers.modal] +display_name = "Modal" +adapter = "openai_compatible" +agent_profile = "kimi" +api_key_url = "https://modal.com/docs/guide/endpoints#proxy-tokens" +priority = 30 +enabled = false + +[providers.modal.extra_headers] +"Modal-Key" = "{{ secrets.MODAL_TOKEN_ID }}" +"Modal-Secret" = "{{ secrets.MODAL_TOKEN_SECRET }}" + +# Modal assigns an endpoint URL when the Shared API or an Auto Endpoint is +# created. To enable Modal, add the endpoint URL to settings.toml: +# +# [llm.providers.modal] +# enabled = true +# base_url = "https://.modal.run/v1" +# +# Then store both proxy-token values in the Fabro server vault: +# +# fabro secret set MODAL_TOKEN_ID wk-... +# fabro secret set MODAL_TOKEN_SECRET ws-... + +[providers.modal.models."kimi-k3"] +api_id = "moonshotai/Kimi-K3" +display_name = "Kimi K3 (via Modal)" +family = "kimi-k3" +default = true +estimated_output_tps = 460 + +[providers.modal.models."kimi-k3".limits] +context_window = 1048576 +max_output = 131072 + +[providers.modal.models."kimi-k3".features] +tools = true +vision = true +reasoning = true +reasoning_effort = "always_adaptive" +prompt_cache = true +sampling_params = false + +[providers.modal.models."kimi-k3".controls] +reasoning_effort = ["low", "high", "max"] + +[providers.modal.models."kimi-k3".costs] +input_cost_per_mtok = 3.0 +output_cost_per_mtok = 15.0 +cache_input_cost_per_mtok = 0.3