Skip to content

Commit 3ac97e6

Browse files
committed
feat(api): add qwen/ prefix routing for Alibaba DashScope provider
Users in Discord #clawcode-get-help (web3g) asked for Qwen 3.6 Plus via native Alibaba DashScope API instead of OpenRouter, which has stricter rate limits. This commit adds first-class routing for qwen/ and bare qwen- prefixed model names. Changes: - DEFAULT_DASHSCOPE_BASE_URL constant: /compatible-mode/v1 endpoint - OpenAiCompatConfig::dashscope() factory mirroring openai()/xai() - DASHSCOPE_ENV_VARS + credential_env_vars() wiring - metadata_for_model: qwen/ and qwen- prefix routes to DashScope with auth_env=DASHSCOPE_API_KEY, reuses ProviderKind::OpenAi because DashScope speaks the OpenAI REST shape - is_reasoning_model: detect qwen-qwq, qwq-*, and *-thinking variants so tuning params (temperature, top_p, etc.) get stripped before payload assembly (same pattern as o1/o3/grok-3-mini) Tests added: - providers::tests::qwen_prefix_routes_to_dashscope_not_anthropic - openai_compat::tests::qwen_reasoning_variants_are_detected 89 api lib tests passing, 0 failing. cargo fmt --check: clean. Closes the user-reported gap: 'use Qwen 3.6 Plus via Alibaba API directly, not OpenRouter' without needing OPENAI_BASE_URL override or unsetting ANTHROPIC_API_KEY.
1 parent 006f7d7 commit 3ac97e6

2 files changed

Lines changed: 87 additions & 4 deletions

File tree

rust/crates/api/src/providers/mod.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,19 @@ pub fn metadata_for_model(model: &str) -> Option<ProviderMetadata> {
181181
default_base_url: openai_compat::DEFAULT_OPENAI_BASE_URL,
182182
});
183183
}
184+
// Alibaba DashScope compatible-mode endpoint. Routes qwen/* and bare
185+
// qwen-* model names (qwen-max, qwen-plus, qwen-turbo, qwen-qwq, etc.)
186+
// to the OpenAI-compat client pointed at DashScope's /compatible-mode/v1.
187+
// Uses the OpenAi provider kind because DashScope speaks the OpenAI REST
188+
// shape — only the base URL and auth env var differ.
189+
if canonical.starts_with("qwen/") || canonical.starts_with("qwen-") {
190+
return Some(ProviderMetadata {
191+
provider: ProviderKind::OpenAi,
192+
auth_env: "DASHSCOPE_API_KEY",
193+
base_url_env: "DASHSCOPE_BASE_URL",
194+
default_base_url: openai_compat::DEFAULT_DASHSCOPE_BASE_URL,
195+
});
196+
}
184197
None
185198
}
186199

@@ -386,6 +399,36 @@ mod tests {
386399
assert_eq!(kind2, ProviderKind::OpenAi);
387400
}
388401

