diff --git a/packages/commands/src/commands/config/agent/index.ts b/packages/commands/src/commands/config/agent/index.ts index b83e11a0..c2a1059b 100644 --- a/packages/commands/src/commands/config/agent/index.ts +++ b/packages/commands/src/commands/config/agent/index.ts @@ -66,6 +66,9 @@ export default defineCommand({ emitBare(`${agentDef.label} configured successfully.`); for (const path of summary.paths) emitBare(` Written: ${path}`); emitBare(` ${summary.nextStep}`); + for (const warning of summary.warnings ?? []) { + process.stderr.write(`Warning: ${warning}\n`); + } } }, }); diff --git a/packages/commands/src/commands/config/agent/writers/claude-code.ts b/packages/commands/src/commands/config/agent/writers/claude-code.ts index 938f84b3..08059719 100644 --- a/packages/commands/src/commands/config/agent/writers/claude-code.ts +++ b/packages/commands/src/commands/config/agent/writers/claude-code.ts @@ -1,25 +1,50 @@ import { homedir } from "os"; import { join } from "path"; -import { backup, readJson, writeJsonAtomic, type AgentDef } from "./utils.ts"; +import { + backup, + readJson, + writeJsonAtomic, + resolveClaudeCodeBaseUrl, + type AgentDef, +} from "./utils.ts"; + +/** Fill a tier/default model env only when the user has not set it yet. */ +function setModelEnvIfAbsent(env: Record, key: string, model: string): void { + const current = env[key]; + if (current === undefined || current.trim() === "") { + env[key] = model; + } +} export default { label: "Claude Code", write({ baseUrl, apiKey, model }) { const settingsPath = join(homedir(), ".claude", "settings.json"); const onboardingPath = join(homedir(), ".claude.json"); + const warnings: string[] = []; + + const resolved = resolveClaudeCodeBaseUrl(baseUrl); + if (resolved.rewrittenFrom) { + warnings.push( + `Rewrote base URL for Claude Code: "${resolved.rewrittenFrom}" → "${resolved.url}" ` + + `(Claude Code needs /apps/anthropic, not OpenAI compatible-mode).`, + ); + } // settings.json — merge env. Base URL + auth token connect Claude Code to - // the endpoint; the model tier vars force every tier onto the chosen model. + // the Anthropic-compatible endpoint; primary model always updates, while + // tier/subagent defaults are filled only when absent so existing setups + // (e.g. Token Plan Haiku/Subagent splits) are not wiped. backup(settingsPath); const settings = readJson(settingsPath); const env = (settings.env ?? {}) as Record; - env.ANTHROPIC_BASE_URL = baseUrl; + env.ANTHROPIC_BASE_URL = resolved.url; env.ANTHROPIC_AUTH_TOKEN = apiKey; env.ANTHROPIC_MODEL = model; - env.ANTHROPIC_DEFAULT_HAIKU_MODEL = model; - env.ANTHROPIC_DEFAULT_SONNET_MODEL = model; - env.ANTHROPIC_DEFAULT_OPUS_MODEL = model; - env.CLAUDE_CODE_SUBAGENT_MODEL = model; + setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_HAIKU_MODEL", model); + setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_SONNET_MODEL", model); + setModelEnvIfAbsent(env, "ANTHROPIC_DEFAULT_OPUS_MODEL", model); + setModelEnvIfAbsent(env, "CLAUDE_CODE_SUBAGENT_MODEL", model); settings.env = env; writeJsonAtomic(settingsPath, settings); @@ -32,6 +57,7 @@ export default { return { paths: [settingsPath, onboardingPath], nextStep: "Run `claude` to start using Claude Code with DashScope.", + warnings: warnings.length > 0 ? warnings : undefined, }; }, } satisfies AgentDef; diff --git a/packages/commands/src/commands/config/agent/writers/openclaw.ts b/packages/commands/src/commands/config/agent/writers/openclaw.ts index 71ec8c57..e736700d 100644 --- a/packages/commands/src/commands/config/agent/writers/openclaw.ts +++ b/packages/commands/src/commands/config/agent/writers/openclaw.ts @@ -2,20 +2,32 @@ import { homedir } from "os"; import { join } from "path"; import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts"; +const PROVIDER_ID = "bailian-cli"; + +function readPrimary(defaults: Record): string | undefined { + const model = defaults.model; + if (!model || typeof model !== "object") return undefined; + const primary = (model as Record).primary; + return typeof primary === "string" && primary.trim() !== "" ? primary.trim() : undefined; +} + export default { label: "OpenClaw", write({ baseUrl, apiKey, model }) { const configPath = join(homedir(), ".openclaw", "openclaw.json"); + const warnings: string[] = []; + const modelRef = `${PROVIDER_ID}/${model}`; backup(configPath); const config = readJson(configPath); - // models.providers["bailian-cli"] + // models.providers["bailian-cli"] — upsert without removing other providers + // (e.g. an existing working bailian-token-plan setup). const models = (config.models ?? {}) as Record; models.mode = "merge"; const providers = (models.providers ?? {}) as Record; const api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions"; - providers["bailian-cli"] = { + providers[PROVIDER_ID] = { baseUrl, apiKey, api, @@ -31,10 +43,27 @@ export default { models.providers = providers; config.models = models; - // agents.defaults + // agents.defaults — register the model in the allow-list. Only set primary + // when unset, or when primary already points at bailian-cli (reconfigure). + // Never steal primary away from another provider such as bailian-token-plan. const agents = (config.agents ?? {}) as Record; const defaults = (agents.defaults ?? {}) as Record; - defaults.model = { primary: `bailian-cli/${model}` }; + const allowedModels = (defaults.models ?? {}) as Record; + allowedModels[modelRef] = allowedModels[modelRef] ?? {}; + defaults.models = allowedModels; + + const existingPrimary = readPrimary(defaults); + if (!existingPrimary) { + defaults.model = { primary: modelRef }; + } else if (existingPrimary.startsWith(`${PROVIDER_ID}/`)) { + defaults.model = { primary: modelRef }; + } else { + warnings.push( + `Left existing primary model unchanged ("${existingPrimary}"). ` + + `Added provider "${PROVIDER_ID}" — switch to "${modelRef}" in OpenClaw if you want to use it.`, + ); + } + agents.defaults = defaults; config.agents = agents; @@ -43,6 +72,7 @@ export default { return { paths: [configPath], nextStep: "Run `openclaw` to start using OpenClaw with DashScope.", + warnings: warnings.length > 0 ? warnings : undefined, }; }, } satisfies AgentDef; diff --git a/packages/commands/src/commands/config/agent/writers/qwen-code.ts b/packages/commands/src/commands/config/agent/writers/qwen-code.ts index 437f19ff..36fadc52 100644 --- a/packages/commands/src/commands/config/agent/writers/qwen-code.ts +++ b/packages/commands/src/commands/config/agent/writers/qwen-code.ts @@ -4,51 +4,98 @@ import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } const ENV_KEY = "BAILIAN_CLI_API_KEY"; +function displayName(model: string): string { + return `${model} (bailian-cli)`; +} + +/** Entries we previously wrote, or still own via envKey. */ +function isBailianCliEntry(entry: Record): boolean { + return entry.envKey === ENV_KEY || entry.name === "bailian-cli"; +} + /** * Qwen Code keys `modelProviders` and `security.auth.selectedType` by the SDK * protocol (an AuthType string), not by a free-form provider id — the runtime - * resolver indexes credentials/defaults by protocol. The `bailian-cli` brand - * therefore lives in the model entry `name` and the env var name. + * resolver indexes credentials/defaults by protocol. Ownership is tracked via + * `envKey` (`BAILIAN_CLI_API_KEY`) and a display `name` suffix `(bailian-cli)`. + * + * Qwen Code does not support duplicate model `id`s (only the first loads), so + * we must never overwrite a pre-existing Token Plan / third-party entry that + * shares the same id. */ export default { label: "Qwen Code", write({ baseUrl, apiKey, model }) { const settingsPath = join(homedir(), ".qwen", "settings.json"); const protocol = isAnthropicEndpoint(baseUrl) ? "anthropic" : "openai"; + const warnings: string[] = []; backup(settingsPath); const settings = readJson(settingsPath); // env — API key read by the provider entry's envKey. + // Qwen Code treats settings.json `env` as lowest priority; a process/shell + // value for the same key wins and can make the first launch fail. const env = (settings.env ?? {}) as Record; env[ENV_KEY] = apiKey; settings.env = env; - // modelProviders[] — upsert the bailian-cli model entry. + const processEnvValue = process.env[ENV_KEY]; + if (processEnvValue !== undefined && processEnvValue !== apiKey) { + warnings.push( + `Shell/environment ${ENV_KEY} is set and overrides settings.json. ` + + `Unset it (e.g. \`unset ${ENV_KEY}\`) so the key written here takes effect.`, + ); + } + + // modelProviders[] — upsert only bailian-cli-owned entries. const providers = (settings.modelProviders ?? {}) as Record< string, Array> >; const entries = (providers[protocol] ?? []) as Array>; - const existing = entries.find( - (entry) => entry.id === model && (entry.baseUrl ?? "") === baseUrl, - ); - if (existing) { - existing.name = "bailian-cli"; - existing.baseUrl = baseUrl; - existing.envKey = ENV_KEY; + const owned = entries.find((entry) => isBailianCliEntry(entry) && entry.id === model); + const conflicting = entries.find((entry) => !isBailianCliEntry(entry) && entry.id === model); + + if (owned) { + owned.name = displayName(model); + owned.baseUrl = baseUrl; + owned.envKey = ENV_KEY; + } else if (conflicting) { + const existingName = + typeof conflicting.name === "string" && conflicting.name.length > 0 + ? conflicting.name + : String(conflicting.id); + warnings.push( + `Model id "${model}" already exists as "${existingName}"; left unchanged ` + + `(Qwen Code loads only the first entry per id). Remove or rename that ` + + `entry if you want bailian-cli to own this model.`, + ); } else { - entries.push({ id: model, name: "bailian-cli", baseUrl, envKey: ENV_KEY }); + entries.push({ + id: model, + name: displayName(model), + baseUrl, + envKey: ENV_KEY, + }); } providers[protocol] = entries; settings.modelProviders = providers; - // security.auth — select the protocol and carry the OpenAI-compatible creds. + // security.auth — select protocol only. Prefer modelProviders + envKey for + // credentials; apiKey/baseUrl on auth are deprecated in Qwen Code. const security = (settings.security ?? {}) as Record; - security.auth = { selectedType: protocol, apiKey, baseUrl }; + const previousAuth = + security.auth && typeof security.auth === "object" + ? (security.auth as Record) + : {}; + security.auth = { ...previousAuth, selectedType: protocol }; + // Drop deprecated inline creds so they cannot disagree with envKey lookup. + delete (security.auth as Record).apiKey; + delete (security.auth as Record).baseUrl; settings.security = security; - // model — active model, disambiguated by baseUrl. + // model — active model id (disambiguated by baseUrl when supported). settings.model = { name: model, baseUrl }; writeJsonAtomic(settingsPath, settings); @@ -56,6 +103,7 @@ export default { return { paths: [settingsPath], nextStep: "Run `qwen` to start using Qwen Code with DashScope.", + warnings: warnings.length > 0 ? warnings : undefined, }; }, } satisfies AgentDef; diff --git a/packages/commands/src/commands/config/agent/writers/utils.ts b/packages/commands/src/commands/config/agent/writers/utils.ts index bbc6a377..52798128 100644 --- a/packages/commands/src/commands/config/agent/writers/utils.ts +++ b/packages/commands/src/commands/config/agent/writers/utils.ts @@ -1,5 +1,6 @@ import { dirname } from "path"; import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs"; +import { BailianError, ExitCode } from "bailian-cli-core"; /** Parameters shared by every agent writer. */ export interface WriteParams { @@ -12,6 +13,8 @@ export interface WriteParams { export interface WriteSummary { paths: string[]; nextStep: string; + /** Non-fatal issues the command should surface to the user. */ + warnings?: string[]; } /** An agent configuration writer: a human label plus a `write` that applies it. */ @@ -57,3 +60,47 @@ export function backup(path: string): void { export function isAnthropicEndpoint(baseUrl: string): boolean { return baseUrl.includes("/apps/anthropic"); } + +/** + * Claude Code speaks Anthropic Messages only. Users often paste the OpenAI + * compatible-mode URL; rewrite that to `/apps/anthropic` when possible, otherwise + * fail with a clear USAGE error before writing a broken config. + */ +export function resolveClaudeCodeBaseUrl(baseUrl: string): { + url: string; + rewrittenFrom?: string; +} { + const trimmed = baseUrl.trim().replace(/\/+$/, ""); + + if (isAnthropicEndpoint(trimmed)) { + return { url: trimmed }; + } + + if (trimmed.includes("/compatible-mode")) { + const rewritten = trimmed.replace(/\/compatible-mode(?:\/v\d+)?/, "/apps/anthropic"); + return { url: rewritten, rewrittenFrom: baseUrl.trim() }; + } + + try { + const parsed = new URL(trimmed); + const host = parsed.hostname; + const isDashScopeHost = + host.includes("dashscope") || + host.includes("maas.aliyuncs.com") || + host.includes("token-plan"); + if (isDashScopeHost && (parsed.pathname === "/" || parsed.pathname === "")) { + return { + url: `${parsed.origin}/apps/anthropic`, + rewrittenFrom: baseUrl.trim(), + }; + } + } catch { + // Fall through to the USAGE error below. + } + + throw new BailianError( + `Claude Code requires an Anthropic-compatible base URL, got "${baseUrl}".`, + ExitCode.USAGE, + "Use a URL ending in /apps/anthropic (not /compatible-mode/v1). Example: https://dashscope.aliyuncs.com/apps/anthropic", + ); +} diff --git a/packages/commands/tests/config-agent-writers.test.ts b/packages/commands/tests/config-agent-writers.test.ts index 1537c204..4f810d66 100644 --- a/packages/commands/tests/config-agent-writers.test.ts +++ b/packages/commands/tests/config-agent-writers.test.ts @@ -70,19 +70,67 @@ describe("config agent writers", () => { expect(readJsonAt(".claude.json").hasCompletedOnboarding).toBe(true); }); + test("claude-code 将 compatible-mode URL 改写为 apps/anthropic", () => { + const tokenPlanOpenAi = "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"; + const summary = claudeCode.write({ + baseUrl: tokenPlanOpenAi, + apiKey: "sk-a", + model: "qwen3.8-max-preview", + }); + const env = readJsonAt(".claude", "settings.json").env as Record; + expect(env.ANTHROPIC_BASE_URL).toBe( + "https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic", + ); + expect(summary.warnings?.some((warning) => warning.includes("Rewrote base URL"))).toBe(true); + }); + + test("claude-code 保留已有分层模型,不整表覆盖", () => { + mkdirSync(join(home, ".claude"), { recursive: true }); + writeFileSync( + join(home, ".claude", "settings.json"), + JSON.stringify({ + env: { + ANTHROPIC_DEFAULT_HAIKU_MODEL: "qwen3.6-flash", + CLAUDE_CODE_SUBAGENT_MODEL: "qwen3.7-max", + }, + }), + ); + + claudeCode.write({ + baseUrl: ANTHROPIC_URL, + apiKey: "sk-a", + model: "qwen3.8-max-preview", + }); + const env = readJsonAt(".claude", "settings.json").env as Record; + expect(env.ANTHROPIC_MODEL).toBe("qwen3.8-max-preview"); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("qwen3.6-flash"); + expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("qwen3.7-max"); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("qwen3.8-max-preview"); + }); + + test("claude-code 拒绝无法改写为 Anthropic 的 base URL", () => { + expect(() => + claudeCode.write({ + baseUrl: "https://api.openai.com/v1", + apiKey: "sk-a", + model: "qwen3-max", + }), + ).toThrow(/Anthropic-compatible base URL/); + }); + test("qwen-code compatible-mode 走 openai 协议", () => { qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-q", model: "qwen3-coder-plus" }); const settings = readJsonAt(".qwen", "settings.json"); const security = settings.security as { auth: Record }; expect(security.auth.selectedType).toBe("openai"); - expect(security.auth.apiKey).toBe("sk-q"); - expect(security.auth.baseUrl).toBe(OAI_URL); + expect(security.auth.apiKey).toBeUndefined(); + expect(security.auth.baseUrl).toBeUndefined(); expect((settings.env as Record).BAILIAN_CLI_API_KEY).toBe("sk-q"); expect((settings.model as Record).name).toBe("qwen3-coder-plus"); const providers = settings.modelProviders as Record>>; expect(providers.openai[0]).toMatchObject({ id: "qwen3-coder-plus", - name: "bailian-cli", + name: "qwen3-coder-plus (bailian-cli)", baseUrl: OAI_URL, envKey: "BAILIAN_CLI_API_KEY", }); @@ -99,12 +147,76 @@ describe("config agent writers", () => { expect(providers.openai).toBeUndefined(); }); - test("qwen-code 对相同 id+baseUrl 的 provider 项做 upsert 而非追加", () => { + test("qwen-code 对自有 provider 项按 id upsert 而非追加", () => { qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-1", model: "qwen3-coder-plus" }); qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-2", model: "qwen3-coder-plus" }); const settings = readJsonAt(".qwen", "settings.json"); const openaiEntries = (settings.modelProviders as Record).openai; expect(openaiEntries).toHaveLength(1); + expect((settings.env as Record).BAILIAN_CLI_API_KEY).toBe("sk-2"); + }); + + test("qwen-code 不劫持已有 Token Plan 同 id 条目的 name/envKey", () => { + mkdirSync(join(home, ".qwen"), { recursive: true }); + writeFileSync( + join(home, ".qwen", "settings.json"), + JSON.stringify({ + env: { BAILIAN_TOKEN_PLAN_API_KEY: "sk-token-plan" }, + modelProviders: { + openai: [ + { + id: "qwen3.8-max-preview", + name: "[Token Plan 个人版] qwen3.8-max-preview", + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + envKey: "BAILIAN_TOKEN_PLAN_API_KEY", + generationConfig: { extra_body: { enable_thinking: true } }, + }, + ], + }, + }), + ); + + const tokenPlanUrl = "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"; + const summary = qwenCode.write({ + baseUrl: tokenPlanUrl, + apiKey: "sk-bailian", + model: "qwen3.8-max-preview", + }); + + const settings = readJsonAt(".qwen", "settings.json"); + const openaiEntries = ( + settings.modelProviders as Record>> + ).openai; + expect(openaiEntries).toHaveLength(1); + expect(openaiEntries[0]).toMatchObject({ + id: "qwen3.8-max-preview", + name: "[Token Plan 个人版] qwen3.8-max-preview", + envKey: "BAILIAN_TOKEN_PLAN_API_KEY", + generationConfig: { extra_body: { enable_thinking: true } }, + }); + expect((settings.env as Record).BAILIAN_TOKEN_PLAN_API_KEY).toBe( + "sk-token-plan", + ); + expect((settings.env as Record).BAILIAN_CLI_API_KEY).toBe("sk-bailian"); + expect(summary.warnings?.some((warning) => warning.includes("already exists"))).toBe(true); + }); + + test("qwen-code 在进程环境变量覆盖 settings.env 时给出警告", () => { + const previous = process.env.BAILIAN_CLI_API_KEY; + process.env.BAILIAN_CLI_API_KEY = "sk-from-shell"; + try { + const summary = qwenCode.write({ + baseUrl: OAI_URL, + apiKey: "sk-from-settings", + model: "qwen3-coder-plus", + }); + expect(summary.warnings?.some((warning) => warning.includes("overrides settings.json"))).toBe( + true, + ); + } finally { + if (previous === undefined) delete process.env.BAILIAN_CLI_API_KEY; + else process.env.BAILIAN_CLI_API_KEY = previous; + } }); test("opencode 按端点选 npm,含 setCacheKey,合并保留其它 provider", () => { @@ -137,7 +249,7 @@ describe("config agent writers", () => { ).toBe("@ai-sdk/openai-compatible"); }); - test("openclaw 写入 provider、api 与 primary", () => { + test("openclaw 写入 provider、api、primary,并登记 defaults.models", () => { openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-coder-plus" }); const config = readJsonAt(".openclaw", "openclaw.json"); const models = config.models as Record; @@ -145,8 +257,11 @@ describe("config agent writers", () => { const bailian = (models.providers as Record>)["bailian-cli"]; expect(bailian.api).toBe("openai-completions"); expect((bailian.models as Array<{ id: string }>)[0].id).toBe("qwen3-coder-plus"); - const agents = config.agents as { defaults: { model: { primary: string } } }; + const agents = config.agents as { + defaults: { model: { primary: string }; models: Record }; + }; expect(agents.defaults.model.primary).toBe("bailian-cli/qwen3-coder-plus"); + expect(agents.defaults.models["bailian-cli/qwen3-coder-plus"]).toEqual({}); // anthropic 端点用 anthropic-messages openclaw.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-c", model: "qwen3-max" }); @@ -156,6 +271,57 @@ describe("config agent writers", () => { "bailian-cli" ].api, ).toBe("anthropic-messages"); + expect( + (config2.agents as { defaults: { model: { primary: string } } }).defaults.model.primary, + ).toBe("bailian-cli/qwen3-max"); + }); + + test("openclaw 不抢占已有 token-plan primary", () => { + mkdirSync(join(home, ".openclaw"), { recursive: true }); + writeFileSync( + join(home, ".openclaw", "openclaw.json"), + JSON.stringify({ + models: { + mode: "merge", + providers: { + "bailian-token-plan": { + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/apps/anthropic", + apiKey: "sk-token-plan", + api: "anthropic-messages", + models: [{ id: "qwen3.8-max-preview", name: "qwen3.8-max-preview" }], + }, + }, + }, + agents: { + defaults: { + model: { primary: "bailian-token-plan/qwen3.8-max-preview" }, + models: { "bailian-token-plan/qwen3.8-max-preview": {} }, + }, + }, + }), + ); + + const summary = openclaw.write({ + baseUrl: "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1", + apiKey: "sk-bailian", + model: "qwen3.8-max-preview", + }); + + const config = readJsonAt(".openclaw", "openclaw.json"); + const agents = config.agents as { + defaults: { model: { primary: string }; models: Record }; + }; + expect(agents.defaults.model.primary).toBe("bailian-token-plan/qwen3.8-max-preview"); + expect(agents.defaults.models["bailian-cli/qwen3.8-max-preview"]).toEqual({}); + expect( + (config.models as { providers: Record }).providers["bailian-token-plan"], + ).toBeDefined(); + expect( + (config.models as { providers: Record }).providers["bailian-cli"], + ).toBeDefined(); + expect(summary.warnings?.some((warning) => warning.includes("Left existing primary"))).toBe( + true, + ); }); test("hermes 写入 custom_providers 与 model,合并保留其它 provider", () => {