1239 lines
40 KiB
JavaScript
1239 lines
40 KiB
JavaScript
const test = require("node:test");
|
||
const assert = require("node:assert/strict");
|
||
const fs = require("node:fs");
|
||
const http = require("node:http");
|
||
const os = require("node:os");
|
||
const path = require("node:path");
|
||
const Module = require("node:module");
|
||
|
||
const ROUTER_MODULE = path.resolve(__dirname, "../electron/lib/router.js");
|
||
|
||
function b64(value) {
|
||
return "b64:" + Buffer.from(String(value || ""), "utf-8").toString("base64");
|
||
}
|
||
|
||
function listen(server) {
|
||
return new Promise((resolve, reject) => {
|
||
server.once("error", reject);
|
||
server.listen(0, "127.0.0.1", () => {
|
||
server.removeListener("error", reject);
|
||
resolve(server.address().port);
|
||
});
|
||
});
|
||
}
|
||
|
||
function close(server) {
|
||
return new Promise((resolve) => server.close(resolve));
|
||
}
|
||
|
||
function fixture(options = {}) {
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-market-router-"));
|
||
const home = path.join(root, "home");
|
||
const userData = path.join(root, "user-data");
|
||
const codexHome = path.join(home, ".codex");
|
||
const claudeHome = path.join(home, ".claude");
|
||
fs.mkdirSync(codexHome, { recursive: true });
|
||
fs.mkdirSync(claudeHome, { recursive: true });
|
||
fs.mkdirSync(userData, { recursive: true });
|
||
|
||
const officialConfig = options.officialConfig || 'model_catalog_json = "cc-switch-model-catalog.json"\n';
|
||
const officialAuthObject = options.officialAuth || {
|
||
auth_mode: "chatgpt",
|
||
tokens: {
|
||
access_token: "official-access-token",
|
||
refresh_token: "official-refresh-token",
|
||
},
|
||
};
|
||
const officialAuth = JSON.stringify(officialAuthObject, null, 2);
|
||
const routerKey = options.routerKey || "rk-test-router-key";
|
||
const groups = [
|
||
{
|
||
id: "default",
|
||
name: "我的账号",
|
||
isDefault: true,
|
||
key: "",
|
||
modelProvider: "",
|
||
providerBlock: "",
|
||
authKey: "",
|
||
origModel: "",
|
||
officialBackup: {
|
||
done: true,
|
||
ts: 1,
|
||
configToml: officialConfig,
|
||
authJson: officialAuth,
|
||
hadTokens: true,
|
||
},
|
||
},
|
||
...(options.groups || []),
|
||
];
|
||
const persistedGroups = groups.map((group) => {
|
||
const out = { ...group, key: b64(group.key) };
|
||
if (out.officialBackup && out.officialBackup.authJson) {
|
||
out.officialBackup = {
|
||
...out.officialBackup,
|
||
authJson: b64(out.officialBackup.authJson),
|
||
};
|
||
}
|
||
return out;
|
||
});
|
||
|
||
fs.writeFileSync(path.join(codexHome, "config.toml"), options.configToml || officialConfig);
|
||
fs.writeFileSync(
|
||
path.join(codexHome, "auth.json"),
|
||
JSON.stringify(options.auth || officialAuthObject, null, 2)
|
||
);
|
||
fs.writeFileSync(
|
||
path.join(userData, "router.json"),
|
||
JSON.stringify({
|
||
port: options.port || 8787,
|
||
routerKey: b64(routerKey),
|
||
activeId: options.activeId || "default",
|
||
activeClaudeId: options.activeClaudeId || null,
|
||
groups: persistedGroups,
|
||
}, null, 2)
|
||
);
|
||
|
||
if (options.claudeSettings) {
|
||
fs.writeFileSync(path.join(claudeHome, "settings.json"), JSON.stringify(options.claudeSettings, null, 2));
|
||
}
|
||
|
||
return { root, home, userData, codexHome, claudeHome, officialConfig, officialAuth, routerKey };
|
||
}
|
||
|
||
function loadRouter(fx) {
|
||
const previousHome = process.env.HOME;
|
||
process.env.HOME = fx.home;
|
||
delete require.cache[ROUTER_MODULE];
|
||
const originalLoad = Module._load;
|
||
Module._load = function load(request, parent, isMain) {
|
||
if (request === "electron") {
|
||
return {
|
||
app: {
|
||
getPath(name) {
|
||
assert.equal(name, "userData");
|
||
return fx.userData;
|
||
},
|
||
},
|
||
safeStorage: {
|
||
isEncryptionAvailable: () => false,
|
||
},
|
||
};
|
||
}
|
||
return originalLoad.call(this, request, parent, isMain);
|
||
};
|
||
try {
|
||
return require(ROUTER_MODULE);
|
||
} finally {
|
||
Module._load = originalLoad;
|
||
if (previousHome == null) delete process.env.HOME;
|
||
else process.env.HOME = previousHome;
|
||
}
|
||
}
|
||
|
||
test("DeepSeek and Kimi get conservative context windows when none is configured", { concurrency: false }, () => {
|
||
const fx = fixture({
|
||
activeId: "deepseek",
|
||
groups: [{
|
||
id: "deepseek",
|
||
name: "DeepSeek",
|
||
baseUrl: "https://proxy.example.test",
|
||
key: "deepseek-key",
|
||
model: "deepseek-chat",
|
||
mode: "responses",
|
||
}],
|
||
});
|
||
const catalogFile = path.join(fx.codexHome, "cc-switch-model-catalog.json");
|
||
fs.writeFileSync(catalogFile, JSON.stringify({
|
||
models: [{
|
||
slug: "template",
|
||
display_name: "Template",
|
||
context_window: 128000,
|
||
max_context_window: 128000,
|
||
effective_context_window_percent: 95,
|
||
visibility: "list",
|
||
}],
|
||
}));
|
||
const router = loadRouter(fx);
|
||
try {
|
||
router.applyToCodex();
|
||
const catalog = JSON.parse(fs.readFileSync(catalogFile, "utf-8"));
|
||
const deepseek = catalog.models.find((model) => model.slug === "deepseek-chat");
|
||
assert.equal(deepseek.context_window, 65536);
|
||
assert.equal(deepseek.max_context_window, 65536);
|
||
assert.equal(deepseek.effective_context_window_percent, 80);
|
||
} finally {
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("takeover links the model catalog into config.toml with an absolute path", { concurrency: false }, () => {
|
||
const fx = fixture({
|
||
activeId: "deepseek",
|
||
groups: [{
|
||
id: "deepseek",
|
||
name: "DeepSeek",
|
||
baseUrl: "https://proxy.example.test",
|
||
key: "deepseek-key",
|
||
model: "deepseek-chat",
|
||
mode: "chat",
|
||
}],
|
||
configToml: 'model_reasoning_effort = "high"\n', // 无 model_catalog_json:Codex 会忽略目录
|
||
});
|
||
const catalogFile = path.join(fx.codexHome, "cc-switch-model-catalog.json");
|
||
fs.writeFileSync(catalogFile, JSON.stringify({
|
||
models: [{
|
||
slug: "template",
|
||
display_name: "Template",
|
||
context_window: 128000,
|
||
max_context_window: 128000,
|
||
effective_context_window_percent: 95,
|
||
visibility: "list",
|
||
}],
|
||
}));
|
||
const router = loadRouter(fx);
|
||
try {
|
||
router.applyToCodex();
|
||
const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8");
|
||
assert.match(configToml, new RegExp(`^model_catalog_json = "${catalogFile.replace(/\./g, "\\.")}"$`, "m"));
|
||
// 再次接管:已是绝对路径时不重复注入
|
||
router.applyToCodex();
|
||
const again = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8");
|
||
assert.equal(again.match(/^model_catalog_json = /gm).length, 1);
|
||
} finally {
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("relative model_catalog_json is upgraded to an absolute path and user catalogs are preserved", { concurrency: false }, () => {
|
||
const fx = fixture({
|
||
activeId: "deepseek",
|
||
groups: [{
|
||
id: "deepseek",
|
||
name: "DeepSeek",
|
||
baseUrl: "https://proxy.example.test",
|
||
key: "deepseek-key",
|
||
model: "deepseek-chat",
|
||
mode: "responses",
|
||
}],
|
||
configToml: 'model_catalog_json = "cc-switch-model-catalog.json"\n',
|
||
});
|
||
const catalogFile = path.join(fx.codexHome, "cc-switch-model-catalog.json");
|
||
fs.writeFileSync(catalogFile, JSON.stringify({ models: [{ slug: "template", context_window: 128000, visibility: "list" }] }));
|
||
const router = loadRouter(fx);
|
||
try {
|
||
router.applyToCodex();
|
||
let configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8");
|
||
assert.match(configToml, new RegExp(`^model_catalog_json = "${catalogFile.replace(/\./g, "\\.")}"$`, "m"));
|
||
assert.doesNotMatch(configToml, /model_catalog_json = "cc-switch/);
|
||
// 指向用户自建目录时不被改写
|
||
fs.writeFileSync(path.join(fx.codexHome, "config.toml"), 'model_catalog_json = "my-own-catalog.json"\n');
|
||
router.applyDirectToCodex("deepseek");
|
||
configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8");
|
||
assert.match(configToml, /^model_catalog_json = "my-own-catalog.json"$/m);
|
||
} finally {
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("small explicit context windows are honored instead of silently ignored", { concurrency: false }, () => {
|
||
const fx = fixture({
|
||
activeId: "deepseek",
|
||
groups: [{
|
||
id: "deepseek",
|
||
name: "DeepSeek",
|
||
baseUrl: "https://proxy.example.test",
|
||
key: "deepseek-key",
|
||
model: "deepseek-chat",
|
||
mode: "responses",
|
||
contextWindow: "2048",
|
||
}],
|
||
});
|
||
const catalogFile = path.join(fx.codexHome, "cc-switch-model-catalog.json");
|
||
fs.writeFileSync(catalogFile, JSON.stringify({
|
||
models: [{ slug: "template", context_window: 128000, max_context_window: 128000, visibility: "list" }],
|
||
}));
|
||
const router = loadRouter(fx);
|
||
try {
|
||
router.applyToCodex();
|
||
const catalog = JSON.parse(fs.readFileSync(catalogFile, "utf-8"));
|
||
const deepseek = catalog.models.find((model) => model.slug === "deepseek-chat");
|
||
assert.equal(deepseek.context_window, 2048);
|
||
assert.equal(deepseek.effective_context_window_percent, 80);
|
||
} finally {
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("running router writes the newly selected model immediately", { concurrency: false }, async () => {
|
||
const probe = http.createServer();
|
||
const port = await listen(probe);
|
||
await close(probe);
|
||
const fx = fixture({
|
||
port,
|
||
activeId: "model-a",
|
||
configToml: [
|
||
'model_provider = "td-direct"',
|
||
'model = "gpt-model-a"',
|
||
"",
|
||
"[model_providers.td-direct]",
|
||
'name = "Old direct route"',
|
||
'base_url = "https://old.example.test"',
|
||
'wire_api = "responses"',
|
||
"requires_openai_auth = true",
|
||
"",
|
||
].join("\n"),
|
||
auth: {
|
||
auth_mode: "chatgpt",
|
||
OPENAI_API_KEY: "key-a",
|
||
tokens: { access_token: "official-access-token" },
|
||
},
|
||
groups: [
|
||
{
|
||
id: "model-a",
|
||
name: "Model A",
|
||
baseUrl: "http://127.0.0.1:1",
|
||
key: "key-a",
|
||
model: "gpt-model-a",
|
||
mode: "responses",
|
||
},
|
||
{
|
||
id: "model-b",
|
||
name: "Model B",
|
||
baseUrl: "http://127.0.0.1:1",
|
||
key: "key-b",
|
||
model: "gpt-model-b",
|
||
mode: "responses",
|
||
},
|
||
],
|
||
});
|
||
const previousFetch = global.fetch;
|
||
global.fetch = async () => {
|
||
throw new Error("offline test");
|
||
};
|
||
const router = loadRouter(fx);
|
||
try {
|
||
await router.start();
|
||
router.applyToCodex();
|
||
const result = router.setActive("model-b");
|
||
assert.equal(result.activeId, "model-b");
|
||
assert.equal(result._needsCodexRefresh, true);
|
||
assert.equal(result._needsRestart, false);
|
||
const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8");
|
||
assert.match(configToml, /^model_provider = "local-router"$/m);
|
||
assert.match(configToml, /^model = "gpt-model-b"$/m);
|
||
assert.doesNotMatch(configToml, /^model = "gpt-model-a"$/m);
|
||
const saved = JSON.parse(fs.readFileSync(path.join(fx.userData, "router.json"), "utf-8"));
|
||
assert.equal(saved.activeId, "model-b");
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
} finally {
|
||
router.stop();
|
||
global.fetch = previousFetch;
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("stopped router writes the selected group directly to Codex immediately", { concurrency: false }, () => {
|
||
const fx = fixture({
|
||
activeId: "default",
|
||
groups: [
|
||
{
|
||
id: "model-b",
|
||
name: "Model B",
|
||
baseUrl: "https://proxy.example.test/v1",
|
||
key: "key-b",
|
||
model: "gpt-model-b",
|
||
mode: "responses",
|
||
},
|
||
],
|
||
});
|
||
const previousFetch = global.fetch;
|
||
global.fetch = async () => {
|
||
throw new Error("offline test");
|
||
};
|
||
const router = loadRouter(fx);
|
||
try {
|
||
assert.equal(router.isRunning(), false);
|
||
const result = router.setActive("model-b");
|
||
assert.equal(result.activeId, "model-b");
|
||
assert.equal(result._mode, "direct");
|
||
assert.equal(result._needsRestart, true);
|
||
|
||
const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8");
|
||
assert.match(configToml, /^model_provider = "td-direct"$/m);
|
||
assert.match(configToml, /^model = "gpt-model-b"$/m);
|
||
assert.match(configToml, /^\[model_providers\.td-direct\]$/m);
|
||
assert.match(configToml, /^base_url = "https:\/\/proxy\.example\.test\/v1"$/m);
|
||
assert.doesNotMatch(configToml, /^\[model_providers\.local-router\]$/m);
|
||
|
||
const auth = JSON.parse(fs.readFileSync(path.join(fx.codexHome, "auth.json"), "utf-8"));
|
||
assert.equal(auth.OPENAI_API_KEY, "key-b");
|
||
assert.equal(auth.tokens.access_token, "official-access-token");
|
||
|
||
const saved = JSON.parse(fs.readFileSync(path.join(fx.userData, "router.json"), "utf-8"));
|
||
assert.equal(saved.activeId, "model-b");
|
||
} finally {
|
||
global.fetch = previousFetch;
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("direct switch retries a transient Windows-style auth.json lock", { concurrency: false }, () => {
|
||
const fx = fixture({
|
||
activeId: "default",
|
||
groups: [
|
||
{
|
||
id: "model-b",
|
||
name: "Model B",
|
||
baseUrl: "https://proxy.example.test/v1",
|
||
key: "key-b",
|
||
model: "gpt-model-b",
|
||
mode: "responses",
|
||
},
|
||
],
|
||
});
|
||
const router = loadRouter(fx);
|
||
const authFile = path.join(fx.codexHome, "auth.json");
|
||
const originalRenameSync = fs.renameSync;
|
||
let authRenameAttempts = 0;
|
||
fs.renameSync = function renameSyncWithTransientLock(from, to) {
|
||
if (to === authFile && authRenameAttempts++ === 0) {
|
||
const error = new Error("file is temporarily locked");
|
||
error.code = "EPERM";
|
||
throw error;
|
||
}
|
||
return originalRenameSync.call(this, from, to);
|
||
};
|
||
try {
|
||
const result = router.setActive("model-b");
|
||
assert.equal(result.activeId, "model-b");
|
||
assert.ok(authRenameAttempts >= 2);
|
||
assert.equal(
|
||
JSON.parse(fs.readFileSync(authFile, "utf-8")).OPENAI_API_KEY,
|
||
"key-b"
|
||
);
|
||
assert.match(
|
||
fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8"),
|
||
/^model_provider = "td-direct"$/m
|
||
);
|
||
} finally {
|
||
fs.renameSync = originalRenameSync;
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("direct switch rolls back config and active group when auth.json stays locked", { concurrency: false }, () => {
|
||
const fx = fixture({
|
||
activeId: "default",
|
||
groups: [
|
||
{
|
||
id: "model-b",
|
||
name: "Model B",
|
||
baseUrl: "https://proxy.example.test/v1",
|
||
key: "key-b",
|
||
model: "gpt-model-b",
|
||
mode: "responses",
|
||
},
|
||
],
|
||
});
|
||
const router = loadRouter(fx);
|
||
const configFile = path.join(fx.codexHome, "config.toml");
|
||
const authFile = path.join(fx.codexHome, "auth.json");
|
||
const beforeConfig = fs.readFileSync(configFile);
|
||
const beforeAuth = fs.readFileSync(authFile);
|
||
const originalRenameSync = fs.renameSync;
|
||
const originalWriteFileSync = fs.writeFileSync;
|
||
fs.renameSync = function renameSyncWithPersistentLock(from, to) {
|
||
if (to === authFile) {
|
||
const error = new Error("file remains locked");
|
||
error.code = "EPERM";
|
||
throw error;
|
||
}
|
||
return originalRenameSync.call(this, from, to);
|
||
};
|
||
fs.writeFileSync = function writeFileSyncWithPersistentLock(file, ...args) {
|
||
if (file === authFile) {
|
||
const error = new Error("file remains locked");
|
||
error.code = "EPERM";
|
||
throw error;
|
||
}
|
||
return originalWriteFileSync.call(this, file, ...args);
|
||
};
|
||
try {
|
||
assert.throws(
|
||
() => router.setActive("model-b"),
|
||
/无法写入配置文件 .*auth\.json/
|
||
);
|
||
assert.deepEqual(fs.readFileSync(configFile), beforeConfig);
|
||
assert.deepEqual(fs.readFileSync(authFile), beforeAuth);
|
||
assert.equal(router.status().activeId, "default");
|
||
assert.equal(
|
||
JSON.parse(fs.readFileSync(path.join(fx.userData, "router.json"), "utf-8")).activeId,
|
||
"default"
|
||
);
|
||
} finally {
|
||
fs.renameSync = originalRenameSync;
|
||
fs.writeFileSync = originalWriteFileSync;
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("claude proxy writes active model to settings and forwards that model upstream", { concurrency: false }, async () => {
|
||
let observed;
|
||
const upstream = http.createServer((req, res) => {
|
||
const chunks = [];
|
||
req.on("data", (chunk) => chunks.push(chunk));
|
||
req.on("end", () => {
|
||
observed = {
|
||
headers: req.headers,
|
||
body: Buffer.concat(chunks).toString("utf-8"),
|
||
};
|
||
res.writeHead(200, { "content-type": "application/json" });
|
||
res.end('{"type":"message","content":[]}');
|
||
});
|
||
});
|
||
const upstreamPort = await listen(upstream);
|
||
const probe = http.createServer();
|
||
const routerPort = await listen(probe);
|
||
await close(probe);
|
||
const fx = fixture({
|
||
port: routerPort,
|
||
activeClaudeId: "claude-glm",
|
||
claudeSettings: {
|
||
env: {
|
||
ANTHROPIC_BASE_URL: "https://original.example.test",
|
||
ANTHROPIC_AUTH_TOKEN: "original-token",
|
||
ANTHROPIC_MODEL: "original-model",
|
||
},
|
||
},
|
||
groups: [
|
||
{
|
||
id: "claude-glm",
|
||
name: "国模",
|
||
baseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||
key: "glm-key",
|
||
model: "glm-5.2",
|
||
mode: "claude",
|
||
},
|
||
],
|
||
});
|
||
const router = loadRouter(fx);
|
||
const payload = JSON.stringify({
|
||
model: "claude-opus-4-8",
|
||
max_tokens: 128,
|
||
messages: [{ role: "user", content: "hi" }],
|
||
});
|
||
try {
|
||
await router.start();
|
||
const applied = router.applyToClaudeCode();
|
||
assert.equal(applied.ok, true);
|
||
const settings = JSON.parse(fs.readFileSync(path.join(fx.claudeHome, "settings.json"), "utf-8"));
|
||
assert.equal(settings.env.ANTHROPIC_BASE_URL, `http://127.0.0.1:${routerPort}`);
|
||
assert.equal(settings.env.ANTHROPIC_AUTH_TOKEN, fx.routerKey);
|
||
assert.equal(settings.env.ANTHROPIC_MODEL, "glm-5.2");
|
||
|
||
const response = await new Promise((resolve, reject) => {
|
||
const req = http.request({
|
||
method: "POST",
|
||
host: "127.0.0.1",
|
||
port: routerPort,
|
||
path: "/v1/messages",
|
||
headers: {
|
||
authorization: "Bearer " + fx.routerKey,
|
||
"content-type": "application/json",
|
||
"content-length": Buffer.byteLength(payload),
|
||
"anthropic-version": "2023-06-01",
|
||
},
|
||
}, (res) => {
|
||
const chunks = [];
|
||
res.on("data", (chunk) => chunks.push(chunk));
|
||
res.on("end", () => resolve({
|
||
statusCode: res.statusCode,
|
||
body: Buffer.concat(chunks).toString("utf-8"),
|
||
}));
|
||
});
|
||
req.on("error", reject);
|
||
req.end(payload);
|
||
});
|
||
|
||
assert.equal(response.statusCode, 200);
|
||
assert.equal(response.body, '{"type":"message","content":[]}');
|
||
assert.ok(observed);
|
||
assert.equal(JSON.parse(observed.body).model, "glm-5.2");
|
||
assert.equal(observed.headers.authorization, "Bearer glm-key");
|
||
assert.equal(observed.headers["x-api-key"], undefined);
|
||
assert.equal(observed.headers["content-length"], String(Buffer.byteLength(observed.body)));
|
||
|
||
const restored = router.restoreClaudeCode();
|
||
assert.equal(restored.ok, true);
|
||
const restoredSettings = JSON.parse(fs.readFileSync(path.join(fx.claudeHome, "settings.json"), "utf-8"));
|
||
assert.equal(restoredSettings.env.ANTHROPIC_MODEL, "original-model");
|
||
} finally {
|
||
router.stop();
|
||
await close(upstream);
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("switching active claude group refreshes the cli model setting while router is running", { concurrency: false }, async () => {
|
||
const probe = http.createServer();
|
||
const routerPort = await listen(probe);
|
||
await close(probe);
|
||
const fx = fixture({
|
||
port: routerPort,
|
||
activeClaudeId: "claude-opus",
|
||
groups: [
|
||
{
|
||
id: "claude-opus",
|
||
name: "Claude",
|
||
baseUrl: "https://claude.example.test",
|
||
key: "opus-key",
|
||
model: "claude-opus-4-8",
|
||
mode: "claude",
|
||
},
|
||
{
|
||
id: "claude-glm",
|
||
name: "国模",
|
||
baseUrl: "https://glm.example.test",
|
||
key: "glm-key",
|
||
model: "glm-5.2",
|
||
mode: "claude",
|
||
},
|
||
],
|
||
});
|
||
const router = loadRouter(fx);
|
||
try {
|
||
await router.start();
|
||
assert.equal(router.applyToClaudeCode().ok, true);
|
||
assert.equal(
|
||
JSON.parse(fs.readFileSync(path.join(fx.claudeHome, "settings.json"), "utf-8")).env.ANTHROPIC_MODEL,
|
||
"claude-opus-4-8"
|
||
);
|
||
|
||
const result = router.setActive("claude-glm");
|
||
assert.equal(result.activeClaudeId, "claude-glm");
|
||
assert.equal(result._mode, "proxy");
|
||
assert.equal(
|
||
JSON.parse(fs.readFileSync(path.join(fx.claudeHome, "settings.json"), "utf-8")).env.ANTHROPIC_MODEL,
|
||
"glm-5.2"
|
||
);
|
||
} finally {
|
||
router.stop();
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("claude switch rolls back active group when settings.json stays locked", { concurrency: false }, () => {
|
||
const fx = fixture({
|
||
activeClaudeId: "claude-opus",
|
||
claudeSettings: {
|
||
env: {
|
||
ANTHROPIC_BASE_URL: "https://opus.example.test",
|
||
ANTHROPIC_AUTH_TOKEN: "opus-key",
|
||
ANTHROPIC_MODEL: "claude-opus-4-8",
|
||
},
|
||
},
|
||
groups: [
|
||
{
|
||
id: "claude-opus",
|
||
name: "Claude",
|
||
baseUrl: "https://opus.example.test",
|
||
key: "opus-key",
|
||
model: "claude-opus-4-8",
|
||
mode: "claude",
|
||
},
|
||
{
|
||
id: "claude-glm",
|
||
name: "国模",
|
||
baseUrl: "https://glm.example.test",
|
||
key: "glm-key",
|
||
model: "glm-5.2",
|
||
mode: "claude",
|
||
},
|
||
],
|
||
});
|
||
const router = loadRouter(fx);
|
||
const settingsFile = path.join(fx.claudeHome, "settings.json");
|
||
const beforeSettings = fs.readFileSync(settingsFile);
|
||
const originalRenameSync = fs.renameSync;
|
||
const originalWriteFileSync = fs.writeFileSync;
|
||
fs.renameSync = function renameSyncWithPersistentLock(from, to) {
|
||
if (to === settingsFile) {
|
||
const error = new Error("file remains locked");
|
||
error.code = "EPERM";
|
||
throw error;
|
||
}
|
||
return originalRenameSync.call(this, from, to);
|
||
};
|
||
fs.writeFileSync = function writeFileSyncWithPersistentLock(file, ...args) {
|
||
if (file === settingsFile) {
|
||
const error = new Error("file remains locked");
|
||
error.code = "EPERM";
|
||
throw error;
|
||
}
|
||
return originalWriteFileSync.call(this, file, ...args);
|
||
};
|
||
try {
|
||
assert.throws(
|
||
() => router.setActive("claude-glm"),
|
||
/无法写入配置文件 .*settings\.json/
|
||
);
|
||
assert.deepEqual(fs.readFileSync(settingsFile), beforeSettings);
|
||
assert.equal(router.status().activeClaudeId, "claude-opus");
|
||
assert.equal(
|
||
JSON.parse(fs.readFileSync(path.join(fx.userData, "router.json"), "utf-8")).activeClaudeId,
|
||
"claude-opus"
|
||
);
|
||
} finally {
|
||
fs.renameSync = originalRenameSync;
|
||
fs.writeFileSync = originalWriteFileSync;
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("stopping restores official config when managed sections or keys remain", { concurrency: false }, () => {
|
||
const fx = fixture({
|
||
activeId: "model-a",
|
||
configToml: [
|
||
'model_provider = "custom-user-provider"',
|
||
'model = "gpt-model-a"',
|
||
"",
|
||
"[model_providers.custom-user-provider]",
|
||
'name = "User provider"',
|
||
'base_url = "https://user.example.test"',
|
||
'wire_api = "responses"',
|
||
"",
|
||
"[model_providers.local-router]",
|
||
'name = "Stale local route"',
|
||
'base_url = "http://127.0.0.1:8787"',
|
||
'wire_api = "responses"',
|
||
"",
|
||
"[model_providers.td-direct]",
|
||
'name = "Stale direct route"',
|
||
'base_url = "https://stale.example.test"',
|
||
'wire_api = "responses"',
|
||
"",
|
||
].join("\n"),
|
||
auth: {
|
||
auth_mode: "chatgpt",
|
||
OPENAI_API_KEY: "rk-test-router-key",
|
||
tokens: { access_token: "official-access-token" },
|
||
},
|
||
groups: [
|
||
{
|
||
id: "model-a",
|
||
name: "Model A",
|
||
baseUrl: "https://proxy.example.test",
|
||
key: "key-a",
|
||
model: "gpt-model-a",
|
||
mode: "responses",
|
||
},
|
||
],
|
||
});
|
||
const router = loadRouter(fx);
|
||
try {
|
||
const result = router.stopAndRestoreDirect();
|
||
assert.equal(result.released, true);
|
||
assert.equal(result.removedManagedProvider, false);
|
||
assert.equal(result.keptUserProvider, true);
|
||
const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8");
|
||
assert.doesNotMatch(configToml, /local-router/);
|
||
assert.doesNotMatch(configToml, /td-direct/);
|
||
assert.match(configToml, /^model_provider = "custom-user-provider"$/m);
|
||
assert.match(configToml, /^\[model_providers\.custom-user-provider\]$/m);
|
||
assert.match(configToml, /^model = "gpt-model-a"$/m);
|
||
} finally {
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("disabling an already-stopped local route removes its Codex takeover", { concurrency: false }, () => {
|
||
const fx = fixture({
|
||
configToml: [
|
||
'model_provider = "local-router"',
|
||
'model = "gpt-model-a"',
|
||
"",
|
||
"[model_providers.local-router]",
|
||
'name = "本地路由"',
|
||
'base_url = "http://127.0.0.1:8787"',
|
||
'wire_api = "responses"',
|
||
"",
|
||
].join("\n"),
|
||
auth: {
|
||
auth_mode: "chatgpt",
|
||
OPENAI_API_KEY: "rk-test-router-key",
|
||
tokens: { access_token: "official-access-token" },
|
||
},
|
||
groups: [],
|
||
});
|
||
const router = loadRouter(fx);
|
||
try {
|
||
assert.equal(router.status().running, false);
|
||
assert.equal(router.status().codexApplied, true);
|
||
const result = router.disableCodex();
|
||
assert.equal(result.released, true);
|
||
assert.equal(result.removedManagedProvider, true);
|
||
assert.equal(result.keptUserProvider, false);
|
||
const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8");
|
||
const auth = JSON.parse(fs.readFileSync(path.join(fx.codexHome, "auth.json"), "utf-8"));
|
||
assert.doesNotMatch(configToml, /local-router/);
|
||
assert.doesNotMatch(configToml, /^model_provider\s*=/m);
|
||
assert.doesNotMatch(configToml, /^model\s*=/m);
|
||
assert.equal(auth.OPENAI_API_KEY, undefined);
|
||
assert.equal(router.status().codexApplied, false);
|
||
} finally {
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("large chunked requests are forwarded with one content-length and no client hop-by-hop headers", { concurrency: false }, async () => {
|
||
let observed;
|
||
const upstream = http.createServer((req, res) => {
|
||
const chunks = [];
|
||
req.on("data", (chunk) => chunks.push(chunk));
|
||
req.on("end", () => {
|
||
observed = {
|
||
headers: req.headers,
|
||
rawHeaders: req.rawHeaders,
|
||
body: Buffer.concat(chunks),
|
||
};
|
||
res.writeHead(200, { "content-type": "application/json" });
|
||
res.end('{"ok":true}');
|
||
});
|
||
});
|
||
const upstreamPort = await listen(upstream);
|
||
const probe = http.createServer();
|
||
const routerPort = await listen(probe);
|
||
await close(probe);
|
||
const fx = fixture({
|
||
port: routerPort,
|
||
activeId: "model-a",
|
||
configToml: [
|
||
'model_provider = "local-router"',
|
||
'model = "gpt-model-a"',
|
||
"",
|
||
"[model_providers.local-router]",
|
||
'name = "Local route"',
|
||
`base_url = "http://127.0.0.1:${routerPort}"`,
|
||
'wire_api = "responses"',
|
||
"",
|
||
].join("\n"),
|
||
auth: {
|
||
auth_mode: "chatgpt",
|
||
OPENAI_API_KEY: "rk-test-router-key",
|
||
tokens: { access_token: "official-access-token" },
|
||
},
|
||
groups: [
|
||
{
|
||
id: "model-a",
|
||
name: "Model A",
|
||
baseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||
key: "upstream-key",
|
||
model: "gpt-model-a",
|
||
mode: "responses",
|
||
},
|
||
],
|
||
});
|
||
const router = loadRouter(fx);
|
||
const payload = Buffer.from(JSON.stringify({
|
||
model: "gpt-model-a",
|
||
instructions: "x".repeat(180 * 1024),
|
||
input: [{ role: "user", content: "long conversation" }],
|
||
}));
|
||
try {
|
||
await router.start();
|
||
const response = await new Promise((resolve, reject) => {
|
||
const req = http.request({
|
||
method: "POST",
|
||
host: "127.0.0.1",
|
||
port: routerPort,
|
||
path: "/responses",
|
||
headers: {
|
||
authorization: "Bearer " + fx.routerKey,
|
||
"content-type": "application/json",
|
||
connection: "keep-alive, x-remove-me",
|
||
"x-remove-me": "must-not-reach-upstream",
|
||
"transfer-encoding": "chunked",
|
||
te: "trailers",
|
||
trailer: "x-client-trailer",
|
||
upgrade: "websocket",
|
||
},
|
||
}, (res) => {
|
||
const chunks = [];
|
||
res.on("data", (chunk) => chunks.push(chunk));
|
||
res.on("end", () => resolve({
|
||
statusCode: res.statusCode,
|
||
body: Buffer.concat(chunks).toString("utf-8"),
|
||
}));
|
||
});
|
||
req.on("error", reject);
|
||
for (let offset = 0; offset < payload.length; offset += 4096) {
|
||
req.write(payload.subarray(offset, offset + 4096));
|
||
}
|
||
req.end();
|
||
});
|
||
|
||
assert.equal(response.statusCode, 200);
|
||
assert.equal(response.body, '{"ok":true}');
|
||
assert.ok(observed);
|
||
assert.equal(observed.body.length, payload.length);
|
||
assert.deepEqual(JSON.parse(observed.body.toString("utf-8")), JSON.parse(payload.toString("utf-8")));
|
||
assert.equal(observed.headers["content-length"], String(payload.length));
|
||
assert.equal(observed.headers["transfer-encoding"], undefined);
|
||
assert.equal(observed.headers.te, undefined);
|
||
assert.equal(observed.headers.trailer, undefined);
|
||
assert.equal(observed.headers.upgrade, undefined);
|
||
assert.equal(observed.headers["x-remove-me"], undefined);
|
||
assert.equal(observed.headers.host, `127.0.0.1:${upstreamPort}`);
|
||
assert.equal(
|
||
observed.rawHeaders.filter((_, index) => index % 2 === 0 && observed.rawHeaders[index].toLowerCase() === "content-length").length,
|
||
1
|
||
);
|
||
} finally {
|
||
router.stop();
|
||
await close(upstream);
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("chat multimodal group injects MiniMax evidence before forwarding to DeepSeek", { concurrency: false }, async () => {
|
||
let upstreamBody;
|
||
const upstream = http.createServer((req, res) => {
|
||
const chunks = [];
|
||
req.on("data", (chunk) => chunks.push(chunk));
|
||
req.on("end", () => {
|
||
upstreamBody = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||
res.end('data: {"choices":[{"delta":{"content":"ok"}}]}\n\ndata: [DONE]\n\n');
|
||
});
|
||
});
|
||
const upstreamPort = await listen(upstream);
|
||
const probe = http.createServer();
|
||
const routerPort = await listen(probe);
|
||
await close(probe);
|
||
const fx = fixture({
|
||
port: routerPort,
|
||
activeId: "deepseek-chat",
|
||
groups: [
|
||
{
|
||
id: "deepseek-chat",
|
||
name: "DeepSeek",
|
||
baseUrl: `http://127.0.0.1:${upstreamPort}`,
|
||
key: "deepseek-key",
|
||
model: "deepseek-chat",
|
||
mode: "chat",
|
||
multimodal: true,
|
||
},
|
||
],
|
||
});
|
||
const previousFetch = global.fetch;
|
||
const previousKey = process.env.TD_MULTIMODAL_GATEWAY_KEY;
|
||
process.env.TD_MULTIMODAL_GATEWAY_KEY = "test-multimodal-key";
|
||
global.fetch = async () => ({
|
||
ok: true,
|
||
json: async () => ({
|
||
choices: [{
|
||
message: {
|
||
content: '{"summary":"登录页截图","items":[{"kind":"ocr","text":"账号登录","confidence":0.99}]}',
|
||
},
|
||
}],
|
||
}),
|
||
});
|
||
const router = loadRouter(fx);
|
||
try {
|
||
await router.start();
|
||
await new Promise((resolve, reject) => {
|
||
const req = http.request({
|
||
method: "POST",
|
||
host: "127.0.0.1",
|
||
port: routerPort,
|
||
path: "/responses",
|
||
headers: {
|
||
authorization: "Bearer " + fx.routerKey,
|
||
"content-type": "application/json",
|
||
},
|
||
}, (res) => {
|
||
res.resume();
|
||
res.on("end", resolve);
|
||
});
|
||
req.on("error", reject);
|
||
req.end(JSON.stringify({
|
||
model: "gpt-5",
|
||
stream: true,
|
||
tool_choice: "auto",
|
||
tools: [
|
||
{ type: "function", name: "exec_command", parameters: { type: "object" } },
|
||
{ type: "function", name: "view_image", parameters: { type: "object" } },
|
||
],
|
||
input: [{
|
||
type: "message",
|
||
role: "user",
|
||
content: [
|
||
{ type: "input_text", text: "图片里有什么?" },
|
||
{ type: "input_image", image_url: "data:image/png;base64,cm91dGVyLXRlc3Q=" },
|
||
],
|
||
}],
|
||
}));
|
||
});
|
||
|
||
assert.ok(upstreamBody);
|
||
assert.equal(upstreamBody.model, "deepseek-chat");
|
||
const userContent = upstreamBody.messages
|
||
.filter((message) => message.role === "user")
|
||
.map((message) => message.content)
|
||
.join("\n");
|
||
assert.match(userContent, /图片里有什么/);
|
||
assert.match(userContent, /\[TD 多模态识别证据\]/);
|
||
assert.match(userContent, /账号登录/);
|
||
assert.doesNotMatch(userContent, /test-multimodal-key/);
|
||
assert.equal(upstreamBody.tool_choice, "auto");
|
||
assert.deepEqual(upstreamBody.tools.map((tool) => tool.function.name), ["exec_command"]);
|
||
} finally {
|
||
router.stop();
|
||
global.fetch = previousFetch;
|
||
if (previousKey == null) delete process.env.TD_MULTIMODAL_GATEWAY_KEY;
|
||
else process.env.TD_MULTIMODAL_GATEWAY_KEY = previousKey;
|
||
await close(upstream);
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("explicit model routes total control and worker requests to their registered groups", { concurrency: false }, async () => {
|
||
const seen = [];
|
||
const makeUpstream = (name) => http.createServer((req, res) => {
|
||
const chunks = [];
|
||
req.on("data", (chunk) => chunks.push(chunk));
|
||
req.on("end", () => {
|
||
seen.push({ name, body: JSON.parse(Buffer.concat(chunks).toString("utf-8")) });
|
||
res.writeHead(200, { "content-type": "application/json" });
|
||
res.end('{"ok":true}');
|
||
});
|
||
});
|
||
const gptUpstream = makeUpstream("gpt");
|
||
const deepseekUpstream = makeUpstream("deepseek");
|
||
const gptPort = await listen(gptUpstream);
|
||
const deepseekPort = await listen(deepseekUpstream);
|
||
const probe = http.createServer();
|
||
const routerPort = await listen(probe);
|
||
await close(probe);
|
||
const fx = fixture({
|
||
port: routerPort,
|
||
activeId: "gpt-group",
|
||
groups: [
|
||
{
|
||
id: "gpt-group",
|
||
name: "GPT 总控",
|
||
baseUrl: `http://127.0.0.1:${gptPort}`,
|
||
key: "gpt-key",
|
||
model: "gpt-5.6-luna",
|
||
mode: "responses",
|
||
},
|
||
{
|
||
id: "deepseek-group",
|
||
name: "DeepSeek 执行",
|
||
baseUrl: `http://127.0.0.1:${deepseekPort}`,
|
||
key: "deepseek-key",
|
||
model: "deepseek-v4-flash",
|
||
mode: "responses",
|
||
},
|
||
],
|
||
});
|
||
const router = loadRouter(fx);
|
||
const send = (model) => new Promise((resolve, reject) => {
|
||
const payload = JSON.stringify({ model, input: [{ role: "user", content: "ping" }] });
|
||
const req = http.request({
|
||
method: "POST",
|
||
host: "127.0.0.1",
|
||
port: routerPort,
|
||
path: "/responses",
|
||
headers: {
|
||
authorization: "Bearer " + fx.routerKey,
|
||
"content-type": "application/json",
|
||
"content-length": Buffer.byteLength(payload),
|
||
},
|
||
}, (res) => {
|
||
res.resume();
|
||
res.on("end", () => resolve(res.statusCode));
|
||
});
|
||
req.on("error", reject);
|
||
req.end(payload);
|
||
});
|
||
try {
|
||
await router.start();
|
||
const route = router.registerModelRoute("deepseek-group", "deepseek-v4-flash");
|
||
assert.equal(route.ok, true);
|
||
assert.equal(route.groupId, "deepseek-group");
|
||
const saved = JSON.parse(fs.readFileSync(path.join(fx.userData, "router.json"), "utf-8"));
|
||
assert.deepEqual(saved.groups.find((g) => g.id === "deepseek-group").routeModels, ["deepseek-v4-flash"]);
|
||
|
||
assert.equal(await send("gpt-5.6-luna"), 200);
|
||
assert.equal(await send("deepseek-v4-flash"), 200);
|
||
assert.deepEqual(seen.map((x) => [x.name, x.body.model]), [
|
||
["gpt", "gpt-5.6-luna"],
|
||
["deepseek", "deepseek-v4-flash"],
|
||
]);
|
||
} finally {
|
||
router.stop();
|
||
await close(gptUpstream);
|
||
await close(deepseekUpstream);
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("stale selector model does not override the active group while router is running", { concurrency: false }, async () => {
|
||
const seen = [];
|
||
const makeUpstream = (name) => http.createServer((req, res) => {
|
||
const chunks = [];
|
||
req.on("data", (chunk) => chunks.push(chunk));
|
||
req.on("end", () => {
|
||
seen.push({ name, body: JSON.parse(Buffer.concat(chunks).toString("utf-8")) });
|
||
res.writeHead(200, { "content-type": "application/json" });
|
||
res.end('{"ok":true}');
|
||
});
|
||
});
|
||
const kimiUpstream = makeUpstream("kimi");
|
||
const deepseekUpstream = makeUpstream("deepseek");
|
||
const kimiPort = await listen(kimiUpstream);
|
||
const deepseekPort = await listen(deepseekUpstream);
|
||
const probe = http.createServer();
|
||
const routerPort = await listen(probe);
|
||
await close(probe);
|
||
const fx = fixture({
|
||
port: routerPort,
|
||
activeId: "deepseek-group",
|
||
groups: [
|
||
{
|
||
id: "kimi-group",
|
||
name: "Kimi",
|
||
baseUrl: `http://127.0.0.1:${kimiPort}`,
|
||
key: "kimi-key",
|
||
model: "kimi-k3",
|
||
mode: "responses",
|
||
},
|
||
{
|
||
id: "deepseek-group",
|
||
name: "DeepSeek",
|
||
baseUrl: `http://127.0.0.1:${deepseekPort}`,
|
||
key: "deepseek-key",
|
||
model: "deepseek-v4-pro",
|
||
mode: "responses",
|
||
},
|
||
],
|
||
});
|
||
const router = loadRouter(fx);
|
||
// Codex 顶部选择器尚未刷新,普通请求仍携带旧模型名。它不应覆盖 activeId。
|
||
const payload = JSON.stringify({
|
||
model: "kimi-k3",
|
||
instructions: "hello",
|
||
input: [{ role: "user", content: "ping" }],
|
||
});
|
||
try {
|
||
await router.start();
|
||
const status = await new Promise((resolve, reject) => {
|
||
const req = http.request({
|
||
method: "POST",
|
||
host: "127.0.0.1",
|
||
port: routerPort,
|
||
path: "/responses",
|
||
headers: {
|
||
authorization: "Bearer " + fx.routerKey,
|
||
"content-type": "application/json",
|
||
"content-length": Buffer.byteLength(payload),
|
||
},
|
||
}, (res) => {
|
||
res.resume();
|
||
res.on("end", () => resolve(res.statusCode));
|
||
});
|
||
req.on("error", reject);
|
||
req.end(payload);
|
||
});
|
||
assert.equal(status, 200);
|
||
assert.deepEqual(seen.map((x) => [x.name, x.body.model]), [
|
||
["deepseek", "deepseek-v4-pro"],
|
||
]);
|
||
} finally {
|
||
router.stop();
|
||
await close(kimiUpstream);
|
||
await close(deepseekUpstream);
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test("agent route hint overrides root model for worker requests", { concurrency: false }, async () => {
|
||
const seen = [];
|
||
const makeUpstream = (name) => http.createServer((req, res) => {
|
||
const chunks = [];
|
||
req.on("data", (chunk) => chunks.push(chunk));
|
||
req.on("end", () => {
|
||
seen.push({ name, body: JSON.parse(Buffer.concat(chunks).toString("utf-8")) });
|
||
res.writeHead(200, { "content-type": "application/json" });
|
||
res.end('{"ok":true}');
|
||
});
|
||
});
|
||
const gptUpstream = makeUpstream("gpt");
|
||
const deepseekUpstream = makeUpstream("deepseek");
|
||
const gptPort = await listen(gptUpstream);
|
||
const deepseekPort = await listen(deepseekUpstream);
|
||
const probe = http.createServer();
|
||
const routerPort = await listen(probe);
|
||
await close(probe);
|
||
const fx = fixture({
|
||
port: routerPort,
|
||
activeId: "gpt-group",
|
||
groups: [
|
||
{
|
||
id: "gpt-group",
|
||
name: "GPT 总控",
|
||
baseUrl: `http://127.0.0.1:${gptPort}`,
|
||
key: "gpt-key",
|
||
model: "gpt-5.6-luna",
|
||
mode: "responses",
|
||
},
|
||
{
|
||
id: "deepseek-group",
|
||
name: "DeepSeek 执行",
|
||
baseUrl: `http://127.0.0.1:${deepseekPort}`,
|
||
key: "deepseek-key",
|
||
model: "deepseek-v4-flash",
|
||
mode: "responses",
|
||
},
|
||
],
|
||
});
|
||
const router = loadRouter(fx);
|
||
const payload = JSON.stringify({
|
||
model: "gpt-5.6-luna",
|
||
instructions: '你是 worker。\n[TD_MODEL_ROUTE model="deepseek-v4-flash" role="worker" agent="frontend"; runtime hint only.]',
|
||
input: [{ role: "user", content: "ping" }],
|
||
});
|
||
try {
|
||
await router.start();
|
||
router.registerModelRoute("deepseek-group", "deepseek-v4-flash");
|
||
const status = await new Promise((resolve, reject) => {
|
||
const req = http.request({
|
||
method: "POST",
|
||
host: "127.0.0.1",
|
||
port: routerPort,
|
||
path: "/responses",
|
||
headers: {
|
||
authorization: "Bearer " + fx.routerKey,
|
||
"content-type": "application/json",
|
||
"content-length": Buffer.byteLength(payload),
|
||
},
|
||
}, (res) => {
|
||
res.resume();
|
||
res.on("end", () => resolve(res.statusCode));
|
||
});
|
||
req.on("error", reject);
|
||
req.end(payload);
|
||
});
|
||
assert.equal(status, 200);
|
||
assert.deepEqual(seen.map((x) => [x.name, x.body.model]), [
|
||
["deepseek", "deepseek-v4-flash"],
|
||
]);
|
||
} finally {
|
||
router.stop();
|
||
await close(gptUpstream);
|
||
await close(deepseekUpstream);
|
||
fs.rmSync(fx.root, { recursive: true, force: true });
|
||
}
|
||
});
|