mirror of https://github.com/openclaw/openclaw.git
Remove Qwen OAuth integration (qwen-portal-auth) (#52709)
* Remove Qwen OAuth integration (qwen-portal-auth) Qwen OAuth via portal.qwen.ai is being deprecated by the Qwen team due to traffic impact on their primary Qwen Code user base. Users should migrate to the officially supported Model Studio (Alibaba Cloud Coding Plan) provider instead. Ref: https://github.com/openclaw/openclaw/issues/49557 - Delete extensions/qwen-portal-auth/ plugin entirely - Remove qwen-portal from onboarding auth choices, provider aliases, auto-enable list, bundled plugin defaults, and pricing cache - Remove Qwen CLI credential sync (external-cli-sync, cli-credentials) - Remove QWEN_OAUTH_MARKER from model auth markers - Update docs/providers/qwen.md to redirect to Model Studio - Update model-providers docs (EN + zh-CN) to remove Qwen OAuth section - Regenerate config and plugin-sdk baselines - Update all affected tests Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * Clean up residual qwen-portal references after OAuth removal * Add migration hint for deprecated qwen-portal OAuth provider * fix: finish qwen oauth removal follow-up --------- Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> Co-authored-by: Frank Yang <frank.ekn@gmail.com>
This commit is contained in:
parent
83e6c12f15
commit
dad68d319b
|
|
@ -221,10 +221,6 @@
|
|||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/open-prose/**"
|
||||
"extensions: qwen-portal-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qwen-portal-auth/**"
|
||||
"extensions: device-pair":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ Docs: https://docs.openclaw.ai
|
|||
|
||||
### Breaking
|
||||
|
||||
- Providers/Qwen: remove the deprecated `qwen-portal-auth` OAuth integration for `portal.qwen.ai`; migrate to Model Studio with `openclaw onboard --auth-choice modelstudio-api-key`. (#52709) Thanks @pomelo-nwu.
|
||||
|
||||
### Changes
|
||||
|
||||
- MiniMax: add image generation provider for `image-01` model, supporting generate and image-to-image editing with aspect ratio control. (#54487) Thanks @liyuan97.
|
||||
|
|
|
|||
|
|
@ -54228,127 +54228,6 @@
|
|||
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.qwen-portal-auth",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "qwen-portal-auth",
|
||||
"help": "Plugin entry for qwen-portal-auth.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.qwen-portal-auth.config",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "qwen-portal-auth Config",
|
||||
"help": "Plugin-defined config payload for qwen-portal-auth.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.qwen-portal-auth.enabled",
|
||||
"kind": "plugin",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Enable qwen-portal-auth",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.qwen-portal-auth.hooks",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Hook Policy",
|
||||
"help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.qwen-portal-auth.hooks.allowPromptInjection",
|
||||
"kind": "plugin",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"access"
|
||||
],
|
||||
"label": "Allow Prompt Injection Hooks",
|
||||
"help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.qwen-portal-auth.subagent",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Subagent Policy",
|
||||
"help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.qwen-portal-auth.subagent.allowedModels",
|
||||
"kind": "plugin",
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"access"
|
||||
],
|
||||
"label": "Plugin Subagent Allowed Models",
|
||||
"help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.qwen-portal-auth.subagent.allowedModels.*",
|
||||
"kind": "plugin",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.qwen-portal-auth.subagent.allowModelOverride",
|
||||
"kind": "plugin",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"access"
|
||||
],
|
||||
"label": "Allow Plugin Subagent Model Override",
|
||||
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.sglang",
|
||||
"kind": "plugin",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5648}
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5639}
|
||||
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
|
|
@ -4691,15 +4691,6 @@
|
|||
{"recordType":"path","path":"plugins.entries.qianfan.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.qianfan.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.qianfan.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.qwen-portal-auth","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"qwen-portal-auth","help":"Plugin entry for qwen-portal-auth.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.qwen-portal-auth.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"qwen-portal-auth Config","help":"Plugin-defined config payload for qwen-portal-auth.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.qwen-portal-auth.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable qwen-portal-auth","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.qwen-portal-auth.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.qwen-portal-auth.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.qwen-portal-auth.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.qwen-portal-auth.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.qwen-portal-auth.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.qwen-portal-auth.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.sglang","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/sglang-provider","help":"OpenClaw SGLang provider plugin (plugin: sglang)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.sglang.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/sglang-provider Config","help":"Plugin-defined config payload for sglang.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.sglang.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/sglang-provider","hasChildren":false}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ Notes:
|
|||
- `models set <model-or-alias>` accepts `provider/model` or an alias.
|
||||
- Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
|
||||
- If you omit the provider, OpenClaw treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
|
||||
- `models status` may show `marker(<value>)` in auth output for non-secret placeholders (for example `OPENAI_API_KEY`, `secretref-managed`, `minimax-oauth`, `qwen-oauth`, `ollama-local`) instead of masking them as secrets.
|
||||
- `models status` may show `marker(<value>)` in auth output for non-secret placeholders (for example `OPENAI_API_KEY`, `secretref-managed`, `minimax-oauth`, `oauth:chutes`, `ollama-local`) instead of masking them as secrets.
|
||||
|
||||
### `models status`
|
||||
|
||||
|
|
|
|||
|
|
@ -108,7 +108,6 @@ Current bundled examples:
|
|||
- `byteplus`, `cloudflare-ai-gateway`, `huggingface`, `kimi-coding`,
|
||||
`modelstudio`, `nvidia`, `qianfan`, `synthetic`, `together`, `venice`,
|
||||
`vercel-ai-gateway`, and `volcengine`: plugin-owned catalogs only
|
||||
- `qwen-portal`: plugin-owned catalog, OAuth login, and OAuth refresh
|
||||
- `minimax` and `xiaomi`: plugin-owned catalogs plus usage auth/snapshot logic
|
||||
|
||||
The bundled `openai` plugin now owns both provider ids: `openai` and
|
||||
|
|
@ -348,22 +347,6 @@ Kimi Coding uses Moonshot AI's Anthropic-compatible endpoint:
|
|||
}
|
||||
```
|
||||
|
||||
### Qwen OAuth (free tier)
|
||||
|
||||
Qwen provides OAuth access to Qwen Coder + Vision via a device-code flow.
|
||||
The bundled provider plugin is enabled by default, so just log in:
|
||||
|
||||
```bash
|
||||
openclaw models auth login --provider qwen-portal --set-default
|
||||
```
|
||||
|
||||
Model refs:
|
||||
|
||||
- `qwen-portal/coder-model`
|
||||
- `qwen-portal/vision-model`
|
||||
|
||||
See [/providers/qwen](/providers/qwen) for setup details and notes.
|
||||
|
||||
### Volcano Engine (Doubao)
|
||||
|
||||
Volcano Engine (火山引擎) provides access to Doubao and other models in China.
|
||||
|
|
|
|||
|
|
@ -469,7 +469,7 @@ Useful env vars:
|
|||
- `OPENCLAW_WORKSPACE_DIR=...` (default: `~/.openclaw/workspace`) mounted to `/home/node/.openclaw/workspace`
|
||||
- `OPENCLAW_PROFILE_FILE=...` (default: `~/.profile`) mounted to `/home/node/.profile` and sourced before running tests
|
||||
- External CLI auth dirs under `$HOME` are mounted read-only under `/host-auth/...`, then copied into `/home/node/...` before tests start
|
||||
- Default: mount all supported dirs (`.codex`, `.claude`, `.qwen`, `.minimax`)
|
||||
- Default: mount all supported dirs (`.codex`, `.claude`, `.minimax`)
|
||||
- Narrowed provider runs mount only the needed dirs inferred from `OPENCLAW_LIVE_PROVIDERS` / `OPENCLAW_LIVE_GATEWAY_PROVIDERS`
|
||||
- Override manually with `OPENCLAW_DOCKER_AUTH_DIRS=all`, `OPENCLAW_DOCKER_AUTH_DIRS=none`, or a comma list like `OPENCLAW_DOCKER_AUTH_DIRS=.claude,.codex`
|
||||
- `OPENCLAW_LIVE_GATEWAY_MODELS=...` / `OPENCLAW_LIVE_MODELS=...` to narrow the run
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
|
|||
- [Perplexity (web search)](/providers/perplexity-provider)
|
||||
- [Qianfan](/providers/qianfan)
|
||||
- [Qwen / Model Studio (Alibaba Cloud)](/providers/qwen_modelstudio)
|
||||
- [Qwen (OAuth)](/providers/qwen)
|
||||
- [SGLang (local models)](/providers/sglang)
|
||||
- [Synthetic](/providers/synthetic)
|
||||
- [Together AI](/providers/together)
|
||||
|
|
|
|||
|
|
@ -1,53 +1,33 @@
|
|||
---
|
||||
summary: "Use Qwen OAuth (free tier) in OpenClaw"
|
||||
summary: "Use Qwen models via Alibaba Cloud Model Studio"
|
||||
read_when:
|
||||
- You want to use Qwen with OpenClaw
|
||||
- You want free-tier OAuth access to Qwen Coder
|
||||
- You previously used Qwen OAuth
|
||||
title: "Qwen"
|
||||
---
|
||||
|
||||
# Qwen
|
||||
|
||||
Qwen provides a free-tier OAuth flow for Qwen Coder and Qwen Vision models
|
||||
(2,000 requests/day, subject to Qwen rate limits).
|
||||
<Warning>
|
||||
|
||||
## Enable the plugin
|
||||
**Qwen OAuth has been removed.** The free-tier OAuth integration
|
||||
(`qwen-portal`) that used `portal.qwen.ai` endpoints is no longer available.
|
||||
See [Issue #49557](https://github.com/openclaw/openclaw/issues/49557) for
|
||||
background.
|
||||
|
||||
</Warning>
|
||||
|
||||
## Recommended: Model Studio (Alibaba Cloud Coding Plan)
|
||||
|
||||
Use [Model Studio](/providers/modelstudio) for officially supported access to
|
||||
Qwen models (Qwen 3.5 Plus, GLM-4.7, Kimi K2.5, MiniMax M2.5, and more).
|
||||
|
||||
```bash
|
||||
openclaw plugins enable qwen-portal-auth
|
||||
# Global endpoint
|
||||
openclaw onboard --auth-choice modelstudio-api-key
|
||||
|
||||
# China endpoint
|
||||
openclaw onboard --auth-choice modelstudio-api-key-cn
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
## Authenticate
|
||||
|
||||
```bash
|
||||
openclaw models auth login --provider qwen-portal --set-default
|
||||
```
|
||||
|
||||
This runs the Qwen device-code OAuth flow and writes a provider entry to your
|
||||
`models.json` (plus a `qwen` alias for quick switching).
|
||||
|
||||
## Model IDs
|
||||
|
||||
- `qwen-portal/coder-model`
|
||||
- `qwen-portal/vision-model`
|
||||
|
||||
Switch models with:
|
||||
|
||||
```bash
|
||||
openclaw models set qwen-portal/coder-model
|
||||
```
|
||||
|
||||
## Reuse Qwen Code CLI login
|
||||
|
||||
If you already logged in with the Qwen Code CLI, OpenClaw will sync credentials
|
||||
from `~/.qwen/oauth_creds.json` when it loads the auth store. You still need a
|
||||
`models.providers.qwen-portal` entry (use the login command above to create one).
|
||||
|
||||
## Notes
|
||||
|
||||
- Tokens auto-refresh; re-run the login command if refresh fails or access is revoked.
|
||||
- Default base URL: `https://portal.qwen.ai/v1` (override with
|
||||
`models.providers.qwen-portal.baseUrl` if Qwen provides a different endpoint).
|
||||
- See [Model providers](/concepts/model-providers) for provider-wide rules.
|
||||
See [Model Studio](/providers/modelstudio) for full setup details.
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ and the [Plugin SDK Overview](/plugins/sdk-overview).
|
|||
`anthropic`, `byteplus`, `cloudflare-ai-gateway`, `github-copilot`, `google`,
|
||||
`huggingface`, `kilocode`, `kimi-coding`, `minimax`, `mistral`, `modelstudio`,
|
||||
`moonshot`, `nvidia`, `openai`, `opencode`, `opencode-go`, `openrouter`,
|
||||
`qianfan`, `qwen-portal-auth`, `synthetic`, `together`, `venice`,
|
||||
`qianfan`, `synthetic`, `together`, `venice`,
|
||||
`vercel-ai-gateway`, `volcengine`, `xiaomi`, `zai`
|
||||
</Accordion>
|
||||
|
||||
|
|
|
|||
|
|
@ -109,7 +109,6 @@ x-i18n:
|
|||
- `byteplus`、`cloudflare-ai-gateway`、`huggingface`、`kimi-coding`、
|
||||
`modelstudio`、`nvidia`、`qianfan`、`synthetic`、`together`、`venice`、
|
||||
`vercel-ai-gateway` 和 `volcengine`:仅插件接管的目录
|
||||
- `qwen-portal`:插件接管的目录、OAuth 登录和 OAuth 刷新
|
||||
- `minimax` 和 `xiaomi`:插件接管的目录,以及使用量身份验证/快照逻辑
|
||||
|
||||
内置的 `openai` 插件现在接管两个提供商 ID:`openai` 和
|
||||
|
|
@ -348,22 +347,6 @@ Kimi Coding 使用 Moonshot AI 的 Anthropic 兼容端点:
|
|||
}
|
||||
```
|
||||
|
||||
### Qwen OAuth(免费层)
|
||||
|
||||
Qwen 通过设备代码流程提供对 Qwen Coder + Vision 的 OAuth 访问。
|
||||
内置提供商插件默认启用,因此只需登录:
|
||||
|
||||
```bash
|
||||
openclaw models auth login --provider qwen-portal --set-default
|
||||
```
|
||||
|
||||
模型引用:
|
||||
|
||||
- `qwen-portal/coder-model`
|
||||
- `qwen-portal/vision-model`
|
||||
|
||||
设置详情和说明请参见 [/providers/qwen](/providers/qwen)。
|
||||
|
||||
### Volcano Engine(Doubao)
|
||||
|
||||
Volcano Engine(火山引擎)为中国用户提供对 Doubao 和其他模型的访问。
|
||||
|
|
|
|||
|
|
@ -1,55 +1,36 @@
|
|||
---
|
||||
read_when:
|
||||
- 你想在 OpenClaw 中使用 Qwen
|
||||
- 你想要免费层 OAuth 访问 Qwen Coder
|
||||
summary: 在 OpenClaw 中使用 Qwen OAuth(免费层)
|
||||
- 你之前使用过 Qwen OAuth
|
||||
summary: 通过阿里云 Model Studio 使用 Qwen 模型
|
||||
title: Qwen
|
||||
x-i18n:
|
||||
generated_at: "2026-02-03T07:53:34Z"
|
||||
generated_at: "2026-03-23T00:00:00Z"
|
||||
model: claude-opus-4-5
|
||||
provider: pi
|
||||
source_hash: 88b88e224e2fecbb1ca26e24fbccdbe25609be40b38335d0451343a5da53fdd4
|
||||
source_hash: ""
|
||||
source_path: providers/qwen.md
|
||||
workflow: 15
|
||||
---
|
||||
|
||||
# Qwen
|
||||
|
||||
Qwen 为 Qwen Coder 和 Qwen Vision 模型提供免费层 OAuth 流程(每天 2,000 次请求,受 Qwen 速率限制约束)。
|
||||
<Warning>
|
||||
|
||||
## 启用插件
|
||||
**Qwen OAuth 已移除。** 使用 `portal.qwen.ai` 端点的免费层 OAuth 集成(`qwen-portal`)已不再可用。详情请参见 [Issue #49557](https://github.com/openclaw/openclaw/issues/49557)。
|
||||
|
||||
</Warning>
|
||||
|
||||
## 推荐方案:Model Studio(阿里云 Coding Plan)
|
||||
|
||||
使用 [Model Studio](/providers/modelstudio) 获取官方支持的 Qwen 模型访问(Qwen 3.5 Plus、GLM-4.7、Kimi K2.5、MiniMax M2.5 等)。
|
||||
|
||||
```bash
|
||||
openclaw plugins enable qwen-portal-auth
|
||||
# 全球端点
|
||||
openclaw onboard --auth-choice modelstudio-api-key
|
||||
|
||||
# 中国端点
|
||||
openclaw onboard --auth-choice modelstudio-api-key-cn
|
||||
```
|
||||
|
||||
启用后重启 Gateway 网关。
|
||||
|
||||
## 认证
|
||||
|
||||
```bash
|
||||
openclaw models auth login --provider qwen-portal --set-default
|
||||
```
|
||||
|
||||
这会运行 Qwen 设备码 OAuth 流程并将提供商条目写入你的 `models.json`(加上一个 `qwen` 别名以便快速切换)。
|
||||
|
||||
## 模型 ID
|
||||
|
||||
- `qwen-portal/coder-model`
|
||||
- `qwen-portal/vision-model`
|
||||
|
||||
切换模型:
|
||||
|
||||
```bash
|
||||
openclaw models set qwen-portal/coder-model
|
||||
```
|
||||
|
||||
## 复用 Qwen Code CLI 登录
|
||||
|
||||
如果你已经使用 Qwen Code CLI 登录,OpenClaw 会在加载认证存储时从 `~/.qwen/oauth_creds.json` 同步凭证。你仍然需要一个 `models.providers.qwen-portal` 条目(使用上面的登录命令创建一个)。
|
||||
|
||||
## 注意
|
||||
|
||||
- 令牌自动刷新;如果刷新失败或访问被撤销,请重新运行登录命令。
|
||||
- 默认基础 URL:`https://portal.qwen.ai/v1`(如果 Qwen 提供不同的端点,使用 `models.providers.qwen-portal.baseUrl` 覆盖)。
|
||||
- 参阅[模型提供商](/concepts/model-providers)了解提供商级别的规则。
|
||||
完整设置详情请参见 [Model Studio](/providers/modelstudio)。
|
||||
|
|
|
|||
|
|
@ -155,7 +155,6 @@ Bundle hook 支持仅限于常规 OpenClaw hook 目录格式(在声明的 hook
|
|||
- OpenCode Zen provider 能力 — 以 `opencode` 形式捆绑(默认启用)
|
||||
- OpenRouter provider 运行时 — 以 `openrouter` 形式捆绑(默认启用)
|
||||
- Qianfan provider catalog — 以 `qianfan` 形式捆绑(默认启用)
|
||||
- Qwen OAuth(provider 身份验证 + catalog)— 以 `qwen-portal-auth` 形式捆绑(默认启用)
|
||||
- Synthetic provider catalog — 以 `synthetic` 形式捆绑(默认启用)
|
||||
- Together provider catalog — 以 `together` 形式捆绑(默认启用)
|
||||
- Venice provider catalog — 以 `venice` 形式捆绑(默认启用)
|
||||
|
|
@ -497,7 +496,7 @@ api.registerHttpRoute({
|
|||
`openclaw/plugin-sdk/minimax-portal-auth`、
|
||||
`openclaw/plugin-sdk/nextcloud-talk`、`openclaw/plugin-sdk/nostr`、
|
||||
`openclaw/plugin-sdk/open-prose`、`openclaw/plugin-sdk/phone-control`、
|
||||
`openclaw/plugin-sdk/qwen-portal-auth`、`openclaw/plugin-sdk/synology-chat`、
|
||||
`openclaw/plugin-sdk/synology-chat`、
|
||||
`openclaw/plugin-sdk/talk-voice`、`openclaw/plugin-sdk/test-utils`、
|
||||
`openclaw/plugin-sdk/thread-ownership`、`openclaw/plugin-sdk/tlon`、
|
||||
`openclaw/plugin-sdk/twitch`、`openclaw/plugin-sdk/voice-call`、
|
||||
|
|
@ -613,7 +612,6 @@ OpenClaw 按以下顺序扫描:
|
|||
- `openrouter`
|
||||
- `phone-control`
|
||||
- `qianfan`
|
||||
- `qwen-portal-auth`
|
||||
- `sglang`
|
||||
- `synthetic`
|
||||
- `talk-voice`
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
# Qwen OAuth (OpenClaw plugin)
|
||||
|
||||
OAuth provider plugin for **Qwen** (free-tier OAuth).
|
||||
|
||||
## Enable
|
||||
|
||||
Bundled plugins are disabled by default. Enable this one:
|
||||
|
||||
```bash
|
||||
openclaw plugins enable qwen-portal-auth
|
||||
```
|
||||
|
||||
Restart the Gateway after enabling.
|
||||
|
||||
## Authenticate
|
||||
|
||||
```bash
|
||||
openclaw models auth login --provider qwen-portal --set-default
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Qwen OAuth uses a device-code login flow.
|
||||
- Tokens auto-refresh; re-run login if refresh fails or access is revoked.
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
import { buildQwenPortalProvider, QWEN_PORTAL_BASE_URL } from "./provider-catalog.js";
|
||||
import {
|
||||
buildOauthProviderAuthResult,
|
||||
definePluginEntry,
|
||||
ensureAuthProfileStore,
|
||||
listProfilesForProvider,
|
||||
QWEN_OAUTH_MARKER,
|
||||
refreshQwenPortalCredentials,
|
||||
type ProviderAuthContext,
|
||||
type ProviderCatalogContext,
|
||||
} from "./runtime-api.js";
|
||||
|
||||
const PROVIDER_ID = "qwen-portal";
|
||||
const PROVIDER_LABEL = "Qwen";
|
||||
const DEFAULT_MODEL = "qwen-portal/coder-model";
|
||||
const DEFAULT_BASE_URL = QWEN_PORTAL_BASE_URL;
|
||||
|
||||
function normalizeBaseUrl(value: string | undefined): string {
|
||||
const raw = value?.trim() || DEFAULT_BASE_URL;
|
||||
const withProtocol = raw.startsWith("http") ? raw : `https://${raw}`;
|
||||
return withProtocol.endsWith("/v1") ? withProtocol : `${withProtocol.replace(/\/+$/, "")}/v1`;
|
||||
}
|
||||
|
||||
function buildProviderCatalog(params: { baseUrl: string; apiKey: string }) {
|
||||
return {
|
||||
...buildQwenPortalProvider(),
|
||||
baseUrl: params.baseUrl,
|
||||
apiKey: params.apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCatalog(ctx: ProviderCatalogContext) {
|
||||
const explicitProvider = ctx.config.models?.providers?.[PROVIDER_ID];
|
||||
const envApiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
|
||||
const authStore = ensureAuthProfileStore(ctx.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const hasProfiles = listProfilesForProvider(authStore, PROVIDER_ID).length > 0;
|
||||
const explicitApiKey =
|
||||
typeof explicitProvider?.apiKey === "string" ? explicitProvider.apiKey.trim() : undefined;
|
||||
const apiKey = envApiKey ?? explicitApiKey ?? (hasProfiles ? QWEN_OAUTH_MARKER : undefined);
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicitBaseUrl =
|
||||
typeof explicitProvider?.baseUrl === "string" ? explicitProvider.baseUrl : undefined;
|
||||
|
||||
return {
|
||||
provider: buildProviderCatalog({
|
||||
baseUrl: normalizeBaseUrl(explicitBaseUrl),
|
||||
apiKey,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "qwen-portal-auth",
|
||||
name: "Qwen OAuth",
|
||||
description: "OAuth flow for Qwen (free-tier) models",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: PROVIDER_LABEL,
|
||||
docsPath: "/providers/qwen",
|
||||
aliases: ["qwen"],
|
||||
envVars: ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"],
|
||||
catalog: {
|
||||
run: async (ctx: ProviderCatalogContext) => resolveCatalog(ctx),
|
||||
},
|
||||
auth: [
|
||||
{
|
||||
id: "device",
|
||||
label: "Qwen OAuth",
|
||||
hint: "Device code login",
|
||||
kind: "device_code",
|
||||
run: async (ctx: ProviderAuthContext) => {
|
||||
const progress = ctx.prompter.progress("Starting Qwen OAuth…");
|
||||
try {
|
||||
const { loginQwenPortalOAuth } = await import("./oauth.runtime.js");
|
||||
const result = await loginQwenPortalOAuth({
|
||||
openUrl: ctx.openUrl,
|
||||
note: ctx.prompter.note,
|
||||
progress,
|
||||
});
|
||||
|
||||
progress.stop("Qwen OAuth complete");
|
||||
|
||||
const baseUrl = normalizeBaseUrl(result.resourceUrl);
|
||||
|
||||
return buildOauthProviderAuthResult({
|
||||
providerId: PROVIDER_ID,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
access: result.access,
|
||||
refresh: result.refresh,
|
||||
expires: result.expires,
|
||||
configPatch: {
|
||||
models: {
|
||||
providers: {
|
||||
[PROVIDER_ID]: {
|
||||
baseUrl,
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"qwen-portal/coder-model": { alias: "qwen" },
|
||||
"qwen-portal/vision-model": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
notes: [
|
||||
"Qwen OAuth tokens auto-refresh. Re-run login if refresh fails or access is revoked.",
|
||||
`Base URL defaults to ${DEFAULT_BASE_URL}. Override models.providers.${PROVIDER_ID}.baseUrl if needed.`,
|
||||
],
|
||||
});
|
||||
} catch (err) {
|
||||
progress.stop("Qwen OAuth failed");
|
||||
await ctx.prompter.note(
|
||||
"If OAuth fails, verify your Qwen account has portal access and try again.",
|
||||
"Qwen OAuth",
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
wizard: {
|
||||
setup: {
|
||||
choiceId: "qwen-portal",
|
||||
choiceLabel: "Qwen OAuth",
|
||||
choiceHint: "Device code login",
|
||||
methodId: "device",
|
||||
},
|
||||
},
|
||||
refreshOAuth: async (cred) => ({
|
||||
...cred,
|
||||
...(await refreshQwenPortalCredentials(cred)),
|
||||
type: "oauth",
|
||||
provider: PROVIDER_ID,
|
||||
email: cred.email,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { loginQwenPortalOAuth } from "./oauth.js";
|
||||
|
|
@ -1,179 +0,0 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { generatePkceVerifierChallenge, toFormUrlEncoded } from "./runtime-api.js";
|
||||
|
||||
const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai";
|
||||
const QWEN_OAUTH_DEVICE_CODE_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/device/code`;
|
||||
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`;
|
||||
const QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56";
|
||||
const QWEN_OAUTH_SCOPE = "openid profile email model.completion";
|
||||
const QWEN_OAUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
|
||||
export type QwenDeviceAuthorization = {
|
||||
device_code: string;
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
verification_uri_complete?: string;
|
||||
expires_in: number;
|
||||
interval?: number;
|
||||
};
|
||||
|
||||
export type QwenOAuthToken = {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
resourceUrl?: string;
|
||||
};
|
||||
|
||||
type TokenPending = { status: "pending"; slowDown?: boolean };
|
||||
|
||||
type DeviceTokenResult =
|
||||
| { status: "success"; token: QwenOAuthToken }
|
||||
| TokenPending
|
||||
| { status: "error"; message: string };
|
||||
|
||||
async function requestDeviceCode(params: { challenge: string }): Promise<QwenDeviceAuthorization> {
|
||||
const response = await fetch(QWEN_OAUTH_DEVICE_CODE_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
"x-request-id": randomUUID(),
|
||||
},
|
||||
body: toFormUrlEncoded({
|
||||
client_id: QWEN_OAUTH_CLIENT_ID,
|
||||
scope: QWEN_OAUTH_SCOPE,
|
||||
code_challenge: params.challenge,
|
||||
code_challenge_method: "S256",
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Qwen device authorization failed: ${text || response.statusText}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as QwenDeviceAuthorization & { error?: string };
|
||||
if (!payload.device_code || !payload.user_code || !payload.verification_uri) {
|
||||
throw new Error(
|
||||
payload.error ??
|
||||
"Qwen device authorization returned an incomplete payload (missing user_code or verification_uri).",
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function pollDeviceToken(params: {
|
||||
deviceCode: string;
|
||||
verifier: string;
|
||||
}): Promise<DeviceTokenResult> {
|
||||
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: toFormUrlEncoded({
|
||||
grant_type: QWEN_OAUTH_GRANT_TYPE,
|
||||
client_id: QWEN_OAUTH_CLIENT_ID,
|
||||
device_code: params.deviceCode,
|
||||
code_verifier: params.verifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let payload: { error?: string; error_description?: string } | undefined;
|
||||
try {
|
||||
payload = (await response.json()) as { error?: string; error_description?: string };
|
||||
} catch {
|
||||
const text = await response.text();
|
||||
return { status: "error", message: text || response.statusText };
|
||||
}
|
||||
|
||||
if (payload?.error === "authorization_pending") {
|
||||
return { status: "pending" };
|
||||
}
|
||||
|
||||
if (payload?.error === "slow_down") {
|
||||
return { status: "pending", slowDown: true };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "error",
|
||||
message: payload?.error_description || payload?.error || response.statusText,
|
||||
};
|
||||
}
|
||||
|
||||
const tokenPayload = (await response.json()) as {
|
||||
access_token?: string | null;
|
||||
refresh_token?: string | null;
|
||||
expires_in?: number | null;
|
||||
token_type?: string;
|
||||
resource_url?: string;
|
||||
};
|
||||
|
||||
if (!tokenPayload.access_token || !tokenPayload.refresh_token || !tokenPayload.expires_in) {
|
||||
return { status: "error", message: "Qwen OAuth returned incomplete token payload." };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
token: {
|
||||
access: tokenPayload.access_token,
|
||||
refresh: tokenPayload.refresh_token,
|
||||
expires: Date.now() + tokenPayload.expires_in * 1000,
|
||||
resourceUrl: tokenPayload.resource_url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loginQwenPortalOAuth(params: {
|
||||
openUrl: (url: string) => Promise<void>;
|
||||
note: (message: string, title?: string) => Promise<void>;
|
||||
progress: { update: (message: string) => void; stop: (message?: string) => void };
|
||||
}): Promise<QwenOAuthToken> {
|
||||
const { verifier, challenge } = generatePkceVerifierChallenge();
|
||||
const device = await requestDeviceCode({ challenge });
|
||||
const verificationUrl = device.verification_uri_complete || device.verification_uri;
|
||||
|
||||
await params.note(
|
||||
[
|
||||
`Open ${verificationUrl} to approve access.`,
|
||||
`If prompted, enter the code ${device.user_code}.`,
|
||||
].join("\n"),
|
||||
"Qwen OAuth",
|
||||
);
|
||||
|
||||
try {
|
||||
await params.openUrl(verificationUrl);
|
||||
} catch {
|
||||
// Fall back to manual copy/paste if browser open fails.
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
let pollIntervalMs = device.interval ? device.interval * 1000 : 2000;
|
||||
const timeoutMs = device.expires_in * 1000;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
params.progress.update("Waiting for Qwen OAuth approval…");
|
||||
const result = await pollDeviceToken({
|
||||
deviceCode: device.device_code,
|
||||
verifier,
|
||||
});
|
||||
|
||||
if (result.status === "success") {
|
||||
return result.token;
|
||||
}
|
||||
|
||||
if (result.status === "error") {
|
||||
throw new Error(`Qwen OAuth failed: ${result.message}`);
|
||||
}
|
||||
|
||||
if (result.status === "pending" && result.slowDown) {
|
||||
pollIntervalMs = Math.min(pollIntervalMs * 1.5, 10000);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
||||
}
|
||||
|
||||
throw new Error("Qwen OAuth timed out waiting for authorization.");
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"id": "qwen-portal-auth",
|
||||
"providers": ["qwen-portal"],
|
||||
"providerAuthEnvVars": {
|
||||
"qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "qwen-portal",
|
||||
"method": "device",
|
||||
"choiceId": "qwen-portal",
|
||||
"choiceLabel": "Qwen OAuth",
|
||||
"choiceHint": "Device code login",
|
||||
"groupId": "qwen",
|
||||
"groupLabel": "Qwen",
|
||||
"groupHint": "OAuth"
|
||||
}
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
import type {
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-models";
|
||||
|
||||
export const QWEN_PORTAL_BASE_URL = "https://portal.qwen.ai/v1";
|
||||
const QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW = 128000;
|
||||
const QWEN_PORTAL_DEFAULT_MAX_TOKENS = 8192;
|
||||
const QWEN_PORTAL_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
function buildModelDefinition(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
input: ModelDefinitionConfig["input"];
|
||||
}): ModelDefinitionConfig {
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
reasoning: false,
|
||||
input: params.input,
|
||||
cost: QWEN_PORTAL_DEFAULT_COST,
|
||||
contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQwenPortalProvider(): ModelProviderConfig {
|
||||
return {
|
||||
baseUrl: QWEN_PORTAL_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
buildModelDefinition({
|
||||
id: "coder-model",
|
||||
name: "Qwen Coder",
|
||||
input: ["text"],
|
||||
}),
|
||||
buildModelDefinition({
|
||||
id: "vision-model",
|
||||
name: "Qwen Vision",
|
||||
input: ["text", "image"],
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { withFetchPreconnect } from "../../test/helpers/extensions/fetch-mock.js";
|
||||
import { refreshQwenPortalCredentials } from "./refresh.js";
|
||||
|
||||
function expiredCredentials() {
|
||||
return {
|
||||
type: "oauth" as const,
|
||||
provider: "qwen-portal",
|
||||
access: "expired-access",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() - 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
describe("refreshQwenPortalCredentials", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const runRefresh = async () => await refreshQwenPortalCredentials(expiredCredentials());
|
||||
|
||||
it("refreshes oauth credentials and preserves existing refresh token when absent", async () => {
|
||||
globalThis.fetch = withFetchPreconnect(
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "new-access",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runRefresh();
|
||||
|
||||
expect(result.access).toBe("new-access");
|
||||
expect(result.refresh).toBe("refresh-token");
|
||||
expect(result.expires).toBeGreaterThan(Date.now());
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://chat.qwen.ai/api/v1/oauth2/token",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.any(URLSearchParams),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces the refresh token when the server rotates it", async () => {
|
||||
globalThis.fetch = withFetchPreconnect(
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "new-access",
|
||||
refresh_token: "rotated-refresh",
|
||||
expires_in: 1200,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await runRefresh();
|
||||
|
||||
expect(result.refresh).toBe("rotated-refresh");
|
||||
});
|
||||
|
||||
it("rejects invalid expires_in payloads", async () => {
|
||||
globalThis.fetch = withFetchPreconnect(
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "new-access",
|
||||
expires_in: 0,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(runRefresh()).rejects.toThrow(
|
||||
"Qwen OAuth refresh response missing or invalid expires_in",
|
||||
);
|
||||
});
|
||||
|
||||
it("turns 400 responses into a re-authenticate hint", async () => {
|
||||
globalThis.fetch = withFetchPreconnect(
|
||||
vi.fn(async () => new Response("bad refresh", { status: 400 })),
|
||||
);
|
||||
|
||||
await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh token expired or invalid");
|
||||
});
|
||||
|
||||
it("requires a refresh token", async () => {
|
||||
await expect(
|
||||
refreshQwenPortalCredentials({
|
||||
type: "oauth",
|
||||
provider: "qwen-portal",
|
||||
access: "expired-access",
|
||||
refresh: "",
|
||||
expires: Date.now() - 60_000,
|
||||
}),
|
||||
).rejects.toThrow("Qwen OAuth refresh token missing");
|
||||
});
|
||||
|
||||
it("rejects missing access tokens", async () => {
|
||||
globalThis.fetch = withFetchPreconnect(
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh response missing access token");
|
||||
});
|
||||
|
||||
it("surfaces non-400 refresh failures", async () => {
|
||||
globalThis.fetch = withFetchPreconnect(
|
||||
vi.fn(async () => new Response("gateway down", { status: 502 })),
|
||||
);
|
||||
|
||||
await expect(runRefresh()).rejects.toThrow("Qwen OAuth refresh failed: gateway down");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
||||
import { formatCliCommand } from "openclaw/plugin-sdk/setup-tools";
|
||||
|
||||
const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai";
|
||||
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`;
|
||||
const QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56";
|
||||
|
||||
export async function refreshQwenPortalCredentials(
|
||||
credentials: OAuthCredentials,
|
||||
): Promise<OAuthCredentials> {
|
||||
const refreshToken = credentials.refresh?.trim();
|
||||
if (!refreshToken) {
|
||||
throw new Error("Qwen OAuth refresh token missing; re-authenticate.");
|
||||
}
|
||||
|
||||
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: QWEN_OAUTH_CLIENT_ID,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 400) {
|
||||
throw new Error(
|
||||
`Qwen OAuth refresh token expired or invalid. Re-authenticate with \`${formatCliCommand("openclaw models auth login --provider qwen-portal")}\`.`,
|
||||
);
|
||||
}
|
||||
throw new Error(`Qwen OAuth refresh failed: ${text || response.statusText}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
const accessToken = payload.access_token?.trim();
|
||||
const newRefreshToken = payload.refresh_token?.trim();
|
||||
const expiresIn = payload.expires_in;
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error("Qwen OAuth refresh response missing access token.");
|
||||
}
|
||||
if (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
||||
throw new Error("Qwen OAuth refresh response missing or invalid expires_in.");
|
||||
}
|
||||
|
||||
return {
|
||||
...credentials,
|
||||
// RFC 6749 section 6: new refresh token is optional; if present, replace old.
|
||||
refresh: newRefreshToken || refreshToken,
|
||||
access: accessToken,
|
||||
expires: Date.now() + expiresIn * 1000,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
export { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth";
|
||||
export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
export type { ProviderAuthContext, ProviderCatalogContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
export { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/provider-auth";
|
||||
export { QWEN_OAUTH_MARKER } from "openclaw/plugin-sdk/agent-runtime";
|
||||
export { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth";
|
||||
export { refreshQwenPortalCredentials } from "./refresh.js";
|
||||
|
|
@ -34,8 +34,6 @@ const allowedRawFetchCallsites = new Set([
|
|||
"extensions/nextcloud-talk/src/room-info.ts:92",
|
||||
"extensions/nextcloud-talk/src/send.ts:107",
|
||||
"extensions/nextcloud-talk/src/send.ts:198",
|
||||
"extensions/qwen-portal-auth/oauth.ts:46",
|
||||
"extensions/qwen-portal-auth/oauth.ts:80",
|
||||
"extensions/talk-voice/index.ts:27",
|
||||
"extensions/thread-ownership/index.ts:105",
|
||||
"extensions/voice-call/src/providers/plivo.ts:95",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
OPENCLAW_DOCKER_LIVE_AUTH_ALL=(.claude .codex .minimax .qwen)
|
||||
OPENCLAW_DOCKER_LIVE_AUTH_ALL=(.claude .codex .minimax)
|
||||
|
||||
openclaw_live_trim() {
|
||||
local value="${1:-}"
|
||||
|
|
@ -30,9 +30,6 @@ openclaw_live_should_include_auth_dir_for_provider() {
|
|||
minimax | minimax-portal)
|
||||
printf '%s\n' ".minimax"
|
||||
;;
|
||||
qwen | qwen-portal-auth)
|
||||
printf '%s\n' ".qwen"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { formatAuthDoctorHint } from "./auth-profiles/doctor.js";
|
||||
import type { AuthProfileStore } from "./auth-profiles/types.js";
|
||||
|
||||
const EMPTY_STORE: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {},
|
||||
};
|
||||
|
||||
describe("formatAuthDoctorHint", () => {
|
||||
it("guides removed qwen portal users to model studio onboarding", async () => {
|
||||
const hint = await formatAuthDoctorHint({
|
||||
store: EMPTY_STORE,
|
||||
provider: "qwen-portal",
|
||||
});
|
||||
|
||||
expect(hint).toContain("openclaw onboard --auth-choice modelstudio-api-key");
|
||||
expect(hint).toContain("modelstudio-api-key-cn");
|
||||
expect(hint).not.toContain("--provider modelstudio");
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,6 @@ import type { AuthProfileStore, OAuthCredential } from "./auth-profiles/types.js
|
|||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readCodexCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null),
|
||||
readQwenCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null),
|
||||
readMiniMaxCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null),
|
||||
}));
|
||||
|
||||
|
|
@ -11,7 +10,6 @@ let syncExternalCliCredentials: typeof import("./auth-profiles/external-cli-sync
|
|||
let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldReplaceStoredOAuthCredential;
|
||||
let CODEX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").CODEX_CLI_PROFILE_ID;
|
||||
let OPENAI_CODEX_DEFAULT_PROFILE_ID: typeof import("./auth-profiles/constants.js").OPENAI_CODEX_DEFAULT_PROFILE_ID;
|
||||
let QWEN_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").QWEN_CLI_PROFILE_ID;
|
||||
let MINIMAX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").MINIMAX_CLI_PROFILE_ID;
|
||||
|
||||
function makeOAuthCredential(
|
||||
|
|
@ -46,12 +44,6 @@ function getProviderCases() {
|
|||
readMock: mocks.readCodexCliCredentialsCached,
|
||||
legacyProfileId: CODEX_CLI_PROFILE_ID,
|
||||
},
|
||||
{
|
||||
label: "Qwen",
|
||||
profileId: QWEN_CLI_PROFILE_ID,
|
||||
provider: "qwen-portal" as const,
|
||||
readMock: mocks.readQwenCliCredentialsCached,
|
||||
},
|
||||
{
|
||||
label: "MiniMax",
|
||||
profileId: MINIMAX_CLI_PROFILE_ID,
|
||||
|
|
@ -65,21 +57,15 @@ describe("syncExternalCliCredentials", () => {
|
|||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null);
|
||||
mocks.readQwenCliCredentialsCached.mockReset().mockReturnValue(null);
|
||||
mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null);
|
||||
vi.doMock("./cli-credentials.js", () => ({
|
||||
readCodexCliCredentialsCached: mocks.readCodexCliCredentialsCached,
|
||||
readQwenCliCredentialsCached: mocks.readQwenCliCredentialsCached,
|
||||
readMiniMaxCliCredentialsCached: mocks.readMiniMaxCliCredentialsCached,
|
||||
}));
|
||||
({ syncExternalCliCredentials, shouldReplaceStoredOAuthCredential } =
|
||||
await import("./auth-profiles/external-cli-sync.js"));
|
||||
({
|
||||
CODEX_CLI_PROFILE_ID,
|
||||
OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
QWEN_CLI_PROFILE_ID,
|
||||
MINIMAX_CLI_PROFILE_ID,
|
||||
} = await import("./auth-profiles/constants.js"));
|
||||
({ CODEX_CLI_PROFILE_ID, OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } =
|
||||
await import("./auth-profiles/constants.js"));
|
||||
});
|
||||
|
||||
describe("shouldReplaceStoredOAuthCredential", () => {
|
||||
|
|
@ -122,7 +108,7 @@ describe("syncExternalCliCredentials", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each([{ providerLabel: "Codex" }, { providerLabel: "Qwen" }, { providerLabel: "MiniMax" }])(
|
||||
it.each([{ providerLabel: "Codex" }, { providerLabel: "MiniMax" }])(
|
||||
"syncs $providerLabel CLI credentials into the target auth profile",
|
||||
({ providerLabel }) => {
|
||||
const providerCase = getProviderCases().find((entry) => entry.label === providerLabel);
|
||||
|
|
@ -195,7 +181,7 @@ describe("syncExternalCliCredentials", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each([{ providerLabel: "Codex" }, { providerLabel: "Qwen" }, { providerLabel: "MiniMax" }])(
|
||||
it.each([{ providerLabel: "Codex" }, { providerLabel: "MiniMax" }])(
|
||||
"does not overwrite newer stored $providerLabel credentials",
|
||||
({ providerLabel }) => {
|
||||
const providerCase = getProviderCases().find((entry) => entry.label === providerLabel);
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import type { AuthProfileStore } from "./auth-profiles/types.js";
|
|||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
syncExternalCliCredentials: vi.fn((store: AuthProfileStore) => {
|
||||
store.profiles["qwen-portal:default"] = {
|
||||
store.profiles["minimax-portal:default"] = {
|
||||
type: "oauth",
|
||||
provider: "qwen-portal",
|
||||
provider: "minimax-portal",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
|
|
@ -61,13 +61,13 @@ describe("auth profiles read-only external CLI sync", () => {
|
|||
expect.any(Object),
|
||||
expect.objectContaining({ log: false }),
|
||||
);
|
||||
expect(loaded.profiles["qwen-portal:default"]).toMatchObject({
|
||||
expect(loaded.profiles["minimax-portal:default"]).toMatchObject({
|
||||
type: "oauth",
|
||||
provider: "qwen-portal",
|
||||
provider: "minimax-portal",
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(authPath, "utf8")) as AuthProfileStore;
|
||||
expect(persisted.profiles["qwen-portal:default"]).toBeUndefined();
|
||||
expect(persisted.profiles["minimax-portal:default"]).toBeUndefined();
|
||||
expect(persisted.profiles["openai:default"]).toMatchObject({
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ export const LEGACY_AUTH_FILENAME = "auth.json";
|
|||
export const CLAUDE_CLI_PROFILE_ID = "anthropic:claude-cli";
|
||||
export const CODEX_CLI_PROFILE_ID = "openai-codex:codex-cli";
|
||||
export const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
|
||||
export const QWEN_CLI_PROFILE_ID = "qwen-portal:qwen-cli";
|
||||
export const MINIMAX_CLI_PROFILE_ID = "minimax-portal:minimax-cli";
|
||||
|
||||
export const AUTH_STORE_LOCK_OPTIONS = {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,15 @@ import { buildProviderAuthDoctorHintWithPlugin } from "../../plugins/provider-ru
|
|||
import { normalizeProviderId } from "../model-selection.js";
|
||||
import type { AuthProfileStore } from "./types.js";
|
||||
|
||||
/**
|
||||
* Migration hints for deprecated/removed OAuth providers.
|
||||
* Users with stale credentials should be guided to migrate.
|
||||
*/
|
||||
const DEPRECATED_PROVIDER_MIGRATION_HINTS: Record<string, string> = {
|
||||
"qwen-portal":
|
||||
"Qwen OAuth via portal.qwen.ai has been deprecated. Please migrate to Model Studio (Alibaba Cloud Coding Plan). Run: openclaw onboard --auth-choice modelstudio-api-key (or modelstudio-api-key-cn for the China endpoint).",
|
||||
};
|
||||
|
||||
export async function formatAuthDoctorHint(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
store: AuthProfileStore;
|
||||
|
|
@ -10,6 +19,13 @@ export async function formatAuthDoctorHint(params: {
|
|||
profileId?: string;
|
||||
}): Promise<string> {
|
||||
const normalizedProvider = normalizeProviderId(params.provider);
|
||||
|
||||
// Check for deprecated provider migration hints first
|
||||
const migrationHint = DEPRECATED_PROVIDER_MIGRATION_HINTS[normalizedProvider];
|
||||
if (migrationHint) {
|
||||
return migrationHint;
|
||||
}
|
||||
|
||||
const pluginHint = await buildProviderAuthDoctorHintWithPlugin({
|
||||
provider: normalizedProvider,
|
||||
context: {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import {
|
||||
readCodexCliCredentialsCached,
|
||||
readQwenCliCredentialsCached,
|
||||
readMiniMaxCliCredentialsCached,
|
||||
} from "../cli-credentials.js";
|
||||
import {
|
||||
EXTERNAL_CLI_SYNC_TTL_MS,
|
||||
OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
QWEN_CLI_PROFILE_ID,
|
||||
MINIMAX_CLI_PROFILE_ID,
|
||||
log,
|
||||
} from "./constants.js";
|
||||
|
|
@ -70,11 +68,6 @@ export function shouldReplaceStoredOAuthCredential(
|
|||
}
|
||||
|
||||
const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [
|
||||
{
|
||||
profileId: QWEN_CLI_PROFILE_ID,
|
||||
provider: "qwen-portal",
|
||||
readCredentials: () => readQwenCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
|
||||
},
|
||||
{
|
||||
profileId: MINIMAX_CLI_PROFILE_ID,
|
||||
provider: "minimax-portal",
|
||||
|
|
@ -127,7 +120,7 @@ function syncExternalCliCredentialsForProvider(
|
|||
}
|
||||
|
||||
/**
|
||||
* Sync OAuth credentials from external CLI tools (Qwen Code CLI, MiniMax CLI, Codex CLI)
|
||||
* Sync OAuth credentials from external CLI tools (MiniMax CLI, Codex CLI)
|
||||
* into the store.
|
||||
*
|
||||
* Returns true if any credentials were updated.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
|||
|
||||
vi.mock("../cli-credentials.js", () => ({
|
||||
readCodexCliCredentialsCached: () => null,
|
||||
readQwenCliCredentialsCached: () => null,
|
||||
readMiniMaxCliCredentialsCached: () => null,
|
||||
resetCliCredentialCachesForTest: () => undefined,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ const {
|
|||
|
||||
vi.mock("../cli-credentials.js", () => ({
|
||||
readCodexCliCredentialsCached: () => null,
|
||||
readQwenCliCredentialsCached: () => null,
|
||||
readMiniMaxCliCredentialsCached: () => null,
|
||||
resetCliCredentialCachesForTest: () => undefined,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import type { AuthProfileStore } from "./types.js";
|
|||
|
||||
vi.mock("../cli-credentials.js", () => ({
|
||||
readCodexCliCredentialsCached: () => null,
|
||||
readQwenCliCredentialsCached: () => null,
|
||||
readMiniMaxCliCredentialsCached: () => null,
|
||||
resetCliCredentialCachesForTest: () => undefined,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ const execFileSyncMock = vi.fn();
|
|||
const CLI_CREDENTIALS_CACHE_TTL_MS = 15 * 60 * 1000;
|
||||
let readClaudeCliCredentialsCached: typeof import("./cli-credentials.js").readClaudeCliCredentialsCached;
|
||||
let readCodexCliCredentialsCached: typeof import("./cli-credentials.js").readCodexCliCredentialsCached;
|
||||
let readQwenCliCredentialsCached: typeof import("./cli-credentials.js").readQwenCliCredentialsCached;
|
||||
let resetCliCredentialCachesForTest: typeof import("./cli-credentials.js").resetCliCredentialCachesForTest;
|
||||
let writeClaudeCliKeychainCredentials: typeof import("./cli-credentials.js").writeClaudeCliKeychainCredentials;
|
||||
let writeClaudeCliCredentials: typeof import("./cli-credentials.js").writeClaudeCliCredentials;
|
||||
|
|
@ -54,28 +53,11 @@ function createJwtWithExp(expSeconds: number): string {
|
|||
return `${encode({ alg: "RS256", typ: "JWT" })}.${encode({ exp: expSeconds })}.signature`;
|
||||
}
|
||||
|
||||
function writePortalCliCredentialFile(
|
||||
filePath: string,
|
||||
options: { access: string; refresh: string; expires: number },
|
||||
) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify({
|
||||
access_token: options.access,
|
||||
refresh_token: options.refresh,
|
||||
expiry_date: options.expires,
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
describe("cli credentials", () => {
|
||||
beforeAll(async () => {
|
||||
({
|
||||
readClaudeCliCredentialsCached,
|
||||
readCodexCliCredentialsCached,
|
||||
readQwenCliCredentialsCached,
|
||||
resetCliCredentialCachesForTest,
|
||||
writeClaudeCliKeychainCredentials,
|
||||
writeClaudeCliCredentials,
|
||||
|
|
@ -372,50 +354,4 @@ describe("cli credentials", () => {
|
|||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("invalidates cached Qwen credentials when oauth_creds.json changes within the TTL window", () => {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qwen-cache-"));
|
||||
const credPath = path.join(tempHome, ".qwen", "oauth_creds.json");
|
||||
try {
|
||||
writePortalCliCredentialFile(credPath, {
|
||||
access: "stale-access",
|
||||
refresh: "stale-refresh",
|
||||
expires: 1_000,
|
||||
});
|
||||
fs.utimesSync(credPath, new Date("2026-03-24T10:00:00Z"), new Date("2026-03-24T10:00:00Z"));
|
||||
vi.setSystemTime(new Date("2026-03-24T10:00:00Z"));
|
||||
|
||||
const first = readQwenCliCredentialsCached({
|
||||
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
|
||||
homeDir: tempHome,
|
||||
});
|
||||
|
||||
expect(first).toMatchObject({
|
||||
access: "stale-access",
|
||||
refresh: "stale-refresh",
|
||||
expires: 1_000,
|
||||
});
|
||||
|
||||
writePortalCliCredentialFile(credPath, {
|
||||
access: "fresh-access",
|
||||
refresh: "fresh-refresh",
|
||||
expires: 2_000,
|
||||
});
|
||||
fs.utimesSync(credPath, new Date("2026-03-24T10:05:00Z"), new Date("2026-03-24T10:05:00Z"));
|
||||
vi.advanceTimersByTime(60_000);
|
||||
|
||||
const second = readQwenCliCredentialsCached({
|
||||
ttlMs: CLI_CREDENTIALS_CACHE_TTL_MS,
|
||||
homeDir: tempHome,
|
||||
});
|
||||
|
||||
expect(second).toMatchObject({
|
||||
access: "fresh-access",
|
||||
refresh: "fresh-refresh",
|
||||
expires: 2_000,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ const log = createSubsystemLogger("agents/auth-profiles");
|
|||
|
||||
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
|
||||
const CODEX_CLI_AUTH_FILENAME = "auth.json";
|
||||
const QWEN_CLI_CREDENTIALS_RELATIVE_PATH = ".qwen/oauth_creds.json";
|
||||
const MINIMAX_CLI_CREDENTIALS_RELATIVE_PATH = ".minimax/oauth_creds.json";
|
||||
|
||||
const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials";
|
||||
|
|
@ -26,13 +25,11 @@ type CachedValue<T> = {
|
|||
|
||||
let claudeCliCache: CachedValue<ClaudeCliCredential> | null = null;
|
||||
let codexCliCache: CachedValue<CodexCliCredential> | null = null;
|
||||
let qwenCliCache: CachedValue<QwenCliCredential> | null = null;
|
||||
let minimaxCliCache: CachedValue<MiniMaxCliCredential> | null = null;
|
||||
|
||||
export function resetCliCredentialCachesForTest(): void {
|
||||
claudeCliCache = null;
|
||||
codexCliCache = null;
|
||||
qwenCliCache = null;
|
||||
minimaxCliCache = null;
|
||||
}
|
||||
|
||||
|
|
@ -60,14 +57,6 @@ export type CodexCliCredential = {
|
|||
accountId?: string;
|
||||
};
|
||||
|
||||
export type QwenCliCredential = {
|
||||
type: "oauth";
|
||||
provider: "qwen-portal";
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
};
|
||||
|
||||
export type MiniMaxCliCredential = {
|
||||
type: "oauth";
|
||||
provider: "minimax-portal";
|
||||
|
|
@ -139,11 +128,6 @@ function resolveCodexHomePath() {
|
|||
}
|
||||
}
|
||||
|
||||
function resolveQwenCliCredentialsPath(homeDir?: string) {
|
||||
const baseDir = homeDir ?? resolveUserPath("~");
|
||||
return path.join(baseDir, QWEN_CLI_CREDENTIALS_RELATIVE_PATH);
|
||||
}
|
||||
|
||||
function resolveMiniMaxCliCredentialsPath(homeDir?: string) {
|
||||
const baseDir = homeDir ?? resolveUserPath("~");
|
||||
return path.join(baseDir, MINIMAX_CLI_CREDENTIALS_RELATIVE_PATH);
|
||||
|
|
@ -281,11 +265,6 @@ function readCodexKeychainCredentials(options?: {
|
|||
}
|
||||
}
|
||||
|
||||
function readQwenCliCredentials(options?: { homeDir?: string }): QwenCliCredential | null {
|
||||
const credPath = resolveQwenCliCredentialsPath(options?.homeDir);
|
||||
return readPortalCliOauthCredentials(credPath, "qwen-portal");
|
||||
}
|
||||
|
||||
function readPortalCliOauthCredentials<TProvider extends string>(
|
||||
credPath: string,
|
||||
provider: TProvider,
|
||||
|
|
@ -583,23 +562,6 @@ export function readCodexCliCredentialsCached(options?: {
|
|||
});
|
||||
}
|
||||
|
||||
export function readQwenCliCredentialsCached(options?: {
|
||||
ttlMs?: number;
|
||||
homeDir?: string;
|
||||
}): QwenCliCredential | null {
|
||||
const credPath = resolveQwenCliCredentialsPath(options?.homeDir);
|
||||
return readCachedCliCredential({
|
||||
ttlMs: options?.ttlMs ?? 0,
|
||||
cache: qwenCliCache,
|
||||
cacheKey: credPath,
|
||||
read: () => readQwenCliCredentials({ homeDir: options?.homeDir }),
|
||||
setCache: (next) => {
|
||||
qwenCliCache = next;
|
||||
},
|
||||
readSourceFingerprint: () => readFileMtimeMs(credPath),
|
||||
});
|
||||
}
|
||||
|
||||
export function readMiniMaxCliCredentialsCached(options?: {
|
||||
ttlMs?: number;
|
||||
homeDir?: string;
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@ describe("lookupContextTokens", () => {
|
|||
});
|
||||
|
||||
it("resolveContextTokensForModel prefers exact provider key over alias-normalized match", async () => {
|
||||
// When both "qwen" and "qwen-portal" exist as config keys (alias pattern),
|
||||
// When both "bedrock" and "amazon-bedrock" exist as config keys (alias pattern),
|
||||
// resolveConfiguredProviderContextWindow must return the exact-key match first,
|
||||
// not the first normalized hit — mirroring pi-embedded-runner/model.ts behaviour.
|
||||
mockDiscoveryDeps([]);
|
||||
|
|
@ -286,8 +286,8 @@ describe("lookupContextTokens", () => {
|
|||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
"qwen-portal": { models: [{ id: "qwen-max", contextWindow: 32_000 }] },
|
||||
qwen: { models: [{ id: "qwen-max", contextWindow: 128_000 }] },
|
||||
"amazon-bedrock": { models: [{ id: "claude-alias-test", contextWindow: 32_000 }] },
|
||||
bedrock: { models: [{ id: "claude-alias-test", contextWindow: 128_000 }] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -295,21 +295,21 @@ describe("lookupContextTokens", () => {
|
|||
const { resolveContextTokensForModel } = await import("./context.js");
|
||||
await flushAsyncWarmup();
|
||||
|
||||
// Exact key "qwen" wins over the alias-normalized match "qwen-portal".
|
||||
const qwenResult = resolveContextTokensForModel({
|
||||
// Exact key "bedrock" wins over the alias-normalized match "amazon-bedrock".
|
||||
const bedrockResult = resolveContextTokensForModel({
|
||||
cfg: cfg as never,
|
||||
provider: "qwen",
|
||||
model: "qwen-max",
|
||||
provider: "bedrock",
|
||||
model: "claude-alias-test",
|
||||
});
|
||||
expect(qwenResult).toBe(128_000);
|
||||
expect(bedrockResult).toBe(128_000);
|
||||
|
||||
// Exact key "qwen-portal" wins (no alias lookup needed).
|
||||
const portalResult = resolveContextTokensForModel({
|
||||
// Exact key "amazon-bedrock" wins (no alias lookup needed).
|
||||
const canonicalResult = resolveContextTokensForModel({
|
||||
cfg: cfg as never,
|
||||
provider: "qwen-portal",
|
||||
model: "qwen-max",
|
||||
provider: "amazon-bedrock",
|
||||
model: "claude-alias-test",
|
||||
});
|
||||
expect(portalResult).toBe(32_000);
|
||||
expect(canonicalResult).toBe(32_000);
|
||||
});
|
||||
|
||||
it("resolveContextTokensForModel(model-only) does not apply config scan for inferred provider", async () => {
|
||||
|
|
|
|||
|
|
@ -324,9 +324,8 @@ function resolveConfiguredProviderContextWindow(
|
|||
}
|
||||
|
||||
// Mirror the lookup order in pi-embedded-runner/model.ts: exact key first,
|
||||
// then normalized fallback. This prevents alias collisions (e.g. when both
|
||||
// "qwen" and "qwen-portal" exist as config keys) from picking the wrong
|
||||
// contextWindow based on Object.entries iteration order.
|
||||
// then normalized fallback. This prevents alias collisions from picking the
|
||||
// wrong contextWindow based on Object.entries iteration order.
|
||||
function findContextWindow(matchProviderId: (id: string) => boolean): number | undefined {
|
||||
for (const [providerId, providerConfig] of Object.entries(providers!)) {
|
||||
if (!matchProviderId(providerId)) {
|
||||
|
|
@ -355,7 +354,7 @@ function resolveConfiguredProviderContextWindow(
|
|||
return exactResult;
|
||||
}
|
||||
|
||||
// 2. Normalized fallback: covers alias keys such as "qwen" → "qwen-portal".
|
||||
// 2. Normalized fallback: covers alias keys such as "z.ai" → "zai".
|
||||
const normalizedProvider = normalizeProviderId(provider);
|
||||
return findContextWindow((id) => normalizeProviderId(id) === normalizedProvider);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,12 +11,15 @@ import {
|
|||
describe("model auth markers", () => {
|
||||
it("recognizes explicit non-secret markers", () => {
|
||||
expect(isNonSecretApiKeyMarker(NON_ENV_SECRETREF_MARKER)).toBe(true);
|
||||
expect(isNonSecretApiKeyMarker("qwen-oauth")).toBe(true);
|
||||
expect(isNonSecretApiKeyMarker(resolveOAuthApiKeyMarker("chutes"))).toBe(true);
|
||||
expect(isNonSecretApiKeyMarker("ollama-local")).toBe(true);
|
||||
expect(isNonSecretApiKeyMarker(GCP_VERTEX_CREDENTIALS_MARKER)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat removed provider markers as active auth markers", () => {
|
||||
expect(isNonSecretApiKeyMarker("qwen-oauth")).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes known env marker names but not arbitrary all-caps keys", () => {
|
||||
expect(isNonSecretApiKeyMarker("OPENAI_API_KEY")).toBe(true);
|
||||
expect(isNonSecretApiKeyMarker("ALLCAPS_EXAMPLE")).toBe(false);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js";
|
|||
|
||||
export const MINIMAX_OAUTH_MARKER = "minimax-oauth";
|
||||
export const OAUTH_API_KEY_MARKER_PREFIX = "oauth:";
|
||||
export const QWEN_OAUTH_MARKER = "qwen-oauth";
|
||||
export const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local";
|
||||
export const CUSTOM_LOCAL_AUTH_MARKER = "custom-local";
|
||||
export const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
|
||||
|
|
@ -80,7 +79,6 @@ export function isNonSecretApiKeyMarker(
|
|||
}
|
||||
const isKnownMarker =
|
||||
trimmed === MINIMAX_OAUTH_MARKER ||
|
||||
trimmed === QWEN_OAUTH_MARKER ||
|
||||
isOAuthApiKeyMarker(trimmed) ||
|
||||
trimmed === OLLAMA_LOCAL_AUTH_MARKER ||
|
||||
trimmed === CUSTOM_LOCAL_AUTH_MARKER ||
|
||||
|
|
|
|||
|
|
@ -466,20 +466,6 @@ describe("getApiKeyForModel", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("resolveEnvApiKey('qwen-portal') accepts QWEN_OAUTH_TOKEN", async () => {
|
||||
await withEnvAsync(
|
||||
{
|
||||
QWEN_OAUTH_TOKEN: "qwen-oauth-token",
|
||||
QWEN_PORTAL_API_KEY: undefined,
|
||||
},
|
||||
async () => {
|
||||
const resolved = resolveEnvApiKey("qwen");
|
||||
expect(resolved?.apiKey).toBe("qwen-oauth-token");
|
||||
expect(resolved?.source).toContain("QWEN_OAUTH_TOKEN");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resolveEnvApiKey('minimax-portal') accepts MINIMAX_OAUTH_TOKEN", async () => {
|
||||
await withEnvAsync(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ describe("model-selection", () => {
|
|||
expect(normalizeProviderId("Z.ai")).toBe("zai");
|
||||
expect(normalizeProviderId("z-ai")).toBe("zai");
|
||||
expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode");
|
||||
expect(normalizeProviderId("qwen")).toBe("qwen-portal");
|
||||
expect(normalizeProviderId("qwen")).toBe("qwen");
|
||||
expect(normalizeProviderId("kimi-code")).toBe("kimi");
|
||||
expect(normalizeProviderId("kimi-coding")).toBe("kimi");
|
||||
expect(normalizeProviderId("bedrock")).toBe("amazon-bedrock");
|
||||
|
|
|
|||
|
|
@ -102,8 +102,6 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [
|
|||
"PI_CODING_AGENT_DIR",
|
||||
"QIANFAN_API_KEY",
|
||||
"MODELSTUDIO_API_KEY",
|
||||
"QWEN_OAUTH_TOKEN",
|
||||
"QWEN_PORTAL_API_KEY",
|
||||
"SYNTHETIC_API_KEY",
|
||||
"TOGETHER_API_KEY",
|
||||
"VOLCANO_ENGINE_API_KEY",
|
||||
|
|
|
|||
|
|
@ -4,11 +4,7 @@ import { tmpdir } from "node:os";
|
|||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import {
|
||||
MINIMAX_OAUTH_MARKER,
|
||||
NON_ENV_SECRETREF_MARKER,
|
||||
QWEN_OAUTH_MARKER,
|
||||
} from "./model-auth-markers.js";
|
||||
import { MINIMAX_OAUTH_MARKER, NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
|
||||
describe("models-config provider auth provenance", () => {
|
||||
|
|
@ -84,7 +80,7 @@ describe("models-config provider auth provenance", () => {
|
|||
expect(providers?.together?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
||||
});
|
||||
|
||||
it("keeps oauth compatibility markers for minimax-portal and qwen-portal", async () => {
|
||||
it("keeps oauth compatibility markers for minimax-portal", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
await writeFile(
|
||||
join(agentDir, "auth-profiles.json"),
|
||||
|
|
@ -99,13 +95,6 @@ describe("models-config provider auth provenance", () => {
|
|||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
"qwen-portal:default": {
|
||||
type: "oauth",
|
||||
provider: "qwen-portal",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
|
|
@ -116,6 +105,5 @@ describe("models-config provider auth provenance", () => {
|
|||
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir, env: {} });
|
||||
expect(providers?.["minimax-portal"]?.apiKey).toBe(MINIMAX_OAUTH_MARKER);
|
||||
expect(providers?.["qwen-portal"]?.apiKey).toBe(QWEN_OAUTH_MARKER);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ export {
|
|||
QIANFAN_BASE_URL,
|
||||
QIANFAN_DEFAULT_MODEL_ID,
|
||||
buildQianfanProvider,
|
||||
buildQwenPortalProvider,
|
||||
buildSyntheticProvider,
|
||||
buildTogetherProvider,
|
||||
buildDoubaoCodingProvider,
|
||||
|
|
|
|||
|
|
@ -679,12 +679,12 @@ describe("resolveModel", () => {
|
|||
|
||||
it("prefers exact provider config over normalized alias match when both keys exist", () => {
|
||||
mockDiscoveredModel({
|
||||
provider: "qwen",
|
||||
modelId: "qwen3-coder-plus",
|
||||
provider: "bedrock",
|
||||
modelId: "bedrock-alias-exact-test",
|
||||
templateModel: {
|
||||
id: "qwen3-coder-plus",
|
||||
name: "Qwen3 Coder Plus",
|
||||
provider: "qwen",
|
||||
id: "bedrock-alias-exact-test",
|
||||
name: "Bedrock alias test",
|
||||
provider: "bedrock",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://default-provider.example.com/v1",
|
||||
reasoning: false,
|
||||
|
|
@ -698,19 +698,19 @@ describe("resolveModel", () => {
|
|||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
"qwen-portal": {
|
||||
baseUrl: "https://canonical-provider.example.com/v1",
|
||||
"amazon-bedrock": {
|
||||
baseUrl: "https://canonical-bedrock.example.com/v1",
|
||||
api: "openai-completions",
|
||||
headers: { "X-Provider": "canonical" },
|
||||
models: [{ ...makeModel("qwen3-coder-plus"), reasoning: false }],
|
||||
models: [{ ...makeModel("bedrock-alias-exact-test"), reasoning: false }],
|
||||
},
|
||||
qwen: {
|
||||
baseUrl: "https://alias-provider.example.com/v1",
|
||||
bedrock: {
|
||||
baseUrl: "https://alias-bedrock.example.com/v1",
|
||||
api: "anthropic-messages",
|
||||
headers: { "X-Provider": "alias" },
|
||||
models: [
|
||||
{
|
||||
...makeModel("qwen3-coder-plus"),
|
||||
...makeModel("bedrock-alias-exact-test"),
|
||||
api: "anthropic-messages",
|
||||
reasoning: true,
|
||||
contextWindow: 262144,
|
||||
|
|
@ -722,14 +722,14 @@ describe("resolveModel", () => {
|
|||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveModelForTest("qwen", "qwen3-coder-plus", "/tmp/agent", cfg);
|
||||
const result = resolveModelForTest("bedrock", "bedrock-alias-exact-test", "/tmp/agent", cfg);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
provider: "qwen",
|
||||
id: "qwen3-coder-plus",
|
||||
provider: "bedrock",
|
||||
id: "bedrock-alias-exact-test",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://alias-provider.example.com",
|
||||
baseUrl: "https://alias-bedrock.example.com",
|
||||
reasoning: true,
|
||||
contextWindow: 262144,
|
||||
maxTokens: 32768,
|
||||
|
|
|
|||
|
|
@ -9,9 +9,6 @@ export function normalizeProviderId(provider: string): string {
|
|||
if (normalized === "opencode-go-auth") {
|
||||
return "opencode-go";
|
||||
}
|
||||
if (normalized === "qwen") {
|
||||
return "qwen-portal";
|
||||
}
|
||||
if (normalized === "kimi" || normalized === "kimi-code" || normalized === "kimi-coding") {
|
||||
return "kimi";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,15 +111,6 @@ describe("buildAuthChoiceOptions", () => {
|
|||
groupId: "together",
|
||||
groupLabel: "Together AI",
|
||||
},
|
||||
{
|
||||
pluginId: "qwen-portal-auth",
|
||||
providerId: "qwen-portal",
|
||||
methodId: "device",
|
||||
choiceId: "qwen-portal",
|
||||
choiceLabel: "Qwen OAuth",
|
||||
groupId: "qwen",
|
||||
groupLabel: "Qwen",
|
||||
},
|
||||
{
|
||||
pluginId: "xai",
|
||||
providerId: "xai",
|
||||
|
|
@ -200,7 +191,6 @@ describe("buildAuthChoiceOptions", () => {
|
|||
"moonshot-api-key",
|
||||
"together-api-key",
|
||||
"chutes",
|
||||
"qwen-portal",
|
||||
"xai-api-key",
|
||||
"mistral-api-key",
|
||||
"volcengine-api-key",
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import opencodeGoPlugin from "../../extensions/opencode-go/index.js";
|
|||
import opencodePlugin from "../../extensions/opencode/index.js";
|
||||
import openrouterPlugin from "../../extensions/openrouter/index.js";
|
||||
import qianfanPlugin from "../../extensions/qianfan/index.js";
|
||||
import qwenPortalAuthPlugin from "../../extensions/qwen-portal-auth/index.js";
|
||||
import syntheticPlugin from "../../extensions/synthetic/index.js";
|
||||
import togetherPlugin from "../../extensions/together/index.js";
|
||||
import venicePlugin from "../../extensions/venice/index.js";
|
||||
|
|
@ -104,7 +103,6 @@ function createDefaultProviderPlugins() {
|
|||
opencodePlugin,
|
||||
openrouterPlugin,
|
||||
qianfanPlugin,
|
||||
qwenPortalAuthPlugin,
|
||||
syntheticPlugin,
|
||||
togetherPlugin,
|
||||
venicePlugin,
|
||||
|
|
@ -1395,7 +1393,7 @@ describe("applyAuthChoice", () => {
|
|||
|
||||
it("writes portal OAuth credentials for plugin providers", async () => {
|
||||
const scenarios: Array<{
|
||||
authChoice: "qwen-portal" | "minimax-global-oauth";
|
||||
authChoice: "minimax-global-oauth";
|
||||
label: string;
|
||||
authId: string;
|
||||
authLabel: string;
|
||||
|
|
@ -1407,18 +1405,6 @@ describe("applyAuthChoice", () => {
|
|||
apiKey: string;
|
||||
selectValue?: string;
|
||||
}> = [
|
||||
{
|
||||
authChoice: "qwen-portal",
|
||||
label: "Qwen",
|
||||
authId: "device",
|
||||
authLabel: "Qwen OAuth",
|
||||
providerId: "qwen-portal",
|
||||
profileId: "qwen-portal:default",
|
||||
baseUrl: "https://portal.qwen.ai/v1",
|
||||
api: "openai-completions",
|
||||
defaultModel: "qwen-portal/coder-model",
|
||||
apiKey: "qwen-oauth", // pragma: allowlist secret
|
||||
},
|
||||
{
|
||||
authChoice: "minimax-global-oauth",
|
||||
label: "MiniMax",
|
||||
|
|
@ -1516,7 +1502,6 @@ describe("resolvePreferredProviderForAuthChoice", () => {
|
|||
it("maps known and unknown auth choices", async () => {
|
||||
const scenarios = [
|
||||
{ authChoice: "github-copilot" as const, expectedProvider: "github-copilot" },
|
||||
{ authChoice: "qwen-portal" as const, expectedProvider: "qwen-portal" },
|
||||
{ authChoice: "mistral-api-key" as const, expectedProvider: "mistral" },
|
||||
{ authChoice: "ollama" as const, expectedProvider: "ollama" },
|
||||
{ authChoice: "unknown" as AuthChoice, expectedProvider: undefined },
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ describe("resolveRequestedLoginProviderOrThrow", () => {
|
|||
it("returns null and resolves provider by id/alias", () => {
|
||||
const providers = [
|
||||
makeProvider({ id: "google-gemini-cli", aliases: ["gemini-cli"] }),
|
||||
makeProvider({ id: "qwen-portal" }),
|
||||
makeProvider({ id: "minimax-portal" }),
|
||||
];
|
||||
const scenarios = [
|
||||
{ requested: undefined, expectedId: null },
|
||||
|
|
@ -32,13 +32,13 @@ describe("resolveRequestedLoginProviderOrThrow", () => {
|
|||
it("throws when requested provider is not loaded", () => {
|
||||
const loadedProviders = [
|
||||
makeProvider({ id: "google-gemini-cli" }),
|
||||
makeProvider({ id: "qwen-portal" }),
|
||||
makeProvider({ id: "minimax-portal" }),
|
||||
];
|
||||
|
||||
expect(() =>
|
||||
resolveRequestedLoginProviderOrThrow(loadedProviders, "google-antigravity"),
|
||||
).toThrowError(
|
||||
'Unknown provider "google-antigravity". Loaded providers: google-gemini-cli, qwen-portal. Verify plugins via `openclaw plugins list --json`.',
|
||||
'Unknown provider "google-antigravity". Loaded providers: google-gemini-cli, minimax-portal. Verify plugins via `openclaw plugins list --json`.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -328,7 +328,6 @@ export async function applyNonInteractiveAuthChoice(params: {
|
|||
if (
|
||||
authChoice === "oauth" ||
|
||||
authChoice === "chutes" ||
|
||||
authChoice === "qwen-portal" ||
|
||||
authChoice === "minimax-global-oauth" ||
|
||||
authChoice === "minimax-cn-oauth"
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ export type BuiltInAuthChoice =
|
|||
| "opencode-go"
|
||||
| "github-copilot"
|
||||
| "copilot-proxy"
|
||||
| "qwen-portal"
|
||||
| "xai-api-key"
|
||||
| "mistral-api-key"
|
||||
| "volcengine-api-key"
|
||||
|
|
@ -77,7 +76,6 @@ export type BuiltInAuthChoiceGroupId =
|
|||
| "synthetic"
|
||||
| "venice"
|
||||
| "mistral"
|
||||
| "qwen"
|
||||
| "together"
|
||||
| "huggingface"
|
||||
| "qianfan"
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ const EMPTY_PLUGIN_MANIFEST_REGISTRY: PluginManifestRegistry = {
|
|||
|
||||
const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [
|
||||
{ pluginId: "google", providerId: "google-gemini-cli" },
|
||||
{ pluginId: "qwen-portal-auth", providerId: "qwen-portal" },
|
||||
{ pluginId: "copilot-proxy", providerId: "copilot-proxy" },
|
||||
{ pluginId: "minimax", providerId: "minimax-portal" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -40,8 +40,6 @@ const PROVIDER_ALIAS_TO_OPENROUTER: Record<string, string> = {
|
|||
moonshot: "moonshotai",
|
||||
moonshotai: "moonshotai",
|
||||
"openai-codex": "openai",
|
||||
qwen: "qwen",
|
||||
"qwen-portal": "qwen",
|
||||
xai: "x-ai",
|
||||
zai: "z-ai",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ vi.mock("../plugins/provider-runtime.ts", () => ({
|
|||
vi.mock("../agents/cli-credentials.js", () => ({
|
||||
readCodexCliCredentialsCached: () => null,
|
||||
readMiniMaxCliCredentialsCached: () => null,
|
||||
readQwenCliCredentialsCached: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/auth-profiles/external-cli-sync.js", () => ({
|
||||
|
|
|
|||
|
|
@ -134,7 +134,6 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [
|
|||
"phone-control",
|
||||
"copilot-proxy",
|
||||
"zai",
|
||||
"qwen-portal-auth",
|
||||
"signal",
|
||||
"synology-chat",
|
||||
"talk-voice",
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ export {
|
|||
QIANFAN_DEFAULT_MODEL_ID,
|
||||
buildQianfanProvider,
|
||||
} from "../../extensions/qianfan/provider-catalog.js";
|
||||
export { buildQwenPortalProvider } from "../../extensions/qwen-portal-auth/provider-catalog.js";
|
||||
export { buildSyntheticProvider } from "../../extensions/synthetic/provider-catalog.js";
|
||||
export { buildTogetherProvider } from "../../extensions/together/provider-catalog.js";
|
||||
export { buildVeniceProvider } from "../../extensions/venice/provider-catalog.js";
|
||||
|
|
|
|||
|
|
@ -127,7 +127,6 @@ describe("plugin-sdk subpath exports", () => {
|
|||
"lobster",
|
||||
"pairing-access",
|
||||
"provider-model-definitions",
|
||||
"qwen-portal-auth",
|
||||
"reply-prefix",
|
||||
"secret-input-runtime",
|
||||
"secret-input-schema",
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = {
|
|||
openrouter: ["OPENROUTER_API_KEY"],
|
||||
perplexity: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
|
||||
qianfan: ["QIANFAN_API_KEY"],
|
||||
"qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"],
|
||||
sglang: ["SGLANG_API_KEY"],
|
||||
synthetic: ["SYNTHETIC_API_KEY"],
|
||||
tavily: ["TAVILY_API_KEY"],
|
||||
|
|
|
|||
|
|
@ -34,10 +34,6 @@ describe("bundled provider auth env vars", () => {
|
|||
"PERPLEXITY_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
]);
|
||||
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["qwen-portal"]).toEqual([
|
||||
"QWEN_OAUTH_TOKEN",
|
||||
"QWEN_PORTAL_API_KEY",
|
||||
]);
|
||||
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.tavily).toEqual(["TAVILY_API_KEY"]);
|
||||
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["minimax-portal"]).toEqual([
|
||||
"MINIMAX_OAUTH_TOKEN",
|
||||
|
|
|
|||
|
|
@ -52,7 +52,6 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>([
|
|||
"openrouter",
|
||||
"phone-control",
|
||||
"qianfan",
|
||||
"qwen-portal-auth",
|
||||
"sglang",
|
||||
"synthetic",
|
||||
"talk-voice",
|
||||
|
|
|
|||
|
|
@ -1,18 +1,8 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAuthTestLifecycle,
|
||||
createExitThrowingRuntime,
|
||||
createWizardPrompter,
|
||||
readAuthProfilesForAgent,
|
||||
requireOpenClawAgentDir,
|
||||
setupAuthTestEnv,
|
||||
} from "../../../test/helpers/auth-wizard.js";
|
||||
import { clearRuntimeAuthProfileStoreSnapshots } from "../../agents/auth-profiles/store.js";
|
||||
import { resolvePreferredProviderForAuthChoice } from "../../plugins/provider-auth-choice-preference.js";
|
||||
import { runProviderPluginAuthMethod } from "../../plugins/provider-auth-choice.js";
|
||||
import { buildProviderPluginMethodChoice } from "../provider-wizard.js";
|
||||
import { requireProviderContractProvider, uniqueProviderContractProviders } from "./registry.js";
|
||||
import { registerProviders, requireProvider } from "./testkit.js";
|
||||
|
||||
type ResolvePluginProviders =
|
||||
typeof import("../../plugins/provider-auth-choice.runtime.js").resolvePluginProviders;
|
||||
|
|
@ -20,53 +10,19 @@ type ResolveProviderPluginChoice =
|
|||
typeof import("../../plugins/provider-auth-choice.runtime.js").resolveProviderPluginChoice;
|
||||
type RunProviderModelSelectedHook =
|
||||
typeof import("../../plugins/provider-auth-choice.runtime.js").runProviderModelSelectedHook;
|
||||
const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn());
|
||||
const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn());
|
||||
const resolvePluginProvidersMock = vi.hoisted(() => vi.fn<ResolvePluginProviders>(() => []));
|
||||
const resolveProviderPluginChoiceMock = vi.hoisted(() => vi.fn<ResolveProviderPluginChoice>());
|
||||
const runProviderModelSelectedHookMock = vi.hoisted(() =>
|
||||
vi.fn<RunProviderModelSelectedHook>(async () => {}),
|
||||
);
|
||||
import qwenPortalPlugin from "../../../extensions/qwen-portal-auth/index.js";
|
||||
|
||||
vi.mock("../../../extensions/qwen-portal-auth/oauth.js", () => ({
|
||||
loginQwenPortalOAuth: loginQwenPortalOAuthMock,
|
||||
}));
|
||||
vi.mock("../../../extensions/github-copilot/login.js", () => ({
|
||||
githubCopilotLoginCommand: githubCopilotLoginCommandMock,
|
||||
}));
|
||||
vi.mock("../../plugins/provider-auth-choice.runtime.js", () => ({
|
||||
resolvePluginProviders: resolvePluginProvidersMock,
|
||||
resolveProviderPluginChoice: resolveProviderPluginChoiceMock,
|
||||
runProviderModelSelectedHook: runProviderModelSelectedHookMock,
|
||||
}));
|
||||
|
||||
type StoredAuthProfile = {
|
||||
type?: string;
|
||||
provider?: string;
|
||||
access?: string;
|
||||
refresh?: string;
|
||||
key?: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
describe("provider auth-choice contract", () => {
|
||||
const lifecycle = createAuthTestLifecycle([
|
||||
"OPENCLAW_STATE_DIR",
|
||||
"OPENCLAW_AGENT_DIR",
|
||||
"PI_CODING_AGENT_DIR",
|
||||
]);
|
||||
let activeStateDir: string | null = null;
|
||||
|
||||
async function setupTempState() {
|
||||
if (activeStateDir) {
|
||||
await lifecycle.cleanup();
|
||||
}
|
||||
const env = await setupAuthTestEnv("openclaw-provider-auth-choice-");
|
||||
activeStateDir = env.stateDir;
|
||||
lifecycle.setStateDir(env.stateDir);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resolvePluginProvidersMock.mockReset();
|
||||
resolvePluginProvidersMock.mockReturnValue(uniqueProviderContractProviders);
|
||||
|
|
@ -90,22 +46,17 @@ describe("provider auth-choice contract", () => {
|
|||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
loginQwenPortalOAuthMock.mockReset();
|
||||
githubCopilotLoginCommandMock.mockReset();
|
||||
resolvePluginProvidersMock.mockReset();
|
||||
resolvePluginProvidersMock.mockReturnValue([]);
|
||||
resolveProviderPluginChoiceMock.mockReset();
|
||||
resolveProviderPluginChoiceMock.mockReturnValue(null);
|
||||
runProviderModelSelectedHookMock.mockReset();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
await lifecycle.cleanup();
|
||||
activeStateDir = null;
|
||||
});
|
||||
|
||||
it("maps provider-plugin choices through the shared preferred-provider fallback resolver", async () => {
|
||||
const pluginFallbackScenarios = [
|
||||
"github-copilot",
|
||||
"qwen-portal",
|
||||
"minimax-portal",
|
||||
"modelstudio",
|
||||
"ollama",
|
||||
|
|
@ -131,114 +82,4 @@ describe("provider auth-choice contract", () => {
|
|||
);
|
||||
expect(resolvePluginProvidersMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs qwen portal auth through the shared plugin auth-method helper", async () => {
|
||||
await setupTempState();
|
||||
const qwenProvider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal");
|
||||
loginQwenPortalOAuthMock.mockResolvedValueOnce({
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 1_700_000_000_000,
|
||||
resourceUrl: "portal.qwen.ai",
|
||||
});
|
||||
|
||||
const note = vi.fn(async () => {});
|
||||
const result = await runProviderPluginAuthMethod({
|
||||
config: {},
|
||||
prompter: createWizardPrompter({ note }),
|
||||
runtime: createExitThrowingRuntime(),
|
||||
method: qwenProvider.auth[0],
|
||||
allowSecretRefPrompt: false,
|
||||
});
|
||||
|
||||
expect(result.config.auth?.profiles?.["qwen-portal:default"]).toMatchObject({
|
||||
provider: "qwen-portal",
|
||||
mode: "oauth",
|
||||
});
|
||||
expect(result.config.models?.providers?.["qwen-portal"]).toMatchObject({
|
||||
baseUrl: "https://portal.qwen.ai/v1",
|
||||
models: [],
|
||||
});
|
||||
expect(result.config.agents?.defaults?.models).toMatchObject({
|
||||
"qwen-portal/coder-model": { alias: "qwen" },
|
||||
"qwen-portal/vision-model": {},
|
||||
});
|
||||
expect(result.defaultModel).toBe("qwen-portal/coder-model");
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Qwen OAuth tokens auto-refresh."),
|
||||
"Provider notes",
|
||||
);
|
||||
|
||||
const stored = await readAuthProfilesForAgent<{ profiles?: Record<string, StoredAuthProfile> }>(
|
||||
requireOpenClawAgentDir(),
|
||||
);
|
||||
expect(stored.profiles?.["qwen-portal:default"]).toMatchObject({
|
||||
type: "oauth",
|
||||
provider: "qwen-portal",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns qwen portal default-model overrides for deferred callers", async () => {
|
||||
await setupTempState();
|
||||
const qwenProvider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal");
|
||||
loginQwenPortalOAuthMock.mockResolvedValueOnce({
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 1_700_000_000_000,
|
||||
resourceUrl: "portal.qwen.ai",
|
||||
});
|
||||
|
||||
const result = await runProviderPluginAuthMethod({
|
||||
config: {},
|
||||
prompter: createWizardPrompter({}),
|
||||
runtime: createExitThrowingRuntime(),
|
||||
method: qwenProvider.auth[0],
|
||||
allowSecretRefPrompt: false,
|
||||
});
|
||||
|
||||
expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"qwen-portal/coder-model": {
|
||||
alias: "qwen",
|
||||
},
|
||||
"qwen-portal/vision-model": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
profiles: {
|
||||
"qwen-portal:default": {
|
||||
provider: "qwen-portal",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
"qwen-portal": {
|
||||
baseUrl: "https://portal.qwen.ai/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultModel: "qwen-portal/coder-model",
|
||||
});
|
||||
|
||||
const stored = await readAuthProfilesForAgent<{
|
||||
profiles?: Record<string, StoredAuthProfile>;
|
||||
}>(requireOpenClawAgentDir());
|
||||
expect(stored.profiles?.["qwen-portal:default"]).toMatchObject({
|
||||
type: "oauth",
|
||||
provider: "qwen-portal",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,8 +12,6 @@ import { registerProviders, requireProvider } from "./testkit.js";
|
|||
|
||||
type LoginOpenAICodexOAuth =
|
||||
(typeof import("openclaw/plugin-sdk/provider-auth-login"))["loginOpenAICodexOAuth"];
|
||||
type LoginQwenPortalOAuth =
|
||||
(typeof import("../../../extensions/qwen-portal-auth/oauth.js"))["loginQwenPortalOAuth"];
|
||||
type GithubCopilotLoginCommand =
|
||||
(typeof import("openclaw/plugin-sdk/provider-auth-login"))["githubCopilotLoginCommand"];
|
||||
type CreateVpsAwareHandlers =
|
||||
|
|
@ -24,7 +22,6 @@ type ListProfilesForProvider =
|
|||
typeof import("openclaw/plugin-sdk/agent-runtime").listProfilesForProvider;
|
||||
|
||||
const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn<LoginOpenAICodexOAuth>());
|
||||
const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn<LoginQwenPortalOAuth>());
|
||||
const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn<GithubCopilotLoginCommand>());
|
||||
const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn<EnsureAuthProfileStore>());
|
||||
const listProfilesForProviderMock = vi.hoisted(() => vi.fn<ListProfilesForProvider>());
|
||||
|
|
@ -47,13 +44,8 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../extensions/qwen-portal-auth/oauth.js", () => ({
|
||||
loginQwenPortalOAuth: loginQwenPortalOAuthMock,
|
||||
}));
|
||||
|
||||
import githubCopilotPlugin from "../../../extensions/github-copilot/index.js";
|
||||
import openAIPlugin from "../../../extensions/openai/index.js";
|
||||
import qwenPortalPlugin from "../../../extensions/qwen-portal-auth/index.js";
|
||||
|
||||
function buildPrompter(): WizardPrompter {
|
||||
const progress: WizardProgress = {
|
||||
|
|
@ -114,7 +106,6 @@ describe("provider auth contract", () => {
|
|||
|
||||
afterEach(() => {
|
||||
loginOpenAICodexOAuthMock.mockReset();
|
||||
loginQwenPortalOAuthMock.mockReset();
|
||||
githubCopilotLoginCommandMock.mockReset();
|
||||
ensureAuthProfileStoreMock.mockReset();
|
||||
listProfilesForProviderMock.mockReset();
|
||||
|
|
@ -377,50 +368,6 @@ describe("provider auth contract", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("keeps Qwen portal OAuth auth results provider-owned", async () => {
|
||||
const provider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal");
|
||||
loginQwenPortalOAuthMock.mockResolvedValueOnce({
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 1_700_000_000_000,
|
||||
resourceUrl: "portal.qwen.ai",
|
||||
});
|
||||
|
||||
const result = await provider.auth[0]?.run(buildAuthContext() as never);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
profiles: [
|
||||
{
|
||||
profileId: "qwen-portal:default",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
provider: "qwen-portal",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 1_700_000_000_000,
|
||||
},
|
||||
},
|
||||
],
|
||||
defaultModel: "qwen-portal/coder-model",
|
||||
configPatch: {
|
||||
models: {
|
||||
providers: {
|
||||
"qwen-portal": {
|
||||
baseUrl: "https://portal.qwen.ai/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result?.notes).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("auto-refresh"),
|
||||
expect.stringContaining("Base URL defaults"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps GitHub Copilot device auth results provider-owned", async () => {
|
||||
const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot");
|
||||
authStore.profiles["github-copilot:github"] = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AuthProfileStore } from "../../agents/auth-profiles/types.js";
|
||||
import { QWEN_OAUTH_MARKER } from "../../agents/model-auth-markers.js";
|
||||
import type { ModelDefinitionConfig } from "../../config/types.models.js";
|
||||
import { registerProviders, requireProvider } from "./testkit.js";
|
||||
|
||||
|
|
@ -12,7 +11,6 @@ const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn());
|
|||
const listProfilesForProviderMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
let runProviderCatalog: typeof import("../provider-discovery.js").runProviderCatalog;
|
||||
let qwenPortalProvider: Awaited<ReturnType<typeof requireProvider>>;
|
||||
let githubCopilotProvider: Awaited<ReturnType<typeof requireProvider>>;
|
||||
let ollamaProvider: Awaited<ReturnType<typeof requireProvider>>;
|
||||
let vllmProvider: Awaited<ReturnType<typeof requireProvider>>;
|
||||
|
|
@ -53,21 +51,6 @@ function setRuntimeAuthStore(store?: AuthProfileStore) {
|
|||
);
|
||||
}
|
||||
|
||||
function setQwenPortalOauthSnapshot() {
|
||||
setRuntimeAuthStore({
|
||||
version: 1,
|
||||
profiles: {
|
||||
"qwen-portal:default": {
|
||||
type: "oauth",
|
||||
provider: "qwen-portal",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setGithubCopilotProfileSnapshot() {
|
||||
setRuntimeAuthStore({
|
||||
version: 1,
|
||||
|
|
@ -169,7 +152,6 @@ describe("provider discovery contract", () => {
|
|||
|
||||
({ runProviderCatalog } = await import("../provider-discovery.js"));
|
||||
const [
|
||||
{ default: qwenPortalPlugin },
|
||||
{ default: githubCopilotPlugin },
|
||||
{ default: ollamaPlugin },
|
||||
{ default: vllmPlugin },
|
||||
|
|
@ -178,7 +160,6 @@ describe("provider discovery contract", () => {
|
|||
{ default: modelStudioPlugin },
|
||||
{ default: cloudflareAiGatewayPlugin },
|
||||
] = await Promise.all([
|
||||
import("../../../extensions/qwen-portal-auth/index.js"),
|
||||
import("../../../extensions/github-copilot/index.js"),
|
||||
import("../../../extensions/ollama/index.js"),
|
||||
import("../../../extensions/vllm/index.js"),
|
||||
|
|
@ -187,7 +168,6 @@ describe("provider discovery contract", () => {
|
|||
import("../../../extensions/modelstudio/index.js"),
|
||||
import("../../../extensions/cloudflare-ai-gateway/index.js"),
|
||||
]);
|
||||
qwenPortalProvider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal");
|
||||
githubCopilotProvider = requireProvider(
|
||||
registerProviders(githubCopilotPlugin),
|
||||
"github-copilot",
|
||||
|
|
@ -215,42 +195,6 @@ describe("provider discovery contract", () => {
|
|||
listProfilesForProviderMock.mockReset();
|
||||
});
|
||||
|
||||
it("keeps qwen portal oauth marker fallback provider-owned", async () => {
|
||||
setQwenPortalOauthSnapshot();
|
||||
|
||||
await expect(
|
||||
runCatalog({
|
||||
provider: qwenPortalProvider,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: {
|
||||
baseUrl: "https://portal.qwen.ai/v1",
|
||||
apiKey: QWEN_OAUTH_MARKER,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
expect.objectContaining({ id: "coder-model", name: "Qwen Coder" }),
|
||||
expect.objectContaining({ id: "vision-model", name: "Qwen Vision" }),
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps qwen portal env api keys higher priority than oauth markers", async () => {
|
||||
setQwenPortalOauthSnapshot();
|
||||
|
||||
await expect(
|
||||
runCatalog({
|
||||
provider: qwenPortalProvider,
|
||||
env: { QWEN_PORTAL_API_KEY: "env-key" } as NodeJS.ProcessEnv,
|
||||
resolveProviderApiKey: () => ({ apiKey: "env-key" }),
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
provider: {
|
||||
apiKey: "env-key",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps GitHub Copilot catalog disabled without env tokens or profiles", async () => {
|
||||
await expect(runCatalog({ provider: githubCopilotProvider })).resolves.toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import opencodeGoPlugin from "../../../extensions/opencode-go/index.js";
|
|||
import opencodePlugin from "../../../extensions/opencode/index.js";
|
||||
import openrouterPlugin from "../../../extensions/openrouter/index.js";
|
||||
import qianfanPlugin from "../../../extensions/qianfan/index.js";
|
||||
import qwenPortalAuthPlugin from "../../../extensions/qwen-portal-auth/index.js";
|
||||
import sglangPlugin from "../../../extensions/sglang/index.js";
|
||||
import syntheticPlugin from "../../../extensions/synthetic/index.js";
|
||||
import togetherPlugin from "../../../extensions/together/index.js";
|
||||
|
|
@ -378,7 +377,6 @@ const bundledProviderPlugins = dedupePlugins([
|
|||
opencodeGoPlugin,
|
||||
openrouterPlugin,
|
||||
qianfanPlugin,
|
||||
qwenPortalAuthPlugin,
|
||||
sglangPlugin,
|
||||
syntheticPlugin,
|
||||
togetherPlugin,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import openAIPlugin from "../../../extensions/openai/index.js";
|
||||
import qwenPortalPlugin from "../../../extensions/qwen-portal-auth/index.js";
|
||||
import { createProviderUsageFetch, makeResponse } from "../../test-utils/provider-usage-fetch.js";
|
||||
import type { ProviderPlugin, ProviderRuntimeModel } from "../types.js";
|
||||
import { requireProviderContractProvider as requireBundledProviderContractProvider } from "./registry.js";
|
||||
|
|
@ -17,14 +16,8 @@ const getOAuthProvidersMock = vi.hoisted(() =>
|
|||
{ id: "anthropic", envApiKey: "ANTHROPIC_API_KEY", oauthTokenEnv: "ANTHROPIC_OAUTH_TOKEN" }, // pragma: allowlist secret
|
||||
{ id: "google", envApiKey: "GOOGLE_API_KEY", oauthTokenEnv: "GOOGLE_OAUTH_TOKEN" }, // pragma: allowlist secret
|
||||
{ id: "openai-codex", envApiKey: "OPENAI_API_KEY", oauthTokenEnv: "OPENAI_OAUTH_TOKEN" }, // pragma: allowlist secret
|
||||
{
|
||||
id: "qwen-portal",
|
||||
envApiKey: "QWEN_PORTAL_API_KEY",
|
||||
oauthTokenEnv: "QWEN_PORTAL_OAUTH_TOKEN",
|
||||
}, // pragma: allowlist secret
|
||||
]),
|
||||
);
|
||||
const refreshQwenPortalCredentialsMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
|
||||
|
|
@ -37,14 +30,6 @@ vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../extensions/qwen-portal-auth/refresh.js", async () => {
|
||||
const actual = await vi.importActual<object>("../../../extensions/qwen-portal-auth/refresh.js");
|
||||
return {
|
||||
...actual,
|
||||
refreshQwenPortalCredentials: refreshQwenPortalCredentialsMock,
|
||||
};
|
||||
});
|
||||
|
||||
function createModel(overrides: Partial<ProviderRuntimeModel> & Pick<ProviderRuntimeModel, "id">) {
|
||||
return {
|
||||
id: overrides.id,
|
||||
|
|
@ -64,9 +49,6 @@ function requireProviderContractProvider(providerId: string): ProviderPlugin {
|
|||
if (providerId === "openai-codex") {
|
||||
return requireProvider(registerProviders(openAIPlugin), providerId);
|
||||
}
|
||||
if (providerId === "qwen-portal") {
|
||||
return requireProvider(registerProviders(qwenPortalPlugin), providerId);
|
||||
}
|
||||
return requireBundledProviderContractProvider(providerId);
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +56,6 @@ describe("provider runtime contract", () => {
|
|||
beforeEach(() => {
|
||||
getOAuthApiKeyMock.mockReset();
|
||||
getOAuthProvidersMock.mockClear();
|
||||
refreshQwenPortalCredentialsMock.mockReset();
|
||||
}, CONTRACT_SETUP_TIMEOUT_MS);
|
||||
|
||||
describe("anthropic", () => {
|
||||
|
|
@ -633,29 +614,6 @@ describe("provider runtime contract", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("qwen-portal", () => {
|
||||
it("owns OAuth refresh", async () => {
|
||||
const provider = requireProviderContractProvider("qwen-portal");
|
||||
const credential = {
|
||||
type: "oauth" as const,
|
||||
provider: "qwen-portal",
|
||||
access: "stale-access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() - 60_000,
|
||||
};
|
||||
const refreshed = {
|
||||
...credential,
|
||||
access: "fresh-access-token",
|
||||
expires: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
refreshQwenPortalCredentialsMock.mockReset();
|
||||
refreshQwenPortalCredentialsMock.mockResolvedValueOnce(refreshed);
|
||||
|
||||
await expect(provider.refreshOAuth?.(credential)).resolves.toEqual(refreshed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zai", () => {
|
||||
it("owns glm-5 forward-compat resolution", () => {
|
||||
const provider = requireProviderContractProvider("zai");
|
||||
|
|
|
|||
Loading…
Reference in New Issue