Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/commands/src/commands/config/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
}
},
});
40 changes: 33 additions & 7 deletions packages/commands/src/commands/config/agent/writers/claude-code.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>, 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<string, string>;
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);

Expand All @@ -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;
38 changes: 34 additions & 4 deletions packages/commands/src/commands/config/agent/writers/openclaw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, unknown>): string | undefined {
const model = defaults.model;
if (!model || typeof model !== "object") return undefined;
const primary = (model as Record<string, unknown>).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<string, unknown>;
models.mode = "merge";
const providers = (models.providers ?? {}) as Record<string, unknown>;
const api = isAnthropicEndpoint(baseUrl) ? "anthropic-messages" : "openai-completions";
providers["bailian-cli"] = {
providers[PROVIDER_ID] = {
baseUrl,
apiKey,
api,
Expand All @@ -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<string, unknown>;
const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
defaults.model = { primary: `bailian-cli/${model}` };
const allowedModels = (defaults.models ?? {}) as Record<string, unknown>;
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;

Expand All @@ -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;
76 changes: 62 additions & 14 deletions packages/commands/src/commands/config/agent/writers/qwen-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,58 +4,106 @@ 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<string, unknown>): 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<string, string>;
env[ENV_KEY] = apiKey;
settings.env = env;

// modelProviders[<protocol>] — 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[<protocol>] — upsert only bailian-cli-owned entries.
const providers = (settings.modelProviders ?? {}) as Record<
string,
Array<Record<string, unknown>>
>;
const entries = (providers[protocol] ?? []) as Array<Record<string, unknown>>;
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<string, unknown>;
security.auth = { selectedType: protocol, apiKey, baseUrl };
const previousAuth =
security.auth && typeof security.auth === "object"
? (security.auth as Record<string, unknown>)
: {};
security.auth = { ...previousAuth, selectedType: protocol };
// Drop deprecated inline creds so they cannot disagree with envKey lookup.
delete (security.auth as Record<string, unknown>).apiKey;
delete (security.auth as Record<string, unknown>).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);

return {
paths: [settingsPath],
nextStep: "Run `qwen` to start using Qwen Code with DashScope.",
warnings: warnings.length > 0 ? warnings : undefined,
};
},
} satisfies AgentDef;
47 changes: 47 additions & 0 deletions packages/commands/src/commands/config/agent/writers/utils.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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. */
Expand Down Expand Up @@ -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",
);
}
Loading