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:
pomelo 2026-03-26 16:32:34 +08:00 committed by GitHub
parent 83e6c12f15
commit dad68d319b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
65 changed files with 135 additions and 1461 deletions

4
.github/labeler.yml vendored
View File

@ -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:

View 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.

View File

@ -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",

View File

@ -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}

View File

@ -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`

View File

@ -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.

View File

@ -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

View File

@ -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)

View File

@ -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.

View File

@ -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>

View File

@ -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 EngineDoubao
Volcano Engine火山引擎为中国用户提供对 Doubao 和其他模型的访问。

View File

@ -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)。

View File

@ -155,7 +155,6 @@ Bundle hook 支持仅限于常规 OpenClaw hook 目录格式(在声明的 hook
- OpenCode Zen provider 能力 — 以 `opencode` 形式捆绑(默认启用)
- OpenRouter provider 运行时 — 以 `openrouter` 形式捆绑(默认启用)
- Qianfan provider catalog — 以 `qianfan` 形式捆绑(默认启用)
- Qwen OAuthprovider 身份验证 + 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`

View File

@ -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.

View File

@ -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,
}),
});
},
});

View File

@ -1 +0,0 @@
export { loginQwenPortalOAuth } from "./oauth.js";

View File

@ -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.");
}

View File

@ -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": {}
}
}

View File

@ -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"],
}),
],
};
}

View File

@ -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");
});
});

View File

@ -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,
};
}

View File

@ -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";

View File

@ -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",

View File

@ -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
}

View File

@ -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");
});
});

View File

@ -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);

View File

@ -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",

View File

@ -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 = {

View File

@ -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: {

View File

@ -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.

View File

@ -23,7 +23,6 @@ vi.mock("@mariozechner/pi-ai/oauth", async () => {
vi.mock("../cli-credentials.js", () => ({
readCodexCliCredentialsCached: () => null,
readQwenCliCredentialsCached: () => null,
readMiniMaxCliCredentialsCached: () => null,
resetCliCredentialCachesForTest: () => undefined,
}));

View File

@ -32,7 +32,6 @@ const {
vi.mock("../cli-credentials.js", () => ({
readCodexCliCredentialsCached: () => null,
readQwenCliCredentialsCached: () => null,
readMiniMaxCliCredentialsCached: () => null,
resetCliCredentialCachesForTest: () => undefined,
}));

View File

@ -4,7 +4,6 @@ import type { AuthProfileStore } from "./types.js";
vi.mock("../cli-credentials.js", () => ({
readCodexCliCredentialsCached: () => null,
readQwenCliCredentialsCached: () => null,
readMiniMaxCliCredentialsCached: () => null,
resetCliCredentialCachesForTest: () => undefined,
}));

View File

@ -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 });
}
});
});

View File

@ -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;

View File

@ -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 () => {

View File

@ -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);
}

View File

@ -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);

View File

@ -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 ||

View File

@ -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(
{

View File

@ -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");

View File

@ -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",

View File

@ -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);
});
});

View File

@ -22,7 +22,6 @@ export {
QIANFAN_BASE_URL,
QIANFAN_DEFAULT_MODEL_ID,
buildQianfanProvider,
buildQwenPortalProvider,
buildSyntheticProvider,
buildTogetherProvider,
buildDoubaoCodingProvider,

View File

@ -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,

View File

@ -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";
}

View File

@ -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",

View File

@ -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 },

View File

@ -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`.',
);
});
});

View File

@ -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"
) {

View File

@ -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"

View File

@ -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" },
];

View File

@ -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",
};

View File

@ -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", () => ({

View File

@ -134,7 +134,6 @@ const LOCAL_EXTENSION_API_BARREL_GUARDS = [
"phone-control",
"copilot-proxy",
"zai",
"qwen-portal-auth",
"signal",
"synology-chat",
"talk-voice",

View File

@ -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";

View File

@ -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",

View File

@ -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"],

View File

@ -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",

View File

@ -52,7 +52,6 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set<string>([
"openrouter",
"phone-control",
"qianfan",
"qwen-portal-auth",
"sglang",
"synthetic",
"talk-voice",

View File

@ -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",
});
});
});

View File

@ -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"] = {

View File

@ -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();
});

View File

@ -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,

View File

@ -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");