Skip to content

Commit 4751145

Browse files
committed
fix(config-agent): 修复 Qwen Code 凭证写入与模型名处理
- 凭证同时写入 env 和 security.auth,避免系统 OPENAI_API_KEY 干扰 - modelProviders 中按 id + baseUrl 作为键,保持 name 为模型显示名 - 修复旧的 bailian-cli 名称,防止其覆盖用户自定义显示名 - model 配置中新增 baseUrl 字段,用于消歧同 id 但不同地址的模型 - 调整测试用例验证上述行为,确保配置一致性和兼容性
1 parent 26a69a7 commit 4751145

2 files changed

Lines changed: 102 additions & 73 deletions

File tree

packages/commands/src/commands/config/agent/writers/qwen-code.ts

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
import { homedir } from "os";
22
import { join } from "path";
3-
import {
4-
backup,
5-
readJson,
6-
writeJsonAtomic,
7-
isAnthropicEndpoint,
8-
type AgentDef,
9-
} from "./utils.ts";
3+
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
104

115
const ENV_KEY = "BAILIAN_CLI_API_KEY";
126

137
/**
148
* Qwen Code keys `modelProviders` and `security.auth.selectedType` by the SDK
159
* protocol (an AuthType string), not by a free-form provider id — the runtime
1610
* resolver indexes credentials/defaults by protocol. The `bailian-cli` brand
17-
* therefore lives in the model entry `name` and the env var name.
11+
* therefore lives only in the env var name (`BAILIAN_CLI_API_KEY`); each model
12+
* entry's `name` stays a human display label (Qwen Code keys models by
13+
* id + baseUrl, never by name).
14+
*
15+
* Credentials are written to BOTH `env` (via the entry's `envKey`) and
16+
* `security.auth` — the resolver reads `security.auth.apiKey/baseUrl` as a
17+
* lower-priority layer, which stops a stray system `OPENAI_API_KEY` from being
18+
* picked up when the provider→envKey path does not resolve first. The active
19+
* `model` also carries its `baseUrl`, as Qwen Code requires to disambiguate
20+
* same-id providers.
1821
*/
1922
export default {
2023
label: "Qwen Code",
@@ -33,41 +36,47 @@ export default {
3336
env[ENV_KEY] = apiKey;
3437
settings.env = env;
3538

36-
// modelProviders[<protocol>] — upsert the bailian-cli model entry.
39+
// modelProviders[<protocol>] — upsert this model's entry, keyed by
40+
// id + baseUrl (the identity Qwen Code's registry uses). `name` is the
41+
// model's DISPLAY label; keep an existing custom name, and heal the old
42+
// "bailian-cli" sentinel a previous version wrote (it collided across every
43+
// configured model in the picker).
3744
const providers = (settings.modelProviders ?? {}) as Record<
3845
string,
3946
Array<Record<string, unknown>>
4047
>;
41-
const entries = (providers[protocol] ?? []) as Array<
42-
Record<string, unknown>
43-
>;
48+
const entries = (providers[protocol] ?? []) as Array<Record<string, unknown>>;
49+
const displayName = `[Bailian] ${model}`;
4450
const existing = entries.find(
4551
(entry) => entry.id === model && (entry.baseUrl ?? "") === baseUrl,
4652
);
4753
if (existing) {
48-
existing.name = "bailian-cli";
4954
existing.baseUrl = baseUrl;
5055
existing.envKey = ENV_KEY;
56+
const currentName = typeof existing.name === "string" ? existing.name.trim() : "";
57+
if (!currentName || currentName === "bailian-cli") existing.name = displayName;
5158
} else {
5259
entries.push({
5360
id: model,
54-
name: "bailian-cli",
61+
name: displayName,
5562
baseUrl,
5663
envKey: ENV_KEY,
5764
});
5865
}
5966
providers[protocol] = entries;
6067
settings.modelProviders = providers;
6168

62-
// security.auth — select the protocol only. Credentials live in env (via
63-
// each provider entry's envKey); writing apiKey/baseUrl here is not part of
64-
// the v3 schema.
69+
// security.auth — select the protocol AND keep credentials as a fallback
70+
// layer (see the file-level note): without this, a stray system
71+
// OPENAI_API_KEY can win when the provider→envKey lookup does not resolve.
6572
const security = (settings.security ?? {}) as Record<string, unknown>;
66-
security.auth = { selectedType: protocol };
73+
security.auth = { selectedType: protocol, apiKey, baseUrl };
6774
settings.security = security;
6875

69-
// model — active model id, resolved inside modelProviders[protocol].
70-
settings.model = { name: model };
76+
// model — active model. baseUrl MUST be written alongside name; Qwen Code
77+
// uses it to disambiguate same-id providers, and omitting it can misroute
78+
// to a different entry (and thus a different credential).
79+
settings.model = { name: model, baseUrl };
7180

7281
writeJsonAtomic(settingsPath, settings);
7382

packages/commands/tests/config-agent-writers.test.ts

Lines changed: 73 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
import {
2-
mkdtempSync,
3-
rmSync,
4-
readFileSync,
5-
writeFileSync,
6-
mkdirSync,
7-
readdirSync,
8-
} from "fs";
1+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
92
import { tmpdir, homedir } from "os";
103
import { join } from "path";
114
import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test";
@@ -90,12 +83,8 @@ describe("config agent writers", () => {
9083
apiKey: "sk-a",
9184
model: "qwen3-max",
9285
});
93-
const settings = JSON.parse(
94-
readFileSync(join(customDir, "settings.json"), "utf8"),
95-
);
96-
expect(
97-
(settings.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN,
98-
).toBe("sk-a");
86+
const settings = JSON.parse(readFileSync(join(customDir, "settings.json"), "utf8"));
87+
expect((settings.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN).toBe("sk-a");
9988
} finally {
10089
delete process.env.CLAUDE_CONFIG_DIR;
10190
}
@@ -110,35 +99,82 @@ describe("config agent writers", () => {
11099
const settings = readJsonAt(".qwen", "settings.json");
111100
expect(settings.$version).toBe(3);
112101
const security = settings.security as { auth: Record<string, unknown> };
113-
// security.auth 只携带 selectedType;凭证在 env + envKey 里
114-
expect(security.auth).toEqual({ selectedType: "openai" });
115-
expect((settings.env as Record<string, string>).BAILIAN_CLI_API_KEY).toBe(
116-
"sk-q",
117-
);
118-
expect(settings.model).toEqual({ name: "qwen3-coder-plus" });
119-
const providers = settings.modelProviders as Record<
120-
string,
121-
Array<Record<string, unknown>>
122-
>;
102+
// security.auth 携带 selectedType 以及凭证兜底(apiKey/baseUrl),
103+
// 避免系统 OPENAI_API_KEY 抢占
104+
expect(security.auth).toEqual({
105+
selectedType: "openai",
106+
apiKey: "sk-q",
107+
baseUrl: OAI_URL,
108+
});
109+
expect((settings.env as Record<string, string>).BAILIAN_CLI_API_KEY).toBe("sk-q");
110+
// model.name 必须与 baseUrl 一同写入(同 id provider 消歧契约)
111+
expect(settings.model).toEqual({
112+
name: "qwen3-coder-plus",
113+
baseUrl: OAI_URL,
114+
});
115+
const providers = settings.modelProviders as Record<string, Array<Record<string, unknown>>>;
116+
// name 是模型显示名(非 provider 品牌常量),品牌只在 envKey 里
123117
expect(providers.openai[0]).toMatchObject({
124118
id: "qwen3-coder-plus",
125-
name: "bailian-cli",
119+
name: "[Bailian] qwen3-coder-plus",
126120
baseUrl: OAI_URL,
127121
envKey: "BAILIAN_CLI_API_KEY",
128122
});
129123
});
130124

125+
test("qwen-code upsert 时治愈旧的 bailian-cli name 但保留用户自定义 name", () => {
126+
mkdirSync(join(home, ".qwen"), { recursive: true });
127+
writeFileSync(
128+
join(home, ".qwen", "settings.json"),
129+
JSON.stringify({
130+
modelProviders: {
131+
openai: [
132+
{
133+
id: "qwen3-coder-plus",
134+
name: "bailian-cli",
135+
baseUrl: OAI_URL,
136+
envKey: "OLD",
137+
},
138+
{
139+
id: "my-model",
140+
name: "My Custom",
141+
baseUrl: OAI_URL,
142+
envKey: "OLD",
143+
},
144+
],
145+
},
146+
}),
147+
);
148+
149+
// 旧 sentinel 被治愈为显示名
150+
qwenCode.write({
151+
baseUrl: OAI_URL,
152+
apiKey: "sk-q",
153+
model: "qwen3-coder-plus",
154+
});
155+
// 用户自定义 name 不被覆盖
156+
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-q", model: "my-model" });
157+
158+
const settings = readJsonAt(".qwen", "settings.json");
159+
const entries = (settings.modelProviders as Record<string, Array<Record<string, unknown>>>)
160+
.openai;
161+
const healed = entries.find((entry) => entry.id === "qwen3-coder-plus")!;
162+
expect(healed.name).toBe("[Bailian] qwen3-coder-plus");
163+
expect(healed.envKey).toBe("BAILIAN_CLI_API_KEY");
164+
const custom = entries.find((entry) => entry.id === "my-model")!;
165+
expect(custom.name).toBe("My Custom");
166+
});
167+
131168
test("qwen-code anthropic 端点走 anthropic 协议", () => {
132169
qwenCode.write({
133170
baseUrl: ANTHROPIC_URL,
134171
apiKey: "sk-q",
135172
model: "qwen3-max",
136173
});
137174
const settings = readJsonAt(".qwen", "settings.json");
138-
expect(
139-
(settings.security as { auth: { selectedType: string } }).auth
140-
.selectedType,
141-
).toBe("anthropic");
175+
expect((settings.security as { auth: { selectedType: string } }).auth.selectedType).toBe(
176+
"anthropic",
177+
);
142178
const providers = settings.modelProviders as Record<string, unknown>;
143179
expect(Array.isArray(providers.anthropic)).toBe(true);
144180
expect(providers.openai).toBeUndefined();
@@ -156,8 +192,7 @@ describe("config agent writers", () => {
156192
model: "qwen3-coder-plus",
157193
});
158194
const settings = readJsonAt(".qwen", "settings.json");
159-
const openaiEntries = (settings.modelProviders as Record<string, unknown[]>)
160-
.openai;
195+
const openaiEntries = (settings.modelProviders as Record<string, unknown[]>).openai;
161196
expect(openaiEntries).toHaveLength(1);
162197
});
163198

@@ -205,9 +240,7 @@ describe("config agent writers", () => {
205240
expect(options.baseURL).toBe(ANTHROPIC_URL);
206241
expect(options.apiKey).toBe("sk-o");
207242
expect(options.setCacheKey).toBe(true);
208-
expect(
209-
(provider["bailian-cli"].models as Record<string, unknown>)["qwen3-max"],
210-
).toBeDefined();
243+
expect((provider["bailian-cli"].models as Record<string, unknown>)["qwen3-max"]).toBeDefined();
211244

212245
// 非 anthropic 端点用 openai-compatible
213246
opencode.write({ baseUrl: OAI_URL, apiKey: "sk-o", model: "qwen3-max" });
@@ -230,9 +263,7 @@ describe("config agent writers", () => {
230263
const config = readJsonAt(".openclaw", "openclaw.json");
231264
const models = config.models as Record<string, unknown>;
232265
expect(models.mode).toBe("merge");
233-
const bailian = (
234-
models.providers as Record<string, Record<string, unknown>>
235-
)["bailian-cli"];
266+
const bailian = (models.providers as Record<string, Record<string, unknown>>)["bailian-cli"];
236267
expect(bailian.api).toBe("openai-completions");
237268
const entry = (bailian.models as Array<Record<string, unknown>>)[0];
238269
expect(entry.id).toBe("qwen3-coder-plus");
@@ -258,8 +289,7 @@ describe("config agent writers", () => {
258289
contextWindow: 1000000,
259290
});
260291
const config2 = readJsonAt(".openclaw", "openclaw.json");
261-
const providers2 = (config2.models as Record<string, unknown>)
262-
.providers as Record<
292+
const providers2 = (config2.models as Record<string, unknown>).providers as Record<
263293
string,
264294
{ api: string; models: Array<Record<string, unknown>> }
265295
>;
@@ -281,9 +311,7 @@ describe("config agent writers", () => {
281311
apiKey: "sk-h",
282312
model: "qwen3-coder-plus",
283313
});
284-
const config = yaml.parse(
285-
readFileSync(join(home, ".hermes", "config.yaml"), "utf8"),
286-
);
314+
const config = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
287315
// OpenAI 兼容端点:按官方文档省略 api_mode;无关顶层键不受影响
288316
expect(config.model).toEqual({
289317
default: "qwen3-coder-plus",
@@ -299,9 +327,7 @@ describe("config agent writers", () => {
299327
apiKey: "sk-h",
300328
model: "qwen3-max",
301329
});
302-
const config2 = yaml.parse(
303-
readFileSync(join(home, ".hermes", "config.yaml"), "utf8"),
304-
);
330+
const config2 = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
305331
expect(config2.model).toEqual({
306332
default: "qwen3-max",
307333
provider: "custom",
@@ -326,10 +352,7 @@ describe("config agent writers", () => {
326352
].join("\n"),
327353
);
328354
// 预置 auth.json 无关键,验证合并保留
329-
writeFileSync(
330-
join(home, ".codex", "auth.json"),
331-
JSON.stringify({ EXISTING: "keep" }),
332-
);
355+
writeFileSync(join(home, ".codex", "auth.json"), JSON.stringify({ EXISTING: "keep" }));
333356

334357
codex.write({
335358
baseUrl: OAI_URL,
@@ -367,10 +390,7 @@ describe("config agent writers", () => {
367390

368391
test("已存在的配置文件会被备份为 .bak.<epoch>", () => {
369392
mkdirSync(join(home, ".openclaw"), { recursive: true });
370-
writeFileSync(
371-
join(home, ".openclaw", "openclaw.json"),
372-
JSON.stringify({ pre: 1 }),
373-
);
393+
writeFileSync(join(home, ".openclaw", "openclaw.json"), JSON.stringify({ pre: 1 }));
374394

375395
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-max" });
376396
const backups = readdirSync(join(home, ".openclaw")).filter((name) =>

0 commit comments

Comments
 (0)