Skip to content

fix(auth): preserve custom provider models on install - #5404

Merged
wenshao merged 2 commits into
QwenLM:mainfrom
tt-a1i:fix/model-add-shortcut
Jun 19, 2026
Merged

fix(auth): preserve custom provider models on install#5404
wenshao merged 2 commits into
QwenLM:mainfrom
tt-a1i:fix/model-add-shortcut

Conversation

@tt-a1i

@tt-a1i tt-a1i commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • keep custom provider ownership detection for UI/ACP discovery, but merge custom provider installs by model identity
  • persist and pass through model.baseUrl during provider install so same model IDs on different endpoints select the newly installed endpoint
  • add regression coverage for preserving custom provider models and baseUrl-specific sync

Refs #4814

Tests

  • from packages/core: npx -p node@22 node ../../scripts/build_package.js
  • npx -p node@22 node node_modules/vitest/vitest.mjs run --coverage.enabled=false packages/core/src/providers/tests packages/core/src/models/modelsConfig.test.ts
  • npx -p node@22 node node_modules/typescript/bin/tsc --noEmit --project packages/core/tsconfig.json
  • npx -p node@22 node node_modules/eslint/bin/eslint.js packages/cli/src/acp-integration/acpAgent.ts packages/cli/src/ui/auth/useAuth.ts packages/cli/src/ui/hooks/useProviderUpdates.ts packages/core/src/models/modelsConfig.test.ts packages/core/src/models/modelsConfig.ts packages/core/src/providers/tests/install.test.ts packages/core/src/providers/tests/presets/custom-provider.test.ts packages/core/src/providers/install.ts packages/core/src/providers/presets/custom-provider.ts packages/core/src/providers/provider-config.ts packages/core/src/providers/types.ts
  • npx -p node@22 node node_modules/prettier/bin/prettier.cjs --check packages/cli/src/acp-integration/acpAgent.ts packages/cli/src/ui/auth/useAuth.ts packages/cli/src/ui/hooks/useProviderUpdates.ts packages/core/src/models/modelsConfig.test.ts packages/core/src/models/modelsConfig.ts packages/core/src/providers/tests/install.test.ts packages/core/src/providers/tests/presets/custom-provider.test.ts packages/core/src/providers/install.ts packages/core/src/providers/presets/custom-provider.ts packages/core/src/providers/provider-config.ts packages/core/src/providers/types.ts
  • git diff --check

AI Assistance Disclosure

I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] acpAgent.ts:1514resolveExistingProviderApiKey credential mix-up

With identity-based merge, models from multiple custom endpoints now coexist under the same env-key prefix (QWEN_CUSTOM_API_KEY_). When the ACP client reconnects to endpoint B without supplying an apiKey, findExistingProviderModels (which matches ALL custom models by prefix) returns models[0] — which may carry endpoint A's envKey. This sends endpoint A's API key to endpoint B, a credential mix-up.

Fix: Filter by baseUrl in resolveExistingProviderApiKey, or compute the envKey directly via generateCustomEnvKey(protocol, baseUrl) instead of walking the stored model array.

— qwen3.7-max via Qwen Code /review

reloadModelProviders: (mp) => config.reloadModelProvidersConfig(mp),
syncAuthState: (authType, modelId) =>
config.getModelsConfig().syncAfterAuthRefresh(authType, modelId),
syncAuthState: (authType, modelId, baseUrl) =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Test failure: useProviderUpdates.test.ts:300 asserts syncAfterAuthRefresh is called with 2 args, but this change forwards a 3rd baseUrl parameter. For non-custom providers, the mock receives (AuthType.USE_OPENAI, 'qwen3.5-plus', undefined) — 3 args, not 2. CI confirms this test fails on all 3 platforms.

Update the test assertion:

expect(mockModelsConfig.syncAfterAuthRefresh).toHaveBeenCalledWith(
  AuthType.USE_OPENAI,
  'qwen3.5-plus',
  undefined,
);

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, fixed the assertion to include the optional third arg (undefined) and re-ran the hook test locally.

