Merge pull request #695 from fabro-sh/fix/modal-provider-catalog

Prioritize Modal and rename Kimi provider to Moonshot
This commit is contained in:
Bryan Helmkamp 2026-08-01 09:38:22 -04:00 • committed by GitHub
commit 33e5dc8d3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 301 additions and 180 deletions

View file

@ -5,7 +5,7 @@ DEEPSEEK_API_KEY=
FIREWORKS_API_KEY=
GEMINI_API_KEY=
INCEPTION_API_KEY=
KIMI_API_KEY=
MOONSHOT_API_KEY=
MINIMAX_API_KEY=
MODAL_KIMI_K3_BASE_URL=
MODAL_TOKEN_ID=

View file

@ -1,3 +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"/>
<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="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 922 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

View file

@ -1,6 +1,3 @@
<svg width="24" height="24" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" fill="currentColor" stroke="currentColor">
<path d="M3 248.945C18 248.945 76 236 106 219C136 202 136 202 198 158C276.497 102.293 332 120.945 423 120.945" stroke-width="90"/>
<path d="M511 121.5L357.25 210.268L357.25 32.7324L511 121.5Z"/>
<path d="M0 249C15 249 73 261.945 103 278.945C133 295.945 133 295.945 195 339.945C273.497 395.652 329 377 420 377" stroke-width="90"/>
<path d="M508 376.445L354.25 287.678L354.25 465.213L508 376.445Z"/>
<svg width="24" height="24" viewBox="0 0 401.4 293.7" xmlns="http://www.w3.org/2000/svg">
<path d="M303.9475,17.19926c42.79734,0,77.48933,34.69327,77.48933,77.48933s-34.69199,77.48933-77.48933,77.48933l76.86166,76.86244c9.76367,9.76313,2.84903,26.45667-10.95697,26.45667h-220.88335c-71.32686,0-129.14889-57.82202-129.14889-129.14889S77.64197,17.19926,148.96884,17.19926h154.97866ZM148.96884,68.85881c-42.79607,0-77.48933,34.69327-77.48933,77.48933s34.69327,77.48933,77.48933,77.48933,77.48933-34.69327,77.48933-77.48933-34.69327-77.48933-77.48933-77.48933Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 533 B

After

Width:  |  Height:  |  Size: 587 B

View file

@ -383,7 +383,7 @@ fabro secret set GEMINI_API_KEY AI...
| `ANTHROPIC_API_KEY` | Anthropic (Claude) |
| `OPENAI_API_KEY` | OpenAI (GPT) |
| `GEMINI_API_KEY` | Google (Gemini) |
| `KIMI_API_KEY` | Kimi |
| `MOONSHOT_API_KEY` or `KIMI_API_KEY` | Moonshot AI; `MOONSHOT_API_KEY` takes precedence |
| `ZAI_API_KEY` | Zai (GLM) |
| `MINIMAX_API_KEY` | Minimax |
| `INCEPTION_API_KEY` | Inception (Mercury) |

View file

@ -26,7 +26,7 @@ Previously, you could only see the final output of a run. Now you can trace exac
</Accordion>
<Accordion title="Improvements">
- Kimi, Z.AI, and Minimax LLM providers added to model catalog
- Moonshot AI, Z.AI, and Minimax LLM providers added to model catalog
- Runs use time-sortable ULIDs instead of UUIDs, making it easier to find recent runs in logs and the API
- Asymmetric JWT authentication between fabro-web and the API server, with GitHub identity in the token
</Accordion>

View file

@ -29,7 +29,7 @@ Schema upgrades now create a consistent `fabro.sqlite3.pre-migration.bak` snapsh
</Accordion>
<Accordion title="Improvements">
- Added Kimi K3 through the direct Kimi provider and OpenRouter
- Added Kimi K3 through the direct Moonshot AI provider and OpenRouter
- Added GLM 5.2 through Z.AI and OpenRouter
- Left-to-right run graphs now zoom to 400% and preserve separate viewport positions for left-to-right and top-to-bottom layouts
</Accordion>

View file

@ -57,8 +57,8 @@ Fabro performs this selection once when creating a run and persists the chosen p
| `gemini-3.5-flash` | gemini | `gemini-35-flash` | 1M | $1.50 / $9.00 | 150 tok/s |
| `gemini-3-flash-preview` | gemini | `gemini-flash` | 1M | $0.50 / $3.00 | 150 tok/s |
| `gemini-3.1-flash-lite` | gemini | `gemini-flash-lite`, `gemini-3.1-flash-lite-preview` | 1M | $0.25 / $1.50 | 200 tok/s |
| `kimi-k2.5` | kimi | | 262K | $0.60 / $3.00 | 50 tok/s |
| `kimi-k3` | kimi | `kimi` | 1M | $3.00 / $15.00 | n/a |
| `kimi-k2.5` | moonshot | | 262K | $0.60 / $3.00 | 50 tok/s |
| `kimi-k3` | moonshot | `kimi` | 1M | $3.00 / $15.00 | n/a |
| `deepseek-v4-flash` | deepseek | `deepseek`, `deepseek-v4`, `deepseek-flash` | 1,048,576 | $0.14 / $0.28 | n/a |
| `deepseek-v4-pro` | deepseek | | 1,048,576 | $0.435 / $0.87 | n/a |
| `laguna-s-2.1` | poolside | `laguna`, `laguna-s` | 1M | $0.10 / $0.20 | n/a |
@ -229,7 +229,7 @@ When no model or provider is specified, Fabro chooses the default offering on th
| `anthropic` | `claude-sonnet-4-6` |
| `openai` | `gpt-5.6-sol` |
| `gemini` | `gemini-3.5-flash` |
| `kimi` | `kimi-k3` |
| `moonshot` | `kimi-k3` |
| `poolside` | `laguna-s-2.1` |
| `zai` | `glm-5.2` |
| `minimax` | `minimax-m2.5` |

View file

@ -151,7 +151,7 @@ For a qualified fallback, the selector may be that provider's canonical model ID
```toml title="run.toml"
[run.model.fallbacks]
"kimi-k3" = [
"kimi:kimi-k3",
"moonshot:kimi-k3",
"openrouter:kimi-k3",
"claude-opus",
]
@ -169,7 +169,7 @@ For example, a server that selects Modal as the primary `kimi-k3` offering can d
```toml title="settings.toml"
[run.model.fallbacks]
"kimi-k3" = ["kimi:kimi-k3", "openrouter:kimi-k3", "claude-opus"]
"kimi-k3" = ["moonshot:kimi-k3", "openrouter:kimi-k3", "claude-opus"]
"glm-5.2" = ["gpt-sol"]
"gpt-sol" = ["claude-opus"]
"claude-opus" = ["gpt-sol"]

View file

@ -366,7 +366,7 @@ For env-backed usage, `EnvCredentialSource` checks for API key environment varia
| `ANTHROPIC_API_KEY` | Anthropic |
| `OPENAI_API_KEY` | OpenAI |
| `GEMINI_API_KEY` or `GOOGLE_API_KEY` | Gemini |
| `KIMI_API_KEY` | Kimi |
| `MOONSHOT_API_KEY` or `KIMI_API_KEY` | Moonshot AI; `MOONSHOT_API_KEY` takes precedence |
| `ZAI_API_KEY` | ZAI |
| `MINIMAX_API_KEY` | Minimax |
| `INCEPTION_API_KEY` | Inception |

