feat(provider): add qwen-code oauth credential support
This commit is contained in:
parent
e9c280324f
commit
bca58acdcb
3 changed files with 501 additions and 6 deletions
|
|
@ -43,7 +43,7 @@ credential is not reused for fallback providers.
|
||||||
| `minimax` | `minimax-intl`, `minimax-io`, `minimax-global`, `minimax-cn`, `minimaxi`, `minimax-oauth`, `minimax-oauth-cn`, `minimax-portal`, `minimax-portal-cn` | No | `MINIMAX_OAUTH_TOKEN`, `MINIMAX_API_KEY` |
|
| `minimax` | `minimax-intl`, `minimax-io`, `minimax-global`, `minimax-cn`, `minimaxi`, `minimax-oauth`, `minimax-oauth-cn`, `minimax-portal`, `minimax-portal-cn` | No | `MINIMAX_OAUTH_TOKEN`, `MINIMAX_API_KEY` |
|
||||||
| `bedrock` | `aws-bedrock` | No | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (optional: `AWS_REGION`) |
|
| `bedrock` | `aws-bedrock` | No | `AWS_ACCESS_KEY_ID` + `AWS_SECRET_ACCESS_KEY` (optional: `AWS_REGION`) |
|
||||||
| `qianfan` | `baidu` | No | `QIANFAN_API_KEY` |
|
| `qianfan` | `baidu` | No | `QIANFAN_API_KEY` |
|
||||||
| `qwen` | `dashscope`, `qwen-intl`, `dashscope-intl`, `qwen-us`, `dashscope-us` | No | `DASHSCOPE_API_KEY` |
|
| `qwen` | `dashscope`, `qwen-intl`, `dashscope-intl`, `qwen-us`, `dashscope-us`, `qwen-code`, `qwen-oauth`, `qwen_oauth` | No | `QWEN_OAUTH_TOKEN`, `DASHSCOPE_API_KEY` |
|
||||||
| `groq` | — | No | `GROQ_API_KEY` |
|
| `groq` | — | No | `GROQ_API_KEY` |
|
||||||
| `mistral` | — | No | `MISTRAL_API_KEY` |
|
| `mistral` | — | No | `MISTRAL_API_KEY` |
|
||||||
| `xai` | `grok` | No | `XAI_API_KEY` |
|
| `xai` | `grok` | No | `XAI_API_KEY` |
|
||||||
|
|
@ -122,6 +122,28 @@ Optional:
|
||||||
- `MINIMAX_OAUTH_REGION=global` or `cn` (defaults by provider alias)
|
- `MINIMAX_OAUTH_REGION=global` or `cn` (defaults by provider alias)
|
||||||
- `MINIMAX_OAUTH_CLIENT_ID` to override the default OAuth client id
|
- `MINIMAX_OAUTH_CLIENT_ID` to override the default OAuth client id
|
||||||
|
|
||||||
|
## Qwen Code OAuth Setup (config.toml)
|
||||||
|
|
||||||
|
Set Qwen Code OAuth mode in config:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
default_provider = "qwen-code"
|
||||||
|
api_key = "qwen-oauth"
|
||||||
|
```
|
||||||
|
|
||||||
|
Credential resolution for `qwen-code`:
|
||||||
|
|
||||||
|
1. Explicit `api_key` value (if not the placeholder `qwen-oauth`)
|
||||||
|
2. `QWEN_OAUTH_TOKEN`
|
||||||
|
3. `~/.qwen/oauth_creds.json` (reuses Qwen Code cached OAuth credentials)
|
||||||
|
4. Optional refresh via `QWEN_OAUTH_REFRESH_TOKEN` (or cached refresh token)
|
||||||
|
5. If no OAuth placeholder is used, `DASHSCOPE_API_KEY` can still be used as fallback
|
||||||
|
|
||||||
|
Optional endpoint override:
|
||||||
|
|
||||||
|
- `QWEN_OAUTH_RESOURCE_URL` (normalized to `https://.../v1` if needed)
|
||||||
|
- If unset, `resource_url` from cached OAuth credentials is used when available
|
||||||
|
|
||||||
## Model Routing (`hint:<name>`)
|
## Model Routing (`hint:<name>`)
|
||||||
|
|
||||||
You can route model calls by hint using `[[model_routes]]`:
|
You can route model calls by hint using `[[model_routes]]`:
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ use crate::memory::{
|
||||||
};
|
};
|
||||||
use crate::providers::{
|
use crate::providers::{
|
||||||
canonical_china_provider_name, is_glm_alias, is_glm_cn_alias, is_minimax_alias,
|
canonical_china_provider_name, is_glm_alias, is_glm_cn_alias, is_minimax_alias,
|
||||||
is_moonshot_alias, is_qianfan_alias, is_qwen_alias, is_zai_alias, is_zai_cn_alias,
|
is_moonshot_alias, is_qianfan_alias, is_qwen_alias, is_qwen_oauth_alias, is_zai_alias,
|
||||||
|
is_zai_cn_alias,
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use console::style;
|
use console::style;
|
||||||
|
|
@ -497,6 +498,10 @@ pub async fn run_quick_setup(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn canonical_provider_name(provider_name: &str) -> &str {
|
fn canonical_provider_name(provider_name: &str) -> &str {
|
||||||
|
if is_qwen_oauth_alias(provider_name) {
|
||||||
|
return "qwen-code";
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(canonical) = canonical_china_provider_name(provider_name) {
|
if let Some(canonical) = canonical_china_provider_name(provider_name) {
|
||||||
return canonical;
|
return canonical;
|
||||||
}
|
}
|
||||||
|
|
@ -547,6 +552,7 @@ fn default_model_for_provider(provider: &str) -> String {
|
||||||
"glm" | "zai" => "glm-5".into(),
|
"glm" | "zai" => "glm-5".into(),
|
||||||
"minimax" => "MiniMax-M2.5".into(),
|
"minimax" => "MiniMax-M2.5".into(),
|
||||||
"qwen" => "qwen-plus".into(),
|
"qwen" => "qwen-plus".into(),
|
||||||
|
"qwen-code" => "qwen3-coder-plus".into(),
|
||||||
"ollama" => "llama3.2".into(),
|
"ollama" => "llama3.2".into(),
|
||||||
"gemini" => "gemini-2.5-pro".into(),
|
"gemini" => "gemini-2.5-pro".into(),
|
||||||
"kimi-code" => "kimi-for-coding".into(),
|
"kimi-code" => "kimi-for-coding".into(),
|
||||||
|
|
@ -823,6 +829,20 @@ fn curated_models_for_provider(provider_name: &str) -> Vec<(String, String)> {
|
||||||
"Qwen Turbo (fast and cost-efficient)".to_string(),
|
"Qwen Turbo (fast and cost-efficient)".to_string(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
"qwen-code" => vec![
|
||||||
|
(
|
||||||
|
"qwen3-coder-plus".to_string(),
|
||||||
|
"Qwen3 Coder Plus (recommended for coding workflows)".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"qwen3.5-plus".to_string(),
|
||||||
|
"Qwen3.5 Plus (reasoning + coding)".to_string(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"qwen3-max-2026-01-23".to_string(),
|
||||||
|
"Qwen3 Max (high-capability coding model)".to_string(),
|
||||||
|
),
|
||||||
|
],
|
||||||
"nvidia" => vec![
|
"nvidia" => vec![
|
||||||
(
|
(
|
||||||
"meta/llama-3.3-70b-instruct".to_string(),
|
"meta/llama-3.3-70b-instruct".to_string(),
|
||||||
|
|
@ -1601,6 +1621,10 @@ fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, Optio
|
||||||
"kimi-code",
|
"kimi-code",
|
||||||
"Kimi Code — coding-optimized Kimi API (KimiCLI)",
|
"Kimi Code — coding-optimized Kimi API (KimiCLI)",
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"qwen-code",
|
||||||
|
"Qwen Code — OAuth tokens reused from ~/.qwen/oauth_creds.json",
|
||||||
|
),
|
||||||
("moonshot", "Moonshot — Kimi API (China endpoint)"),
|
("moonshot", "Moonshot — Kimi API (China endpoint)"),
|
||||||
(
|
(
|
||||||
"moonshot-intl",
|
"moonshot-intl",
|
||||||
|
|
@ -1809,11 +1833,48 @@ fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, Optio
|
||||||
|
|
||||||
key
|
key
|
||||||
}
|
}
|
||||||
|
} else if canonical_provider_name(provider_name) == "qwen-code" {
|
||||||
|
if std::env::var("QWEN_OAUTH_TOKEN").is_ok() {
|
||||||
|
print_bullet(&format!(
|
||||||
|
"{} QWEN_OAUTH_TOKEN environment variable detected!",
|
||||||
|
style("✓").green().bold()
|
||||||
|
));
|
||||||
|
"qwen-oauth".to_string()
|
||||||
|
} else {
|
||||||
|
print_bullet(
|
||||||
|
"Qwen Code OAuth credentials are usually stored in ~/.qwen/oauth_creds.json.",
|
||||||
|
);
|
||||||
|
print_bullet(
|
||||||
|
"Run `qwen` once and complete OAuth login to populate cached credentials.",
|
||||||
|
);
|
||||||
|
print_bullet("You can also set QWEN_OAUTH_TOKEN directly.");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
let key: String = Input::new()
|
||||||
|
.with_prompt(
|
||||||
|
" Paste your Qwen OAuth token (or press Enter to auto-detect cached OAuth)",
|
||||||
|
)
|
||||||
|
.allow_empty(true)
|
||||||
|
.interact_text()?;
|
||||||
|
|
||||||
|
if key.trim().is_empty() {
|
||||||
|
print_bullet(&format!(
|
||||||
|
"Using OAuth auto-detection. Set {} and optional {} if needed.",
|
||||||
|
style("QWEN_OAUTH_TOKEN").yellow(),
|
||||||
|
style("QWEN_OAUTH_RESOURCE_URL").yellow()
|
||||||
|
));
|
||||||
|
"qwen-oauth".to_string()
|
||||||
|
} else {
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let key_url = if is_moonshot_alias(provider_name)
|
let key_url = if is_moonshot_alias(provider_name)
|
||||||
|| canonical_provider_name(provider_name) == "kimi-code"
|
|| canonical_provider_name(provider_name) == "kimi-code"
|
||||||
{
|
{
|
||||||
"https://platform.moonshot.cn/console/api-keys"
|
"https://platform.moonshot.cn/console/api-keys"
|
||||||
|
} else if canonical_provider_name(provider_name) == "qwen-code" {
|
||||||
|
"https://qwen.readthedocs.io/en/latest/getting_started/installation.html"
|
||||||
} else if is_glm_cn_alias(provider_name) || is_zai_cn_alias(provider_name) {
|
} else if is_glm_cn_alias(provider_name) || is_zai_cn_alias(provider_name) {
|
||||||
"https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys"
|
"https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys"
|
||||||
} else if is_glm_alias(provider_name) || is_zai_alias(provider_name) {
|
} else if is_glm_alias(provider_name) || is_zai_alias(provider_name) {
|
||||||
|
|
@ -2066,6 +2127,10 @@ fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, Optio
|
||||||
|
|
||||||
/// Map provider name to its conventional env var
|
/// Map provider name to its conventional env var
|
||||||
fn provider_env_var(name: &str) -> &'static str {
|
fn provider_env_var(name: &str) -> &'static str {
|
||||||
|
if canonical_provider_name(name) == "qwen-code" {
|
||||||
|
return "QWEN_OAUTH_TOKEN";
|
||||||
|
}
|
||||||
|
|
||||||
match canonical_provider_name(name) {
|
match canonical_provider_name(name) {
|
||||||
"openrouter" => "OPENROUTER_API_KEY",
|
"openrouter" => "OPENROUTER_API_KEY",
|
||||||
"anthropic" => "ANTHROPIC_API_KEY",
|
"anthropic" => "ANTHROPIC_API_KEY",
|
||||||
|
|
@ -5044,6 +5109,7 @@ mod tests {
|
||||||
);
|
);
|
||||||
assert_eq!(default_model_for_provider("qwen"), "qwen-plus");
|
assert_eq!(default_model_for_provider("qwen"), "qwen-plus");
|
||||||
assert_eq!(default_model_for_provider("qwen-intl"), "qwen-plus");
|
assert_eq!(default_model_for_provider("qwen-intl"), "qwen-plus");
|
||||||
|
assert_eq!(default_model_for_provider("qwen-code"), "qwen3-coder-plus");
|
||||||
assert_eq!(default_model_for_provider("glm-cn"), "glm-5");
|
assert_eq!(default_model_for_provider("glm-cn"), "glm-5");
|
||||||
assert_eq!(default_model_for_provider("minimax-cn"), "MiniMax-M2.5");
|
assert_eq!(default_model_for_provider("minimax-cn"), "MiniMax-M2.5");
|
||||||
assert_eq!(default_model_for_provider("zai-cn"), "glm-5");
|
assert_eq!(default_model_for_provider("zai-cn"), "glm-5");
|
||||||
|
|
@ -5078,6 +5144,8 @@ mod tests {
|
||||||
fn canonical_provider_name_normalizes_regional_aliases() {
|
fn canonical_provider_name_normalizes_regional_aliases() {
|
||||||
assert_eq!(canonical_provider_name("qwen-intl"), "qwen");
|
assert_eq!(canonical_provider_name("qwen-intl"), "qwen");
|
||||||
assert_eq!(canonical_provider_name("dashscope-us"), "qwen");
|
assert_eq!(canonical_provider_name("dashscope-us"), "qwen");
|
||||||
|
assert_eq!(canonical_provider_name("qwen-code"), "qwen-code");
|
||||||
|
assert_eq!(canonical_provider_name("qwen-oauth"), "qwen-code");
|
||||||
assert_eq!(canonical_provider_name("moonshot-intl"), "moonshot");
|
assert_eq!(canonical_provider_name("moonshot-intl"), "moonshot");
|
||||||
assert_eq!(canonical_provider_name("kimi-cn"), "moonshot");
|
assert_eq!(canonical_provider_name("kimi-cn"), "moonshot");
|
||||||
assert_eq!(canonical_provider_name("kimi_coding"), "kimi-code");
|
assert_eq!(canonical_provider_name("kimi_coding"), "kimi-code");
|
||||||
|
|
@ -5188,6 +5256,18 @@ mod tests {
|
||||||
assert!(ids.contains(&"kimi-k2.5".to_string()));
|
assert!(ids.contains(&"kimi-k2.5".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn curated_models_for_qwen_code_include_coding_plan_models() {
|
||||||
|
let ids: Vec<String> = curated_models_for_provider("qwen-code")
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, _)| id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert!(ids.contains(&"qwen3-coder-plus".to_string()));
|
||||||
|
assert!(ids.contains(&"qwen3.5-plus".to_string()));
|
||||||
|
assert!(ids.contains(&"qwen3-max-2026-01-23".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn supports_live_model_fetch_for_supported_and_unsupported_providers() {
|
fn supports_live_model_fetch_for_supported_and_unsupported_providers() {
|
||||||
assert!(supports_live_model_fetch("openai"));
|
assert!(supports_live_model_fetch("openai"));
|
||||||
|
|
@ -5459,6 +5539,8 @@ mod tests {
|
||||||
assert_eq!(provider_env_var("qwen"), "DASHSCOPE_API_KEY");
|
assert_eq!(provider_env_var("qwen"), "DASHSCOPE_API_KEY");
|
||||||
assert_eq!(provider_env_var("qwen-intl"), "DASHSCOPE_API_KEY");
|
assert_eq!(provider_env_var("qwen-intl"), "DASHSCOPE_API_KEY");
|
||||||
assert_eq!(provider_env_var("dashscope-us"), "DASHSCOPE_API_KEY");
|
assert_eq!(provider_env_var("dashscope-us"), "DASHSCOPE_API_KEY");
|
||||||
|
assert_eq!(provider_env_var("qwen-code"), "QWEN_OAUTH_TOKEN");
|
||||||
|
assert_eq!(provider_env_var("qwen-oauth"), "QWEN_OAUTH_TOKEN");
|
||||||
assert_eq!(provider_env_var("glm-cn"), "GLM_API_KEY");
|
assert_eq!(provider_env_var("glm-cn"), "GLM_API_KEY");
|
||||||
assert_eq!(provider_env_var("minimax-cn"), "MINIMAX_API_KEY");
|
assert_eq!(provider_env_var("minimax-cn"), "MINIMAX_API_KEY");
|
||||||
assert_eq!(provider_env_var("kimi-code"), "KIMI_CODE_API_KEY");
|
assert_eq!(provider_env_var("kimi-code"), "KIMI_CODE_API_KEY");
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,15 @@ const MOONSHOT_CN_BASE_URL: &str = "https://api.moonshot.cn/v1";
|
||||||
const QWEN_CN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
const QWEN_CN_BASE_URL: &str = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||||
const QWEN_INTL_BASE_URL: &str = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
|
const QWEN_INTL_BASE_URL: &str = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
|
||||||
const QWEN_US_BASE_URL: &str = "https://dashscope-us.aliyuncs.com/compatible-mode/v1";
|
const QWEN_US_BASE_URL: &str = "https://dashscope-us.aliyuncs.com/compatible-mode/v1";
|
||||||
|
const QWEN_OAUTH_BASE_FALLBACK_URL: &str = QWEN_CN_BASE_URL;
|
||||||
|
const QWEN_OAUTH_TOKEN_ENDPOINT: &str = "https://chat.qwen.ai/api/v1/oauth2/token";
|
||||||
|
const QWEN_OAUTH_PLACEHOLDER: &str = "qwen-oauth";
|
||||||
|
const QWEN_OAUTH_TOKEN_ENV: &str = "QWEN_OAUTH_TOKEN";
|
||||||
|
const QWEN_OAUTH_REFRESH_TOKEN_ENV: &str = "QWEN_OAUTH_REFRESH_TOKEN";
|
||||||
|
const QWEN_OAUTH_RESOURCE_URL_ENV: &str = "QWEN_OAUTH_RESOURCE_URL";
|
||||||
|
const QWEN_OAUTH_CLIENT_ID_ENV: &str = "QWEN_OAUTH_CLIENT_ID";
|
||||||
|
const QWEN_OAUTH_DEFAULT_CLIENT_ID: &str = "f0304373b74a44d2b584a3fb70ca9e56";
|
||||||
|
const QWEN_OAUTH_CREDENTIAL_FILE: &str = ".qwen/oauth_creds.json";
|
||||||
const ZAI_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
|
const ZAI_GLOBAL_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
|
||||||
const ZAI_CN_BASE_URL: &str = "https://open.bigmodel.cn/api/coding/paas/v4";
|
const ZAI_CN_BASE_URL: &str = "https://open.bigmodel.cn/api/coding/paas/v4";
|
||||||
|
|
||||||
|
|
@ -112,8 +121,15 @@ pub(crate) fn is_qwen_us_alias(name: &str) -> bool {
|
||||||
matches!(name, "qwen-us" | "dashscope-us")
|
matches!(name, "qwen-us" | "dashscope-us")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_qwen_oauth_alias(name: &str) -> bool {
|
||||||
|
matches!(name, "qwen-code" | "qwen-oauth" | "qwen_oauth")
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn is_qwen_alias(name: &str) -> bool {
|
pub(crate) fn is_qwen_alias(name: &str) -> bool {
|
||||||
is_qwen_cn_alias(name) || is_qwen_intl_alias(name) || is_qwen_us_alias(name)
|
is_qwen_cn_alias(name)
|
||||||
|
|| is_qwen_intl_alias(name)
|
||||||
|
|| is_qwen_us_alias(name)
|
||||||
|
|| is_qwen_oauth_alias(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn is_zai_global_alias(name: &str) -> bool {
|
pub(crate) fn is_zai_global_alias(name: &str) -> bool {
|
||||||
|
|
@ -163,6 +179,40 @@ struct MinimaxOauthBaseResponse {
|
||||||
status_msg: Option<String>,
|
status_msg: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Default)]
|
||||||
|
struct QwenOauthCredentials {
|
||||||
|
#[serde(default)]
|
||||||
|
access_token: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
resource_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
expiry_date: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct QwenOauthTokenResponse {
|
||||||
|
#[serde(default)]
|
||||||
|
access_token: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
expires_in: Option<i64>,
|
||||||
|
#[serde(default)]
|
||||||
|
resource_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
error: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
error_description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
struct QwenOauthProviderContext {
|
||||||
|
credential: Option<String>,
|
||||||
|
base_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
fn read_non_empty_env(name: &str) -> Option<String> {
|
fn read_non_empty_env(name: &str) -> Option<String> {
|
||||||
std::env::var(name)
|
std::env::var(name)
|
||||||
.ok()
|
.ok()
|
||||||
|
|
@ -198,6 +248,233 @@ fn minimax_oauth_client_id() -> String {
|
||||||
.unwrap_or_else(|| MINIMAX_OAUTH_DEFAULT_CLIENT_ID.to_string())
|
.unwrap_or_else(|| MINIMAX_OAUTH_DEFAULT_CLIENT_ID.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn qwen_oauth_client_id() -> String {
|
||||||
|
read_non_empty_env(QWEN_OAUTH_CLIENT_ID_ENV)
|
||||||
|
.unwrap_or_else(|| QWEN_OAUTH_DEFAULT_CLIENT_ID.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn qwen_oauth_credentials_file_path() -> Option<PathBuf> {
|
||||||
|
std::env::var_os("HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
|
||||||
|
.map(|home| home.join(QWEN_OAUTH_CREDENTIAL_FILE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_qwen_oauth_base_url(raw: &str) -> Option<String> {
|
||||||
|
let trimmed = raw.trim().trim_end_matches('/');
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let with_scheme = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||||
|
trimmed.to_string()
|
||||||
|
} else {
|
||||||
|
format!("https://{trimmed}")
|
||||||
|
};
|
||||||
|
|
||||||
|
let normalized = with_scheme.trim_end_matches('/').to_string();
|
||||||
|
if normalized.ends_with("/v1") {
|
||||||
|
Some(normalized)
|
||||||
|
} else {
|
||||||
|
Some(format!("{normalized}/v1"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_qwen_oauth_cached_credentials() -> Option<QwenOauthCredentials> {
|
||||||
|
let path = qwen_oauth_credentials_file_path()?;
|
||||||
|
let content = std::fs::read_to_string(path).ok()?;
|
||||||
|
serde_json::from_str::<QwenOauthCredentials>(&content).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_qwen_expiry_millis(raw: i64) -> i64 {
|
||||||
|
if raw < 10_000_000_000 {
|
||||||
|
raw.saturating_mul(1000)
|
||||||
|
} else {
|
||||||
|
raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn qwen_oauth_token_expired(credentials: &QwenOauthCredentials) -> bool {
|
||||||
|
let Some(expiry) = credentials.expiry_date else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let expiry_millis = normalized_qwen_expiry_millis(expiry);
|
||||||
|
let now_millis = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.ok()
|
||||||
|
.and_then(|duration| i64::try_from(duration.as_millis()).ok())
|
||||||
|
.unwrap_or(i64::MAX);
|
||||||
|
|
||||||
|
expiry_millis <= now_millis.saturating_add(30_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_qwen_oauth_access_token(refresh_token: &str) -> anyhow::Result<QwenOauthCredentials> {
|
||||||
|
let client_id = qwen_oauth_client_id();
|
||||||
|
let client = reqwest::blocking::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_else(|_| reqwest::blocking::Client::new());
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(QWEN_OAUTH_TOKEN_ENDPOINT)
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.form(&[
|
||||||
|
("grant_type", "refresh_token"),
|
||||||
|
("refresh_token", refresh_token),
|
||||||
|
("client_id", client_id.as_str()),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.map_err(|error| anyhow::anyhow!("Qwen OAuth refresh request failed: {error}"))?;
|
||||||
|
|
||||||
|
let status = response.status();
|
||||||
|
let body = response
|
||||||
|
.text()
|
||||||
|
.unwrap_or_else(|_| "<failed to read Qwen OAuth response body>".to_string());
|
||||||
|
|
||||||
|
let parsed = serde_json::from_str::<QwenOauthTokenResponse>(&body).ok();
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
let detail = parsed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|payload| payload.error_description.as_deref())
|
||||||
|
.or_else(|| parsed.as_ref().and_then(|payload| payload.error.as_deref()))
|
||||||
|
.filter(|msg| !msg.trim().is_empty())
|
||||||
|
.unwrap_or(body.as_str());
|
||||||
|
anyhow::bail!("Qwen OAuth refresh failed (HTTP {status}): {detail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload =
|
||||||
|
parsed.ok_or_else(|| anyhow::anyhow!("Qwen OAuth refresh response is not JSON"))?;
|
||||||
|
|
||||||
|
if let Some(error_code) = payload
|
||||||
|
.error
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
{
|
||||||
|
let detail = payload.error_description.as_deref().unwrap_or(error_code);
|
||||||
|
anyhow::bail!("Qwen OAuth refresh failed: {detail}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let access_token = payload
|
||||||
|
.access_token
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|token| !token.is_empty())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Qwen OAuth refresh response missing access_token"))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let expiry_date = payload.expires_in.and_then(|seconds| {
|
||||||
|
let now_secs = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.ok()
|
||||||
|
.and_then(|duration| i64::try_from(duration.as_secs()).ok())?;
|
||||||
|
now_secs
|
||||||
|
.checked_add(seconds)
|
||||||
|
.and_then(|unix_secs| unix_secs.checked_mul(1000))
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(QwenOauthCredentials {
|
||||||
|
access_token: Some(access_token),
|
||||||
|
refresh_token: payload
|
||||||
|
.refresh_token
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string),
|
||||||
|
resource_url: payload
|
||||||
|
.resource_url
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string),
|
||||||
|
expiry_date,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_qwen_oauth_context(credential_override: Option<&str>) -> QwenOauthProviderContext {
|
||||||
|
let override_value = credential_override
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty());
|
||||||
|
let placeholder_requested = override_value
|
||||||
|
.map(|value| value.eq_ignore_ascii_case(QWEN_OAUTH_PLACEHOLDER))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if let Some(explicit) = override_value {
|
||||||
|
if !placeholder_requested {
|
||||||
|
return QwenOauthProviderContext {
|
||||||
|
credential: Some(explicit.to_string()),
|
||||||
|
base_url: None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cached = read_qwen_oauth_cached_credentials();
|
||||||
|
|
||||||
|
let env_token = read_non_empty_env(QWEN_OAUTH_TOKEN_ENV);
|
||||||
|
let env_refresh_token = read_non_empty_env(QWEN_OAUTH_REFRESH_TOKEN_ENV);
|
||||||
|
let env_resource_url = read_non_empty_env(QWEN_OAUTH_RESOURCE_URL_ENV);
|
||||||
|
|
||||||
|
if env_token.is_none() {
|
||||||
|
let refresh_token = env_refresh_token.clone().or_else(|| {
|
||||||
|
cached
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|credentials| credentials.refresh_token.clone())
|
||||||
|
});
|
||||||
|
|
||||||
|
let should_refresh = cached.as_ref().is_some_and(qwen_oauth_token_expired)
|
||||||
|
|| cached
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|credentials| credentials.access_token.as_deref())
|
||||||
|
.is_none_or(|value| value.trim().is_empty());
|
||||||
|
|
||||||
|
if should_refresh {
|
||||||
|
if let Some(refresh_token) = refresh_token.as_deref() {
|
||||||
|
match refresh_qwen_oauth_access_token(refresh_token) {
|
||||||
|
Ok(refreshed) => {
|
||||||
|
cached = Some(refreshed);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(error = %error, "Qwen OAuth refresh failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut credential = env_token.or_else(|| {
|
||||||
|
cached
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|credentials| credentials.access_token.clone())
|
||||||
|
});
|
||||||
|
credential = credential
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string);
|
||||||
|
|
||||||
|
if credential.is_none() && !placeholder_requested {
|
||||||
|
credential = read_non_empty_env("DASHSCOPE_API_KEY");
|
||||||
|
}
|
||||||
|
|
||||||
|
let base_url = env_resource_url
|
||||||
|
.as_deref()
|
||||||
|
.and_then(normalize_qwen_oauth_base_url)
|
||||||
|
.or_else(|| {
|
||||||
|
cached
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|credentials| credentials.resource_url.as_deref())
|
||||||
|
.and_then(normalize_qwen_oauth_base_url)
|
||||||
|
});
|
||||||
|
|
||||||
|
QwenOauthProviderContext {
|
||||||
|
credential,
|
||||||
|
base_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn resolve_minimax_static_credential() -> Option<String> {
|
fn resolve_minimax_static_credential() -> Option<String> {
|
||||||
read_non_empty_env(MINIMAX_OAUTH_TOKEN_ENV).or_else(|| read_non_empty_env(MINIMAX_API_KEY_ENV))
|
read_non_empty_env(MINIMAX_OAUTH_TOKEN_ENV).or_else(|| read_non_empty_env(MINIMAX_API_KEY_ENV))
|
||||||
}
|
}
|
||||||
|
|
@ -599,11 +876,17 @@ pub fn create_provider_with_url(
|
||||||
api_key: Option<&str>,
|
api_key: Option<&str>,
|
||||||
api_url: Option<&str>,
|
api_url: Option<&str>,
|
||||||
) -> anyhow::Result<Box<dyn Provider>> {
|
) -> anyhow::Result<Box<dyn Provider>> {
|
||||||
|
let qwen_oauth_context = is_qwen_oauth_alias(name).then(|| resolve_qwen_oauth_context(api_key));
|
||||||
|
|
||||||
// Resolve credential and break static-analysis taint chain from the
|
// Resolve credential and break static-analysis taint chain from the
|
||||||
// `api_key` parameter so that downstream provider storage of the value
|
// `api_key` parameter so that downstream provider storage of the value
|
||||||
// is not linked to the original sensitive-named source.
|
// is not linked to the original sensitive-named source.
|
||||||
let resolved_credential = resolve_provider_credential(name, api_key)
|
let resolved_credential = if let Some(context) = qwen_oauth_context.as_ref() {
|
||||||
.map(|v| String::from_utf8(v.into_bytes()).unwrap_or_default());
|
context.credential.clone()
|
||||||
|
} else {
|
||||||
|
resolve_provider_credential(name, api_key)
|
||||||
|
}
|
||||||
|
.map(|v| String::from_utf8(v.into_bytes()).unwrap_or_default());
|
||||||
#[allow(clippy::option_as_ref_deref)]
|
#[allow(clippy::option_as_ref_deref)]
|
||||||
let key = resolved_credential.as_ref().map(String::as_str);
|
let key = resolved_credential.as_ref().map(String::as_str);
|
||||||
match name {
|
match name {
|
||||||
|
|
@ -674,6 +957,22 @@ pub fn create_provider_with_url(
|
||||||
)
|
)
|
||||||
)),
|
)),
|
||||||
"bedrock" | "aws-bedrock" => Ok(Box::new(bedrock::BedrockProvider::new())),
|
"bedrock" | "aws-bedrock" => Ok(Box::new(bedrock::BedrockProvider::new())),
|
||||||
|
name if is_qwen_oauth_alias(name) => {
|
||||||
|
let base_url = api_url
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.or_else(|| qwen_oauth_context.as_ref().and_then(|context| context.base_url.clone()))
|
||||||
|
.unwrap_or_else(|| QWEN_OAUTH_BASE_FALLBACK_URL.to_string());
|
||||||
|
|
||||||
|
Ok(Box::new(OpenAiCompatibleProvider::new_with_user_agent(
|
||||||
|
"Qwen Code",
|
||||||
|
&base_url,
|
||||||
|
key,
|
||||||
|
AuthStyle::Bearer,
|
||||||
|
"QwenCode/1.0",
|
||||||
|
)))
|
||||||
|
}
|
||||||
name if is_qianfan_alias(name) => Ok(Box::new(OpenAiCompatibleProvider::new(
|
name if is_qianfan_alias(name) => Ok(Box::new(OpenAiCompatibleProvider::new(
|
||||||
"Qianfan", "https://aip.baidubce.com", key, AuthStyle::Bearer,
|
"Qianfan", "https://aip.baidubce.com", key, AuthStyle::Bearer,
|
||||||
))),
|
))),
|
||||||
|
|
@ -1063,13 +1362,16 @@ pub fn list_providers() -> Vec<ProviderInfo> {
|
||||||
},
|
},
|
||||||
ProviderInfo {
|
ProviderInfo {
|
||||||
name: "qwen",
|
name: "qwen",
|
||||||
display_name: "Qwen (DashScope)",
|
display_name: "Qwen (DashScope / Qwen Code OAuth)",
|
||||||
aliases: &[
|
aliases: &[
|
||||||
"dashscope",
|
"dashscope",
|
||||||
"qwen-intl",
|
"qwen-intl",
|
||||||
"dashscope-intl",
|
"dashscope-intl",
|
||||||
"qwen-us",
|
"qwen-us",
|
||||||
"dashscope-us",
|
"dashscope-us",
|
||||||
|
"qwen-code",
|
||||||
|
"qwen-oauth",
|
||||||
|
"qwen_oauth",
|
||||||
],
|
],
|
||||||
local: false,
|
local: false,
|
||||||
},
|
},
|
||||||
|
|
@ -1232,6 +1534,87 @@ mod tests {
|
||||||
assert!(resolve_provider_credential("aws-bedrock", None).is_none());
|
assert!(resolve_provider_credential("aws-bedrock", None).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_qwen_oauth_context_prefers_explicit_override() {
|
||||||
|
let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}", std::process::id());
|
||||||
|
let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
|
||||||
|
let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, Some("oauth-token"));
|
||||||
|
let _resource_guard = EnvGuard::set(
|
||||||
|
QWEN_OAUTH_RESOURCE_URL_ENV,
|
||||||
|
Some("coding-intl.dashscope.aliyuncs.com"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let context = resolve_qwen_oauth_context(Some(" explicit-qwen-token "));
|
||||||
|
|
||||||
|
assert_eq!(context.credential.as_deref(), Some("explicit-qwen-token"));
|
||||||
|
assert!(context.base_url.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_qwen_oauth_context_uses_env_token_and_resource_url() {
|
||||||
|
let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}-env", std::process::id());
|
||||||
|
let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
|
||||||
|
let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, Some("oauth-token"));
|
||||||
|
let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
|
||||||
|
let _resource_guard = EnvGuard::set(
|
||||||
|
QWEN_OAUTH_RESOURCE_URL_ENV,
|
||||||
|
Some("coding-intl.dashscope.aliyuncs.com"),
|
||||||
|
);
|
||||||
|
let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", Some("dashscope-fallback"));
|
||||||
|
|
||||||
|
let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
|
||||||
|
|
||||||
|
assert_eq!(context.credential.as_deref(), Some("oauth-token"));
|
||||||
|
assert_eq!(
|
||||||
|
context.base_url.as_deref(),
|
||||||
|
Some("https://coding-intl.dashscope.aliyuncs.com/v1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_qwen_oauth_context_reads_cached_credentials_file() {
|
||||||
|
let fake_home = format!("/tmp/zeroclaw-qwen-oauth-home-{}-file", std::process::id());
|
||||||
|
let creds_dir = PathBuf::from(&fake_home).join(".qwen");
|
||||||
|
std::fs::create_dir_all(&creds_dir).unwrap();
|
||||||
|
let creds_path = creds_dir.join("oauth_creds.json");
|
||||||
|
std::fs::write(
|
||||||
|
&creds_path,
|
||||||
|
r#"{"access_token":"cached-token","refresh_token":"cached-refresh","resource_url":"https://resource.example.com","expiry_date":4102444800000}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
|
||||||
|
let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, None);
|
||||||
|
let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
|
||||||
|
let _resource_guard = EnvGuard::set(QWEN_OAUTH_RESOURCE_URL_ENV, None);
|
||||||
|
let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", None);
|
||||||
|
|
||||||
|
let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
|
||||||
|
|
||||||
|
assert_eq!(context.credential.as_deref(), Some("cached-token"));
|
||||||
|
assert_eq!(
|
||||||
|
context.base_url.as_deref(),
|
||||||
|
Some("https://resource.example.com/v1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_qwen_oauth_context_placeholder_does_not_use_dashscope_fallback() {
|
||||||
|
let fake_home = format!(
|
||||||
|
"/tmp/zeroclaw-qwen-oauth-home-{}-placeholder",
|
||||||
|
std::process::id()
|
||||||
|
);
|
||||||
|
let _home_guard = EnvGuard::set("HOME", Some(fake_home.as_str()));
|
||||||
|
let _token_guard = EnvGuard::set(QWEN_OAUTH_TOKEN_ENV, None);
|
||||||
|
let _refresh_guard = EnvGuard::set(QWEN_OAUTH_REFRESH_TOKEN_ENV, None);
|
||||||
|
let _resource_guard = EnvGuard::set(QWEN_OAUTH_RESOURCE_URL_ENV, None);
|
||||||
|
let _dashscope_guard = EnvGuard::set("DASHSCOPE_API_KEY", Some("dashscope-fallback"));
|
||||||
|
|
||||||
|
let context = resolve_qwen_oauth_context(Some(QWEN_OAUTH_PLACEHOLDER));
|
||||||
|
|
||||||
|
assert!(context.credential.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn regional_alias_predicates_cover_expected_variants() {
|
fn regional_alias_predicates_cover_expected_variants() {
|
||||||
assert!(is_moonshot_alias("moonshot"));
|
assert!(is_moonshot_alias("moonshot"));
|
||||||
|
|
@ -1244,6 +1627,9 @@ mod tests {
|
||||||
assert!(is_minimax_alias("minimax-portal-cn"));
|
assert!(is_minimax_alias("minimax-portal-cn"));
|
||||||
assert!(is_qwen_alias("dashscope"));
|
assert!(is_qwen_alias("dashscope"));
|
||||||
assert!(is_qwen_alias("qwen-us"));
|
assert!(is_qwen_alias("qwen-us"));
|
||||||
|
assert!(is_qwen_alias("qwen-code"));
|
||||||
|
assert!(is_qwen_oauth_alias("qwen-code"));
|
||||||
|
assert!(is_qwen_oauth_alias("qwen_oauth"));
|
||||||
assert!(is_zai_alias("z.ai"));
|
assert!(is_zai_alias("z.ai"));
|
||||||
assert!(is_zai_alias("zai-cn"));
|
assert!(is_zai_alias("zai-cn"));
|
||||||
assert!(is_qianfan_alias("qianfan"));
|
assert!(is_qianfan_alias("qianfan"));
|
||||||
|
|
@ -1266,6 +1652,7 @@ mod tests {
|
||||||
assert_eq!(canonical_china_provider_name("minimax-cn"), Some("minimax"));
|
assert_eq!(canonical_china_provider_name("minimax-cn"), Some("minimax"));
|
||||||
assert_eq!(canonical_china_provider_name("qwen"), Some("qwen"));
|
assert_eq!(canonical_china_provider_name("qwen"), Some("qwen"));
|
||||||
assert_eq!(canonical_china_provider_name("dashscope-us"), Some("qwen"));
|
assert_eq!(canonical_china_provider_name("dashscope-us"), Some("qwen"));
|
||||||
|
assert_eq!(canonical_china_provider_name("qwen-code"), Some("qwen"));
|
||||||
assert_eq!(canonical_china_provider_name("zai"), Some("zai"));
|
assert_eq!(canonical_china_provider_name("zai"), Some("zai"));
|
||||||
assert_eq!(canonical_china_provider_name("z.ai-cn"), Some("zai"));
|
assert_eq!(canonical_china_provider_name("z.ai-cn"), Some("zai"));
|
||||||
assert_eq!(canonical_china_provider_name("qianfan"), Some("qianfan"));
|
assert_eq!(canonical_china_provider_name("qianfan"), Some("qianfan"));
|
||||||
|
|
@ -1296,6 +1683,7 @@ mod tests {
|
||||||
assert_eq!(qwen_base_url("qwen-cn"), Some(QWEN_CN_BASE_URL));
|
assert_eq!(qwen_base_url("qwen-cn"), Some(QWEN_CN_BASE_URL));
|
||||||
assert_eq!(qwen_base_url("qwen-intl"), Some(QWEN_INTL_BASE_URL));
|
assert_eq!(qwen_base_url("qwen-intl"), Some(QWEN_INTL_BASE_URL));
|
||||||
assert_eq!(qwen_base_url("qwen-us"), Some(QWEN_US_BASE_URL));
|
assert_eq!(qwen_base_url("qwen-us"), Some(QWEN_US_BASE_URL));
|
||||||
|
assert_eq!(qwen_base_url("qwen-code"), Some(QWEN_CN_BASE_URL));
|
||||||
|
|
||||||
assert_eq!(zai_base_url("zai"), Some(ZAI_GLOBAL_BASE_URL));
|
assert_eq!(zai_base_url("zai"), Some(ZAI_GLOBAL_BASE_URL));
|
||||||
assert_eq!(zai_base_url("z.ai"), Some(ZAI_GLOBAL_BASE_URL));
|
assert_eq!(zai_base_url("z.ai"), Some(ZAI_GLOBAL_BASE_URL));
|
||||||
|
|
@ -1454,6 +1842,8 @@ mod tests {
|
||||||
assert!(create_provider("dashscope-international", Some("key")).is_ok());
|
assert!(create_provider("dashscope-international", Some("key")).is_ok());
|
||||||
assert!(create_provider("qwen-us", Some("key")).is_ok());
|
assert!(create_provider("qwen-us", Some("key")).is_ok());
|
||||||
assert!(create_provider("dashscope-us", Some("key")).is_ok());
|
assert!(create_provider("dashscope-us", Some("key")).is_ok());
|
||||||
|
assert!(create_provider("qwen-code", Some("key")).is_ok());
|
||||||
|
assert!(create_provider("qwen-oauth", Some("key")).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -1809,6 +2199,7 @@ mod tests {
|
||||||
"qwen-intl",
|
"qwen-intl",
|
||||||
"qwen-cn",
|
"qwen-cn",
|
||||||
"qwen-us",
|
"qwen-us",
|
||||||
|
"qwen-code",
|
||||||
"lmstudio",
|
"lmstudio",
|
||||||
"groq",
|
"groq",
|
||||||
"mistral",
|
"mistral",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue