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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- Add provider-native web search for documented DeepSeek V4 routes through the
Responses API, with fail-closed capability gating for compatible custom
endpoints.
Expand Down
11 changes: 11 additions & 0 deletions crates/config/src/route/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ impl CapabilityState {
/// - OpenAI Responses web search: <https://developers.openai.com/api/docs/guides/tools-web-search>
/// - Anthropic web search tool: <https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool>
/// - xAI web search tool: <https://docs.x.ai/developers/tools/web-search>
/// - Alibaba Model Studio Token Plan Harness tools: <https://help.aliyun.com/en/model-studio/token-plan-harness-tool>
/// - DeepSeek Responses web search: <https://api-docs.deepseek.com/api/create-response/>
#[must_use]
pub(crate) fn documented_server_side_web_search(
Expand All @@ -75,6 +76,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"
),
"deepseek" => matches!(
wire_model_id.as_str(),
"deepseek-v4-flash" | "deepseek-v4-pro" | "deepseek-v4-flash-vision-exp"
Expand Down Expand Up @@ -164,6 +169,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
);
assert_eq!(
documented_server_side_web_search("deepseek", "deepseek-v4-flash"),
CapabilityState::Supported
Expand All @@ -177,6 +186,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"),
("deepseek", "deepseek-v4-flash-preview"),
] {
assert_eq!(
Expand Down
4 changes: 4 additions & 0 deletions crates/config/src/route/offering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,10 @@ pub fn bundled_offerings() -> Vec<ProviderModelOffering> {
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()
}
}
Expand Down
49 changes: 49 additions & 0 deletions crates/config/src/route/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1433,6 +1433,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;
Expand Down
3 changes: 3 additions & 0 deletions crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- Add provider-native web search for documented DeepSeek V4 routes through the
Responses API, with fail-closed capability gating for compatible custom
endpoints.
Expand Down
75 changes: 74 additions & 1 deletion crates/tui/src/client/provider_native_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ impl ProviderNativeSearchClient {
ApiProvider::Openai
| ApiProvider::Anthropic
| ApiProvider::Xai
| ApiProvider::ModelstudioTokenPlan
| ApiProvider::Deepseek
| ApiProvider::DeepseekCN
)
Expand Down Expand Up @@ -117,6 +118,11 @@ impl ProviderNativeSearchClient {
request,
ResponsesSearchDialect::Xai,
),
ApiProvider::ModelstudioTokenPlan => build_responses_search_body(
&self.inner.default_model,
request,
ResponsesSearchDialect::ModelStudio,
),
ApiProvider::Deepseek | ApiProvider::DeepseekCN => build_responses_search_body(
&self.inner.default_model,
request,
Expand All @@ -135,7 +141,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::Deepseek | ApiProvider::DeepseekCN => {
responses_api_url(&self.inner.base_url, self.inner.api_provider)
}
Expand All @@ -162,6 +170,7 @@ impl ProviderNativeSearchClient {
let mut parsed = match self.inner.api_provider {
ApiProvider::Openai
| ApiProvider::Xai
| ApiProvider::ModelstudioTokenPlan
| ApiProvider::Deepseek
| ApiProvider::DeepseekCN => parse_responses_search(&payload),
ApiProvider::Anthropic => parse_anthropic_search(&payload),
Expand All @@ -176,6 +185,7 @@ impl ProviderNativeSearchClient {
enum ResponsesSearchDialect {
Openai,
Xai,
ModelStudio,
Deepseek,
}

Expand Down Expand Up @@ -215,6 +225,9 @@ fn build_responses_search_body(
ResponsesSearchDialect::Xai => {
body["tool_choice"] = json!("required");
}
ResponsesSearchDialect::ModelStudio => {
body["tool_choice"] = json!("required");
}
ResponsesSearchDialect::Deepseek => {
body["tool_choice"] = json!({ "type": "web_search" });
}
Expand Down Expand Up @@ -537,6 +550,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 deepseek_payload_uses_its_responses_search_contract() {
let body = build_responses_search_body(
Expand Down Expand Up @@ -736,6 +763,52 @@ 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 deepseek_adapter_uses_authenticated_responses_endpoint() {
let server = MockServer::start().await;
Expand Down
2 changes: 1 addition & 1 deletion docs/PROVIDERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
Loading