mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Add Modal LLM provider
This commit is contained in:
parent
450a1701b2
commit
8771c971d7
13 changed files with 700 additions and 0 deletions
|
|
@ -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=
|
||||
|
|
|
|||
3
apps/fabro-web/public/images/providers/modal.svg
Normal file
3
apps/fabro-web/public/images/providers/modal.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="24" height="24" viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M121.683 75.25L149.997 124L91.4816 224.75C90.3128 226.757 88.155 228 85.8174 228H32.9664C31.7976 228 30.6778 227.691 29.697 227.131C28.7161 226.57 27.8906 225.758 27.3021 224.75L0.876625 179.25C-0.292208 177.243 -0.292208 174.765 0.876625 172.75L57.512 75.25C58.0923 74.2425 58.9259 73.43 59.9068 72.8694C60.8876 72.3088 62.0074 72 63.1762 72H116.027C118.365 72 120.523 73.2431 121.692 75.25H121.683ZM299.125 172.75L242.49 75.25C241.91 74.2425 241.076 73.43 240.095 72.8694C239.114 72.3088 237.995 72 236.826 72H183.975C181.637 72 179.479 73.2431 178.311 75.25L149.997 124L208.512 224.75C209.681 226.757 211.839 228 214.177 228H267.027C268.196 228 269.316 227.691 270.297 227.131C271.278 226.57 272.103 225.758 272.692 224.75L299.117 179.25C300.286 177.243 300.286 174.765 299.117 172.75H299.125Z" fill="#62DE61"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 917 B |
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@
|
|||
"integrations/bedrock",
|
||||
"integrations/poolside",
|
||||
"integrations/openrouter",
|
||||
"integrations/modal",
|
||||
"integrations/fireworks",
|
||||
"integrations/slack",
|
||||
"integrations/brave-search"
|
||||
|
|
|
|||
162
docs/public/integrations/modal.mdx
Normal file
162
docs/public/integrations/modal.mdx
Normal file
|
|
@ -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-...
|
||||
```
|
||||
|
||||
<Note>
|
||||
`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.
|
||||
</Note>
|
||||
|
||||
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
|
||||
|
||||
<Columns cols={2}>
|
||||
<Card title="Modal Kimi K3" icon="microchip" href="https://modal.com/library/moonshot/kimi-k3">
|
||||
Shared API prices, model specifications, and Auto Endpoint setup.
|
||||
</Card>
|
||||
<Card title="Modal endpoint authentication" icon="key" href="https://modal.com/docs/guide/endpoints#proxy-tokens">
|
||||
Proxy-token headers and endpoint calling conventions.
|
||||
</Card>
|
||||
</Columns>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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![]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }}""#);
|
||||
|
|
|
|||
|
|
@ -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 }}""#);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
50
lib/foundation/fabro-model/src/catalog/providers/modal.toml
Normal file
50
lib/foundation/fabro-model/src/catalog/providers/modal.toml
Normal file
|
|
@ -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://<your-modal-endpoint>.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
|
||||
Loading…
Add table
Reference in a new issue