Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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")
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/crates/assembly/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
43 changes: 37 additions & 6 deletions src/crates/assembly/core/src/infrastructure/ai/provider_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ fn validate_overlay(overlay: &ProviderOverlayDocument) -> Result<(), String> {
let mut provider_ids = BTreeSet::new();
let mut catalog_provider_owners = BTreeMap::<String, String>::new();
let mut trusted_urls = BTreeMap::<String, String>::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));
Expand Down Expand Up @@ -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
));
}
}
}
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
22 changes: 22 additions & 0 deletions src/shared/ai-provider-catalog/providers.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions src/web-ui/src/locales/en-US/settings/models.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions src/web-ui/src/locales/zh-CN/settings/models.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 系列模型",
Expand Down
11 changes: 11 additions & 0 deletions src/web-ui/src/locales/zh-TW/settings/models.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 系列模型",
Expand Down
Loading