Comment thread packages/core/src/providers/install.ts Outdated
if (plan.modelSelection?.modelId) {
currentStep = 'syncAuthState';
syncAuthState?.(plan.authType, plan.modelSelection.modelId);
if (plan.modelSelection.baseUrl) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The if/else branching is unnecessary. The syncAuthState callback type already declares baseUrl?: string (optional), so a single call suffices:

Suggested change
if (plan.modelSelection.baseUrl) {
syncAuthState?.(
plan.authType,
plan.modelSelection.modelId,
plan.modelSelection.baseUrl,
);

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, collapsed this to the single optional-baseUrl call and updated the affected test expectation.

@tt-a1i

tt-a1i commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

fixed the credential mix-up too. ACP now resolves the stored key after parsing the requested protocol/baseUrl, so reconnecting endpoint B looks up B’s env key instead of whatever custom model happens to be first. added a two-custom-endpoint regression test for that path.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No review findings. Downgraded from Approve to Comment: CI still running.

The R1 Critical findings (credential mix-up in resolveExistingProviderApiKey, test assertion mismatch) are addressed in this revision. The lazy callback pattern for apiKey resolution is correct — env key is derived from the requested (protocol, baseUrl) pair, not the first stored model. mergeModelsByIdentity correctly scopes install-time deletion to identity-matched entries while preserving prefix-based ownsModel for listing flows. Tests pass locally.

— qwen3.7-max via Qwen Code /review

@tt-a1i
tt-a1i marked this pull request as ready for review June 19, 2026 14:23

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical issue from the earlier review appears resolved in the current head. I rechecked the endpoint-scoped custom provider key reuse path and did not find new critical issues in this pass.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. LGTM ✅

Re-reviewed at HEAD 4678b2c3 — CI is now green (51 checks; my earlier review only held off because CI was still running). The R1 credential-mix-up Critical is resolved: resolveExistingProviderApiKey now resolves the stored key for the requested protocol/baseUrl (via a callback invoked after parsing), so reconnecting endpoint B looks up B's key rather than reusing the first model's. Both earlier inline comments (the useProviderUpdates test assertion and the install.ts optional-baseUrl collapse) are addressed. Verified locally: cli+core typecheck clean, changed-area tests pass (core 101, cli 138).

— claude-opus-4-8 via Claude Code /qreview

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No review findings. LGTM! ✅

The identity-based merge strategy is well-scoped, and the baseUrl threading through resolveExistingProviderApiKey/syncAfterAuthRefresh correctly scopes credential resolution to the requested endpoint. CI all pass (30/30).

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

Verification report — preserve custom provider models on install

I built this PR locally and verified it with real runtime tests: real vitest, a real-source + real-settings.json A/B harness driving the actual buildInstallPlan / applyProviderInstallPlan, and an end-to-end run of the built dist/cli.js binary over ACP (qwen/providers/connect).

Verdict: ✅ Fix is correct and well-covered. Recommend merge. Both commits do what they claim, with genuine regression coverage. Only minor, non-blocking notes below.

Environment & method
  • Two commits verified, merged into current main (a55eae9 → merge b39ded4): ad3d0c01 (preserve custom provider models on install) and 4678b2c3 (scope ACP custom key reuse by endpoint).
  • ⚠️ Heads-up for reviewers: a local git diff origin/main...HEAD showed 81 files until I refreshed origin/main. The PR branch is built on a few not-yet-merged commits, so a stale local main poisons the 3-dot diff. After git fetch origin main, the real diff is the 13 files / +348 / −74 GitHub shows.
  • Built dist/cli.js (npm run build --cli-only && npm run bundle, typecheck 0 errors).
  • A/B run on base source vs PR source via tsx (relative ./src imports, so each side runs its own code) against a real on-disk settings.json.
  • Node v22.22.2.

1. Unit tests (real vitest, on the merged tree)

Suite Result
core install.test.ts + custom-provider.test.ts + zai.test.ts + modelsConfig.test.ts 104/104 pass
core full providers/ + models/ dirs (regression sweep) 300/300 pass (16 files)
cli acpAgent.test.ts + useProviderUpdates.test.ts 138/138 pass
core + cli tsc (during build) 0 errors

2. Revert-proof (PR's new tests fail on base source)

  • install.test.ts > preserves existing custom provider models and selects the installed endpointfails on base (expected [Function ownsModel] to be undefined).
  • install.test.ts > persists env, auth selection, …fails on base (syncAuthState called with 2 args, test expects the 3rd baseUrl).
  • modelsConfig.test.ts > should use explicit provider baseUrl when syncing after provider installfails on base.
  • acpAgent.test.ts > qwen/providers/connect reuses the custom apiKey for the requested baseUrl onlyfails on base (commit 2 — base resolves the wrong endpoint's key).

3. Real-source + real-settings.json A/B (commit 1 — the core fix)

Scenario: two custom models already installed at endpoint A (keep-model@A, shared-model@A), then install shared-model at a different endpoint B.

Observable BASE (the bug) PR (the fix)
modelProviders.openai after install only shared-model@Bkeep-model@A and shared-model@A WIPED all three preserved: shared-model@B, keep-model@A, shared-model@A
model.baseUrl "" (cleared) …endpoint-b… (persisted)
syncAuthState(...) (openai, shared-model) (openai, shared-model, …endpoint-b…)
plan.modelProviders[0].ownsModel function (removes all prefix-owned) undefined (merges by id+baseUrl)

This is exactly the bug in #4814: under the old ownsModel prefix match, installing any custom model deleted every other custom model. The PR keeps ownsModel for discovery but installs merge by identity.

4. Real binary E2E over ACP (dist/cli.js, both commits, also re-run in tmux)

Four sequential qwen/providers/connect calls against the shipping binary: shared-model@A (key sk-A), keep-model@A (key sk-A), shared-model@B (key sk-B), then keep-model@A with NO apiKey.

  • All four return success, and the response now carries baseUrl (a PR addition).
  • Persisted modelProviders.openai holds all 3 distinct identities — nothing wiped across installs at different endpoints.
  • Both endpoint keys persist: env has the endpoint-A key = sk-A and the endpoint-B key = sk-B (keys are namespaced per baseUrl).
  • The 4th call (no apiKey) succeeds by reusing endpoint-A's stored key — this is commit 2: on base, resolveExistingProviderApiKey used the first stored model's key (which, after the B install, would be sk-B), reusing the wrong endpoint's secret.

Findings (all non-blocking)

  1. Test-import inconsistency in custom-provider.test.ts. Unlike its sibling provider tests (which import relatively), it imports customProvider / buildInstallPlan from the package entry @qwen-code/qwen-code-core, i.e. the built dist/. Those assertions therefore validate freshly-built output, not raw source — fine under build-then-test CI (the PR's test plan builds first), but it can silently pass against stale dist/ in a local vitest-only loop. The file even re-imports generateCustomEnvKey relatively to dodge this. Consider importing all of them from the relative source for consistency.
  2. Endpoint key reuse is keyed by exact normalized baseUrl (commit 2). Reconnecting with a cosmetically different baseUrl (e.g. a trailing slash) yields a different env key, so the stored secret won't be found and the user must re-enter it. Correct-by-design, but a possible sharp edge worth a docs note.
  3. Installs no longer remove other custom models — distinct custom models/endpoints now accumulate by design. Re-installing the same id+baseUrl replaces in place (no duplicate), but there is no per-entry cleanup short of uninstall/identity-replace. This is the intended behavior; noting for awareness.

What's good

  • Focused, correct fix: ownsModel retained for UI/ACP discovery, while mergeModelsByIdentity makes installs merge by id+baseUrl instead of nuking every prefix-owned entry.
  • model.baseUrl is persisted and threaded into syncAfterAuthRefresh(…, providerBaseUrlOverride), so the just-installed endpoint is selected even when the same model id exists on multiple endpoints.
  • Backward compatible: the new baseUrl / providerBaseUrlOverride params are optional; the general refreshAuth sync path is unchanged.
  • Genuine regression coverage across both commits (every new assertion fails on base).
🇨🇳 中文版(点击展开)

验证报告 —— 安装时保留自定义 provider 的模型

我在本地构建了该 PR 并用真实运行时测试验证:真实 vitest、一个直接驱动真实 buildInstallPlan / applyProviderInstallPlan 且写真实 settings.json 的“真实源码 + 真实文件”A/B 脚本,以及用构建出的 dist/cli.js 二进制通过 ACP(qwen/providers/connect)做端到端验证。

结论:✅ 修复正确、覆盖充分,建议合并。 两个 commit 都做到了所声明的行为,且有真正的回归测试覆盖。只有下面几条不阻塞合并的小提示。

环境与方法

  • 验证两个 commit,合并进当前 maina55eae9 → 合并提交 b39ded4):ad3d0c01(安装时保留自定义 provider 模型)和 4678b2c3(按 endpoint 限定 ACP 自定义 key 复用)。
  • ⚠️ 给 reviewer 的提醒:在我刷新 origin/main 之前,本地 git diff origin/main...HEAD 显示 81 个文件。PR 分支是基于几个尚未合并的 commit 构建的,因此本地 main 过期会污染三点 diff。git fetch origin main 之后,真实 diff 就是 GitHub 显示的 13 个文件 / +348 / −74
  • 构建 dist/cli.jsnpm run build --cli-only && npm run bundle,typecheck 0 错误)。
  • A/B 通过 tsxbase 源码 vs PR 源码 上各自运行(相对 ./src 导入,所以两边各跑各自的代码),写入真实磁盘上的 settings.json。Node v22.22.2。

1. 单元测试(真实 vitest,合并后的代码树)

  • core install.test.ts + custom-provider.test.ts + zai.test.ts + modelsConfig.test.ts104/104 通过
  • core 整个 providers/ + models/ 目录(回归扫描):300/300 通过(16 个文件)
  • cli acpAgent.test.ts + useProviderUpdates.test.ts138/138 通过;构建期 tsc0 错误

2. 回归证明(PR 新增测试在 base 源码上失败)

  • install.test.ts > preserves existing custom provider models …base 失败expected [Function ownsModel] to be undefined)。
  • install.test.ts > persists env, auth selection …base 失败(syncAuthState 只传了 2 个参数,测试要求第 3 个 baseUrl)。
  • modelsConfig.test.ts > should use explicit provider baseUrl when syncing after provider installbase 失败
  • acpAgent.test.ts > qwen/providers/connect reuses the custom apiKey for the requested baseUrl onlybase 失败(commit 2 —— base 取错了 endpoint 的 key)。

3. 真实源码 + 真实 settings.json 的 A/B(commit 1 —— 核心修复)
场景:endpoint A 上已装两个自定义模型(keep-model@Ashared-model@A),随后在不同的 endpoint B 上安装 shared-model

观测项 BASE(bug) PR(修复)
安装后的 modelProviders.openai 只剩 shared-model@Bkeep-model@Ashared-model@A清除 三个全部保留:shared-model@Bkeep-model@Ashared-model@A
model.baseUrl ""(被清空) …endpoint-b…(被持久化)
syncAuthState(...) (openai, shared-model) (openai, shared-model, …endpoint-b…)
plan.modelProviders[0].ownsModel function(删除所有 prefix-owned) undefined(按 id+baseUrl 合并)

这正是 #4814 的 bug:旧的 ownsModel 前缀匹配下,安装任意自定义模型都会删掉其它所有自定义模型。PR 保留 ownsModel 用于发现,但安装改为按身份合并。

4. 真实二进制 ACP 端到端(dist/cli.js,覆盖两个 commit,并在 tmux 中复跑)
对 shipping 二进制连续发 4 次 qwen/providers/connectshared-model@A(key sk-A)、keep-model@A(key sk-A)、shared-model@B(key sk-B),最后 keep-model@A 不带 apiKey

  • 四次都返回 success,且响应里现在带上了 baseUrl(PR 新增)。
  • 持久化后的 modelProviders.openai 保留了全部 3 个不同身份——跨不同 endpoint 多次安装没有丢失任何条目。
  • 两个 endpoint 的 key 都在:env 里 endpoint-A 的 key = sk-A,endpoint-B 的 key = sk-B(key 按 baseUrl 命名空间隔离)。
  • 第 4 次(不带 apiKey)靠复用 endpoint-A 已存的 key 成功——这就是 commit 2:在 base 上,resolveExistingProviderApiKey 用的是第一个已存模型的 key(在装了 B 之后会是 sk-B),从而复用了错误 endpoint 的密钥。

发现的问题(均不阻塞合并)

  1. custom-provider.test.ts 的导入不一致:和同目录其它 provider 测试(相对导入)不同,它从包入口 @qwen-code/qwen-code-core(即构建出的 dist/)导入 customProvider / buildInstallPlan。这些断言因此校验的是构建产物而非源码——在“先构建再测试”的 CI 下没问题(PR 的测试计划会先构建),但在本地只跑 vitest 时可能对着过期的 dist/ 通过。该文件甚至特意把 generateCustomEnvKey 改成相对导入来规避这一点。建议统一改为相对源码导入。
  2. endpoint key 复用按归一化后的精确 baseUrl 命中(commit 2):用一个外观略不同的 baseUrl(如多了结尾斜杠)重连会算出不同的 env key,从而找不到已存密钥、需要用户重新输入。属于设计正确,但是个可能的“坑”,值得在文档里提一句。
  3. 安装不再删除其它自定义模型——不同的自定义模型/endpoint 现在会按设计累积:重装相同 id+baseUrl 会原地替换(不重复),但除了 uninstall/同身份替换之外没有逐条清理。这是预期行为,仅作提醒。

优点

  • 修复聚焦且正确:保留 ownsModel 用于 UI/ACP 发现,同时用 mergeModelsByIdentity 让安装按 id+baseUrl 合并,而不是清空所有 prefix-owned 条目。
  • model.baseUrl 被持久化并透传进 syncAfterAuthRefresh(…, providerBaseUrlOverride),因此即便同一个 model id 存在于多个 endpoint,也能选中刚安装的那个 endpoint。
  • 向后兼容:新增的 baseUrl / providerBaseUrlOverride 参数都是可选的;通用 refreshAuth 同步路径未变。
  • 两个 commit 都有真正的回归覆盖(每条新断言在 base 上都会失败)。

Verified by building the PR merged with main, running the suites, an A/B harness on base-vs-PR source against a real settings.json, and driving the built dist/cli.js over ACP.

@wenshao
wenshao merged commit e66b777 into QwenLM:main Jun 19, 2026
61 checks passed
models,
mergeStrategy: 'prepend-and-remove-owned' as const,
ownsModel: resolveOwnsModel(config),
...(ownsModel ? { ownsModel } : {}),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Trailing-slash normalization mismatch between identity merge and env key derivation

This line suppresses ownsModel when mergeModelsByIdentity is true, causing applyModelProvidersPatch to fall through to isSameModelIdentity which compares baseUrl with raw string equality: (a.baseUrl ?? '') === (b.baseUrl ?? '') (install.ts:40-44).

However, generateCustomEnvKey strips trailing slashes before hashing (stripTrailingSlashes(baseUrl.trim())). Two URLs differing only in trailing slash — e.g. https://proxy.example/v1 vs https://proxy.example/v1/ — produce the same envKey but are treated as different model identities. The second install does not replace the first; it prepends a duplicate entry sharing the same API key. Each reinstall with a slash variant accumulates another orphan entry.

Normalize baseUrl in isSameModelIdentity to match generateCustomEnvKey's canonicalization:

Suggested change
...(ownsModel ? { ownsModel } : {}),
function isSameModelIdentity(
a: { id: string; baseUrl?: string },
b: { id: string; baseUrl?: string },
): boolean {
const normalizeUrl = (url?: string) => (url ?? '').trim().replace(/\/+$/, '');
return a.id === b.id && normalizeUrl(a.baseUrl) === normalizeUrl(b.baseUrl);
}

Alternatively, normalize inputs.baseUrl once in readProviderSetupInputs before it flows into model configs and env key derivation.

— qwen3.7-max via Qwen Code /review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants