Skip to content
Merged
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
151 changes: 144 additions & 7 deletions __tests__/claude-code-router-config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,22 +120,50 @@ describe("ClaudeCodeRouterConfig", () => {
});
});

describe("error handling", () => {
beforeEach(() => {
config = new ClaudeCodeRouterConfig();
});

it("should handle transformer file write errors", async () => {
const error = new Error("Write failed");
fs.writeFile.mockRejectedValueOnce(error);
fs.writeJson.mockResolvedValue(); // Make sure other fs operations succeed

await expect(config.createTransformerFile()).rejects.toThrow(
"Write failed"
);
});

it("should handle config file write errors", async () => {
const error = new Error("JSON write failed");
fs.writeJson.mockRejectedValueOnce(error);
fs.writeFile.mockResolvedValue(); // Make sure other fs operations succeed

await expect(config.createConfigFile("test-key", "cn")).rejects.toThrow(
"JSON write failed"
);
});
});

describe("createConfigFile", () => {
beforeEach(() => {
config = new ClaudeCodeRouterConfig();
});

it("should create config file with API Key from environment variable", async () => {
const testApiKey = "test-api-key-from-env";
const testRegion = "cn";

await config.createConfigFile(testApiKey);
await config.createConfigFile(testApiKey, testRegion);

expect(fs.writeJson).toHaveBeenCalledWith(
config.configFile,
expect.objectContaining({
Providers: expect.arrayContaining([
expect.objectContaining({
api_key: testApiKey,
api_base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
}),
]),
}),
Expand All @@ -145,15 +173,17 @@ describe("ClaudeCodeRouterConfig", () => {

it("should create config file with provided API Key", async () => {
const testApiKey = "test-api-key-provided";
const testRegion = "intl";

await config.createConfigFile(testApiKey);
await config.createConfigFile(testApiKey, testRegion);

expect(fs.writeJson).toHaveBeenCalledWith(
config.configFile,
expect.objectContaining({
Providers: expect.arrayContaining([
expect.objectContaining({
api_key: testApiKey,
api_base_url: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions",
}),
]),
}),
Expand All @@ -162,7 +192,7 @@ describe("ClaudeCodeRouterConfig", () => {
});

it("should use undefined API Key when no API Key is provided", async () => {
await config.createConfigFile();
await config.createConfigFile(undefined, "cn");

expect(fs.writeJson).toHaveBeenCalledWith(
config.configFile,
Expand All @@ -178,7 +208,7 @@ describe("ClaudeCodeRouterConfig", () => {
});

it("should contain correct configuration structure", async () => {
await config.createConfigFile();
await config.createConfigFile("test-key", "cn");

const writeJsonCall = fs.writeJson.mock.calls[0];
const configContent = writeJsonCall[1];
Expand Down Expand Up @@ -232,6 +262,7 @@ describe("ClaudeCodeRouterConfig", () => {
jest.spyOn(config, "createConfigFile").mockResolvedValue();
jest.spyOn(config, "createTransformerFile").mockResolvedValue();
jest.spyOn(config, "promptForApiKey").mockResolvedValue("user-input-key");
jest.spyOn(config, "promptForRegion").mockResolvedValue("cn");
});

afterEach(() => {
Expand All @@ -244,8 +275,9 @@ describe("ClaudeCodeRouterConfig", () => {

await config.setup();

expect(config.promptForRegion).toHaveBeenCalled();
expect(config.createDirectories).toHaveBeenCalled();
expect(config.createConfigFile).toHaveBeenCalledWith("env-test-key");
expect(config.createConfigFile).toHaveBeenCalledWith("env-test-key", "cn");
expect(config.createTransformerFile).toHaveBeenCalled();
expect(config.promptForApiKey).not.toHaveBeenCalled();
});
Expand All @@ -260,7 +292,7 @@ describe("ClaudeCodeRouterConfig", () => {
"DASHSCOPE_API_KEY environment variable detected"
)
);
expect(config.createConfigFile).toHaveBeenCalledWith("test-key-from-env");
expect(config.createConfigFile).toHaveBeenCalledWith("test-key-from-env", "cn");
});

it("should prompt for API Key when environment variable is not present", async () => {
Expand All @@ -274,7 +306,7 @@ describe("ClaudeCodeRouterConfig", () => {
)
);
expect(config.promptForApiKey).toHaveBeenCalled();
expect(config.createConfigFile).toHaveBeenCalledWith("user-input-key");
expect(config.createConfigFile).toHaveBeenCalledWith("user-input-key", "cn");
});

it("should handle errors during setup process", async () => {
Expand All @@ -287,6 +319,111 @@ describe("ClaudeCodeRouterConfig", () => {
});
});

describe("promptForRegion", () => {
beforeEach(() => {
config = new ClaudeCodeRouterConfig();

// Mock console.log
jest.spyOn(console, "log").mockImplementation();
});

afterEach(() => {
console.log.mockRestore();
});

it("should prompt for region and return 'cn' when user selects 1", async () => {
const mockReadline = {
question: jest.fn(),
close: jest.fn()
};

const readline = require("readline");
jest.spyOn(readline, "createInterface").mockReturnValue(mockReadline);

mockReadline.question.mockImplementation((prompt, callback) => {
callback("1");
});

const result = await config.promptForRegion();

expect(readline.createInterface).toHaveBeenCalledWith({
input: process.stdin,
output: process.stdout
});
expect(mockReadline.question).toHaveBeenCalled();
expect(mockReadline.close).toHaveBeenCalled();
expect(result).toBe("cn");
});

it("should prompt for region and return 'intl' when user selects 2", async () => {
const mockReadline = {
question: jest.fn(),
close: jest.fn()
};

const readline = require("readline");
jest.spyOn(readline, "createInterface").mockReturnValue(mockReadline);

mockReadline.question.mockImplementation((prompt, callback) => {
callback("2");
});

const result = await config.promptForRegion();

expect(result).toBe("intl");
expect(mockReadline.close).toHaveBeenCalled();
});

it("should re-prompt when invalid input is provided", async () => {
const mockReadline = {
question: jest.fn(),
close: jest.fn()
};

const readline = require("readline");
jest.spyOn(readline, "createInterface").mockReturnValue(mockReadline);

let callCount = 0;
mockReadline.question.mockImplementation((prompt, callback) => {
callCount++;
if (callCount === 1) {
callback("invalid");
} else {
callback("1");
}
});

const result = await config.promptForRegion();

expect(mockReadline.question).toHaveBeenCalledTimes(2);
expect(result).toBe("cn");
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining("Please enter 1 or 2")
);
});
});

describe("getApiBaseUrl", () => {
beforeEach(() => {
config = new ClaudeCodeRouterConfig();
});

it("should return China URL for 'cn' region", () => {
const result = config.getApiBaseUrl("cn");
expect(result).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions");
});

it("should return International URL for 'intl' region", () => {
const result = config.getApiBaseUrl("intl");
expect(result).toBe("https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions");
});

it("should default to China URL for unknown region", () => {
const result = config.getApiBaseUrl("unknown");
expect(result).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions");
});
});

describe("promptForApiKey", () => {
beforeEach(() => {
config = new ClaudeCodeRouterConfig();
Expand Down
61 changes: 57 additions & 4 deletions bin/claude-code-router-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,42 @@ class ClaudeCodeRouterConfig {
return locale.toLowerCase().includes('zh') ? 'zh' : 'en';
}

async promptForRegion() {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});

return new Promise((resolve) => {
const askForRegion = () => {
const prompt = `${this.messages.regionPrompt}\n${this.messages.regionOption1}\n${this.messages.regionOption2}\n${this.messages.regionInput}`;

rl.question(prompt, (answer) => {
const choice = answer.trim();
if (choice === '1') {
console.log(this.messages.regionSelected1);
rl.close();
resolve('cn');
} else if (choice === '2') {
console.log(this.messages.regionSelected2);
rl.close();
resolve('intl');
} else {
console.log(this.messages.regionInvalid);
askForRegion();
}
});
};
askForRegion();
});
}

getApiBaseUrl(region) {
return region === 'intl'
? "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions"
: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
}

getMessages() {
const messages = {
zh: {
Expand All @@ -38,6 +74,13 @@ class ClaudeCodeRouterConfig {
step2: "2. 请确保已安装 @musistudio/claude-code-router",
step3Warning: "3. ⚠️ 请手动配置环境变量DASHSCOPE_API_KEY:",
step3Success: "3. ✅ API Key 已从环境变量自动配置",
regionPrompt: "请选择服务区域 (Please select service region):",
regionOption1: "1. 阿里云 (Alibaba Cloud China)",
regionOption2: "2. 阿里云国际站 (Alibaba Cloud International)",
regionInput: "请输入 1 或 2 (Enter 1 or 2): ",
regionSelected1: "✅ 已选择阿里云中国站",
regionSelected2: "✅ 已选择阿里云国际站",
regionInvalid: "❌ 请输入 1 或 2",
promptApiKey: "请输入您的 DashScope API Key:",
apiKeyPrompt: "DashScope API Key",
apiKeyConfigured: "✅ API Key 已配置完成",
Expand Down Expand Up @@ -65,6 +108,13 @@ class ClaudeCodeRouterConfig {
step2: "2. Please ensure @musistudio/claude-code-router is installed",
step3Warning: "3. ⚠️ Please manually set your DASHSCOPE_API_KEY environment variable:",
step3Success: "3. ✅ API Key automatically configured from environment variable",
regionPrompt: "Please select service region:",
regionOption1: "1. Alibaba Cloud China",
regionOption2: "2. Alibaba Cloud International",
regionInput: "Enter 1 or 2: ",
regionSelected1: "✅ Selected Alibaba Cloud China",
regionSelected2: "✅ Selected Alibaba Cloud International",
regionInvalid: "❌ Please enter 1 or 2",
promptApiKey: "Please enter your DashScope API Key:",
apiKeyPrompt: "DashScope API Key",
apiKeyConfigured: "✅ API Key configured successfully",
Expand Down Expand Up @@ -113,6 +163,9 @@ class ClaudeCodeRouterConfig {
try {
console.log(this.messages.configuring);

// 询问用户选择服务区域
const region = await this.promptForRegion();

// 检查环境变量
let apiKey = process.env.DASHSCOPE_API_KEY;
const hasEnvApiKey = !!apiKey;
Expand All @@ -129,7 +182,7 @@ class ClaudeCodeRouterConfig {
await this.createDirectories();

// 创建配置文件
await this.createConfigFile(apiKey);
await this.createConfigFile(apiKey, region);

// 创建插件文件
await this.createTransformerFile();
Expand Down Expand Up @@ -158,9 +211,10 @@ class ClaudeCodeRouterConfig {
console.log(this.messages.createDir, this.configDir);
}

async createConfigFile(apiKey) {
async createConfigFile(apiKey, region) {
// 使用传入的 API Key(可能来自环境变量或用户输入)
const dashscopeApiKey = apiKey;
const apiBaseUrl = this.getApiBaseUrl(region);

const configContent = {
LOG: true,
Expand All @@ -183,8 +237,7 @@ class ClaudeCodeRouterConfig {
Providers: [
{
name: "dashscope",
api_base_url:
"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
api_base_url: apiBaseUrl,
api_key: dashscopeApiKey,
models: ["qwen3-235b-a22b"],
transformer: {
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.