Skip to content

Commit 56d9a96

Browse files
committed
feat(claude-code, openclaw): enhance model handling and URL resolution
1 parent 74c902f commit 56d9a96

4 files changed

Lines changed: 216 additions & 13 deletions

File tree

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,50 @@
11
import { homedir } from "os";
22
import { join } from "path";
3-
import { backup, readJson, writeJsonAtomic, type AgentDef } from "./utils.ts";
3+
import {
4+
backup,
5+
readJson,
6+
writeJsonAtomic,
7+
resolveClaudeCodeBaseUrl,
8+
type AgentDef,
9+
} from "./utils.ts";
10+
11+
/** Fill a tier/default model env only when the user has not set it yet. */
12+
function setModelEnvIfAbsent(env: Record<string, string>, key: string, model: string): void {
13+
const current = env[key];
14+
if (current === undefined || current.trim() === "") {
15+
env[key] = model;
16+
}
17+
}
418

519
export default {
620
label: "Claude Code",
721
write({ baseUrl, apiKey, model }) {
822
const settingsPath = join(homedir(), ".claude", "settings.json");
923
const onboardingPath = join(homedir(), ".claude.json");
24+
const warnings: string[] = [];
25+
26+
const resolved = resolveClaudeCodeBaseUrl(baseUrl);
27+
if (resolved.rewrittenFrom) {
28+
warnings.push(
29+
`Rewrote base URL for Claude Code: "${resolved.rewrittenFrom}" → "${resolved.url}" ` +
30+
`(Claude Code needs /apps/anthropic, not OpenAI compatible-mode).`,
31+
);
32+
}
1033

1134
// settings.json — merge env. Base URL + auth token connect Claude Code to
12-
// the endpoint; the model tier vars force every tier onto the chosen model.
35+
// the Anthropic-compatible endpoint; primary model always updates, while
36+
// tier/subagent defaults are filled only when absent so existing setups
37+
// (e.g. Token Plan Haiku/Subagent splits) are not wiped.
1338
backup(settingsPath);
1439
const settings = readJson(settingsPath);
1540
const env = (settings.env ?? {}) as Record<string, string>;
16-
env.ANTHROPIC_BASE_URL = baseUrl;
41+
env.ANTHROPIC_BASE_URL = resolved.url;
1742
env.ANTHROPIC_AUTH_TOKEN = apiKey;
1843
env.ANTHROPIC_MODEL = model;
19-
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = model;
20-
env.ANTHROPIC_DEFAULT_SONNET_MODEL = model;
21-
env.ANTHROPIC_DEFAULT_OPUS_MODEL = model;
22-
env.CLAUDE_CODE_SUBAGENT_MODEL = model;
44+
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL", model);
45+
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_SONNET_MODEL", model);
46+
setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_OPUS_MODEL", model);
47+
setModelEnvIfAbsent(env, "CLAUDE_CODE_SUBAGENT_MODEL", model);
2348
settings.env = env;
2449
writeJsonAtomic(settingsPath, settings);
2550

@@ -32,6 +57,7 @@ export default {
3257
return {
3358
paths: [settingsPath, onboardingPath],
3459
nextStep: "Run `claude` to start using Claude Code with DashScope.",
60+
warnings: warnings.length > 0 ? warnings : undefined,
3561
};
3662
},
3763
} satisfies AgentDef;

packages/commands/src/commands/config/agent/writers/openclaw.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,32 @@ import { homedir } from "os";
22
import { join } from "path";
33
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
44

5+
const PROVIDER_ID = "bailian-cli";
6+
7+
function readPrimary(defaults: Record<string, unknown>): string | undefined {
8+
const model = defaults.model;
9+
if (!model || typeof model !== "object") return undefined;
10+
const primary = (model as Record<string, unknown>).primary;
11+
return typeof primary === "string" && primary.trim() !== "" ? primary.trim() : undefined;
12+
}
13+
514
export default {
615
label: "OpenClaw",
716
write({ baseUrl, apiKey, model }) {
817
const configPath = join(homedir(), ".openclaw", "openclaw.json");
18+
const warnings: string[] = [];
19+
const modelRef = `${PROVIDER_ID}/${model}`;
920

1021
backup(configPath);
1122
const config = readJson(configPath);
1223

13-
// models.providers["bailian-cli"]
24+
// models.providers["bailian-cli"] — upsert without removing other providers
25+
// (e.g. an existing working bailian-token-plan setup).
1426
const models = (config.models ?? {}) as Record<string, unknown>;
1527
models.mode = "merge";
1628
const providers = (models.providers ?? {}) as Record<string, unknown>;
1729
const api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions";
18-
providers["bailian-cli"] = {
30+
providers[PROVIDER_ID] = {
1931
baseUrl,
2032
apiKey,
2133
api,
@@ -31,10 +43,27 @@ export default {
3143
models.providers = providers;
3244
config.models = models;
3345

34-
// agents.defaults
46+
// agents.defaults — register the model in the allow-list. Only set primary
47+
// when unset, or when primary already points at bailian-cli (reconfigure).
48+
// Never steal primary away from another provider such as bailian-token-plan.
3549
const agents = (config.agents ?? {}) as Record<string, unknown>;
3650
const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
37-
defaults.model = { primary: `bailian-cli/${model}` };
51+
const allowedModels = (defaults.models ?? {}) as Record<string, unknown>;
52+
allowedModels[modelRef] = allowedModels[modelRef] ?? {};
53+
defaults.models = allowedModels;
54+
55+
const existingPrimary = readPrimary(defaults);
56+
if (!existingPrimary) {
57+
defaults.model = { primary: modelRef };
58+
} else if (existingPrimary.startsWith(`${PROVIDER_ID}/`)) {
59+
defaults.model = { primary: modelRef };
60+
} else {
61+
warnings.push(
62+
`Left existing primary model unchanged ("${existingPrimary}"). ` +
63+
`Added provider "${PROVIDER_ID}" — switch to "${modelRef}" in OpenClaw if you want to use it.`,
64+
);
65+
}
66+
3867
agents.defaults = defaults;
3968
config.agents = agents;
4069

@@ -43,6 +72,7 @@ export default {
4372
return {
4473
paths: [configPath],
4574
nextStep: "Run `openclaw` to start using OpenClaw with DashScope.",
75+
warnings: warnings.length > 0 ? warnings : undefined,
4676
};
4777
},
4878
} satisfies AgentDef;

packages/commands/src/commands/config/agent/writers/utils.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { dirname } from "path";
22
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs";
3+
import { BailianError, ExitCode } from "bailian-cli-core";
34

45
/** Parameters shared by every agent writer. */
56
export interface WriteParams {
@@ -59,3 +60,47 @@ export function backup(path: string): void {
5960
export function isAnthropicEndpoint(baseUrl: string): boolean {
6061
return baseUrl.includes("/apps/anthropic");
6162
}
63+
64+
/**
65+
* Claude Code speaks Anthropic Messages only. Users often paste the OpenAI
66+
* compatible-mode URL; rewrite that to `/apps/anthropic` when possible, otherwise
67+
* fail with a clear USAGE error before writing a broken config.
68+
*/
69+
export function resolveClaudeCodeBaseUrl(baseUrl: string): {
70+
url: string;
71+
rewrittenFrom?: string;
72+
} {
73+
const trimmed = baseUrl.trim().replace(/\/+$/, "");
74+
75+
if (isAnthropicEndpoint(trimmed)) {
76+
return { url: trimmed };
77+
}
78+
79+
if (trimmed.includes("/compatible-mode")) {
80+
const rewritten = trimmed.replace(/\/compatible-mode(?:\/v\d+)?/, "/apps/anthropic");
81+
return { url: rewritten, rewrittenFrom: baseUrl.trim() };
82+
}
83+
84+
try {
85+
const parsed = new URL(trimmed);
86+
const host = parsed.hostname;
87+
const isDashScopeHost =
88+
host.includes("dashscope") ||
89+
host.includes("maas.aliyuncs.com") ||
90+
host.includes("token-plan");
91+
if (isDashScopeHost && (parsed.pathname === "/" || parsed.pathname === "")) {
92+
return {
93+
url: `${parsed.origin}/apps/anthropic`,
94+
rewrittenFrom: baseUrl.trim(),
95+
};
96+
}
97+
} catch {
98+
// Fall through to the USAGE error below.
99+
}
100+
101+
throw new BailianError(
102+
`Claude Code requires an Anthropic-compatible base URL, got "${baseUrl}".`,
103+
ExitCode.USAGE,
104+
"Use a URL ending in /apps/anthropic (not /compatible-mode/v1). Example: https://dashscope.aliyuncs.com/apps/anthropic",
105+
);
106+
}

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

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,54 @@ describe("config agent writers", () => {
7070
expect(readJsonAt(".claude.json").hasCompletedOnboarding).toBe(true);
7171
});
7272

73+
test("claude-code 将 compatible-mode URL 改写为 apps/anthropic", () => {
74+
const tokenPlanOpenAi = "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1";
75+
const summary = claudeCode.write({
76+
baseUrl: tokenPlanOpenAi,
77+
apiKey: "sk-a",
78+
model: "qwen3.8-max-preview",
79+
});
80+
const env = readJsonAt(".claude", "settings.json").env as Record<string, string>;
81+
expect(env.ANTHROPIC_BASE_URL).toBe(
82+
"https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic",
83+
);
84+
expect(summary.warnings?.some((warning) => warning.includes("Rewrote base URL"))).toBe(true);
85+
});
86+
87+
test("claude-code 保留已有分层模型,不整表覆盖", () => {
88+
mkdirSync(join(home, ".claude"), { recursive: true });
89+
writeFileSync(
90+
join(home, ".claude", "settings.json"),
91+
JSON.stringify({
92+
env: {
93+
ANTHROPIC_DEFAULT_HAIKU_MODEL: "qwen3.6-flash",
94+
CLAUDE_CODE_SUBAGENT_MODEL: "qwen3.7-max",
95+
},
96+
}),
97+
);
98+
99+
claudeCode.write({
100+
baseUrl: ANTHROPIC_URL,
101+
apiKey: "sk-a",
102+
model: "qwen3.8-max-preview",
103+
});
104+
const env = readJsonAt(".claude", "settings.json").env as Record<string, string>;
105+
expect(env.ANTHROPIC_MODEL).toBe("qwen3.8-max-preview");
106+
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("qwen3.6-flash");
107+
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("qwen3.7-max");
108+
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("qwen3.8-max-preview");
109+
});
110+
111+
test("claude-code 拒绝无法改写为 Anthropic 的 base URL", () => {
112+
expect(() =>
113+
claudeCode.write({
114+
baseUrl: "https://api.openai.com/v1",
115+
apiKey: "sk-a",
116+
model: "qwen3-max",
117+
}),
118+
).toThrow(/Anthropic-compatible base URL/);
119+
});
120+
73121
test("qwen-code compatible-mode 走 openai 协议", () => {
74122
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-q", model: "qwen3-coder-plus" });
75123
const settings = readJsonAt(".qwen", "settings.json");
@@ -201,16 +249,19 @@ describe("config agent writers", () => {
201249
).toBe("@ai-sdk/openai-compatible");
202250
});
203251

204-
test("openclaw 写入 provider、apiprimary", () => {
252+
test("openclaw 写入 provider、apiprimary,并登记 defaults.models", () => {
205253
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-coder-plus" });
206254
const config = readJsonAt(".openclaw", "openclaw.json");
207255
const models = config.models as Record<string, unknown>;
208256
expect(models.mode).toBe("merge");
209257
const bailian = (models.providers as Record<string, Record<string, unknown>>)["bailian-cli"];
210258
expect(bailian.api).toBe("openai-completions");
211259
expect((bailian.models as Array<{ id: string }>)[0].id).toBe("qwen3-coder-plus");
212-
const agents = config.agents as { defaults: { model: { primary: string } } };
260+
const agents = config.agents as {
261+
defaults: { model: { primary: string }; models: Record<string, unknown> };
262+
};
213263
expect(agents.defaults.model.primary).toBe("bailian-cli/qwen3-coder-plus");
264+
expect(agents.defaults.models["bailian-cli/qwen3-coder-plus"]).toEqual({});
214265

215266
// anthropic 端点用 anthropic-messages
216267
openclaw.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-c", model: "qwen3-max" });
@@ -220,6 +271,57 @@ describe("config agent writers", () => {
220271
"bailian-cli"
221272
].api,
222273
).toBe("anthropic-messages");
274+
expect(
275+
(config2.agents as { defaults: { model: { primary: string } } }).defaults.model.primary,
276+
).toBe("bailian-cli/qwen3-max");
277+
});
278+
279+
test("openclaw 不抢占已有 token-plan primary", () => {
280+
mkdirSync(join(home, ".openclaw"), { recursive: true });
281+
writeFileSync(
282+
join(home, ".openclaw", "openclaw.json"),
283+
JSON.stringify({
284+
models: {
285+
mode: "merge",
286+
providers: {
287+
"bailian-token-plan": {
288+
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic",
289+
apiKey: "sk-token-plan",
290+
api: "anthropic-messages",
291+
models: [{ id: "qwen3.8-max-preview", name: "qwen3.8-max-preview" }],
292+
},
293+
},
294+
},
295+
agents: {
296+
defaults: {
297+
model: { primary: "bailian-token-plan/qwen3.8-max-preview" },
298+
models: { "bailian-token-plan/qwen3.8-max-preview": {} },
299+
},
300+
},
301+
}),
302+
);
303+
304+
const summary = openclaw.write({
305+
baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
306+
apiKey: "sk-bailian",
307+
model: "qwen3.8-max-preview",
308+
});
309+
310+
const config = readJsonAt(".openclaw", "openclaw.json");
311+
const agents = config.agents as {
312+
defaults: { model: { primary: string }; models: Record<string, unknown> };
313+
};
314+
expect(agents.defaults.model.primary).toBe("bailian-token-plan/qwen3.8-max-preview");
315+
expect(agents.defaults.models["bailian-cli/qwen3.8-max-preview"]).toEqual({});
316+
expect(
317+
(config.models as { providers: Record<string, unknown> }).providers["bailian-token-plan"],
318+
).toBeDefined();
319+
expect(
320+
(config.models as { providers: Record<string, unknown> }).providers["bailian-cli"],
321+
).toBeDefined();
322+
expect(summary.warnings?.some((warning) => warning.includes("Left existing primary"))).toBe(
323+
true,
324+
);
223325
});
224326

225327
test("hermes 写入 custom_providers 与 model,合并保留其它 provider", () => {

0 commit comments

Comments
 (0)