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