View file

@ -3515,7 +3515,7 @@ root = "{}"
assert!(ids.contains(&ProviderId::anthropic()));
assert!(ids.contains(&ProviderId::openai()));
assert!(ids.contains(&ProviderId::gemini()));
assert!(ids.contains(&ProviderId::new("kimi")));
assert!(ids.contains(&ProviderId::new("moonshot")));
assert!(ids.contains(&ProviderId::new("zai")));
assert!(ids.contains(&ProviderId::new("minimax")));
assert!(ids.contains(&ProviderId::new("inception")));

View file

@ -723,7 +723,7 @@ mod tests {
.body(
serde_json::json!({
"model_id": "kimi-k2.5",
"provider": "kimi",
"provider": "moonshot",
"status": "skip"
})
.to_string(),

View file

@ -386,7 +386,7 @@ mod tests {
ProviderId::anthropic(),
ProviderId::openai(),
ProviderId::gemini(),
ProviderId::new("kimi"),
ProviderId::new("moonshot"),
ProviderId::new("zai"),
ProviderId::new("minimax"),
ProviderId::new("inception"),

View file

@ -18,6 +18,7 @@ fn remove_provider_env(cmd: &mut Command) -> &mut Command {
.env_remove("OPENAI_API_KEY")
.env_remove("GEMINI_API_KEY")
.env_remove("GOOGLE_API_KEY")
.env_remove("MOONSHOT_API_KEY")
.env_remove("KIMI_API_KEY")
.env_remove("ZAI_API_KEY")
.env_remove("MINIMAX_API_KEY")

View file

@ -1542,7 +1542,7 @@ enabled = true
let mut checks = Vec::new();
let configured = std::collections::BTreeMap::from([(
"kimi-k3".to_string(),
model_refs(&["kimi:kimi-k3", "openrouter:kimi-k3"]),
model_refs(&["moonshot:kimi-k3", "openrouter:kimi-k3"]),
)]);
assert!(run_model_fallback_check(
@ -1554,13 +1554,12 @@ enabled = true
let check = checks.last().expect("fallback check should be present");
assert_eq!(check.status, CheckStatus::Warning);
assert!(
check
.details
.iter()
.any(|detail| detail.warn
&& detail.text.contains("provider `kimi` is not configured"))
);
assert!(check.details.iter().any(|detail| {
detail.warn
&& detail
.text
.contains("provider `moonshot` is not configured")
}));
}
#[test]
@ -1603,15 +1602,15 @@ enabled = true
})
}
fn ready_kimi_and_openrouter_state(
fn ready_moonshot_and_openrouter_state(
server: &httpmock::MockServer,
) -> Arc<crate::server::AppState> {
let kimi_url = server.url("/kimi/v1");
let moonshot_url = server.url("/moonshot/v1");
let openrouter_url = server.url("/openrouter/v1");
let llm_catalog_settings: LlmCatalogSettings = toml::from_str(&format!(
r#"
[providers.kimi]
base_url = "{kimi_url}"
[providers.moonshot]
base_url = "{moonshot_url}"
[providers.openrouter]
base_url = "{openrouter_url}"
@ -1623,7 +1622,7 @@ enabled = true
crate::test_support::TestAppStateBuilder::new()
.llm_catalog_settings(llm_catalog_settings)
.vault_entries([
(EnvVars::KIMI_API_KEY, "test-kimi-key"),
(EnvVars::KIMI_API_KEY, "test-moonshot-key"),
(EnvVars::OPENROUTER_API_KEY, "test-openrouter-key"),
])
.build()
@ -1640,7 +1639,7 @@ enabled = true
.unwrap_or_default();
ready_providers.sort();
assert_eq!(ready_providers, vec![
ProviderId::new("kimi"),
ProviderId::new("moonshot"),
ProviderId::new("openrouter")
]);
@ -2663,7 +2662,7 @@ digraph Demo {
.json_body(openai_compatible_completion("anthropic/claude-fable-5"));
})
.await;
let state = ready_kimi_and_openrouter_state(&server);
let state = ready_moonshot_and_openrouter_state(&server);
let (response, _ok) = preflight_for_model(&state, "claude-fable").await;
@ -2687,18 +2686,18 @@ digraph Demo {
#[tokio::test]
async fn preflight_uses_ready_providers_for_unknown_unqualified_model() {
let server = httpmock::MockServer::start_async().await;
let kimi_probe = server
let moonshot_probe = server
.mock_async(|when, then| {
when.method(httpmock::Method::POST)
.path("/kimi/v1/chat/completions")
.header("authorization", "Bearer test-kimi-key")
.path("/moonshot/v1/chat/completions")
.header("authorization", "Bearer test-moonshot-key")
.json_body_includes(r#"{"model":"provider-private-preview"}"#);
then.status(200)
.header("content-type", "application/json")
.json_body(openai_compatible_completion("provider-private-preview"));
})
.await;
let state = ready_kimi_and_openrouter_state(&server);
let state = ready_moonshot_and_openrouter_state(&server);
let (response, _ok) = preflight_for_model(&state, "provider-private-preview").await;
@ -2717,10 +2716,10 @@ digraph Demo {
.iter()
.map(|detail| detail.text.as_str())
.find(|detail| detail.starts_with("Provider: ")),
Some("Provider: kimi")
Some("Provider: moonshot")
);
assert_eq!(llm_check.status, types::PreflightCheckResultStatus::Pass);
kimi_probe.assert_async().await;
moonshot_probe.assert_async().await;
}
#[test]

View file

@ -7446,7 +7446,7 @@ async fn list_models_exposes_reasoning_effort_controls() {
let req = Request::builder()
.method("GET")
.uri(api("/models?provider=kimi"))
.uri(api("/models?provider=moonshot"))
.body(Body::empty())
.unwrap();
@ -16443,7 +16443,7 @@ async fn create_completion_unsupported_reasoning_efforts_return_bad_request() {
then.status(500);
});
let state = TestAppStateBuilder::new()
.provider_base_url("kimi", upstream.url("/v1"))
.provider_base_url("moonshot", upstream.url("/v1"))
.vault_entries([(EnvVars::KIMI_API_KEY, "test-kimi-api-key")])
.build();
let app = crate::test_support::build_test_router(state);
@ -16456,7 +16456,7 @@ async fn create_completion_unsupported_reasoning_efforts_return_bad_request() {
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"provider": "kimi",
"provider": "moonshot",
"model": "kimi-k3",
"reasoning_effort": effort,
"stream": stream,
@ -16515,7 +16515,7 @@ async fn create_completion_returns_disjoint_usage_buckets() {
}));
});
let state = TestAppStateBuilder::new()
.provider_base_url("kimi", upstream.base_url())
.provider_base_url("moonshot", upstream.base_url())
.vault_entries([(EnvVars::KIMI_API_KEY, "test-kimi-api-key")])
.build();
let app = crate::test_support::build_test_router(state);
@ -16526,7 +16526,7 @@ async fn create_completion_returns_disjoint_usage_buckets() {
.header("content-type", "application/json")
.body(Body::from(
json!({
"provider": "kimi",
"provider": "moonshot",
"model": "kimi-k3",
"stream": false,
"messages": [{
@ -16655,7 +16655,7 @@ async fn create_completion_structured_output_forwards_reasoning_effort() {
}));
});
let state = TestAppStateBuilder::new()
.provider_base_url("kimi", upstream.base_url())
.provider_base_url("moonshot", upstream.base_url())
.vault_entries([(EnvVars::KIMI_API_KEY, "test-kimi-api-key")])
.build();
let app = crate::test_support::build_test_router(state);
@ -16666,7 +16666,7 @@ async fn create_completion_structured_output_forwards_reasoning_effort() {
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"provider": "kimi",
"provider": "moonshot",
"model": "kimi-k3",
"reasoning_effort": "high",
"stream": false,

View file

@ -143,7 +143,7 @@ async fn mock_anthropic_install_validation(server: &MockServer) -> httpmock::Moc
.await
}
async fn mock_kimi_install_validation(server: &MockServer) -> httpmock::Mock<'_> {
async fn mock_moonshot_install_validation(server: &MockServer) -> httpmock::Mock<'_> {
server
.mock_async(|when, then| {
when.method("POST")
@ -1355,11 +1355,13 @@ async fn token_install_finish_invokes_shutdown_callback_after_accepting() {
#[tokio::test]
async fn install_llm_accepts_catalog_openai_compatible_provider() {
let llm_mock = MockServer::start_async().await;
mock_kimi_install_validation(&llm_mock).await;
mock_moonshot_install_validation(&llm_mock).await;
let app = build_install_router(
InstallAppState::for_test("test-install-token")
.with_provider_base_url(ProviderId::new("kimi"), format!("{}/v1", llm_mock.url(""))),
InstallAppState::for_test("test-install-token").with_provider_base_url(
ProviderId::new("moonshot"),
format!("{}/v1", llm_mock.url("")),
),
);
let test_response = app
@ -1371,7 +1373,7 @@ async fn install_llm_accepts_catalog_openai_compatible_provider() {
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"provider":"kimi","api_key":"kimi-test-key"}"#,
r#"{"provider":"moonshot","api_key":"kimi-test-key"}"#,
))
.unwrap(),
)
@ -1388,7 +1390,7 @@ async fn install_llm_accepts_catalog_openai_compatible_provider() {
.header("authorization", "Bearer test-install-token")
.header("content-type", "application/json")
.body(Body::from(
r#"{"providers":[{"provider":"kimi","api_key":"kimi-test-key"}]}"#,
r#"{"providers":[{"provider":"moonshot","api_key":"kimi-test-key"}]}"#,
))
.unwrap(),
)

View file

@ -443,12 +443,12 @@ mod tests {
#[test]
fn summary_budget_for_always_reasoning_route_without_effort_adds_headroom() {
let kimi = ProviderId::new("kimi");
let model = catalog_model(&kimi, "kimi-k2.5");
let moonshot = ProviderId::new("moonshot");
let model = catalog_model(&moonshot, "kimi-k2.5");
assert!(model.supports_reasoning());
assert!(!model.supports_reasoning_effort());
assert_eq!(
builtin_summary_max_tokens(&kimi, "kimi-k2.5"),
builtin_summary_max_tokens(&moonshot, "kimi-k2.5"),
SUMMARY_MAX_TOKENS + REASONING_HEADROOM_TOKENS
);
}

View file

@ -100,7 +100,7 @@ impl KimiProfile {
Self {
base: BaseProfile {
profile_kind: AgentProfileKind::Kimi,
provider_id: ProviderId::new("kimi"),
provider_id: ProviderId::new("moonshot"),
model: model.into(),
catalog: None,
registry,
@ -178,8 +178,8 @@ mod tests {
#[test]
fn kimi_models_select_the_kimi_profile_on_every_provider() {
for (catalog, provider, model) in [
(catalog(), "kimi", "kimi-k3"),
(catalog(), "kimi", "kimi-k2.5"),
(catalog(), "moonshot", "kimi-k3"),
(catalog(), "moonshot", "kimi-k2.5"),
(catalog_with_openrouter(), "openrouter", "kimi-k3"),
(catalog_with_openrouter(), "openrouter", "kimi-k2.6"),
] {

View file

@ -548,7 +548,7 @@ mod tests {
let options = native_tool_options(AgentProfileKind::OpenAi, has_web_search);
let deps = ProfileDeps::standalone(options);
OpenAiProfile::with_native_tools("kimi-k2.5", &deps).with_route(
ProviderId::new("kimi"),
ProviderId::new("moonshot"),
Arc::new(Catalog::from_builtin().unwrap()),
)
}

View file

@ -229,19 +229,19 @@ mod tests {
}
#[test]
fn kimi_provider_prompt_uses_catalog_display_name() {
fn moonshot_provider_prompt_uses_catalog_display_name() {
let profile =
OpenAiProfile::new("kimi-k2.5").with_route(ProviderId::new("kimi"), test_catalog());
OpenAiProfile::new("kimi-k2.5").with_route(ProviderId::new("moonshot"), test_catalog());
let env = MockSandbox::linux();
let prompt = profile.build_system_prompt(&env, &EnvContext::default(), &[], None, &[]);
assert!(prompt.contains("powered by Kimi"));
assert!(prompt.contains("powered by Moonshot AI"));
assert!(!prompt.contains("powered by OpenAI"));
}
#[test]
fn openai_compatible_profile_uses_json_schema_edit_tool() {
let profile =
OpenAiProfile::new("kimi-k2.5").with_route(ProviderId::new("kimi"), test_catalog());
OpenAiProfile::new("kimi-k2.5").with_route(ProviderId::new("moonshot"), test_catalog());
let names = profile.tool_registry().names();
assert!(names.contains(&"edit_file".to_string()));

View file

@ -2,7 +2,7 @@
source: lib/components/fabro-agent/src/profiles/mod.rs
expression: system_prompt(&openai_edit_file_profile(true))
---
You are a coding agent powered by Kimi, running in a terminal-based agentic coding assistant. You are expected to be precise, safe, and helpful.
You are a coding agent powered by Moonshot AI, running in a terminal-based agentic coding assistant. You are expected to be precise, safe, and helpful.
You can receive user prompts and context such as files in the workspace, communicate with the user by streaming thinking and responses, and emit function calls to run terminal commands and edit files.

View file

@ -2,7 +2,7 @@
source: lib/components/fabro-agent/src/profiles/mod.rs
expression: system_prompt(&openai_edit_file_profile(false))
---
You are a coding agent powered by Kimi, running in a terminal-based agentic coding assistant. You are expected to be precise, safe, and helpful.
You are a coding agent powered by Moonshot AI, running in a terminal-based agentic coding assistant. You are expected to be precise, safe, and helpful.
You can receive user prompts and context such as files in the workspace, communicate with the user by streaming thinking and responses, and emit function calls to run terminal commands and edit files.

View file

@ -31,7 +31,7 @@ struct OpenAiTwinOptions {
fn summarizer_model_id(provider: &Provider) -> ModelHandle {
match provider.as_str() {
ProviderId::OPENAI | "kimi" | "zai" | "minimax" | "inception" => ModelHandle::ByName {
ProviderId::OPENAI | "moonshot" | "zai" | "minimax" | "inception" => ModelHandle::ByName {
provider: ProviderId::openai(),
model: "gpt-5.4-mini".to_string(),
},
@ -279,7 +279,7 @@ macro_rules! provider_tests {
);
provider_test!(
$scenario,
ProviderId::new("kimi"),
ProviderId::new("moonshot"),
"kimi-k2.5",
kimi,
keys = ["KIMI_API_KEY"]
@ -413,7 +413,7 @@ provider_test!(
);
provider_test!(
web_fetch,
ProviderId::new("kimi"),
ProviderId::new("moonshot"),
"kimi-k2.5",
kimi,
keys = ["KIMI_API_KEY", "OPENAI_API_KEY"]
@ -461,7 +461,7 @@ web_search_provider_test!(
keys = ["GEMINI_API_KEY", "BRAVE_SEARCH_API_KEY"]
);
web_search_provider_test!(
ProviderId::new("kimi"),
ProviderId::new("moonshot"),
"kimi-k2.5",
kimi,
keys = ["KIMI_API_KEY", "BRAVE_SEARCH_API_KEY"]
@ -518,7 +518,7 @@ macro_rules! non_openai_provider_tests {
);
provider_test!(
$scenario,
ProviderId::new("kimi"),
ProviderId::new("moonshot"),
"kimi-k2.5",
kimi,
keys = ["KIMI_API_KEY"]
@ -800,7 +800,7 @@ reasoning_effort_tests!(
keys = ["GEMINI_API_KEY"]
);
reasoning_effort_tests!(
ProviderId::new("kimi"),
ProviderId::new("moonshot"),
"kimi-k2.5",
kimi_reasoning_effort,
keys = ["KIMI_API_KEY"]
@ -882,7 +882,7 @@ loop_detection_tests!(
keys = ["GEMINI_API_KEY"]
);
loop_detection_tests!(
ProviderId::new("kimi"),
ProviderId::new("moonshot"),
"kimi-k2.5",
kimi_loop_detection,
keys = ["KIMI_API_KEY"]

View file

@ -427,10 +427,10 @@ mod tests {
fn openai_compatible_factory_uses_provider_id_for_name() {
let config = AdapterConfig {
base_url: Some("https://api.moonshot.ai/v1".to_string()),
..AdapterConfig::new("kimi", ApiKeyHeader::Bearer("k".to_string()))
..AdapterConfig::new("moonshot", ApiKeyHeader::Bearer("k".to_string()))
};
let adapter = factory_for(AdapterKind::OpenAiCompatible)(config).unwrap();
assert_eq!(adapter.name(), "kimi");
assert_eq!(adapter.name(), "moonshot");
}
#[test]
@ -491,7 +491,7 @@ mod tests {
#[test]
fn openai_compatible_factory_errors_without_base_url() {
let config = AdapterConfig::new("kimi", ApiKeyHeader::Bearer("k".to_string()));
let config = AdapterConfig::new("moonshot", ApiKeyHeader::Bearer("k".to_string()));
let Err(err) = factory_for(AdapterKind::OpenAiCompatible)(config) else {
panic!("expected missing base_url error");
};

View file

@ -1529,13 +1529,16 @@ base_url = "{}/v1"
let mut client = Client::new(HashMap::new(), None, vec![]);
client.catalog = Some(Arc::clone(&catalog));
client
.register_provider(Arc::new(MockProvider::new("kimi", "should not dispatch")))
.register_provider(Arc::new(MockProvider::new(
"moonshot",
"should not dispatch",
)))
.await
.unwrap();
let mut request = test_request();
request.model = "kimi-k2.5".to_string();
request.provider = Some("kimi".to_string());
request.provider = Some("moonshot".to_string());
request.reasoning_effort = Some(ReasoningEffort::High);
let err = client.complete(&request).await.unwrap_err();
@ -1554,13 +1557,13 @@ base_url = "{}/v1"
let mut client = Client::new(HashMap::new(), None, vec![]);
client.catalog = Some(Arc::clone(&catalog));
client
.register_provider(Arc::new(MockProvider::new("kimi", "accepted")))
.register_provider(Arc::new(MockProvider::new("moonshot", "accepted")))
.await
.unwrap();
let mut request = test_request();
request.model = "kimi-k3".to_string();
request.provider = Some("kimi".to_string());
request.provider = Some("moonshot".to_string());
request.reasoning_effort = Some(ReasoningEffort::High);
let response = client.complete(&request).await.unwrap();
@ -1724,7 +1727,7 @@ base_url = "{}/v1"
let catalog = catalog_with("");
let client = Client::from_credentials(
vec![ApiCredential {
provider: ProviderId::new("kimi"),
provider: ProviderId::new("moonshot"),
auth_header: Some(ApiKeyHeader::Bearer("kimi-key".to_string())),
extra_headers: HashMap::new(),
base_url: None,
@ -1737,8 +1740,8 @@ base_url = "{}/v1"
.await
.unwrap();
assert_eq!(client.provider_names(), vec!["kimi"]);
assert_eq!(client.default_provider(), Some("kimi"));
assert_eq!(client.provider_names(), vec!["moonshot"]);
assert_eq!(client.default_provider(), Some("moonshot"));
}
#[tokio::test]

View file

@ -73,7 +73,7 @@ pub(crate) struct CodecCtx<'a> {
/// the stream model fallback uses `request.model`).
pub request: &'a Request,
/// Identity stamped into `Response.provider`, and the `provider_options`
/// namespace key for the openai_compatible codec (kimi/zai/…).
/// namespace key for the openai_compatible codec (moonshot/zai/…).
pub provider_name: &'a str,
/// The model id to send on the wire — catalog `api_id`, resolved by the
/// route (today `api_id == id` everywhere).

View file

@ -1,6 +1,6 @@
//! The OpenAI Chat Completions (`/chat/completions`) codec.
//!
//! Serves every "OpenAI-compatible" route (kimi, zai, minimax, venice,
//! Serves every "OpenAI-compatible" route (moonshot, zai, minimax, venice,
//! inception, ollama, litellm, …). Pure translation: no HTTP, auth, or base
//! URL — the adapter shell owns those. Count-tokens and error mapping use the
//! `Codec` trait defaults (this dialect has no count route and uses the shared

View file

@ -110,7 +110,7 @@ fn apply_cache_breakpoints(messages: &mut [ChatMessage]) {
/// Merge `provider_options.<provider_name>` fields into the serialized API
/// request body.
///
/// The provider name is configurable (e.g. "groq", "together", "kimi"),
/// The provider name is configurable (e.g. "groq", "together", "moonshot"),
/// allowing each instance to have its own namespace in `provider_options`.
pub(super) fn merge_provider_options(
body: &mut serde_json::Value,
@ -221,7 +221,7 @@ mod tests {
let mut request = minimal_request();
request.reasoning_effort = Some(ReasoningEffort::High);
let body = encode_body(&request, "kimi", false);
let body = encode_body(&request, "moonshot", false);
assert_eq!(body["reasoning_effort"], "high");
}
@ -229,7 +229,7 @@ mod tests {
#[test]
fn encode_omits_sampling_params_for_models_that_reject_them() {
let model = Catalog::builtin()
.get_on_provider(&ProviderId::new("kimi"), "kimi-k3")
.get_on_provider(&ProviderId::new("moonshot"), "kimi-k3")
.unwrap();
let mut request = minimal_request();
request.model = model.id.to_string();
@ -238,7 +238,7 @@ mod tests {
let params = CodecParams::default();
let ctx = CodecCtx {
request: &request,
provider_name: "kimi",
provider_name: "moonshot",
deployment_id: model.id.as_str(),
model: Some(model),
params: &params,
@ -262,7 +262,7 @@ mod tests {
let deployment_id = request.model.clone();
let ctx = CodecCtx {
request: &request,
provider_name: "kimi",
provider_name: "moonshot",
deployment_id: &deployment_id,
model: None,
params: &params,

View file

@ -169,7 +169,8 @@ impl Adapter {
/// Collect a streaming response into a single [`Response`].
///
/// Used by non-Anthropic providers (e.g. Kimi) that require `stream=true`.
/// Used by non-Anthropic providers (e.g. Moonshot) that require
/// `stream=true`.
async fn complete_via_stream(&self, request: &Request) -> Result<Response, Error> {
use futures::StreamExt;
@ -266,7 +267,7 @@ impl ProviderAdapter for Adapter {
self.validate_request(request)?;
let route = self.route_config();
// Non-Anthropic providers (e.g. Kimi) require stream=true even for
// Non-Anthropic providers (e.g. Moonshot) require stream=true even for
// blocking calls. Collect the stream into a single Response.
if route.force_streaming {
return self.complete_via_stream(request).await;
@ -367,8 +368,8 @@ mod tests {
#[test]
fn adapter_with_name() {
let adapter = Adapter::new("key").with_name("kimi");
assert_eq!(adapter.name(), "kimi");
let adapter = Adapter::new("key").with_name("moonshot");
assert_eq!(adapter.name(), "moonshot");
}
#[test]

View file

@ -178,7 +178,7 @@ async fn openai_gpt_5_5_pro_complete() {
async fn kimi_k3_reasoning_tool_round_trip() {
let api_key = std::env::var(EnvVars::KIMI_API_KEY).expect("KIMI_API_KEY must be set");
let adapter = OpenAiCompatibleAdapter::new(api_key, "https://api.moonshot.ai/v1")
.with_name("kimi")
.with_name("moonshot")
.with_catalog(Arc::new(Catalog::from_builtin().unwrap()));
let tool = ToolDefinition::function(
"multiply",

View file

@ -835,11 +835,11 @@ async fn stream_without_message_stop_emits_no_finish() {
}
// ---------------------------------------------------------------------------
// Custom-named route (the Kimi-over-anthropic shape)
// Custom-named route (the Moonshot Kimi-over-anthropic shape)
// ---------------------------------------------------------------------------
/// Shared setup for a custom-named anthropic-dialect (Kimi) stream route; the
/// request and event halves are pinned by separate tests.
/// Shared setup for a custom-named Moonshot Kimi stream route using the
/// Anthropic dialect; separate tests pin the request and event halves.
async fn custom_named_stream_capture() -> (WireCapture, Vec<serde_json::Value>) {
let sse = support::sse_transcript(&[
(
@ -865,15 +865,15 @@ async fn custom_named_stream_capture() -> (WireCapture, Vec<serde_json::Value>)
("message_stop", r#"{"type":"message_stop"}"#),
]);
stream_capture(
adapter().with_name("kimi"),
adapter().with_name("moonshot"),
&base_request("kimi-test"),
&sse,
)
.await
}
/// A custom-named (Kimi) route authenticates with a bearer token and sends no
/// `anthropic-version` header. This pins that route shape on the wire.
/// A custom-named Moonshot route authenticates with a bearer token and sends
/// no `anthropic-version` header. This pins that route shape on the wire.
#[tokio::test]
async fn custom_named_stream_route() {
let (capture, _) = custom_named_stream_capture().await;
@ -903,7 +903,7 @@ async fn custom_named_stream_error_identity() {
),
]);
let (_capture, events) = stream_capture(
adapter().with_name("kimi"),
adapter().with_name("moonshot"),
&base_request("kimi-test"),
&sse,
)

View file

@ -253,7 +253,7 @@ async fn encode_kimi_k3_uses_catalog_reasoning_and_sampling_controls() {
..base_request(MODEL)
};
let capture = encode_capture_with(&request, move |adapter| {
adapter.with_name("kimi").with_catalog(catalog)
adapter.with_name("moonshot").with_catalog(catalog)
})
.await;
@ -398,9 +398,9 @@ async fn encode_openrouter_claude_auto_cache_opt_out() {
async fn encode_provider_options_keyed_by_adapter_name() {
let request = corpus_provider_options(
MODEL,
serde_json::json!({"kimi": {"repetition_penalty": 1.2}}),
serde_json::json!({"moonshot": {"repetition_penalty": 1.2}}),
);
let capture = encode_capture_with(&request, |adapter| adapter.with_name("kimi")).await;
let capture = encode_capture_with(&request, |adapter| adapter.with_name("moonshot")).await;
fabro_test::fabro_json_snapshot!(capture.body);
}
@ -411,7 +411,7 @@ async fn encode_provider_options_other_namespace_ignored() {
MODEL,
serde_json::json!({"openai": {"repetition_penalty": 1.2}}),
);
let capture = encode_capture_with(&request, |adapter| adapter.with_name("kimi")).await;
let capture = encode_capture_with(&request, |adapter| adapter.with_name("moonshot")).await;
fabro_test::fabro_json_snapshot!(capture.body);
}
@ -904,11 +904,11 @@ async fn stream_without_done_or_content_synthesizes_nothing() {
async fn custom_named_complete_identity() {
let server = MockServer::start();
let (mock, _slot) = mount_capture(&server, "/chat/completions", minimal_body());
let adapter = adapter(&server).with_name("kimi");
let adapter = adapter(&server).with_name("moonshot");
let response = adapter
.complete(&base_request(MODEL))
.await
.expect("complete should succeed");
mock.assert();
assert_eq!(response.provider, "kimi");
assert_eq!(response.provider, "moonshot");
}

View file

@ -7,7 +7,7 @@ expression: rendered
"type": "stream_start"
},
{
"stream_item_error": "Server error from kimi: Overloaded",
"stream_item_error": "Server error from moonshot: Overloaded",
"retryable": true,
"failover_eligible": true
}

View file

@ -32,7 +32,7 @@ expression: rendered
"response": {
"id": "msg_kimi",
"model": "kimi-test",
"provider": "kimi",
"provider": "moonshot",
"message": {
"role": "assistant",
"content": [

View file

@ -3348,7 +3348,7 @@ enabled = true
let policy = ModelFallbackPolicy::new(std::collections::BTreeMap::from([(
"kimi-k3".to_string(),
vec![
FallbackTarget::new("kimi", "kimi-k3"),
FallbackTarget::new("moonshot", "kimi-k3"),
FallbackTarget::new("openrouter", "kimi-k3"),
FallbackTarget::new("anthropic", "claude-opus-5"),
],

View file

@ -474,7 +474,7 @@ enabled = true
let eligible = [ProviderId::new("openrouter")];
let configured = BTreeMap::from([(
"kimi-k3".to_string(),
references(&["kimi:kimi-k3", "openrouter:kimi-k3"]),
references(&["moonshot:kimi-k3", "openrouter:kimi-k3"]),
)]);
let resolved = resolve_model_fallbacks(&catalog, &eligible, &configured).unwrap();
@ -491,7 +491,7 @@ enabled = true
requested_model,
provider,
..
}] if requested_model == "kimi-k3" && provider == &ProviderId::new("kimi")
}] if requested_model == "kimi-k3" && provider == &ProviderId::new("moonshot")
));
}
@ -512,13 +512,13 @@ enabled = true
};
let eligible = [
ProviderId::new("modal"),
ProviderId::new("kimi"),
ProviderId::new("moonshot"),
ProviderId::new("openrouter"),
];
let configured = BTreeMap::from([
(
"kimi-k3".to_string(),
references(&["kimi:kimi-k3", "openrouter:kimi-k3", "claude-opus"]),
references(&["moonshot:kimi-k3", "openrouter:kimi-k3", "claude-opus"]),
),
("glm-5.2".to_string(), references(&["gpt-sol"])),
("gpt-sol".to_string(), references(&["claude-opus"])),
@ -541,7 +541,7 @@ enabled = true
.expect("requested model should have a chain")
};
assert_eq!(chain("kimi-k3"), [
FallbackTarget::new("kimi", "kimi-k3"),
FallbackTarget::new("moonshot", "kimi-k3"),
FallbackTarget::new("openrouter", "kimi-k3"),
FallbackTarget::new("openrouter", "claude-opus-5"),
]);

View file

@ -630,6 +630,60 @@ base_url = "https://example--kimi-k3.modal.run/v1"
);
}
#[tokio::test]
async fn resolve_moonshot_api_request_prefers_moonshot_env_key() {
let dir = tempfile::tempdir().unwrap();
let vault = Vault::load(dir.path().join("secrets.json")).unwrap();
let resolver = test_resolver(
vault,
Arc::new(|name| match name {
EnvVars::MOONSHOT_API_KEY => Some("moonshot-key".to_string()),
EnvVars::KIMI_API_KEY => Some("kimi-key".to_string()),
_ => None,
}),
);
let resolved = resolver
.resolve(
ProviderId::new("moonshot"),
CredentialUsage::ApiRequest,
&default_catalog(),
)
.await
.unwrap();
let ResolvedCredential::Api(api) = resolved;
assert_eq!(
api.auth_header,
Some(ApiKeyHeader::Bearer("moonshot-key".to_string()))
);
}
#[tokio::test]
async fn resolve_moonshot_api_request_falls_back_to_kimi_env_key() {
let dir = tempfile::tempdir().unwrap();
let vault = Vault::load(dir.path().join("secrets.json")).unwrap();
let resolver = test_resolver(
vault,
Arc::new(|name| (name == EnvVars::KIMI_API_KEY).then(|| "kimi-key".to_string())),
);
let resolved = resolver
.resolve(
ProviderId::new("moonshot"),
CredentialUsage::ApiRequest,
&default_catalog(),
)
.await
.unwrap();
let ResolvedCredential::Api(api) = resolved;
assert_eq!(
api.auth_header,
Some(ApiKeyHeader::Bearer("kimi-key".to_string()))
);
}
#[tokio::test]
async fn resolve_openai_api_request_falls_back_to_codex_oauth_credential() {
let dir = tempfile::tempdir().unwrap();

View file

@ -3,16 +3,23 @@
//! Holds the trusted, mergeable LLM provider/model catalog data:
//!
//! ```toml
//! [llm.providers.kimi]
//! display_name = "Kimi"
//! [llm.providers.moonshot]
//! display_name = "Moonshot AI"
//! adapter = "openai_compatible"
//! base_url = "https://api.moonshot.ai/v1"
//! auth = { credentials = ["env:KIMI_API_KEY", "vault:KIMI_API_KEY"] }
//! priority = 60
//! enabled = true
//! aliases = ["moonshot"]
//! aliases = ["moonshot-ai"]
//!
//! [llm.providers.kimi.models."kimi-k2.5"]
//! [llm.providers.moonshot.auth]
//! credentials = [
//! "env:MOONSHOT_API_KEY",
//! "env:KIMI_API_KEY",
//! "vault:MOONSHOT_API_KEY",
//! "vault:KIMI_API_KEY",
//! ]
//!
//! [llm.providers.moonshot.models."kimi-k2.5"]
//! ...
//! ```
//!
@ -489,33 +496,46 @@ agent_profile = "gemini"
#[test]
fn parses_minimal_provider_entry() {
let toml = r#"
[providers.kimi]
display_name = "Kimi"
[providers.moonshot]
display_name = "Moonshot AI"
adapter = "openai_compatible"
agent_profile = "openai"
base_url = "https://api.moonshot.ai/v1"
priority = 60
enabled = true
aliases = ["moonshot"]
aliases = ["moonshot-ai"]
[providers.kimi.auth]
credentials = ["env:KIMI_API_KEY", "vault:KIMI_API_KEY"]
[providers.moonshot.auth]
credentials = [
"env:MOONSHOT_API_KEY",
"env:KIMI_API_KEY",
"vault:MOONSHOT_API_KEY",
"vault:KIMI_API_KEY",
]
"#;
let layer: LlmLayer = toml::from_str(toml).unwrap();
let kimi = layer.providers.get("kimi").unwrap();
assert_eq!(kimi.display_name.as_deref(), Some("Kimi"));
assert_eq!(kimi.adapter.as_deref(), Some("openai_compatible"));
assert_eq!(kimi.agent_profile, Some(AgentProfileKind::OpenAi));
let auth = kimi.auth.as_ref().expect("expected api_key auth");
let moonshot = layer.providers.get("moonshot").unwrap();
assert_eq!(moonshot.display_name.as_deref(), Some("Moonshot AI"));
assert_eq!(moonshot.adapter.as_deref(), Some("openai_compatible"));
assert_eq!(moonshot.agent_profile, Some(AgentProfileKind::OpenAi));
let auth = moonshot.auth.as_ref().expect("expected api_key auth");
assert_eq!(auth.header, ApiKeyHeaderPolicy::Bearer);
assert_eq!(auth.credentials, vec![
CredentialRef::Env("MOONSHOT_API_KEY".to_string()),
CredentialRef::Env("KIMI_API_KEY".to_string()),
CredentialRef::Vault("MOONSHOT_API_KEY".to_string()),
CredentialRef::Vault("KIMI_API_KEY".to_string()),
]);
assert_eq!(kimi.base_url.as_deref(), Some("https://api.moonshot.ai/v1"));
assert_eq!(kimi.priority, Some(60));
assert_eq!(kimi.enabled, Some(true));
assert_eq!(kimi.aliases.as_deref(), Some(&["moonshot".to_string()][..]));
assert_eq!(
moonshot.base_url.as_deref(),
Some("https://api.moonshot.ai/v1")
);
assert_eq!(moonshot.priority, Some(60));
assert_eq!(moonshot.enabled, Some(true));
assert_eq!(
moonshot.aliases.as_deref(),
Some(&["moonshot-ai".to_string()][..])
);
}
#[test]
@ -584,7 +604,7 @@ x-portkey-api-key = "sk-portkey-literal"
fn parses_full_model_entry() {
let toml = r#"
[models."kimi-k2.5"]
provider = "kimi"
provider = "moonshot"
api_id = "kimi-k2.5"
display_name = "Kimi K2.5"
family = "kimi"
@ -611,7 +631,7 @@ cache_input_cost_per_mtok = 0.15
"#;
let layer: LlmLayer = toml::from_str(toml).unwrap();
let m = layer.models.get("kimi-k2.5").unwrap();
assert_eq!(m.provider.as_deref(), Some("kimi"));
assert_eq!(m.provider.as_deref(), Some("moonshot"));
assert_eq!(m.api_id.as_deref(), Some("kimi-k2.5"));
assert_eq!(m.display_name.as_deref(), Some("Kimi K2.5"));
assert_eq!(m.family.as_deref(), Some("kimi"));
@ -714,7 +734,7 @@ cache_input_cost_per_mtok = 9.0
#[test]
fn rejects_unknown_provider_field() {
let toml = r#"
[providers.kimi]
[providers.moonshot]
adapter = "openai_compatible"
unknown_field = true
"#;
@ -725,7 +745,7 @@ unknown_field = true
#[test]
fn rejects_removed_provider_base_url_env_field() {
let toml = r#"
[providers.kimi]
[providers.moonshot]
adapter = "openai_compatible"
base_url_env = "KIMI_BASE_URL"
"#;
@ -902,7 +922,7 @@ mystery = 1
fn merge_map_field_merges_per_provider_id() {
let mut high_map: std::collections::HashMap<String, ProviderSettings> =
std::collections::HashMap::new();
high_map.insert("kimi".to_string(), ProviderSettings {
high_map.insert("moonshot".to_string(), ProviderSettings {
base_url: Some("https://override".to_string()),
..ProviderSettings::default()
});
@ -910,7 +930,7 @@ mystery = 1
let mut low_map: std::collections::HashMap<String, ProviderSettings> =
std::collections::HashMap::new();
low_map.insert("kimi".to_string(), ProviderSettings {
low_map.insert("moonshot".to_string(), ProviderSettings {
adapter: Some("openai_compatible".to_string()),
base_url: Some("https://defaults".to_string()),
..ProviderSettings::default()
@ -918,9 +938,9 @@ mystery = 1
let low: MergeMap<ProviderSettings> = MergeMap::from(low_map);
let merged = high.combine(low);
let kimi = merged.get("kimi").unwrap();
assert_eq!(kimi.adapter.as_deref(), Some("openai_compatible"));
assert_eq!(kimi.base_url.as_deref(), Some("https://override"));
let moonshot = merged.get("moonshot").unwrap();
assert_eq!(moonshot.adapter.as_deref(), Some("openai_compatible"));
assert_eq!(moonshot.base_url.as_deref(), Some("https://override"));
}
#[test]

View file

@ -292,15 +292,15 @@ mod tests {
#[test]
fn accepts_new_llm_providers_subtree() {
let parsed = "[llm.providers.kimi]\nadapter = \"openai_compatible\"\n"
let parsed = "[llm.providers.moonshot]\nadapter = \"openai_compatible\"\n"
.parse::<SettingsLayer>()
.unwrap();
assert!(parsed.llm.unwrap().providers.contains_key("kimi"));
assert!(parsed.llm.unwrap().providers.contains_key("moonshot"));
}
#[test]
fn accepts_new_llm_models_subtree() {
let parsed = "[llm.providers.kimi.models.\"foo\"]\n"
let parsed = "[llm.providers.moonshot.models.\"foo\"]\n"
.parse::<SettingsLayer>()
.unwrap();
assert!(
@ -308,7 +308,7 @@ mod tests {
.llm
.unwrap()
.providers
.get("kimi")
.get("moonshot")
.unwrap()
.models
.contains_key("foo")

View file

@ -1414,9 +1414,10 @@ output_cost_per_mtok = 2.0
#[test]
fn old_provider_tagged_pricing_policy_is_rejected() {
let error =
serde_json::from_value::<ModelPricingPolicy>(openai_pricing_json("provider", "kimi"))
.unwrap_err();
let error = serde_json::from_value::<ModelPricingPolicy>(openai_pricing_json(
"provider", "moonshot",
))
.unwrap_err();
assert!(error.to_string().contains("algorithm"));
}

View file

@ -3743,7 +3743,7 @@ enabled = true
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_eq!(provider.priority, 75);
assert!(provider.auth.is_none());
assert_eq!(
provider.extra_headers,
@ -3858,8 +3858,28 @@ enabled = true
}
#[test]
fn builtin_kimi_k3_selection_prefers_direct_kimi_then_modal_over_openrouter() {
let kimi = ProviderId::new("kimi");
fn builtin_moonshot_provider_prefers_moonshot_api_key() {
let moonshot = ProviderId::new("moonshot");
let catalog = Catalog::builtin();
let provider = catalog
.provider(&moonshot)
.expect("Moonshot provider should be present");
assert_eq!(provider.auth.as_ref().unwrap().credentials, vec![
CredentialRef::Env("MOONSHOT_API_KEY".to_string()),
CredentialRef::Env("KIMI_API_KEY".to_string()),
CredentialRef::Vault("MOONSHOT_API_KEY".to_string()),
CredentialRef::Vault("KIMI_API_KEY".to_string()),
]);
assert_eq!(
catalog.provider_vault_secret_name(&moonshot),
Some("MOONSHOT_API_KEY")
);
}
#[test]
fn builtin_kimi_k3_selection_prefers_modal_then_moonshot_over_openrouter() {
let moonshot = ProviderId::new("moonshot");
let modal = ProviderId::new("modal");
let openrouter = ProviderId::new("openrouter");
let catalog = Catalog::from_builtin_with_overrides(&minimal_settings(
@ -3877,10 +3897,19 @@ enabled = true
.select(
"kimi-k3",
None,
&HashSet::from([kimi.clone(), modal.clone(), openrouter.clone()]),
&HashSet::from([moonshot.clone(), modal.clone(), openrouter.clone()]),
)
.expect("direct Kimi should win portable Kimi K3 selection");
assert_eq!(selected.provider, kimi);
.expect("Modal should win portable Kimi K3 selection");
assert_eq!(selected.provider, modal);
let selected = catalog
.select(
"kimi-k3",
None,
&HashSet::from([moonshot.clone(), openrouter.clone()]),
)
.expect("Moonshot should win when Modal is unavailable");
assert_eq!(selected.provider, moonshot);
let selected = catalog
.select("kimi-k3", None, &HashSet::from([modal.clone(), openrouter]))
@ -4405,7 +4434,7 @@ enabled = true
);
assert_eq!(
catalog
.provider(&ProviderId::new("kimi"))
.provider(&ProviderId::new("moonshot"))
.unwrap()
.base_url
.as_deref(),
@ -4464,7 +4493,7 @@ enabled = true
("anthropic", CodecKind::AnthropicMessages),
("openai", CodecKind::OpenAiResponses),
("gemini", CodecKind::GeminiGenerate),
("kimi", CodecKind::OpenAiCompatible),
("moonshot", CodecKind::OpenAiCompatible),
] {
let provider_id = ProviderId::new(provider);
assert_eq!(catalog.provider(&provider_id).unwrap().codec, expected);
@ -6920,7 +6949,7 @@ sampling_params = false
insta::assert_debug_snapshot!(m, @r#"
Model {
id: "kimi-k2.5",
provider: kimi,
provider: moonshot,
family: "kimi-k2",
display_name: "Kimi K2.5",
limits: ModelLimits {
@ -6976,7 +7005,7 @@ sampling_params = false
insta::assert_debug_snapshot!(m, @r#"
Model {
id: "kimi-k3",
provider: kimi,
provider: moonshot,
family: "kimi-k3",
display_name: "Kimi K3",
limits: ModelLimits {
@ -7327,11 +7356,11 @@ context_window = 1050000
}
#[test]
fn closest_model_haiku_to_kimi() {
fn closest_model_haiku_to_moonshot() {
let haiku = Catalog::builtin().get("claude-haiku-4-5").unwrap();
assert!(
Catalog::builtin()
.closest(&ProviderId::new("kimi"), haiku)
.closest(&ProviderId::new("moonshot"), haiku)
.is_none()
);
}

View file

@ -3,7 +3,7 @@ display_name = "Modal"
adapter = "openai_compatible"
agent_profile = "kimi"
api_key_url = "https://modal.com/docs/guide/endpoints#proxy-tokens"
priority = 30
priority = 75
enabled = false
[providers.modal.extra_headers]

View file

@ -1,26 +1,31 @@
[providers.kimi]
display_name = "Kimi"
[providers.moonshot]
display_name = "Moonshot AI"
adapter = "openai_compatible"
agent_profile = "kimi"
api_key_url = "https://platform.kimi.ai/console/api-keys"
base_url = "https://api.moonshot.ai/v1"
priority = 70
[providers.kimi.auth]
credentials = ["env:KIMI_API_KEY", "vault:KIMI_API_KEY"]
[providers.moonshot.auth]
credentials = [
"env:MOONSHOT_API_KEY",
"env:KIMI_API_KEY",
"vault:MOONSHOT_API_KEY",
"vault:KIMI_API_KEY",
]
[providers.kimi.models."kimi-k2.5"]
[providers.moonshot.models."kimi-k2.5"]
display_name = "Kimi K2.5"
family = "kimi-k2"
training = "2025-10-01"
knowledge_cutoff = "October 2025"
estimated_output_tps = 50
[providers.kimi.models."kimi-k2.5".limits]
[providers.moonshot.models."kimi-k2.5".limits]
context_window = 262144
max_output = 32768
[providers.kimi.models."kimi-k2.5".features]
[providers.moonshot.models."kimi-k2.5".features]
tools = true
vision = true
reasoning = true
@ -28,24 +33,24 @@ reasoning_by_default = true
prompt_cache = true
sampling_params = false
[providers.kimi.models."kimi-k2.5".costs]
[providers.moonshot.models."kimi-k2.5".costs]
input_cost_per_mtok = 0.6
output_cost_per_mtok = 3.0
cache_input_cost_per_mtok = 0.1
[providers.kimi.models."kimi-k3"]
[providers.moonshot.models."kimi-k3"]
display_name = "Kimi K3"
family = "kimi-k3"
default = true
aliases = ["kimi"]
[providers.kimi.models."kimi-k3".limits]
[providers.moonshot.models."kimi-k3".limits]
context_window = 1048576
# K3 accepts explicit completion budgets up to 1048576, but Fabro also uses
# max_output as the default request budget. Match Kimi's 131072-token default.
max_output = 131072
[providers.kimi.models."kimi-k3".features]
[providers.moonshot.models."kimi-k3".features]
tools = true
vision = true
reasoning = true
@ -53,10 +58,10 @@ reasoning_effort = "always_adaptive"
prompt_cache = true
sampling_params = false
[providers.kimi.models."kimi-k3".controls]
[providers.moonshot.models."kimi-k3".controls]
reasoning_effort = ["low", "high", "max"]
[providers.kimi.models."kimi-k3".costs]
[providers.moonshot.models."kimi-k3".costs]
input_cost_per_mtok = 3.0
output_cost_per_mtok = 15.0
cache_input_cost_per_mtok = 0.3

View file

@ -174,9 +174,9 @@ mod tests {
#[test]
fn provider_id_is_transparent_string_in_json() {
let id = ProviderId::new("kimi");
let id = ProviderId::new("moonshot");
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, "\"kimi\"");
assert_eq!(json, "\"moonshot\"");
let back: ProviderId = serde_json::from_str(&json).unwrap();
assert_eq!(back, id);
}

View file

@ -57,6 +57,7 @@ impl EnvVars {
pub const INCEPTION_API_KEY: &'static str = "INCEPTION_API_KEY";
pub const KIMI_API_KEY: &'static str = "KIMI_API_KEY";
pub const MINIMAX_API_KEY: &'static str = "MINIMAX_API_KEY";
pub const MOONSHOT_API_KEY: &'static str = "MOONSHOT_API_KEY";
pub const MODAL_TOKEN_ID: &'static str = "MODAL_TOKEN_ID";
pub const MODAL_TOKEN_SECRET: &'static str = "MODAL_TOKEN_SECRET";
pub const OPENAI_API_KEY: &'static str = "OPENAI_API_KEY";
@ -208,6 +209,7 @@ mod tests {
EnvVars::INCEPTION_API_KEY,
EnvVars::KIMI_API_KEY,
EnvVars::MINIMAX_API_KEY,
EnvVars::MOONSHOT_API_KEY,
EnvVars::OPENAI_API_KEY,
EnvVars::OPENAI_BASE_URL,
EnvVars::OPENAI_ORGANIZATION,

View file

@ -31,6 +31,7 @@ const OPTIONAL_VAULT_SECRETS: &[&str] = &[
EnvVars::INCEPTION_API_KEY,
EnvVars::KIMI_API_KEY,
EnvVars::MINIMAX_API_KEY,
EnvVars::MOONSHOT_API_KEY,
EnvVars::OPENAI_API_KEY,
EnvVars::OPENROUTER_API_KEY,
EnvVars::POOLSIDE_API_KEY,
@ -99,6 +100,7 @@ mod tests {
EnvVars::INCEPTION_API_KEY,
EnvVars::KIMI_API_KEY,
EnvVars::MINIMAX_API_KEY,
EnvVars::MOONSHOT_API_KEY,
EnvVars::OPENAI_API_KEY,
EnvVars::OPENROUTER_API_KEY,
EnvVars::POOLSIDE_API_KEY,