From ecbfe4ad73d7f2d7349bc32116ee9460fd134701 Mon Sep 17 00:00:00 2001 From: hexin <372726039@qq.com> Date: Fri, 28 Aug 2026 16:12:11 +0800 Subject: [PATCH 1/2] feat(web): add Qwen native search adapter Signed-off-by: hexin <372726039@qq.com> --- CHANGELOG.md | 3 + crates/config/src/route/capabilities.rs | 11 +++ crates/config/src/route/offering.rs | 4 + crates/config/src/route/tests.rs | 49 +++++++++++ .../tui/src/client/provider_native_search.rs | 87 ++++++++++++++++++- docs/PROVIDERS.md | 2 +- 6 files changed, 151 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5c38c17d6..301fc2065d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add provider-native web search for documented Qwen models on ModelStudio + Token Plan's Responses Harness, without enabling Coding Plan or Anthropic + routes. - Z.ai `GLM-5.3-Flash` and OpenRouter `z-ai/glm-5.3-flash` are first-class picker rows (`/model GLM-5.3-Flash`). Flash is the faster/explore sibling of `GLM-5.3`; the Z.ai default stays `GLM-5.3`. List price is $0.15/$0.50 diff --git a/crates/config/src/route/capabilities.rs b/crates/config/src/route/capabilities.rs index 58fba589ef..9a0ffc8e63 100644 --- a/crates/config/src/route/capabilities.rs +++ b/crates/config/src/route/capabilities.rs @@ -50,6 +50,7 @@ impl CapabilityState { /// - OpenAI Responses web search: /// - Anthropic web search tool: /// - xAI web search tool: +/// - Alibaba Model Studio Token Plan Harness tools: #[must_use] pub(crate) fn documented_server_side_web_search( provider_id: &str, @@ -74,6 +75,10 @@ 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"), + "modelstudio-token-plan" => matches!( + wire_model_id.as_str(), + "qwen3.8-max" | "qwen3.7-plus" | "qwen3.7-max" + ), _ => false, }; if supported { @@ -159,6 +164,10 @@ mod tests { documented_server_side_web_search("anthropic", "claude-sonnet-4-6"), CapabilityState::Supported ); + assert_eq!( + documented_server_side_web_search("modelstudio-token-plan", "qwen3.8-max"), + CapabilityState::Supported + ); for (provider, model) in [ ("openrouter", "openai/gpt-5.6"), @@ -168,6 +177,8 @@ mod tests { ("xai", "grok-4.6-latest"), ("xai", "grok-4.5-fast"), ("anthropic", "claude-haiku-4-5"), + ("modelstudio-coding-plan", "qwen3.8-max"), + ("modelstudio-token-plan", "qwen3.8-max-preview"), ] { assert_eq!( documented_server_side_web_search(provider, model), diff --git a/crates/config/src/route/offering.rs b/crates/config/src/route/offering.rs index 20590494ce..8efa3ca127 100644 --- a/crates/config/src/route/offering.rs +++ b/crates/config/src/route/offering.rs @@ -311,6 +311,10 @@ pub fn bundled_offerings() -> Vec { structured_output: CapabilityState::Supported, streaming: CapabilityState::Supported, image_input, + server_side_web_search: super::documented_server_side_web_search( + "modelstudio-token-plan", + model, + ), ..RouteCapabilities::default() } } diff --git a/crates/config/src/route/tests.rs b/crates/config/src/route/tests.rs index 5d5f42c9cb..eefed7ff3f 100644 --- a/crates/config/src/route/tests.rs +++ b/crates/config/src/route/tests.rs @@ -1421,6 +1421,55 @@ fn provider_native_web_search_requires_exact_direct_endpoint_offering() { ); } +#[test] +fn qwen_native_search_is_exact_to_token_plan_responses_routes() { + use crate::route::CapabilityState; + + let resolver = RouteResolver::new(); + let direct = resolver + .resolve(&req( + Some(ProviderKind::ModelstudioTokenPlan), + Some("qwen3.8-max"), + )) + .expect("Token Plan Qwen route resolves"); + assert_eq!( + direct.capabilities().server_side_web_search, + CapabilityState::Supported + ); + + let preview = resolver + .resolve(&req( + Some(ProviderKind::ModelstudioTokenPlan), + Some("qwen3.8-max-preview"), + )) + .expect("neighboring preview route resolves"); + assert_eq!( + preview.capabilities().server_side_web_search, + CapabilityState::Unknown + ); + + for base_url in [ + "https://coding-intl.dashscope.aliyuncs.com/v1", + "https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic", + "https://compatible.example.test/v1", + ] { + let alternate = resolver + .resolve(&RouteRequest { + explicit_provider: Some(ProviderKind::ModelstudioTokenPlan), + model_selector: Some(LogicalModelRef::from("qwen3.8-max")), + saved_provider_model: None, + base_url_override: Some(base_url.to_string()), + limit_overrides: Vec::new(), + }) + .expect("alternate product route resolves"); + assert_eq!( + alternate.capabilities().server_side_web_search, + CapabilityState::Unknown, + "{base_url} must not inherit Token Plan Responses search" + ); + } +} + #[test] fn priced_offering_yields_token_pricing_sku() { use super::candidate::PricingSku; diff --git a/crates/tui/src/client/provider_native_search.rs b/crates/tui/src/client/provider_native_search.rs index 33e03f2ce3..94904d2e03 100644 --- a/crates/tui/src/client/provider_native_search.rs +++ b/crates/tui/src/client/provider_native_search.rs @@ -45,7 +45,10 @@ impl ProviderNativeSearchClient { pub(crate) fn new(inner: DeepSeekClient) -> Option { matches!( inner.api_provider, - ApiProvider::Openai | ApiProvider::Anthropic | ApiProvider::Xai + ApiProvider::Openai + | ApiProvider::Anthropic + | ApiProvider::Xai + | ApiProvider::ModelstudioTokenPlan ) .then_some(Self { inner }) } @@ -108,6 +111,11 @@ impl ProviderNativeSearchClient { request, ResponsesSearchDialect::Xai, ), + ApiProvider::ModelstudioTokenPlan => build_responses_search_body( + &self.inner.default_model, + request, + ResponsesSearchDialect::ModelStudio, + ), ApiProvider::Anthropic => { let route_cap = self .inner @@ -121,7 +129,9 @@ impl ProviderNativeSearchClient { _ => bail!("active provider has no native web-search adapter"), }; let url = match self.inner.api_provider { - ApiProvider::Openai | ApiProvider::Xai => api_url(&self.inner.base_url, "responses"), + ApiProvider::Openai | ApiProvider::Xai | ApiProvider::ModelstudioTokenPlan => { + api_url(&self.inner.base_url, "responses") + } ApiProvider::Anthropic => anthropic_messages_url(&self.inner.base_url), _ => unreachable!("provider checked above"), }; @@ -143,7 +153,9 @@ impl ProviderNativeSearchClient { .await .context("provider-native web search returned invalid JSON")?; let mut parsed = match self.inner.api_provider { - ApiProvider::Openai | ApiProvider::Xai => parse_responses_search(&payload), + ApiProvider::Openai | ApiProvider::Xai | ApiProvider::ModelstudioTokenPlan => { + parse_responses_search(&payload) + } ApiProvider::Anthropic => parse_anthropic_search(&payload), _ => unreachable!("provider checked above"), }; @@ -156,6 +168,7 @@ impl ProviderNativeSearchClient { enum ResponsesSearchDialect { Openai, Xai, + ModelStudio, } fn search_prompt(request: &ProviderNativeSearchRequest) -> String { @@ -172,7 +185,12 @@ fn build_responses_search_body( dialect: ResponsesSearchDialect, ) -> Value { let mut tool = json!({ "type": "web_search" }); - if !request.domains.is_empty() { + if !request.domains.is_empty() + && matches!( + dialect, + ResponsesSearchDialect::Openai | ResponsesSearchDialect::Xai + ) + { tool["filters"] = json!({ "allowed_domains": request.domains }); } let mut body = json!({ @@ -461,6 +479,20 @@ mod tests { assert_eq!(body["include"][0], "web_search_call.action.sources"); } + #[test] + fn modelstudio_payload_uses_required_harness_search_without_filters() { + let body = build_responses_search_body( + "qwen3.8-max", + &request(), + ResponsesSearchDialect::ModelStudio, + ); + assert_eq!(body["tools"][0]["type"], "web_search"); + assert!(body["tools"][0].get("filters").is_none()); + assert_eq!(body["tool_choice"], "required"); + assert!(body.get("include").is_none()); + assert!(body.get("store").is_none()); + } + #[test] fn anthropic_payload_uses_basic_direct_search_contract() { let body = build_anthropic_search_body("claude-opus-4-8", &request(), 2_048); @@ -595,6 +627,53 @@ mod tests { assert_eq!(response.citations[0].url, "https://example.com/source"); } + #[tokio::test] + async fn modelstudio_adapter_uses_token_plan_responses_contract() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v1/responses")) + .and(header("authorization", "Bearer modelstudio-test-key")) + .and(body_partial_json(json!({ + "model": "qwen3.8-max", + "tools": [{ "type": "web_search" }], + "tool_choice": "required" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "output": [{ + "type": "web_search_call", + "action": { + "sources": [{ + "url": "https://example.com/qwen", + "title": "Qwen source" + }] + } + }] + }))) + .expect(1) + .mount(&server) + .await; + let config = Config { + provider: Some("modelstudio-token-plan".to_string()), + providers: Some(ProvidersConfig { + modelstudio_token_plan: ProviderConfig { + api_key: Some("modelstudio-test-key".to_string()), + base_url: Some(format!("{}/v1", server.uri())), + model: Some("qwen3.8-max".to_string()), + ..ProviderConfig::default() + }, + ..ProvidersConfig::default() + }), + ..Config::default() + }; + let inner = DeepSeekClient::new(&config).expect("test ModelStudio client"); + let client = ProviderNativeSearchClient::new(inner).expect("Qwen native adapter"); + + let response = client.search(&request()).await.expect("native search"); + + assert_eq!(response.citations.len(), 1); + assert_eq!(response.citations[0].url, "https://example.com/qwen"); + } + #[tokio::test] async fn native_search_obeys_attached_run_ownership_without_blocking_unrelated_runtime() { let server = MockServer::start().await; diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 5048e6c7fc..8bd5adccf9 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -605,7 +605,7 @@ overlay and lets DSH resolve its own keys. | `mistral` | `[providers.mistral]` | `MISTRAL_API_KEY` | `MISTRAL_BASE_URL`; default `https://api.mistral.ai/v1` | `mistral-code-latest` (default; `codestral-latest` accepted as alias), `mistral-medium-latest` (aliases: `mistral-medium-3-5`), `mistral-small-latest` (aliases: `mistral-small-2603`), `mistral-large-latest` | Mistral AI (la Plateforme) OpenAI-compatible Chat route. On the documented first-party HTTPS `/v1` hosts, Medium and Small send adjustable `reasoning_effort` (`none` or `high` only), parse Mistral's polymorphic thinking/text blocks, and replay stored thinking in that same wire shape. Deprecated native Magistral IDs remain explicit-configuration compatibility routes: they are always-reasoning and never receive the adjustable effort field. Code and Large are non-reasoning. A custom `MISTRAL_BASE_URL` keeps generic Chat semantics unless it is one of the documented first-party hosts. `MISTRAL_MODEL` is accepted. Provider aliases: `mistral-ai`, `mistralai`, `la-plateforme`. | | `edenai` | `[providers.edenai]` | `EDENAI_API_KEY` | `EDENAI_BASE_URL`; default `https://api.edenai.run/v3`; EU `https://api.eu.edenai.run/v3` | `deepseek/deepseek-v4-pro` (default); live `/models` catalog of `provider/model` ids | Eden AI OpenAI-compatible aggregation gateway. Catalog rows remain provider-scoped; generic reasoning controls are omitted because supported fields depend on the selected upstream family. `EDENAI_MODEL` is accepted. The default `deepseek/deepseek-v4-pro` is listed on the global catalog only; on the EU endpoint set `EDENAI_MODEL` (or `model`) to a row from the EU `/models` list, for example `qwen/deepseek-v4-pro`. Provider aliases: `eden-ai`, `eden_ai`. | | `xai` | `[providers.xai]` | `XAI_API_KEY`, Codewhale-owned device OAuth, or explicit read-only Grok CLI consent | `XAI_BASE_URL`; default `https://api.x.ai/v1` | `grok-4.6` (default), `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | xAI/Grok OpenAI-compatible Chat Completions route. Grok 4.6 has a 500K context window, text/image input, function calls, structured output, server-side web search, and `low`/`medium`/`high`/`xhigh` reasoning (default `high`). Its standard rates double when the prompt reaches 200K tokens; the same 2x long-context rule applies to `grok-4.5` (500K context, $2.00 / $0.30 cached / $6.00) and `grok-4.3` (1M context, $1.25 / $0.20 cached / $2.50) per their [model pages](https://docs.x.ai/docs/models/grok-4.5). There is no documented `latest`/`fast` alias and no published numeric output limit. **API-key** (default): Bearer token from console.x.ai via `XAI_API_KEY` / keyring / `api_key`. **OAuth**: `codewhale auth xai-device` uses SSH-friendly device login and Codewhale-owned storage, which may refresh itself. Existing Grok CLI credentials require `codewhale auth external-consent --provider xai --mode read-only`; the granted external file is never refreshed or rewritten. OAuth may return HTTP 403 on some SuperGrok tiers — keep API-key as the reliable fallback. `XAI_MODEL` is accepted. Provider aliases: `x-ai`, `x_ai`, `grok`. | -| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_TOKEN_PLAN_BASE_URL`; default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | `qwen3.8-max` (default), `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | Alibaba Cloud Model Studio Token Plan OpenAI-compatible Chat Completions route. Token Plan Personal and Team share this endpoint. All listed models are reasoning-capable text/coding models. DeepSeek and GLM entries are provider-scoped and do not collide with first-party routes. `MODELSTUDIO_TOKEN_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-token-plan`, `alibaba-token-plan`, `dashscope-token-plan`. | +| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_TOKEN_PLAN_BASE_URL`; default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | `qwen3.8-max` (default), `qwen3.8-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | Alibaba Cloud Model Studio Token Plan OpenAI-compatible Chat Completions route. Token Plan Personal and Team share this endpoint. `qwen3.8-max`, `qwen3.7-plus`, and `qwen3.7-max` can use provider-native web search through the Token Plan Responses Harness; the preview, Coding Plan, and Anthropic routes do not inherit that capability. All listed models are reasoning-capable text/coding models. DeepSeek and GLM entries are provider-scoped and do not collide with first-party routes. `MODELSTUDIO_TOKEN_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-token-plan`, `alibaba-token-plan`, `dashscope-token-plan`. | | `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-token-plan` | Token Plan Anthropic-compatible Messages route (`/apps/anthropic`). Same API key as the OpenAI dialect. Provider aliases: `modelstudio-token-plan-anthropic`, `alibaba-token-plan-anthropic`. | | `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_CODING_PLAN_BASE_URL`; default `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.8-max` (default); same catalog as Token Plan | Alibaba Cloud Model Studio Coding Plan OpenAI-compatible Chat Completions route. `MODELSTUDIO_CODING_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-coding-plan`, `alibaba-coding-plan`, `dashscope-coding-plan`. | | `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-coding-plan` | Coding Plan Anthropic-compatible Messages route (`/apps/anthropic`). Provider aliases: `modelstudio-coding-plan-anthropic`, `alibaba-coding-plan-anthropic`. | From 219a7c4df338fe73df40040c0f2446661fc7eeba Mon Sep 17 00:00:00 2001 From: hexin <372726039@qq.com> Date: Fri, 28 Aug 2026 16:49:07 +0800 Subject: [PATCH 2/2] =?UTF-8?q?chore(changelog):=20=E5=90=8C=E6=AD=A5=20We?= =?UTF-8?q?b=20=E6=90=9C=E7=B4=A2=E5=8F=98=E6=9B=B4=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: hexin <372726039@qq.com> --- crates/tui/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 131b300715..726366e159 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add provider-native web search for documented Qwen models on ModelStudio + Token Plan's Responses Harness, without enabling Coding Plan or Anthropic + routes. - Z.ai `GLM-5.3-Flash` and OpenRouter `z-ai/glm-5.3-flash` are first-class picker rows (`/model GLM-5.3-Flash`). Flash is the faster/explore sibling of `GLM-5.3`; the Z.ai default stays `GLM-5.3`. List price is $0.15/$0.50