402+
#[test]
403+
fn qwen_prefix_routes_to_dashscope_not_anthropic() {
404+
// User request from Discord #clawcode-get-help: web3g wants to use
405+
// Qwen 3.6 Plus via native Alibaba DashScope API (not OpenRouter,
406+
// which has lower rate limits). metadata_for_model must route
407+
// qwen/* and bare qwen-* to the OpenAi provider kind pointed at
408+
// the DashScope compatible-mode endpoint, regardless of whether
409+
// ANTHROPIC_API_KEY is present in the environment.
410+
let meta = super::metadata_for_model("qwen/qwen-max")
411+
.expect("qwen/ prefix must resolve to DashScope metadata");
412+
assert_eq!(meta.provider, ProviderKind::OpenAi);
413+
assert_eq!(meta.auth_env, "DASHSCOPE_API_KEY");
414+
assert_eq!(meta.base_url_env, "DASHSCOPE_BASE_URL");
415+
assert!(meta.default_base_url.contains("dashscope.aliyuncs.com"));
416+
417+
// Bare qwen- prefix also routes
418+
let meta2 = super::metadata_for_model("qwen-plus")
419+
.expect("qwen- prefix must resolve to DashScope metadata");
420+
assert_eq!(meta2.provider, ProviderKind::OpenAi);
421+
assert_eq!(meta2.auth_env, "DASHSCOPE_API_KEY");
422+
423+
// detect_provider_kind must agree even if ANTHROPIC_API_KEY is set
424+
let kind = detect_provider_kind("qwen/qwen3-coder");
425+
assert_eq!(
426+
kind,
427+
ProviderKind::OpenAi,
428+
"qwen/ prefix must win over auth-sniffer order"
429+
);
430+
}
431+
389432
#[test]
390433
fn keeps_existing_max_token_heuristic() {
391434
assert_eq!(max_tokens_for_model("opus"), 32_000);

rust/crates/api/src/providers/openai_compat.rs

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use super::{preflight_message_request, Provider, ProviderFuture};
1818

1919
pub const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
2020
pub const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
21+
pub const DEFAULT_DASHSCOPE_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
2122
const REQUEST_ID_HEADER: &str = "request-id";
2223
const ALT_REQUEST_ID_HEADER: &str = "x-request-id";
2324
const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_secs(1);
@@ -34,6 +35,7 @@ pub struct OpenAiCompatConfig {
3435

3536
const XAI_ENV_VARS: &[&str] = &["XAI_API_KEY"];
3637
const OPENAI_ENV_VARS: &[&str] = &["OPENAI_API_KEY"];
38+
const DASHSCOPE_ENV_VARS: &[&str] = &["DASHSCOPE_API_KEY"];
3739

3840
impl OpenAiCompatConfig {
3941
#[must_use]
@@ -55,11 +57,27 @@ impl OpenAiCompatConfig {
5557
default_base_url: DEFAULT_OPENAI_BASE_URL,
5658
}
5759
}
60+
61+
/// Alibaba DashScope compatible-mode endpoint (Qwen family models).
62+
/// Uses the OpenAI-compatible REST shape at /compatible-mode/v1.
63+
/// Requested via Discord #clawcode-get-help: native Alibaba API for
64+
/// higher rate limits than going through OpenRouter.
65+
#[must_use]
66+
pub const fn dashscope() -> Self {
67+
Self {
68+
provider_name: "DashScope",
69+
api_key_env: "DASHSCOPE_API_KEY",
70+
base_url_env: "DASHSCOPE_BASE_URL",
71+
default_base_url: DEFAULT_DASHSCOPE_BASE_URL,
72+
}
73+
}
74+
5875
#[must_use]
5976
pub fn credential_env_vars(self) -> &'static [&'static str] {
6077
match self.provider_name {
6178
"xAI" => XAI_ENV_VARS,
6279
"OpenAI" => OPENAI_ENV_VARS,
80+
"DashScope" => DASHSCOPE_ENV_VARS,
6381
_ => &[],
6482
}
6583
}
@@ -689,12 +707,18 @@ struct ErrorBody {
689707
/// reasoning/chain-of-thought models with fixed sampling.
690708
fn is_reasoning_model(model: &str) -> bool {
691709
let lowered = model.to_ascii_lowercase();
710+
// Strip any provider/ prefix for the check (e.g. qwen/qwen-qwq -> qwen-qwq)
711+
let canonical = lowered.rsplit('/').next().unwrap_or(lowered.as_str());
692712
// OpenAI reasoning models
693-
lowered.starts_with("o1")
694-
|| lowered.starts_with("o3")
695-
|| lowered.starts_with("o4")
713+
canonical.starts_with("o1")
714+
|| canonical.starts_with("o3")
715+
|| canonical.starts_with("o4")
696716
// xAI reasoning: grok-3-mini always uses reasoning mode
697-
|| lowered == "grok-3-mini"
717+
|| canonical == "grok-3-mini"
718+
// Alibaba DashScope reasoning variants (QwQ + Qwen3-Thinking family)
719+
|| canonical.starts_with("qwen-qwq")
720+
|| canonical.starts_with("qwq")
721+
|| canonical.contains("thinking")
698722
}
699723

700724
fn build_chat_completion_request(request: &MessageRequest, config: OpenAiCompatConfig) -> Value {
@@ -1259,6 +1283,22 @@ mod tests {
12591283
assert!(!is_reasoning_model("claude-sonnet-4-6"));
12601284
}
12611285

1286+
#[test]
1287+
fn qwen_reasoning_variants_are_detected() {
1288+
// QwQ reasoning model
1289+
assert!(is_reasoning_model("qwen-qwq-32b"));
1290+
assert!(is_reasoning_model("qwen/qwen-qwq-32b"));
1291+
// Qwen3 thinking family
1292+
assert!(is_reasoning_model("qwen3-30b-a3b-thinking"));
1293+
assert!(is_reasoning_model("qwen/qwen3-30b-a3b-thinking"));
1294+
// Bare qwq
1295+
assert!(is_reasoning_model("qwq-plus"));
1296+
// Regular Qwen models must NOT be classified as reasoning
1297+
assert!(!is_reasoning_model("qwen-max"));
1298+
assert!(!is_reasoning_model("qwen/qwen-plus"));
1299+
assert!(!is_reasoning_model("qwen-turbo"));
1300+
}
1301+
12621302
#[test]
12631303
fn tuning_params_omitted_from_payload_when_none() {
12641304
let request = MessageRequest {

0 commit comments

Comments
 (0)