diff --git a/src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs b/src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs index 5fd36310b7..dce0ba9cef 100644 --- a/src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs +++ b/src/crates/adapters/ai-adapters/src/providers/anthropic/request.rs @@ -32,7 +32,7 @@ struct ClaudeModelVersion { /// Anthropic-compatible endpoints whose vendors document `Authorization: Bearer` /// (ANTHROPIC_AUTH_TOKEN) instead of `x-api-key`: Zhipu bigmodel.cn / Z.AI, -/// Moonshot's /anthropic gateway, and Kimi For Coding. +/// Moonshot's /anthropic gateway, Kimi For Coding, and Xiaomi MiMo. fn wants_bearer_auth(url: &str) -> bool { // Nous Portal uses the same OAuth bearer for its Messages and model-list // endpoints. Do not send that subscription token as an Anthropic API key. @@ -41,7 +41,18 @@ fn wants_bearer_auth(url: &str) -> bool { && url.host_str() == Some("inference-api.nousresearch.com") && url.port_or_known_default() == Some(443) }); + // MiMo accepts Bearer or api-key, rather than Anthropic's x-api-key. + let xiaomi_mimo = reqwest::Url::parse(url).ok().is_some_and(|url| { + url.scheme() == "https" + && matches!( + url.host_str(), + Some("api.xiaomimimo.com" | "token-plan-cn.xiaomimimo.com") + ) + && url.port_or_known_default() == Some(443) + && (url.path() == "/anthropic" || url.path().starts_with("/anthropic/")) + }); nous_portal + || xiaomi_mimo || url.contains("bigmodel.cn") || url.contains("api.z.ai") || url.contains("api.kimi.com/coding") @@ -557,6 +568,40 @@ pub(crate) async fn send_stream( mod tests { use super::*; + #[test] + fn xiaomi_mimo_headers_use_bearer_for_messages_and_discovery() { + for host in ["api.xiaomimimo.com", "token-plan-cn.xiaomimimo.com"] { + for suffix in ["v1/messages", "v1/models"] { + let url = format!("https://{host}/anthropic/{suffix}"); + let client = AIClient::new( + serde_json::from_value(serde_json::json!({ + "name": "MiMo", "base_url": format!("https://{host}/anthropic"), + "request_url": url, "api_key": "synthetic", + "model": "mimo-v2.6-pro", "format": "anthropic", + "context_window": 4096, "inline_think_in_text": false, + "skip_ssl_verify": false + })) + .unwrap(), + ); + let request = apply_headers(&client, client.client.post(&url), &url) + .build() + .unwrap(); + assert_eq!(request.headers()["authorization"], "Bearer synthetic"); + assert!(!request.headers().contains_key("x-api-key")); + } + } + for url in [ + "https://api.xiaomimimo.com.evil.test/anthropic/v1/messages", + "https://evil.test/anthropic/api.xiaomimimo.com/v1/messages", + "http://api.xiaomimimo.com/anthropic/v1/messages", + "https://api.xiaomimimo.com:8443/anthropic/v1/messages", + "https://api.xiaomimimo.com/v1/chat/completions", + "https://api.xiaomimimo.com/anthropic-other/v1/messages", + ] { + assert!(!wants_bearer_auth(url), "{url}"); + } + } + #[test] fn bearer_auth_matches_each_gateway_documented_scheme() { // Vendors that document ANTHROPIC_AUTH_TOKEN for their Claude-compatible gateway. diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index c14ebb6507..baa0512b89 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -212,6 +212,12 @@ or test-target layout. Workspace checks and product-wide tests are CI-backed and are not the default Core precheck. For documentation-only changes, run `git diff --check`. +For built-in provider overlay, trusted endpoint validation, and reasoning catalog changes: + +```bash +cargo test -p openbitfun-core --no-default-features --features ai-adapter-runtime --lib infrastructure::ai:: +``` + Configuration persistence, account settings import, backup restore, legacy field/deletion compatibility, local-change notifications, and save/reload/model concurrency regressions have feature-free fixtures: diff --git a/src/crates/assembly/core/src/infrastructure/ai/provider_catalog.rs b/src/crates/assembly/core/src/infrastructure/ai/provider_catalog.rs index 8ac3c4cb74..0836ffa3d5 100644 --- a/src/crates/assembly/core/src/infrastructure/ai/provider_catalog.rs +++ b/src/crates/assembly/core/src/infrastructure/ai/provider_catalog.rs @@ -212,6 +212,7 @@ fn validate_overlay(overlay: &ProviderOverlayDocument) -> Result<(), String> { let mut provider_ids = BTreeSet::new(); let mut catalog_provider_owners = BTreeMap::::new(); let mut trusted_urls = BTreeMap::::new(); + let mut trusted_routes = BTreeSet::new(); for provider in &overlay.providers { if provider.id.trim().is_empty() || !provider_ids.insert(provider.id.as_str()) { return Err(format!("duplicate or empty provider ID '{}'", provider.id)); @@ -339,11 +340,19 @@ fn validate_overlay(overlay: &ProviderOverlayDocument) -> Result<(), String> { provider.id, endpoint.id )); } + // One provider may expose multiple protocols at the same base, + // but ownership and each protocol's binding must be unambiguous. + let unique_route = trusted_routes.insert(( + normalized.clone(), + endpoint.api_format.trim().to_ascii_lowercase(), + )); if let Some(previous) = trusted_urls.insert(normalized, provider.id.clone()) { - return Err(format!( - "trusted endpoint is claimed by providers '{previous}' and '{}'", - provider.id - )); + if previous != provider.id || !unique_route { + return Err(format!( + "trusted endpoint is claimed by providers '{previous}' and '{}'", + provider.id + )); + } } } } @@ -572,10 +581,32 @@ mod tests { use super::{parse_overlay, resolve_builtin_provider_catalog, trusted_models_dev_binding}; use openbitfun_ai_adapters::models_dev::ModelsDevCatalog; + #[test] + fn trusted_urls_allow_distinct_protocols_only_within_one_provider() { + let overlay = parse_overlay().expect("valid overlay with MiMo protocols"); + let index = overlay + .providers + .iter() + .position(|p| p.id == "xiaomi") + .unwrap(); + let mut duplicate = overlay.clone(); + // A second spelling of the same protocol/normalized URL is ambiguous. + duplicate.providers[index].endpoints[1].api_format = " OpenAI ".into(); + assert!(super::validate_overlay(&duplicate).is_err()); + + let mut cross_provider = overlay.clone(); + let mut other = overlay.providers[index].clone(); + other.id = "other-provider".into(); + other.endpoints.truncate(1); + other.endpoints[0].api_format = "gemini".into(); + cross_provider.providers.push(other); + assert!(super::validate_overlay(&cross_provider).is_err()); + } + #[test] fn overlay_is_valid_and_keeps_product_endpoint_decisions() { let overlay = parse_overlay().expect("valid overlay"); - assert_eq!(overlay.providers.len(), 14); + assert_eq!(overlay.providers.len(), 15); let go = overlay .providers .iter() @@ -812,7 +843,7 @@ mod tests { "bundle".to_string(), ProviderCatalogSource::Bundle, ); - assert_eq!(resolved.providers.len(), 14); + assert_eq!(resolved.providers.len(), 15); let go = resolved .providers .iter() diff --git a/src/shared/ai-provider-catalog/providers.json b/src/shared/ai-provider-catalog/providers.json index e6cc4e55f0..0a48b4b07e 100644 --- a/src/shared/ai-provider-catalog/providers.json +++ b/src/shared/ai-provider-catalog/providers.json @@ -172,6 +172,28 @@ "additional_models": [] } }, + { + "id": "xiaomi", + "display_order": 65, + "region": "cn", + "name": "Xiaomi MiMo", + "description": "Xiaomi MiMo API and Token Plan", + "help_url": "https://platform.xiaomimimo.com", + "requires_api_key": true, + "catalog_provider_ids": [], + "endpoints": [ + { "id": "default", "base_url": "https://api.xiaomimimo.com/v1", "api_format": "openai", "label": "default", "is_default": true, "trusted_for_auto_detection": true, "catalog_provider_ids": [] }, + { "id": "responses", "base_url": "https://api.xiaomimimo.com/v1/responses", "api_format": "responses", "label": "responses", "trusted_for_auto_detection": true, "catalog_provider_ids": [] }, + { "id": "anthropic", "base_url": "https://api.xiaomimimo.com/anthropic", "api_format": "anthropic", "label": "anthropic", "trusted_for_auto_detection": true, "catalog_provider_ids": [] }, + { "id": "token-plan", "base_url": "https://token-plan-cn.xiaomimimo.com/v1", "api_format": "openai", "label": "tokenPlan", "trusted_for_auto_detection": true, "catalog_provider_ids": [] }, + { "id": "token-plan-anthropic", "base_url": "https://token-plan-cn.xiaomimimo.com/anthropic", "api_format": "anthropic", "label": "tokenPlanAnthropic", "trusted_for_auto_detection": true, "catalog_provider_ids": [] } + ], + "model_policy": { + "mode": "curated", + "curated_models": ["mimo-v2.6-pro", "mimo-v2.6-flash", "mimo-v2.6-pro-ultraspeed", "mimo-v2.5-pro", "mimo-v2.5"], + "additional_models": [] + } + }, { "id": "gemini", "display_order": 70, diff --git a/src/web-ui/src/locales/en-US/settings/models.json b/src/web-ui/src/locales/en-US/settings/models.json index 0037b6fbc8..7885410dac 100644 --- a/src/web-ui/src/locales/en-US/settings/models.json +++ b/src/web-ui/src/locales/en-US/settings/models.json @@ -170,6 +170,17 @@ "intlOpenai": "Global - OpenAI Compatible" } }, + "xiaomi": { + "name": "Xiaomi MiMo", + "description": "Xiaomi MiMo models. API and Token Plan keys are separate.", + "urlOptions": { + "default": "API - OpenAI Chat Completions - Default", + "responses": "API - OpenAI Responses", + "anthropic": "API - Anthropic Messages", + "tokenPlan": "China - Token Plan - OpenAI", + "tokenPlanAnthropic": "China - Token Plan - Anthropic" + } + }, "moonshot": { "name": "Moonshot AI", "description": "Moonshot Kimi series models", diff --git a/src/web-ui/src/locales/zh-CN/settings/models.json b/src/web-ui/src/locales/zh-CN/settings/models.json index 29e9ae71c7..e58e3a4267 100644 --- a/src/web-ui/src/locales/zh-CN/settings/models.json +++ b/src/web-ui/src/locales/zh-CN/settings/models.json @@ -170,6 +170,17 @@ "intlOpenai": "海外-OpenAI兼容格式" } }, + "xiaomi": { + "name": "Xiaomi MiMo", + "description": "小米 MiMo 系列模型,API 与 Token Plan 密钥不可混用。", + "urlOptions": { + "default": "API - OpenAI Chat Completions(默认)", + "responses": "API - OpenAI Responses", + "anthropic": "API - Anthropic Messages", + "tokenPlan": "国内 - Token Plan - OpenAI", + "tokenPlanAnthropic": "国内 - Token Plan - Anthropic" + } + }, "moonshot": { "name": "月之暗面", "description": "月之暗面 Kimi 系列模型", diff --git a/src/web-ui/src/locales/zh-TW/settings/models.json b/src/web-ui/src/locales/zh-TW/settings/models.json index 6c736435a4..6084d77cea 100644 --- a/src/web-ui/src/locales/zh-TW/settings/models.json +++ b/src/web-ui/src/locales/zh-TW/settings/models.json @@ -170,6 +170,17 @@ "intlOpenai": "海外-OpenAI兼容格式" } }, + "xiaomi": { + "name": "Xiaomi MiMo", + "description": "小米 MiMo 系列模型,API 與 Token Plan 金鑰不可混用。", + "urlOptions": { + "default": "API - OpenAI Chat Completions(預設)", + "responses": "API - OpenAI Responses", + "anthropic": "API - Anthropic Messages", + "tokenPlan": "中國 - Token Plan - OpenAI", + "tokenPlanAnthropic": "中國 - Token Plan - Anthropic" + } + }, "moonshot": { "name": "月之暗面", "description": "月之暗面 Kimi 系列模型",