mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
fix(config-agent): align agent writers with cc-switch and official Model Studio docs
- claude-code: honor CLAUDE_CONFIG_DIR; drop stale ANTHROPIC_API_KEY - qwen-code: write $version:3; security.auth carries selectedType only - opencode: tolerate JSONC (comments/trailing commas) via stripJsonc - openclaw: add --context-window flag (default 256000), full cost fields, agents.defaults.models allowlist - hermes: switch to official flat model.* block; api_mode only for anthropic endpoints - codex: official env_key + auth.json fallback; add --wire-api flag (default chat, responses for supported models)
This commit is contained in:
@@ -11,14 +11,36 @@ const FLAGS = {
|
||||
required: true,
|
||||
choices: VALID_AGENT_NAMES,
|
||||
},
|
||||
baseUrl: { type: "string", valueHint: "<url>", description: "API base URL", required: true },
|
||||
apiKey: { type: "string", valueHint: "<key>", description: "API key", required: true },
|
||||
baseUrl: {
|
||||
type: "string",
|
||||
valueHint: "<url>",
|
||||
description: "API base URL",
|
||||
required: true,
|
||||
},
|
||||
apiKey: {
|
||||
type: "string",
|
||||
valueHint: "<key>",
|
||||
description: "API key",
|
||||
required: true,
|
||||
},
|
||||
model: {
|
||||
type: "string",
|
||||
valueHint: "<model>",
|
||||
description: "Default model name",
|
||||
required: true,
|
||||
},
|
||||
contextWindow: {
|
||||
type: "number",
|
||||
valueHint: "<tokens>",
|
||||
description: "OpenClaw only: model context window in tokens (default: 256000)",
|
||||
},
|
||||
wireApi: {
|
||||
type: "string",
|
||||
valueHint: "<api>",
|
||||
description:
|
||||
'Codex only: wire protocol — "chat" works with every model; "responses" for models supporting the Responses API (default: chat)',
|
||||
choices: ["chat", "responses"],
|
||||
},
|
||||
} satisfies FlagsDef;
|
||||
|
||||
export default defineCommand({
|
||||
@@ -34,7 +56,7 @@ export default defineCommand({
|
||||
async run(ctx) {
|
||||
const { settings, flags } = ctx;
|
||||
const agentName = flags.agent;
|
||||
const { baseUrl, apiKey, model } = flags;
|
||||
const { baseUrl, apiKey, model, contextWindow, wireApi } = flags;
|
||||
const agentDef = AGENTS[agentName];
|
||||
const format = detectOutputFormat(settings.output);
|
||||
|
||||
@@ -59,7 +81,13 @@ export default defineCommand({
|
||||
return;
|
||||
}
|
||||
|
||||
const params: WriteParams = { baseUrl, apiKey, model };
|
||||
const params: WriteParams = {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
model,
|
||||
contextWindow,
|
||||
wireApi,
|
||||
};
|
||||
const summary = agentDef.write(params);
|
||||
|
||||
if (!settings.quiet) {
|
||||
|
||||
@@ -5,7 +5,10 @@ import { backup, readJson, writeJsonAtomic, type AgentDef } from "./utils.ts";
|
||||
export default {
|
||||
label: "Claude Code",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const settingsPath = join(homedir(), ".claude", "settings.json");
|
||||
// Claude Code honors CLAUDE_CONFIG_DIR for its settings location.
|
||||
const configDir =
|
||||
process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
||||
const settingsPath = join(configDir, "settings.json");
|
||||
const onboardingPath = join(homedir(), ".claude.json");
|
||||
|
||||
// settings.json — merge env. Base URL + auth token connect Claude Code to
|
||||
@@ -15,6 +18,9 @@ export default {
|
||||
const env = (settings.env ?? {}) as Record<string, string>;
|
||||
env.ANTHROPIC_BASE_URL = baseUrl;
|
||||
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||
// AUTH_TOKEN and API_KEY are mutually exclusive credential fields — drop a
|
||||
// stale ANTHROPIC_API_KEY so it cannot shadow the token we just wrote.
|
||||
delete env.ANTHROPIC_API_KEY;
|
||||
env.ANTHROPIC_MODEL = model;
|
||||
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = model;
|
||||
env.ANTHROPIC_DEFAULT_SONNET_MODEL = model;
|
||||
|
||||
@@ -2,13 +2,19 @@ import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
||||
import { backup, readJson, writeJsonAtomic, writeTextAtomic, type AgentDef } from "./utils.ts";
|
||||
import {
|
||||
backup,
|
||||
readJson,
|
||||
writeJsonAtomic,
|
||||
writeTextAtomic,
|
||||
type AgentDef,
|
||||
} from "./utils.ts";
|
||||
|
||||
const PROVIDER_KEY = "bailian-cli";
|
||||
|
||||
export default {
|
||||
label: "Codex",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
write({ baseUrl, apiKey, model, wireApi: wireApiParam }) {
|
||||
const configPath = join(homedir(), ".codex", "config.toml");
|
||||
|
||||
// config.toml — merge into existing config so unrelated settings
|
||||
@@ -17,7 +23,10 @@ export default {
|
||||
let config: Record<string, unknown> = {};
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
config = parseToml(readFileSync(configPath, "utf-8")) as Record<string, unknown>;
|
||||
config = parseToml(readFileSync(configPath, "utf-8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
@@ -25,8 +34,10 @@ export default {
|
||||
|
||||
config.model_provider = PROVIDER_KEY;
|
||||
config.model = model;
|
||||
config.model_reasoning_effort = "high";
|
||||
config.disable_response_storage = true;
|
||||
|
||||
// wire_api: "responses" for models supporting the Responses API (e.g.
|
||||
// qwen3.7/3.8 series); "chat" works with every model via Chat Completions.
|
||||
const wireApi = wireApiParam === "responses" ? "responses" : "chat";
|
||||
|
||||
const providers = (config.model_providers ?? {}) as Record<string, unknown>;
|
||||
const existing = (providers[PROVIDER_KEY] ?? {}) as Record<string, unknown>;
|
||||
@@ -34,14 +45,17 @@ export default {
|
||||
...existing,
|
||||
name: PROVIDER_KEY,
|
||||
base_url: baseUrl,
|
||||
wire_api: "responses",
|
||||
// env_key is the official-doc credential mechanism: Codex resolves the
|
||||
// key from the OPENAI_API_KEY env var, falling back to auth.json below.
|
||||
env_key: "OPENAI_API_KEY",
|
||||
wire_api: wireApi,
|
||||
requires_openai_auth: true,
|
||||
};
|
||||
config.model_providers = providers;
|
||||
|
||||
writeTextAtomic(configPath, stringifyToml(config) + "\n");
|
||||
|
||||
// auth.json — Codex reads OPENAI_API_KEY from here.
|
||||
// auth.json — Codex reads OPENAI_API_KEY from here when the env var is unset.
|
||||
const authPath = join(homedir(), ".codex", "auth.json");
|
||||
backup(authPath);
|
||||
const auth = readJson(authPath);
|
||||
|
||||
@@ -2,9 +2,12 @@ import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import yaml from "yaml";
|
||||
import { backup, writeTextAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
|
||||
const PROVIDER_NAME = "bailian-cli";
|
||||
import {
|
||||
backup,
|
||||
writeTextAtomic,
|
||||
isAnthropicEndpoint,
|
||||
type AgentDef,
|
||||
} from "./utils.ts";
|
||||
|
||||
export default {
|
||||
label: "Hermes Agent",
|
||||
@@ -16,32 +19,25 @@ export default {
|
||||
let config: Record<string, unknown> = {};
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
config = (yaml.parse(readFileSync(configPath, "utf-8")) ?? {}) as Record<string, unknown>;
|
||||
config = (yaml.parse(readFileSync(configPath, "utf-8")) ??
|
||||
{}) as Record<string, unknown>;
|
||||
} catch {
|
||||
config = {};
|
||||
}
|
||||
}
|
||||
|
||||
const apiMode = isAnthropicEndpoint(baseUrl) ? "anthropic_messages" : "chat_completions";
|
||||
const providerEntry = {
|
||||
name: PROVIDER_NAME,
|
||||
// Official Model Studio doc shape: a single flat `model` block holding the
|
||||
// active endpoint + credentials. `api_mode: anthropic_messages` is required
|
||||
// for /apps/anthropic endpoints; for the OpenAI-compatible endpoint the
|
||||
// doc says to omit api_mode entirely (chat completions is the default).
|
||||
const block: Record<string, unknown> = {
|
||||
default: model,
|
||||
provider: "custom",
|
||||
base_url: baseUrl,
|
||||
api_key: apiKey,
|
||||
api_mode: apiMode,
|
||||
models: [{ id: model, name: model }],
|
||||
};
|
||||
|
||||
// custom_providers — upsert the bailian-cli entry by name.
|
||||
const providers = Array.isArray(config.custom_providers)
|
||||
? (config.custom_providers as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const index = providers.findIndex((entry) => entry.name === PROVIDER_NAME);
|
||||
if (index >= 0) providers[index] = providerEntry;
|
||||
else providers.push(providerEntry);
|
||||
config.custom_providers = providers;
|
||||
|
||||
// model — select the bailian-cli provider and default model.
|
||||
config.model = { default: model, provider: PROVIDER_NAME };
|
||||
if (isAnthropicEndpoint(baseUrl)) block.api_mode = "anthropic_messages";
|
||||
config.model = block;
|
||||
|
||||
writeTextAtomic(configPath, yaml.stringify(config));
|
||||
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
import {
|
||||
backup,
|
||||
readJson,
|
||||
writeJsonAtomic,
|
||||
isAnthropicEndpoint,
|
||||
type AgentDef,
|
||||
} from "./utils.ts";
|
||||
|
||||
// Safe default when --context-window is not given: most Model Studio models
|
||||
// offer ≥256K context; users can raise it per model via the flag.
|
||||
const DEFAULT_CONTEXT_WINDOW = 256000;
|
||||
|
||||
export default {
|
||||
label: "OpenClaw",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
write({ baseUrl, apiKey, model, contextWindow }) {
|
||||
const configPath = join(homedir(), ".openclaw", "openclaw.json");
|
||||
|
||||
backup(configPath);
|
||||
@@ -14,7 +24,9 @@ export default {
|
||||
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";
|
||||
const api = isAnthropicEndpoint(baseUrl)
|
||||
? "anthropic-messages"
|
||||
: "openai-completions";
|
||||
providers["bailian-cli"] = {
|
||||
baseUrl,
|
||||
apiKey,
|
||||
@@ -23,18 +35,22 @@ export default {
|
||||
{
|
||||
id: model,
|
||||
name: model,
|
||||
contextWindow: 1000000,
|
||||
cost: { input: 0, output: 0 },
|
||||
contextWindow: contextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
},
|
||||
],
|
||||
};
|
||||
models.providers = providers;
|
||||
config.models = models;
|
||||
|
||||
// agents.defaults
|
||||
// agents.defaults — select the model and register it in the allowlist.
|
||||
const agents = (config.agents ?? {}) as Record<string, unknown>;
|
||||
const defaults = (agents.defaults ?? {}) as Record<string, unknown>;
|
||||
defaults.model = { primary: `bailian-cli/${model}` };
|
||||
const primary = `bailian-cli/${model}`;
|
||||
defaults.model = { primary };
|
||||
const allowlist = (defaults.models ?? {}) as Record<string, unknown>;
|
||||
allowlist[primary] = allowlist[primary] ?? {};
|
||||
defaults.models = allowlist;
|
||||
agents.defaults = defaults;
|
||||
config.agents = agents;
|
||||
|
||||
@@ -42,7 +58,8 @@ export default {
|
||||
|
||||
return {
|
||||
paths: [configPath],
|
||||
nextStep: "Run `openclaw` to start using OpenClaw with DashScope.",
|
||||
nextStep:
|
||||
"Run `openclaw gateway restart`, then `openclaw` to start using OpenClaw with DashScope.",
|
||||
};
|
||||
},
|
||||
} satisfies AgentDef;
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
import {
|
||||
backup,
|
||||
readJsonc,
|
||||
writeJsonAtomic,
|
||||
isAnthropicEndpoint,
|
||||
type AgentDef,
|
||||
} from "./utils.ts";
|
||||
|
||||
export default {
|
||||
label: "OpenCode",
|
||||
write({ baseUrl, apiKey, model }) {
|
||||
const configPath = join(homedir(), ".config", "opencode", "opencode.json");
|
||||
|
||||
// opencode.json is JSONC — tolerate comments and trailing commas on read.
|
||||
backup(configPath);
|
||||
const config = readJson(configPath);
|
||||
const config = readJsonc(configPath);
|
||||
|
||||
if (!config.$schema) config.$schema = "https://opencode.ai/config.json";
|
||||
|
||||
const provider = (config.provider ?? {}) as Record<string, unknown>;
|
||||
const npm = isAnthropicEndpoint(baseUrl) ? "@ai-sdk/anthropic" : "@ai-sdk/openai-compatible";
|
||||
const npm = isAnthropicEndpoint(baseUrl)
|
||||
? "@ai-sdk/anthropic"
|
||||
: "@ai-sdk/openai-compatible";
|
||||
provider["bailian-cli"] = {
|
||||
npm,
|
||||
name: "Alibaba Cloud Model Studio",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { backup, readJson, writeJsonAtomic, isAnthropicEndpoint, type AgentDef } from "./utils.ts";
|
||||
import {
|
||||
backup,
|
||||
readJson,
|
||||
writeJsonAtomic,
|
||||
isAnthropicEndpoint,
|
||||
type AgentDef,
|
||||
} from "./utils.ts";
|
||||
|
||||
const ENV_KEY = "BAILIAN_CLI_API_KEY";
|
||||
|
||||
@@ -19,6 +25,9 @@ export default {
|
||||
backup(settingsPath);
|
||||
const settings = readJson(settingsPath);
|
||||
|
||||
// $version — Qwen Code v3 settings schema (official Model Studio doc shape).
|
||||
settings.$version = 3;
|
||||
|
||||
// env — API key read by the provider entry's envKey.
|
||||
const env = (settings.env ?? {}) as Record<string, string>;
|
||||
env[ENV_KEY] = apiKey;
|
||||
@@ -29,7 +38,9 @@ export default {
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>;
|
||||
const entries = (providers[protocol] ?? []) as Array<Record<string, unknown>>;
|
||||
const entries = (providers[protocol] ?? []) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const existing = entries.find(
|
||||
(entry) => entry.id === model && (entry.baseUrl ?? "") === baseUrl,
|
||||
);
|
||||
@@ -38,18 +49,25 @@ export default {
|
||||
existing.baseUrl = baseUrl;
|
||||
existing.envKey = ENV_KEY;
|
||||
} else {
|
||||
entries.push({ id: model, name: "bailian-cli", baseUrl, envKey: ENV_KEY });
|
||||
entries.push({
|
||||
id: model,
|
||||
name: "bailian-cli",
|
||||
baseUrl,
|
||||
envKey: ENV_KEY,
|
||||
});
|
||||
}
|
||||
providers[protocol] = entries;
|
||||
settings.modelProviders = providers;
|
||||
|
||||
// security.auth — select the protocol and carry the OpenAI-compatible creds.
|
||||
// security.auth — select the protocol only. Credentials live in env (via
|
||||
// each provider entry's envKey); writing apiKey/baseUrl here is not part of
|
||||
// the v3 schema.
|
||||
const security = (settings.security ?? {}) as Record<string, unknown>;
|
||||
security.auth = { selectedType: protocol, apiKey, baseUrl };
|
||||
security.auth = { selectedType: protocol };
|
||||
settings.security = security;
|
||||
|
||||
// model — active model, disambiguated by baseUrl.
|
||||
settings.model = { name: model, baseUrl };
|
||||
// model — active model id, resolved inside modelProviders[protocol].
|
||||
settings.model = { name: model };
|
||||
|
||||
writeJsonAtomic(settingsPath, settings);
|
||||
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { dirname } from "path";
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, copyFileSync } from "fs";
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
renameSync,
|
||||
copyFileSync,
|
||||
} from "fs";
|
||||
|
||||
/** Parameters shared by every agent writer. */
|
||||
export interface WriteParams {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
/** OpenClaw model entry context window (tokens). */
|
||||
contextWindow?: number;
|
||||
/** Codex provider wire protocol: "responses" or "chat". */
|
||||
wireApi?: string;
|
||||
}
|
||||
|
||||
/** What a writer reports back after configuring an agent. */
|
||||
@@ -20,6 +31,91 @@ export interface AgentDef {
|
||||
write(params: WriteParams): WriteSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip JSONC syntax (line / block comments and trailing commas) so the result
|
||||
* parses with `JSON.parse`. String contents are preserved verbatim.
|
||||
*/
|
||||
export function stripJsonc(text: string): string {
|
||||
// Pass 1 — drop comments (string contents preserved verbatim).
|
||||
let uncommented = "";
|
||||
let index = 0;
|
||||
let inString = false;
|
||||
while (index < text.length) {
|
||||
const char = text[index];
|
||||
const next = text[index + 1];
|
||||
if (inString) {
|
||||
uncommented += char;
|
||||
if (char === "\\") {
|
||||
uncommented += next ?? "";
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') inString = false;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
uncommented += char;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "/") {
|
||||
while (index < text.length && text[index] !== "\n") index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
index += 2;
|
||||
while (
|
||||
index < text.length &&
|
||||
!(text[index] === "*" && text[index + 1] === "/")
|
||||
)
|
||||
index += 1;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
uncommented += char;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
// Pass 2 — drop trailing commas (a comma whose next non-whitespace char
|
||||
// closes an object/array). Runs after comment removal so a trailing comment
|
||||
// cannot hide the closing bracket.
|
||||
let output = "";
|
||||
index = 0;
|
||||
inString = false;
|
||||
while (index < uncommented.length) {
|
||||
const char = uncommented[index];
|
||||
if (inString) {
|
||||
output += char;
|
||||
if (char === "\\") {
|
||||
output += uncommented[index + 1] ?? "";
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') inString = false;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') inString = true;
|
||||
if (char === ",") {
|
||||
let lookahead = index + 1;
|
||||
while (
|
||||
lookahead < uncommented.length &&
|
||||
/\s/.test(uncommented[lookahead])
|
||||
)
|
||||
lookahead += 1;
|
||||
if (uncommented[lookahead] === "}" || uncommented[lookahead] === "]") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
output += char;
|
||||
index += 1;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/** Read a JSON object file, returning `{}` when missing or unparseable. */
|
||||
export function readJson(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) return {};
|
||||
@@ -30,6 +126,19 @@ export function readJson(path: string): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Like {@link readJson}, but tolerates JSONC (comments / trailing commas). */
|
||||
export function readJsonc(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
return JSON.parse(stripJsonc(readFileSync(path, "utf-8"))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically write `data` as pretty JSON with owner-only permissions. */
|
||||
export function writeJsonAtomic(path: string, data: unknown): void {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs";
|
||||
import {
|
||||
mkdtempSync,
|
||||
rmSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
} from "fs";
|
||||
import { tmpdir, homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vite-plus/test";
|
||||
@@ -41,11 +48,14 @@ function readJsonAt(...segments: string[]): Record<string, unknown> {
|
||||
|
||||
describe("config agent writers", () => {
|
||||
test("claude-code 写入 env 与 onboarding,并合并已有 env", () => {
|
||||
// 预置一个无关 env 键,验证合并保留
|
||||
// 预置一个无关 env 键与旧的 ANTHROPIC_API_KEY,验证合并保留 / 旧键清理
|
||||
mkdirSync(join(home, ".claude"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".claude", "settings.json"),
|
||||
JSON.stringify({ env: { KEEP_ME: "1" }, other: true }),
|
||||
JSON.stringify({
|
||||
env: { KEEP_ME: "1", ANTHROPIC_API_KEY: "sk-stale" },
|
||||
other: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const summary = claudeCode.write({
|
||||
@@ -61,6 +71,7 @@ describe("config agent writers", () => {
|
||||
expect(settings.other).toBe(true);
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe(ANTHROPIC_URL);
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe("sk-a");
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
|
||||
expect(env.ANTHROPIC_MODEL).toBe("qwen3-max");
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe("qwen3-max");
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe("qwen3-max");
|
||||
@@ -70,16 +81,45 @@ describe("config agent writers", () => {
|
||||
expect(readJsonAt(".claude.json").hasCompletedOnboarding).toBe(true);
|
||||
});
|
||||
|
||||
test("qwen-code compatible-mode 走 openai 协议", () => {
|
||||
qwenCode.write({ baseUrl: OAI_URL, apiKey: "sk-q", model: "qwen3-coder-plus" });
|
||||
test("claude-code 尊重 CLAUDE_CONFIG_DIR", () => {
|
||||
const customDir = join(home, "custom-claude");
|
||||
process.env.CLAUDE_CONFIG_DIR = customDir;
|
||||
try {
|
||||
claudeCode.write({
|
||||
baseUrl: ANTHROPIC_URL,
|
||||
apiKey: "sk-a",
|
||||
model: "qwen3-max",
|
||||
});
|
||||
const settings = JSON.parse(
|
||||
readFileSync(join(customDir, "settings.json"), "utf8"),
|
||||
);
|
||||
expect(
|
||||
(settings.env as Record<string, string>).ANTHROPIC_AUTH_TOKEN,
|
||||
).toBe("sk-a");
|
||||
} finally {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
});
|
||||
|
||||
test("qwen-code compatible-mode 走 openai 协议(官方 v3 结构)", () => {
|
||||
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<string, string> };
|
||||
expect(security.auth.selectedType).toBe("openai");
|
||||
expect(security.auth.apiKey).toBe("sk-q");
|
||||
expect(security.auth.baseUrl).toBe(OAI_URL);
|
||||
expect((settings.env as Record<string, string>).BAILIAN_CLI_API_KEY).toBe("sk-q");
|
||||
expect((settings.model as Record<string, string>).name).toBe("qwen3-coder-plus");
|
||||
const providers = settings.modelProviders as Record<string, Array<Record<string, unknown>>>;
|
||||
expect(settings.$version).toBe(3);
|
||||
const security = settings.security as { auth: Record<string, unknown> };
|
||||
// security.auth 只携带 selectedType;凭证在 env + envKey 里
|
||||
expect(security.auth).toEqual({ selectedType: "openai" });
|
||||
expect((settings.env as Record<string, string>).BAILIAN_CLI_API_KEY).toBe(
|
||||
"sk-q",
|
||||
);
|
||||
expect(settings.model).toEqual({ name: "qwen3-coder-plus" });
|
||||
const providers = settings.modelProviders as Record<
|
||||
string,
|
||||
Array<Record<string, unknown>>
|
||||
>;
|
||||
expect(providers.openai[0]).toMatchObject({
|
||||
id: "qwen3-coder-plus",
|
||||
name: "bailian-cli",
|
||||
@@ -89,24 +129,62 @@ describe("config agent writers", () => {
|
||||
});
|
||||
|
||||
test("qwen-code anthropic 端点走 anthropic 协议", () => {
|
||||
qwenCode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-q", model: "qwen3-max" });
|
||||
qwenCode.write({
|
||||
baseUrl: ANTHROPIC_URL,
|
||||
apiKey: "sk-q",
|
||||
model: "qwen3-max",
|
||||
});
|
||||
const settings = readJsonAt(".qwen", "settings.json");
|
||||
expect((settings.security as { auth: { selectedType: string } }).auth.selectedType).toBe(
|
||||
"anthropic",
|
||||
);
|
||||
expect(
|
||||
(settings.security as { auth: { selectedType: string } }).auth
|
||||
.selectedType,
|
||||
).toBe("anthropic");
|
||||
const providers = settings.modelProviders as Record<string, unknown>;
|
||||
expect(Array.isArray(providers.anthropic)).toBe(true);
|
||||
expect(providers.openai).toBeUndefined();
|
||||
});
|
||||
|
||||
test("qwen-code 对相同 id+baseUrl 的 provider 项做 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" });
|
||||
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<string, unknown[]>).openai;
|
||||
const openaiEntries = (settings.modelProviders as Record<string, unknown[]>)
|
||||
.openai;
|
||||
expect(openaiEntries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("opencode 容忍 JSONC(注释与尾逗号)", () => {
|
||||
mkdirSync(join(home, ".config", "opencode"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".config", "opencode", "opencode.json"),
|
||||
[
|
||||
"{",
|
||||
" // user comment",
|
||||
' "provider": {',
|
||||
' "other": { "name": "Other" }, // inline comment',
|
||||
" },",
|
||||
" /* block */",
|
||||
' "theme": "dark",',
|
||||
"}",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
opencode.write({ baseUrl: OAI_URL, apiKey: "sk-o", model: "qwen3-max" });
|
||||
const config = readJsonAt(".config", "opencode", "opencode.json");
|
||||
expect(config.theme).toBe("dark");
|
||||
const provider = config.provider as Record<string, unknown>;
|
||||
expect(provider.other).toBeDefined();
|
||||
expect(provider["bailian-cli"]).toBeDefined();
|
||||
});
|
||||
|
||||
test("opencode 按端点选 npm,含 setCacheKey,合并保留其它 provider", () => {
|
||||
mkdirSync(join(home, ".config", "opencode"), { recursive: true });
|
||||
writeFileSync(
|
||||
@@ -114,7 +192,11 @@ describe("config agent writers", () => {
|
||||
JSON.stringify({ provider: { other: { name: "Other" } } }),
|
||||
);
|
||||
|
||||
opencode.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-o", model: "qwen3-max" });
|
||||
opencode.write({
|
||||
baseUrl: ANTHROPIC_URL,
|
||||
apiKey: "sk-o",
|
||||
model: "qwen3-max",
|
||||
});
|
||||
const config = readJsonAt(".config", "opencode", "opencode.json");
|
||||
const provider = config.provider as Record<string, Record<string, unknown>>;
|
||||
expect(provider.other).toBeDefined();
|
||||
@@ -123,7 +205,9 @@ describe("config agent writers", () => {
|
||||
expect(options.baseURL).toBe(ANTHROPIC_URL);
|
||||
expect(options.apiKey).toBe("sk-o");
|
||||
expect(options.setCacheKey).toBe(true);
|
||||
expect((provider["bailian-cli"].models as Record<string, unknown>)["qwen3-max"]).toBeDefined();
|
||||
expect(
|
||||
(provider["bailian-cli"].models as Record<string, unknown>)["qwen3-max"],
|
||||
).toBeDefined();
|
||||
|
||||
// 非 anthropic 端点用 openai-compatible
|
||||
opencode.write({ baseUrl: OAI_URL, apiKey: "sk-o", model: "qwen3-max" });
|
||||
@@ -138,59 +222,96 @@ describe("config agent writers", () => {
|
||||
});
|
||||
|
||||
test("openclaw 写入 provider、api 与 primary", () => {
|
||||
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-coder-plus" });
|
||||
openclaw.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-c",
|
||||
model: "qwen3-coder-plus",
|
||||
});
|
||||
const config = readJsonAt(".openclaw", "openclaw.json");
|
||||
const models = config.models as Record<string, unknown>;
|
||||
expect(models.mode).toBe("merge");
|
||||
const bailian = (models.providers as Record<string, Record<string, unknown>>)["bailian-cli"];
|
||||
const bailian = (
|
||||
models.providers as Record<string, Record<string, unknown>>
|
||||
)["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 entry = (bailian.models as Array<Record<string, unknown>>)[0];
|
||||
expect(entry.id).toBe("qwen3-coder-plus");
|
||||
// 未传 --context-window 时使用安全默认值,不再硬编码 1M
|
||||
expect(entry.contextWindow).toBe(256000);
|
||||
expect(entry.cost).toEqual({
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
});
|
||||
const agents = config.agents as {
|
||||
defaults: { model: { primary: string }; models: Record<string, unknown> };
|
||||
};
|
||||
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" });
|
||||
// --context-window 覆盖默认值;anthropic 端点用 anthropic-messages
|
||||
openclaw.write({
|
||||
baseUrl: ANTHROPIC_URL,
|
||||
apiKey: "sk-c",
|
||||
model: "qwen3-max",
|
||||
contextWindow: 1000000,
|
||||
});
|
||||
const config2 = readJsonAt(".openclaw", "openclaw.json");
|
||||
expect(
|
||||
((config2.models as Record<string, unknown>).providers as Record<string, { api: string }>)[
|
||||
"bailian-cli"
|
||||
].api,
|
||||
).toBe("anthropic-messages");
|
||||
const providers2 = (config2.models as Record<string, unknown>)
|
||||
.providers as Record<
|
||||
string,
|
||||
{ api: string; models: Array<Record<string, unknown>> }
|
||||
>;
|
||||
expect(providers2["bailian-cli"].api).toBe("anthropic-messages");
|
||||
expect(providers2["bailian-cli"].models[0].contextWindow).toBe(1000000);
|
||||
});
|
||||
|
||||
test("hermes 写入 custom_providers 与 model,合并保留其它 provider", () => {
|
||||
test("hermes 写入官方扁平 model.* 结构,保留其它顶层键", () => {
|
||||
mkdirSync(join(home, ".hermes"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, ".hermes", "config.yaml"),
|
||||
yaml.stringify({ custom_providers: [{ name: "other", base_url: "https://x" }] }),
|
||||
yaml.stringify({
|
||||
custom_providers: [{ name: "other", base_url: "https://x" }],
|
||||
}),
|
||||
);
|
||||
|
||||
hermes.write({ baseUrl: OAI_URL, apiKey: "sk-h", model: "qwen3-coder-plus" });
|
||||
const config = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
|
||||
expect(config.model).toEqual({ default: "qwen3-coder-plus", provider: "bailian-cli" });
|
||||
const names = (config.custom_providers as Array<{ name: string }>).map((p) => p.name);
|
||||
expect(names).toContain("other");
|
||||
const entry = (config.custom_providers as Array<Record<string, unknown>>).find(
|
||||
(provider) => provider.name === "bailian-cli",
|
||||
)!;
|
||||
expect(entry.base_url).toBe(OAI_URL);
|
||||
expect(entry.api_key).toBe("sk-h");
|
||||
expect(entry.api_mode).toBe("chat_completions");
|
||||
hermes.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-h",
|
||||
model: "qwen3-coder-plus",
|
||||
});
|
||||
const config = yaml.parse(
|
||||
readFileSync(join(home, ".hermes", "config.yaml"), "utf8"),
|
||||
);
|
||||
// OpenAI 兼容端点:按官方文档省略 api_mode;无关顶层键不受影响
|
||||
expect(config.model).toEqual({
|
||||
default: "qwen3-coder-plus",
|
||||
provider: "custom",
|
||||
base_url: OAI_URL,
|
||||
api_key: "sk-h",
|
||||
});
|
||||
expect(config.custom_providers).toHaveLength(1);
|
||||
|
||||
// anthropic 端点用 anthropic_messages
|
||||
hermes.write({ baseUrl: ANTHROPIC_URL, apiKey: "sk-h", model: "qwen3-max" });
|
||||
const config2 = yaml.parse(readFileSync(join(home, ".hermes", "config.yaml"), "utf8"));
|
||||
const entry2 = (config2.custom_providers as Array<Record<string, unknown>>).find(
|
||||
(provider) => provider.name === "bailian-cli",
|
||||
)!;
|
||||
expect(entry2.api_mode).toBe("anthropic_messages");
|
||||
// upsert:bailian-cli 项不重复
|
||||
expect(
|
||||
(config2.custom_providers as Array<{ name: string }>).filter((p) => p.name === "bailian-cli"),
|
||||
).toHaveLength(1);
|
||||
// anthropic 端点:必须带 api_mode = anthropic_messages
|
||||
hermes.write({
|
||||
baseUrl: ANTHROPIC_URL,
|
||||
apiKey: "sk-h",
|
||||
model: "qwen3-max",
|
||||
});
|
||||
const config2 = yaml.parse(
|
||||
readFileSync(join(home, ".hermes", "config.yaml"), "utf8"),
|
||||
);
|
||||
expect(config2.model).toEqual({
|
||||
default: "qwen3-max",
|
||||
provider: "custom",
|
||||
base_url: ANTHROPIC_URL,
|
||||
api_key: "sk-h",
|
||||
api_mode: "anthropic_messages",
|
||||
});
|
||||
});
|
||||
|
||||
test("codex 写入 config.toml 与 auth.json(cc-switch 对齐结构,合并保留)", () => {
|
||||
test("codex 写入 config.toml 与 auth.json(官方 env_key 结构,合并保留)", () => {
|
||||
// 预置 config.toml 无关顶层键与另一个 provider,验证非破坏性合并
|
||||
mkdirSync(join(home, ".codex"), { recursive: true });
|
||||
writeFileSync(
|
||||
@@ -205,17 +326,24 @@ describe("config agent writers", () => {
|
||||
].join("\n"),
|
||||
);
|
||||
// 预置 auth.json 无关键,验证合并保留
|
||||
writeFileSync(join(home, ".codex", "auth.json"), JSON.stringify({ EXISTING: "keep" }));
|
||||
writeFileSync(
|
||||
join(home, ".codex", "auth.json"),
|
||||
JSON.stringify({ EXISTING: "keep" }),
|
||||
);
|
||||
|
||||
codex.write({ baseUrl: OAI_URL, apiKey: "sk-x", model: "qwen3-coder-plus" });
|
||||
codex.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-x",
|
||||
model: "qwen3-coder-plus",
|
||||
});
|
||||
const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8");
|
||||
expect(toml).toContain('model_provider = "bailian-cli"');
|
||||
expect(toml).toContain('model = "qwen3-coder-plus"');
|
||||
expect(toml).toContain('model_reasoning_effort = "high"');
|
||||
expect(toml).toContain("disable_response_storage = true");
|
||||
expect(toml).toContain("[model_providers.bailian-cli]");
|
||||
expect(toml).toContain(`base_url = "${OAI_URL}"`);
|
||||
expect(toml).toContain('wire_api = "responses"');
|
||||
expect(toml).toContain('env_key = "OPENAI_API_KEY"');
|
||||
// 未传 --wire-api 时默认 chat(所有模型可用)
|
||||
expect(toml).toContain('wire_api = "chat"');
|
||||
expect(toml).toContain("requires_openai_auth = true");
|
||||
// 合并:保留用户已有的无关配置
|
||||
expect(toml).toContain('approval_policy = "on-request"');
|
||||
@@ -224,11 +352,25 @@ describe("config agent writers", () => {
|
||||
const auth = readJsonAt(".codex", "auth.json");
|
||||
expect(auth.OPENAI_API_KEY).toBe("sk-x");
|
||||
expect(auth.EXISTING).toBe("keep");
|
||||
|
||||
// --wire-api responses:支持 Responses API 的模型
|
||||
codex.write({
|
||||
baseUrl: OAI_URL,
|
||||
apiKey: "sk-x",
|
||||
model: "qwen3.7-plus",
|
||||
wireApi: "responses",
|
||||
});
|
||||
const toml2 = readFileSync(join(home, ".codex", "config.toml"), "utf8");
|
||||
expect(toml2).toContain('wire_api = "responses"');
|
||||
expect(toml2).toContain('model = "qwen3.7-plus"');
|
||||
});
|
||||
|
||||
test("已存在的配置文件会被备份为 .bak.<epoch>", () => {
|
||||
mkdirSync(join(home, ".openclaw"), { recursive: true });
|
||||
writeFileSync(join(home, ".openclaw", "openclaw.json"), JSON.stringify({ pre: 1 }));
|
||||
writeFileSync(
|
||||
join(home, ".openclaw", "openclaw.json"),
|
||||
JSON.stringify({ pre: 1 }),
|
||||
);
|
||||
|
||||
openclaw.write({ baseUrl: OAI_URL, apiKey: "sk-c", model: "qwen3-max" });
|
||||
const backups = readdirSync(join(home, ".openclaw")).filter((name) =>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "fs";
|
||||
import {
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
existsSync,
|
||||
} from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
@@ -11,29 +17,49 @@ import { CONFIG_ROUTES } from "./topic-routes.ts";
|
||||
|
||||
describe("e2e: config", () => {
|
||||
test("config show --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "show", "--help"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"show",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/show|config/i);
|
||||
});
|
||||
|
||||
test("config set --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--help"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"set",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/set|--key|--value/i);
|
||||
});
|
||||
|
||||
test("config list/use --help 正常退出", async () => {
|
||||
const listResult = await runCommandE2e(CONFIG_ROUTES, ["config", "list", "--help"]);
|
||||
const listResult = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"list",
|
||||
"--help",
|
||||
]);
|
||||
expect(listResult.exitCode, listResult.stderr).toBe(0);
|
||||
expect(listResult.stderr).toMatch(/list|active|profile/i);
|
||||
|
||||
const useResult = await runCommandE2e(CONFIG_ROUTES, ["config", "use", "--help"]);
|
||||
const useResult = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"use",
|
||||
"--help",
|
||||
]);
|
||||
expect(useResult.exitCode, useResult.stderr).toBe(0);
|
||||
expect(useResult.stderr).toMatch(/use|--name|active/i);
|
||||
});
|
||||
|
||||
test("config ui --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "ui", "--help"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"ui",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/ui|--port|--no-open|web/i);
|
||||
});
|
||||
@@ -105,13 +131,21 @@ describe("e2e: config", () => {
|
||||
});
|
||||
|
||||
test("config set 缺少 --key / --value 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "set", "--quiet"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"set",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(2);
|
||||
expect(stderr).toMatch(/--key|--value|Usage:/i);
|
||||
});
|
||||
|
||||
test("config use 缺少 --name 时报用法错误并退出 (2)", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "use", "--quiet"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"use",
|
||||
"--quiet",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(2);
|
||||
expect(stderr).toMatch(/--name|Usage:/i);
|
||||
});
|
||||
@@ -120,7 +154,10 @@ describe("e2e: config", () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-"));
|
||||
try {
|
||||
const configPath = join(configDir, "config.json");
|
||||
writeFileSync(configPath, JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n",
|
||||
);
|
||||
const env = { BAILIAN_CONFIG_DIR: configDir };
|
||||
|
||||
const useResult = await runCommandE2e(
|
||||
@@ -129,10 +166,13 @@ describe("e2e: config", () => {
|
||||
env,
|
||||
);
|
||||
expect(useResult.exitCode, useResult.stderr).toBe(0);
|
||||
expect(parseStdoutJson<{ active_config?: string }>(useResult.stdout).active_config).toBe(
|
||||
expect(
|
||||
parseStdoutJson<{ active_config?: string }>(useResult.stdout)
|
||||
.active_config,
|
||||
).toBe("dev");
|
||||
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe(
|
||||
"dev",
|
||||
);
|
||||
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBe("dev");
|
||||
|
||||
const listResult = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
@@ -155,17 +195,23 @@ describe("e2e: config", () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-dry-run-"));
|
||||
try {
|
||||
const configPath = join(configDir, "config.json");
|
||||
writeFileSync(configPath, JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ dev: { output: "json" } }, null, 2) + "\n",
|
||||
);
|
||||
const result = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "use", "--name", "dev", "--dry-run", "--output", "json"],
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(parseStdoutJson<{ would_activate?: string }>(result.stdout).would_activate).toBe(
|
||||
"dev",
|
||||
);
|
||||
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBeUndefined();
|
||||
expect(
|
||||
parseStdoutJson<{ would_activate?: string }>(result.stdout)
|
||||
.would_activate,
|
||||
).toBe("dev");
|
||||
expect(
|
||||
JSON.parse(readFileSync(configPath, "utf8")).active_config,
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -175,7 +221,10 @@ describe("e2e: config", () => {
|
||||
const configDir = mkdtempSync(join(tmpdir(), "bl-config-use-missing-"));
|
||||
try {
|
||||
const configPath = join(configDir, "config.json");
|
||||
writeFileSync(configPath, JSON.stringify({ output: "text" }, null, 2) + "\n");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({ output: "text" }, null, 2) + "\n",
|
||||
);
|
||||
const result = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "use", "--name", "missing", "--output", "json"],
|
||||
@@ -183,7 +232,9 @@ describe("e2e: config", () => {
|
||||
);
|
||||
expect(result.exitCode).toBe(2);
|
||||
expect(result.stderr).toMatch(/does not exist/);
|
||||
expect(JSON.parse(readFileSync(configPath, "utf8")).active_config).toBeUndefined();
|
||||
expect(
|
||||
JSON.parse(readFileSync(configPath, "utf8")).active_config,
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(configDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -246,16 +297,24 @@ describe("e2e: config", () => {
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(setResult.exitCode, setResult.stderr).toBe(0);
|
||||
expect(parseStdoutJson<{ base_url?: string }>(setResult.stdout).base_url).toBe(
|
||||
"https://proxy.example.com/bailian",
|
||||
);
|
||||
expect(JSON.parse(readFileSync(join(configDir, "config.json"), "utf8")).base_url).toBe(
|
||||
"https://proxy.example.com/bailian",
|
||||
);
|
||||
expect(
|
||||
parseStdoutJson<{ base_url?: string }>(setResult.stdout).base_url,
|
||||
).toBe("https://proxy.example.com/bailian");
|
||||
expect(
|
||||
JSON.parse(readFileSync(join(configDir, "config.json"), "utf8"))
|
||||
.base_url,
|
||||
).toBe("https://proxy.example.com/bailian");
|
||||
|
||||
const invalidResult = await runCommandE2e(
|
||||
CONFIG_ROUTES,
|
||||
["config", "set", "--key", "base_url", "--value", "ftp://example.com/models"],
|
||||
[
|
||||
"config",
|
||||
"set",
|
||||
"--key",
|
||||
"base_url",
|
||||
"--value",
|
||||
"ftp://example.com/models",
|
||||
],
|
||||
{ BAILIAN_CONFIG_DIR: configDir },
|
||||
);
|
||||
expect(invalidResult.exitCode).toBe(2);
|
||||
@@ -295,7 +354,9 @@ describe("e2e: config", () => {
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_set?: { default_text_model?: string } }>(stdout);
|
||||
const data = parseStdoutJson<{
|
||||
would_set?: { default_text_model?: string };
|
||||
}>(stdout);
|
||||
expect(data.would_set?.default_text_model).toBe("qwen3.7-max");
|
||||
});
|
||||
|
||||
@@ -315,7 +376,9 @@ describe("e2e: config", () => {
|
||||
const data = parseStdoutJson<{
|
||||
would_set?: { default_image_to_video_model?: string };
|
||||
}>(stdout);
|
||||
expect(data.would_set?.default_image_to_video_model).toBe("happyhorse-1.1-i2v");
|
||||
expect(data.would_set?.default_image_to_video_model).toBe(
|
||||
"happyhorse-1.1-i2v",
|
||||
);
|
||||
});
|
||||
|
||||
test("config set --dry-run 支持参考生视频默认模型别名", async () => {
|
||||
@@ -334,7 +397,9 @@ describe("e2e: config", () => {
|
||||
const data = parseStdoutJson<{
|
||||
would_set?: { default_reference_to_video_model?: string };
|
||||
}>(stdout);
|
||||
expect(data.would_set?.default_reference_to_video_model).toBe("happyhorse-1.1-r2v");
|
||||
expect(data.would_set?.default_reference_to_video_model).toBe(
|
||||
"happyhorse-1.1-r2v",
|
||||
);
|
||||
});
|
||||
|
||||
test("config set --dry-run 展示归一化后的 Base URL", async () => {
|
||||
@@ -367,7 +432,9 @@ describe("e2e: config", () => {
|
||||
"json",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const data = parseStdoutJson<{ would_set?: { access_key_id?: string } }>(stdout);
|
||||
const data = parseStdoutJson<{ would_set?: { access_key_id?: string } }>(
|
||||
stdout,
|
||||
);
|
||||
expect(data.would_set?.access_key_id).toBe("LTAI-config-placeholder");
|
||||
});
|
||||
|
||||
@@ -385,7 +452,11 @@ describe("e2e: config", () => {
|
||||
});
|
||||
|
||||
test("config agent --help 正常退出", async () => {
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, ["config", "agent", "--help"]);
|
||||
const { stderr, exitCode } = await runCommandE2e(CONFIG_ROUTES, [
|
||||
"config",
|
||||
"agent",
|
||||
"--help",
|
||||
]);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
expect(stderr).toMatch(/agent|--base-url|--model/i);
|
||||
});
|
||||
@@ -452,7 +523,9 @@ describe("e2e: config", () => {
|
||||
api_key?: string;
|
||||
}>(stdout);
|
||||
expect(data.agent).toBe("claude-code");
|
||||
expect(data.base_url).toBe("https://dashscope.aliyuncs.com/apps/anthropic");
|
||||
expect(data.base_url).toBe(
|
||||
"https://dashscope.aliyuncs.com/apps/anthropic",
|
||||
);
|
||||
expect(data.model).toBe("qwen3-max");
|
||||
expect(stdout).not.toContain("sk-secret-placeholder");
|
||||
expect(existsSync(join(home, ".claude", "settings.json"))).toBe(false);
|
||||
@@ -461,7 +534,7 @@ describe("e2e: config", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("config agent codex 写入 config.toml 与 auth.json(cc-switch 对齐结构)", async () => {
|
||||
test("config agent codex 写入 config.toml 与 auth.json(官方 env_key 结构)", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-codex-"));
|
||||
try {
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
@@ -477,22 +550,27 @@ describe("e2e: config", () => {
|
||||
"sk-codex-placeholder",
|
||||
"--model",
|
||||
"qwen3-coder-plus",
|
||||
"--wire-api",
|
||||
"responses",
|
||||
],
|
||||
{ HOME: home },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const toml = readFileSync(join(home, ".codex", "config.toml"), "utf8");
|
||||
expect(toml).toContain('model_provider = "bailian-cli"');
|
||||
expect(toml).toContain('env_key = "OPENAI_API_KEY"');
|
||||
expect(toml).toContain("requires_openai_auth = true");
|
||||
expect(toml).toContain('wire_api = "responses"');
|
||||
const auth = JSON.parse(readFileSync(join(home, ".codex", "auth.json"), "utf8"));
|
||||
const auth = JSON.parse(
|
||||
readFileSync(join(home, ".codex", "auth.json"), "utf8"),
|
||||
);
|
||||
expect(auth.OPENAI_API_KEY).toBe("sk-codex-placeholder");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("config agent hermes 写入 custom_providers 结构", async () => {
|
||||
test("config agent hermes 写入官方扁平 model.* 结构", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "bl-config-agent-hermes-"));
|
||||
try {
|
||||
const { stderr, exitCode } = await runCommandE2e(
|
||||
@@ -512,10 +590,18 @@ describe("e2e: config", () => {
|
||||
{ HOME: home },
|
||||
);
|
||||
expect(exitCode, stderr).toBe(0);
|
||||
const yamlText = readFileSync(join(home, ".hermes", "config.yaml"), "utf8");
|
||||
expect(yamlText).toContain("custom_providers");
|
||||
expect(yamlText).toContain("bailian-cli");
|
||||
expect(yamlText).toContain("api_mode: chat_completions");
|
||||
const yamlText = readFileSync(
|
||||
join(home, ".hermes", "config.yaml"),
|
||||
"utf8",
|
||||
);
|
||||
expect(yamlText).toContain("default: qwen3-coder-plus");
|
||||
expect(yamlText).toContain("provider: custom");
|
||||
expect(yamlText).toContain(
|
||||
"base_url: https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
);
|
||||
expect(yamlText).toContain("api_key: sk-hermes-placeholder");
|
||||
// OpenAI 兼容端点不写 api_mode
|
||||
expect(yamlText).not.toContain("api_mode");
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -28,12 +28,14 @@ Index: [index.md](index.md)
|
||||
|
||||
#### Flags
|
||||
|
||||
| Flag | Type | Required | Description |
|
||||
| --------------------------------------------------------------------- | ------ | -------- | ----------------------------------------------------------------------- |
|
||||
| `--agent <claude-code\|qwen-code\|opencode\|openclaw\|hermes\|codex>` | string | yes | Target agent: claude-code, qwen-code, opencode, openclaw, hermes, codex |
|
||||
| `--base-url <url>` | string | yes | API base URL |
|
||||
| `--api-key <key>` | string | yes | API key |
|
||||
| `--model <model>` | string | yes | Default model name |
|
||||
| Flag | Type | Required | Description |
|
||||
| --------------------------------------------------------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `--agent <claude-code\|qwen-code\|opencode\|openclaw\|hermes\|codex>` | string | yes | Target agent: claude-code, qwen-code, opencode, openclaw, hermes, codex |
|
||||
| `--base-url <url>` | string | yes | API base URL |
|
||||
| `--api-key <key>` | string | yes | API key |
|
||||
| `--model <model>` | string | yes | Default model name |
|
||||
| `--context-window <tokens>` | number | no | OpenClaw only: model context window in tokens (default: 256000) |
|
||||
| `--wire-api <chat\|responses>` | string | no | Codex only: wire protocol — "chat" works with every model; "responses" for models supporting the Responses API (default: chat) |
|
||||
|
||||
#### Examples
|
||||
|
||||
|
||||
Reference in New Issue
Block a user