diff --git a/CHANGELOG.md b/CHANGELOG.md index 825028a813..9ec4f66b54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add provider-native web search for documented Xiaomi MiMo 2.5 Pro and 2.5 + chat routes while keeping neighboring models and custom gateways fail-closed. - Add structured provider-native web search for exact Z.AI global and Zhipu China general API routes, using each site's documented search engine value. Existing `open.bigmodel.cn` configurations now share Z.AI's official model diff --git a/crates/config/src/route/capabilities.rs b/crates/config/src/route/capabilities.rs index 517a39696b..07eed7d167 100644 --- a/crates/config/src/route/capabilities.rs +++ b/crates/config/src/route/capabilities.rs @@ -52,6 +52,7 @@ impl CapabilityState { /// - OpenAI Responses web search: /// - Anthropic web search tool: /// - xAI web search tool: +/// - Xiaomi MiMo web search: /// - Z.AI Web Search API: /// - Zhipu Web Search API: /// - Alibaba Model Studio Token Plan Harness tools: @@ -80,6 +81,7 @@ pub(crate) fn documented_server_side_web_search( | "claude-sonnet-4-6" ), "xai" => matches!(wire_model_id.as_str(), "grok-4.6" | "grok-4.5"), + "xiaomi-mimo" => matches!(wire_model_id.as_str(), "mimo-v2.5-pro" | "mimo-v2.5"), "zai" => matches!( wire_model_id.as_str(), "glm-5.3" | "glm-5.3-flash" | "glm-5.2" | "glm-5.1" | "glm-5-turbo" @@ -198,6 +200,10 @@ mod tests { documented_server_side_web_search("anthropic", "claude-sonnet-4-6"), CapabilityState::Supported ); + assert_eq!( + documented_server_side_web_search("xiaomi-mimo", "mimo-v2.5-pro"), + CapabilityState::Supported + ); assert_eq!( documented_server_side_web_search("zai", "GLM-5.3"), CapabilityState::Supported @@ -219,6 +225,7 @@ mod tests { ("xai", "grok-4.6-latest"), ("xai", "grok-4.5-fast"), ("anthropic", "claude-haiku-4-5"), + ("xiaomi-mimo", "mimo-v2.5-pro-ultraspeed"), ("zai", "glm-5.3-preview"), ("modelstudio-coding-plan", "qwen3.8-max"), ("modelstudio-token-plan", "qwen3.8-max-preview"), diff --git a/crates/config/src/route/tests.rs b/crates/config/src/route/tests.rs index 91eabfd510..a1d5068b44 100644 --- a/crates/config/src/route/tests.rs +++ b/crates/config/src/route/tests.rs @@ -1433,6 +1433,47 @@ fn provider_native_web_search_requires_exact_direct_endpoint_offering() { ); } +#[test] +fn mimo_native_search_is_exact_to_documented_chat_models() { + use crate::route::CapabilityState; + + let resolver = RouteResolver::new(); + for model in ["mimo-v2.5-pro", "mimo-v2.5"] { + let direct = resolver + .resolve(&req(Some(ProviderKind::XiaomiMimo), Some(model))) + .expect("official MiMo chat route resolves"); + assert_eq!( + direct.capabilities().server_side_web_search, + CapabilityState::Supported + ); + } + + let neighboring = resolver + .resolve(&req( + Some(ProviderKind::XiaomiMimo), + Some("mimo-v2.5-pro-ultraspeed"), + )) + .expect("neighboring MiMo model resolves"); + assert_eq!( + neighboring.capabilities().server_side_web_search, + CapabilityState::Unknown + ); + + let custom = resolver + .resolve(&RouteRequest { + explicit_provider: Some(ProviderKind::XiaomiMimo), + model_selector: Some(LogicalModelRef::from("mimo-v2.5-pro")), + saved_provider_model: None, + base_url_override: Some("https://compatible.example.test/v1".to_string()), + limit_overrides: Vec::new(), + }) + .expect("custom MiMo-compatible route resolves"); + assert_eq!( + custom.capabilities().server_side_web_search, + CapabilityState::Unknown + ); +} + #[test] fn zai_native_search_requires_exact_general_api_product() { use crate::route::CapabilityState; diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index efc206efdb..0042f57726 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add provider-native web search for documented Xiaomi MiMo 2.5 Pro and 2.5 + chat routes while keeping neighboring models and custom gateways fail-closed. - Add structured provider-native web search for exact Z.AI global and Zhipu China general API routes, using each site's documented search engine value. Existing `open.bigmodel.cn` configurations now share Z.AI's official model diff --git a/crates/tui/src/client/provider_native_search.rs b/crates/tui/src/client/provider_native_search.rs index 89ea60d75b..0600c6fbb8 100644 --- a/crates/tui/src/client/provider_native_search.rs +++ b/crates/tui/src/client/provider_native_search.rs @@ -50,6 +50,7 @@ impl ProviderNativeSearchClient { ApiProvider::Openai | ApiProvider::Anthropic | ApiProvider::Xai + | ApiProvider::XiaomiMimo | ApiProvider::Zai | ApiProvider::ModelstudioTokenPlan | ApiProvider::Deepseek @@ -141,6 +142,7 @@ impl ProviderNativeSearchClient { 2_048_u32.min(route_cap), ) } + ApiProvider::XiaomiMimo => build_mimo_search_body(&self.inner.default_model, request), ApiProvider::Zai => zai::build_body(request, &self.inner.base_url)?, _ => bail!("active provider has no native web-search adapter"), }; @@ -152,6 +154,7 @@ impl ProviderNativeSearchClient { responses_api_url(&self.inner.base_url, self.inner.api_provider) } ApiProvider::Anthropic => anthropic_messages_url(&self.inner.base_url), + ApiProvider::XiaomiMimo => api_url(&self.inner.base_url, "chat/completions"), ApiProvider::Zai => api_url(&self.inner.base_url, "web_search"), _ => unreachable!("provider checked above"), }; @@ -179,6 +182,7 @@ impl ProviderNativeSearchClient { | ApiProvider::Deepseek | ApiProvider::DeepseekCN => parse_responses_search(&payload), ApiProvider::Anthropic => parse_anthropic_search(&payload), + ApiProvider::XiaomiMimo => parse_mimo_search(&payload), ApiProvider::Zai => zai::parse(&payload), _ => unreachable!("provider checked above"), }; @@ -262,6 +266,23 @@ fn build_anthropic_search_body( }) } +fn build_mimo_search_body(model: &str, request: &ProviderNativeSearchRequest) -> Value { + json!({ + "model": model, + "messages": [{ "role": "user", "content": search_prompt(request) }], + "tools": [{ + "type": "web_search", + "max_keyword": 1, + "force_search": true, + "limit": request.max_results, + }], + "tool_choice": "auto", + "max_completion_tokens": 2_048, + "stream": false, + "thinking": { "type": "disabled" }, + }) +} + fn anthropic_messages_url(base_url: &str) -> String { let base = base_url.trim_end_matches('/'); if base.ends_with("/v1") { @@ -388,6 +409,48 @@ fn parse_anthropic_search(payload: &Value) -> ProviderNativeSearchResponse { } } +fn parse_mimo_search(payload: &Value) -> ProviderNativeSearchResponse { + let message = payload.pointer("/choices/0/message"); + let answer = message + .and_then(|value| value.get("content")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_string); + let mut citations = Vec::new(); + if let Some(annotations) = message + .and_then(|value| value.get("annotations")) + .and_then(Value::as_array) + .or_else(|| payload.get("annotations").and_then(Value::as_array)) + { + for annotation in annotations { + let Some(url) = annotation.get("url").and_then(Value::as_str) else { + continue; + }; + let title = annotation + .get("title") + .and_then(Value::as_str) + .map(str::to_string); + let snippet = annotation + .get("summary") + .and_then(Value::as_str) + .map(str::to_string); + let published = annotation + .get("publish_time") + .and_then(Value::as_str) + .map(str::to_string); + push_citation( + &mut citations, + citation_from_url(url, title, snippet, published), + ); + } + } + ProviderNativeSearchResponse { + answer: bounded_answer(answer.into_iter().collect()), + citations, + } +} + fn citation_from_value( value: &Value, snippet: Option, @@ -596,6 +659,16 @@ mod tests { assert_eq!(tiny_route["max_tokens"], 128); } + #[test] + fn mimo_payload_forces_bounded_web_search_plugin() { + let body = build_mimo_search_body("mimo-v2.5-pro", &request()); + assert_eq!(body["tools"][0]["type"], "web_search"); + assert_eq!(body["tools"][0]["force_search"], true); + assert_eq!(body["tools"][0]["limit"], 3); + assert_eq!(body["max_completion_tokens"], 2_048); + assert_eq!(body["thinking"]["type"], "disabled"); + } + #[test] fn responses_parser_separates_answer_and_deduplicated_citations() { let payload = json!({ @@ -706,6 +779,28 @@ mod tests { ); } + #[test] + fn mimo_parser_keeps_non_streaming_annotations() { + let parsed = parse_mimo_search(&json!({ + "choices": [{ + "message": { + "content": "Grounded answer.", + "annotations": [{ + "type": "url_citation", + "url": "https://example.com/weather", + "title": "Weather", + "summary": "Forecast", + "publish_time": "2026-08-28" + }] + } + }] + })); + assert_eq!(parsed.answer.as_deref(), Some("Grounded answer.")); + assert_eq!(parsed.citations.len(), 1); + assert_eq!(parsed.citations[0].snippet.as_deref(), Some("Forecast")); + assert_eq!(parsed.citations[0].published.as_deref(), Some("2026-08-28")); + } + #[test] fn non_http_citations_are_rejected() { let payload = json!({ "citations": ["javascript:alert(1)"] }); diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 508602c8d7..0d78eca63c 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -572,7 +572,7 @@ overlay and lets DSH resolve its own keys. | `volcengine` | `[providers.volcengine]` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` | `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, `ARK_BASE_URL`; default `https://ark.cn-beijing.volces.com/api/coding/v3` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | Volcengine/Volcano Engine Ark OpenAI-compatible coding endpoint. `VOLCENGINE_MODEL` and `VOLCENGINE_ARK_MODEL` are accepted. | | `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; recent large IDs include `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `google/gemma-4-31b-it`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. | | `orcarouter` | `[providers.orcarouter]` | `ORCAROUTER_API_KEY` | `ORCAROUTER_BASE_URL`; default `https://api.orcarouter.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; router alias `orcarouter/auto`; recent large IDs mirror the OpenRouter namespaced catalog | [OrcaRouter](https://www.orcarouter.ai) OpenAI-compatible aggregation gateway. Shares the namespaced `vendor/model` wire-model format and DeepSeek model set with OpenRouter, so the OpenRouter base-URL and model-normalization rules apply. `ORCAROUTER_MODEL` is accepted. Provider aliases: `orcarouter`, `orca_router`, `orca`. | -| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`, `XIAOMI_MIMO_MODE`, `MIMO_MODE`; default `https://token-plan-sgp.xiaomimimo.com/v1` | Chat: `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`; speech/TTS: `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, `mimo-v2-tts` | Xiaomi MiMo OpenAI-compatible chat completions route. Token Plan keys (`tp-...`) use `api-key` auth and the token-plan endpoint by default; pay-as-you-go mode uses standard API keys (`sk-...`) and `https://api.xiaomimimo.com/v1`. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. Token Plan cost/usage is credit/quota based; Codewhale shows it as unknown until Xiaomi exposes a reliable balance API. `codewhale speech` / `tts` uses the TTS models. | +| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_TOKEN_PLAN_API_KEY`, `MIMO_TOKEN_PLAN_API_KEY`, `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`, `XIAOMI_MIMO_MODE`, `MIMO_MODE`; default `https://token-plan-sgp.xiaomimimo.com/v1` | Chat: `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`; speech/TTS: `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, `mimo-v2-tts` | Xiaomi MiMo OpenAI-compatible chat completions route. `mimo-v2.5-pro` and `mimo-v2.5` can use the documented provider-native web-search plugin; ultraspeed, speech/TTS, and custom-compatible routes do not inherit it. Token Plan keys (`tp-...`) use `api-key` auth and the token-plan endpoint by default; pay-as-you-go mode uses standard API keys (`sk-...`) and `https://api.xiaomimimo.com/v1`. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. Token Plan cost/usage is credit/quota based; Codewhale shows it as unknown until Xiaomi exposes a reliable balance API. `codewhale speech` / `tts` uses the TTS models. | | `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/openai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | OpenAI-compatible hosted route for DeepSeek model IDs. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | | `fireworks` | `[providers.fireworks]` | `FIREWORKS_API_KEY` | `FIREWORKS_BASE_URL`; default `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/deepseek-v4-pro` | OpenAI-compatible hosted route. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | | `siliconflow` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.com/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | OpenAI-compatible hosted route. Official docs use the `.com` endpoint. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. |