@timo972/cc-router 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/Dockerfile +42 -0
- package/LICENSE +21 -0
- package/README.md +716 -0
- package/accounts.example.json +25 -0
- package/dist/cli/cmd-accounts.js +248 -0
- package/dist/cli/cmd-client.js +612 -0
- package/dist/cli/cmd-configure.js +145 -0
- package/dist/cli/cmd-docker.js +140 -0
- package/dist/cli/cmd-logs.js +85 -0
- package/dist/cli/cmd-models.js +125 -0
- package/dist/cli/cmd-service.js +193 -0
- package/dist/cli/cmd-setup.js +501 -0
- package/dist/cli/cmd-start.js +318 -0
- package/dist/cli/cmd-status.js +177 -0
- package/dist/cli/cmd-stop.js +100 -0
- package/dist/cli/cmd-telemetry.js +58 -0
- package/dist/cli/cmd-update.js +37 -0
- package/dist/cli/index.js +59 -0
- package/dist/config/manager.js +262 -0
- package/dist/config/paths.js +21 -0
- package/dist/config/telemetry.js +64 -0
- package/dist/daemon/launcher.js +163 -0
- package/dist/daemon/pid.js +98 -0
- package/dist/daemon/service.js +260 -0
- package/dist/interceptor/mitmproxy-manager.js +616 -0
- package/dist/protocol/anthropic-to-openai.js +51 -0
- package/dist/protocol/anthropic-types.js +1 -0
- package/dist/protocol/model-ref.js +36 -0
- package/dist/protocol/model-routing-config.js +30 -0
- package/dist/protocol/openai-response-to-anthropic.js +20 -0
- package/dist/protocol/openai-responses-types.js +1 -0
- package/dist/protocol/openai-stream-to-anthropic.js +75 -0
- package/dist/protocol/openai-to-anthropic.js +61 -0
- package/dist/protocol/sse.js +17 -0
- package/dist/providers/model-discovery.js +71 -0
- package/dist/providers/openai/account-pool.js +11 -0
- package/dist/providers/openai/account-record.js +33 -0
- package/dist/providers/openai/codex-transport.js +36 -0
- package/dist/providers/openai/device-oauth.js +116 -0
- package/dist/providers/openai/token-refresher.js +56 -0
- package/dist/providers/route-selector.js +8 -0
- package/dist/providers/types.js +1 -0
- package/dist/proxy/account-deletion.js +44 -0
- package/dist/proxy/anthropic-proxy.js +26 -0
- package/dist/proxy/anthropic-routing.js +90 -0
- package/dist/proxy/lease-lifecycle.js +68 -0
- package/dist/proxy/logger.js +39 -0
- package/dist/proxy/messages-cross-route.js +179 -0
- package/dist/proxy/models-server.js +150 -0
- package/dist/proxy/provider-routing.js +14 -0
- package/dist/proxy/responses-server.js +91 -0
- package/dist/proxy/server.js +875 -0
- package/dist/proxy/session-router.js +171 -0
- package/dist/proxy/stats.js +25 -0
- package/dist/proxy/stream-lifecycle.js +83 -0
- package/dist/proxy/token-pool.js +407 -0
- package/dist/proxy/token-refresher.js +209 -0
- package/dist/proxy/types.js +29 -0
- package/dist/ui/Dashboard.js +640 -0
- package/dist/ui/accountsApi.js +48 -0
- package/dist/ui/modelsApi.js +47 -0
- package/dist/utils/claude-config.js +185 -0
- package/dist/utils/codex-config.js +62 -0
- package/dist/utils/network.js +16 -0
- package/dist/utils/platform.js +13 -0
- package/dist/utils/self-update.js +239 -0
- package/dist/utils/telemetry.js +88 -0
- package/dist/utils/token-extractor.js +95 -0
- package/dist/utils/token-validator.js +26 -0
- package/docker-compose.yml +63 -0
- package/litellm-config.yaml +44 -0
- package/package.json +69 -0
- package/src/interceptor/addon.py +78 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { acquireRequestRoute } from "./lease-lifecycle.js";
|
|
2
|
+
import { normalizeSessionId } from "./session-router.js";
|
|
3
|
+
import { EmptyPoolError } from "./token-pool.js";
|
|
4
|
+
const SESSION_HEADER = "x-claude-code-session-id";
|
|
5
|
+
/** Extract exactly one native HTTP session header field without joined duplicates. */
|
|
6
|
+
export function extractClaudeSessionId(request) {
|
|
7
|
+
const distinct = request.headersDistinct;
|
|
8
|
+
if (distinct !== undefined) {
|
|
9
|
+
const values = distinct[SESSION_HEADER];
|
|
10
|
+
if (!values || values.length !== 1)
|
|
11
|
+
return undefined;
|
|
12
|
+
return normalizeSessionId(values[0]);
|
|
13
|
+
}
|
|
14
|
+
const values = [];
|
|
15
|
+
for (let index = 0; index < request.rawHeaders.length; index += 2) {
|
|
16
|
+
if (request.rawHeaders[index]?.toLowerCase() !== SESSION_HEADER)
|
|
17
|
+
continue;
|
|
18
|
+
values.push(request.rawHeaders[index + 1] ?? "");
|
|
19
|
+
}
|
|
20
|
+
if (values.length !== 1)
|
|
21
|
+
return undefined;
|
|
22
|
+
return normalizeSessionId(values[0]);
|
|
23
|
+
}
|
|
24
|
+
/** Acquire the production route and bind its lease to downstream termination. */
|
|
25
|
+
export function createAnthropicRoutingMiddleware(options) {
|
|
26
|
+
return (request, response, next) => {
|
|
27
|
+
const routedRequest = request;
|
|
28
|
+
try {
|
|
29
|
+
const selected = acquireRequestRoute(extractClaudeSessionId(request), response, options.sessionRouter);
|
|
30
|
+
routedRequest._ccRoute = selected.route;
|
|
31
|
+
routedRequest._ccReleaseLease = selected.release;
|
|
32
|
+
routedRequest._ccAccount = selected.route.account;
|
|
33
|
+
next();
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error instanceof EmptyPoolError && options.onEmptyPool) {
|
|
37
|
+
options.onEmptyPool(error, request, response);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
next(error);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function requestTerminated(request, response) {
|
|
45
|
+
return request.aborted || response.destroyed || response.writableEnded;
|
|
46
|
+
}
|
|
47
|
+
/** Prepare the selected account, but never continue after downstream termination. */
|
|
48
|
+
export function createAnthropicRefreshMiddleware(options) {
|
|
49
|
+
return async (request, response, next) => {
|
|
50
|
+
const routedRequest = request;
|
|
51
|
+
const account = routedRequest._ccAccount;
|
|
52
|
+
const release = routedRequest._ccReleaseLease;
|
|
53
|
+
if (!account || !release) {
|
|
54
|
+
next(new Error("Anthropic route missing before refresh"));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
if (options.needsRefresh(account)) {
|
|
59
|
+
const ok = await options.refresh(account);
|
|
60
|
+
if (requestTerminated(request, response)) {
|
|
61
|
+
release();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!ok) {
|
|
65
|
+
release();
|
|
66
|
+
options.onRefreshFailure(account);
|
|
67
|
+
response.status(401).json({
|
|
68
|
+
type: "error",
|
|
69
|
+
error: {
|
|
70
|
+
type: "authentication_error",
|
|
71
|
+
message: "Anthropic subscription token refresh failed",
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (requestTerminated(request, response)) {
|
|
78
|
+
release();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
next();
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
release();
|
|
85
|
+
if (requestTerminated(request, response))
|
|
86
|
+
return;
|
|
87
|
+
next(error);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tie an account lease to every terminal HTTP response path while retaining
|
|
3
|
+
* one explicit cleanup callback for failures that happen before forwarding.
|
|
4
|
+
*/
|
|
5
|
+
export function attachLeaseLifecycle(response, lease) {
|
|
6
|
+
let released = false;
|
|
7
|
+
const release = () => {
|
|
8
|
+
if (released)
|
|
9
|
+
return;
|
|
10
|
+
released = true;
|
|
11
|
+
lease.release();
|
|
12
|
+
};
|
|
13
|
+
response.once("finish", release);
|
|
14
|
+
response.once("close", release);
|
|
15
|
+
return release;
|
|
16
|
+
}
|
|
17
|
+
/** Keep route diagnostics useful without ever copying a session ID to logs. */
|
|
18
|
+
export function routeReasonDetails(route) {
|
|
19
|
+
return route.fallback ? `${route.reason}:fallback` : route.reason;
|
|
20
|
+
}
|
|
21
|
+
/** Retain bounded routing context when a later failure updates the log. */
|
|
22
|
+
export function routeFailureDetails(route, failure) {
|
|
23
|
+
return `${routeReasonDetails(route)}:${failure}`;
|
|
24
|
+
}
|
|
25
|
+
/** Acquire and immediately bind a routed lease to its response lifecycle. */
|
|
26
|
+
export function acquireRequestRoute(sessionHeader, response, router) {
|
|
27
|
+
const route = router.acquire(sessionHeader);
|
|
28
|
+
return {
|
|
29
|
+
route,
|
|
30
|
+
release: attachLeaseLifecycle(response, route),
|
|
31
|
+
details: routeReasonDetails(route),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function retryAfterSeconds(value) {
|
|
35
|
+
if (typeof value !== "string" && typeof value !== "number")
|
|
36
|
+
return 60;
|
|
37
|
+
if (typeof value === "string" && value.trim().length === 0)
|
|
38
|
+
return 60;
|
|
39
|
+
const candidate = Number(value);
|
|
40
|
+
const milliseconds = candidate * 1_000;
|
|
41
|
+
return Number.isFinite(candidate) &&
|
|
42
|
+
candidate >= 0 &&
|
|
43
|
+
Number.isFinite(milliseconds)
|
|
44
|
+
? candidate
|
|
45
|
+
: 60;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Apply only routing state changes implied by an upstream failure. The
|
|
49
|
+
* current response remains owned by the proxy's native byte stream; callers
|
|
50
|
+
* use the returned seconds solely for status logging.
|
|
51
|
+
*/
|
|
52
|
+
export function applyUpstreamFailureRouting(status, retryAfterHeader, route, router, pool) {
|
|
53
|
+
if (status !== 401 && status !== 429 && status !== 529)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (route.sessionId !== undefined && route.bindingGeneration !== undefined) {
|
|
56
|
+
router.invalidate(route.sessionId, route.account.id, route.bindingGeneration);
|
|
57
|
+
}
|
|
58
|
+
if (status === 429) {
|
|
59
|
+
const seconds = retryAfterSeconds(retryAfterHeader);
|
|
60
|
+
pool.setCooldownForAccount(route.account, seconds * 1_000);
|
|
61
|
+
return seconds;
|
|
62
|
+
}
|
|
63
|
+
if (status === 529) {
|
|
64
|
+
pool.setCooldownForAccount(route.account, 30_000);
|
|
65
|
+
return 30;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
function ts() {
|
|
3
|
+
return new Date().toISOString().slice(11, 19); // HH:MM:SS
|
|
4
|
+
}
|
|
5
|
+
export function logRoute(accountId, requestCount, expiresInMin) {
|
|
6
|
+
console.log(chalk.gray(`[${ts()}]`) +
|
|
7
|
+
chalk.green(` → ${accountId}`) +
|
|
8
|
+
chalk.gray(` req#${requestCount}`) +
|
|
9
|
+
chalk.yellow(` exp=${expiresInMin}min`));
|
|
10
|
+
}
|
|
11
|
+
export function logRefresh(accountId, ok, expiresInMin) {
|
|
12
|
+
if (ok) {
|
|
13
|
+
console.log(chalk.yellow(`[${ts()}] [REFRESH] ${accountId}: OK — expires in ${expiresInMin}min`));
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
console.log(chalk.red(`[${ts()}] [REFRESH] ${accountId}: FAILED`));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function logError(accountId, status, message) {
|
|
20
|
+
const statusStr = status > 0 ? ` HTTP ${status}` : "";
|
|
21
|
+
console.log(chalk.red(`[${ts()}] [ERROR] ${accountId}:${statusStr} ${message}`));
|
|
22
|
+
}
|
|
23
|
+
function formatStartupAccountCounts(counts) {
|
|
24
|
+
const total = counts.anthropic + counts.openai;
|
|
25
|
+
return `${total} (Claude ${counts.anthropic}, OpenAI ${counts.openai})`;
|
|
26
|
+
}
|
|
27
|
+
export function logStartup(port, host, mode, target, accountCounts) {
|
|
28
|
+
const listen = host === "127.0.0.1" ? `http://localhost:${port}` : `http://${host}:${port}`;
|
|
29
|
+
const accounts = formatStartupAccountCounts(accountCounts);
|
|
30
|
+
console.log(chalk.cyan(`
|
|
31
|
+
╔══════════════════════════════════════════════╗
|
|
32
|
+
║ CC-Router ║
|
|
33
|
+
║ Listening: ${listen.padEnd(33)}║
|
|
34
|
+
║ Mode : ${mode.padEnd(33)}║
|
|
35
|
+
║ Target : ${target.slice(0, 33).padEnd(33)}║
|
|
36
|
+
║ Accounts : ${accounts.padEnd(33)}║
|
|
37
|
+
╚══════════════════════════════════════════════╝
|
|
38
|
+
`));
|
|
39
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { selectRoute } from "../providers/route-selector.js";
|
|
3
|
+
import { anthropicToOpenAIResponses } from "../protocol/anthropic-to-openai.js";
|
|
4
|
+
import { openAIResponseToAnthropicMessage } from "../protocol/openai-response-to-anthropic.js";
|
|
5
|
+
import { createOpenAIStreamToAnthropicNormalizer } from "../protocol/openai-stream-to-anthropic.js";
|
|
6
|
+
import { encodeSseEvent, parseSseLines } from "../protocol/sse.js";
|
|
7
|
+
import { forwardOpenAICodexResponse } from "../providers/openai/codex-transport.js";
|
|
8
|
+
function isAnthropicMessagesRequest(value) {
|
|
9
|
+
return (typeof value === "object" &&
|
|
10
|
+
value !== null &&
|
|
11
|
+
Array.isArray(value.messages));
|
|
12
|
+
}
|
|
13
|
+
async function sendOpenAIAsAnthropic(upstream, res, requestedStream) {
|
|
14
|
+
const contentType = upstream.headers.get("content-type") ?? "";
|
|
15
|
+
if (contentType.includes("text/event-stream")) {
|
|
16
|
+
if (requestedStream) {
|
|
17
|
+
await sendOpenAIStreamAsAnthropic(upstream, res);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
res.status(upstream.status).json(await collectOpenAIStreamAsAnthropicMessage(upstream));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (!contentType.includes("application/json")) {
|
|
24
|
+
res.status(upstream.status);
|
|
25
|
+
res.setHeader("content-type", contentType || "text/plain");
|
|
26
|
+
res.send(await upstream.text());
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const json = await upstream.json();
|
|
30
|
+
res.status(upstream.status).json(openAIResponseToAnthropicMessage(json));
|
|
31
|
+
}
|
|
32
|
+
async function collectOpenAIStreamAsAnthropicMessage(upstream) {
|
|
33
|
+
const reader = upstream.body?.getReader();
|
|
34
|
+
if (!reader) {
|
|
35
|
+
return openAIResponseToAnthropicMessage({ id: "", model: "", output: [], usage: {} });
|
|
36
|
+
}
|
|
37
|
+
const decoder = new TextDecoder();
|
|
38
|
+
let remainder = "";
|
|
39
|
+
let id = "";
|
|
40
|
+
let model = "";
|
|
41
|
+
let text = "";
|
|
42
|
+
let usage = {};
|
|
43
|
+
const applyEvent = (event) => {
|
|
44
|
+
if (typeof event !== "object" || event === null)
|
|
45
|
+
return;
|
|
46
|
+
const openAIEvent = event;
|
|
47
|
+
if (openAIEvent.type === "response.created") {
|
|
48
|
+
id = openAIEvent.response?.id ?? id;
|
|
49
|
+
model = openAIEvent.response?.model ?? model;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (openAIEvent.type === "response.output_text.delta") {
|
|
53
|
+
text += openAIEvent.delta ?? "";
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (openAIEvent.type === "response.completed") {
|
|
57
|
+
id = openAIEvent.response?.id ?? id;
|
|
58
|
+
model = openAIEvent.response?.model ?? model;
|
|
59
|
+
usage = openAIEvent.response?.usage ?? usage;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
while (true) {
|
|
63
|
+
const { value, done } = await reader.read();
|
|
64
|
+
if (done)
|
|
65
|
+
break;
|
|
66
|
+
const parsed = parseSseLines(remainder + decoder.decode(value, { stream: true }));
|
|
67
|
+
remainder = parsed.remainder;
|
|
68
|
+
parsed.events.forEach(applyEvent);
|
|
69
|
+
}
|
|
70
|
+
const tail = decoder.decode();
|
|
71
|
+
if (tail || remainder) {
|
|
72
|
+
parseSseLines(remainder + tail + "\n").events.forEach(applyEvent);
|
|
73
|
+
}
|
|
74
|
+
return openAIResponseToAnthropicMessage({
|
|
75
|
+
id,
|
|
76
|
+
model,
|
|
77
|
+
output: text ? [{
|
|
78
|
+
type: "message",
|
|
79
|
+
role: "assistant",
|
|
80
|
+
content: [{ type: "output_text", text }],
|
|
81
|
+
}] : [],
|
|
82
|
+
usage,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
async function sendOpenAIStreamAsAnthropic(upstream, res) {
|
|
86
|
+
res.status(upstream.status);
|
|
87
|
+
res.setHeader("content-type", "text/event-stream");
|
|
88
|
+
res.setHeader("cache-control", "no-cache");
|
|
89
|
+
res.flushHeaders?.();
|
|
90
|
+
const normalizer = createOpenAIStreamToAnthropicNormalizer();
|
|
91
|
+
const reader = upstream.body?.getReader();
|
|
92
|
+
if (!reader) {
|
|
93
|
+
res.end();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const decoder = new TextDecoder();
|
|
97
|
+
let remainder = "";
|
|
98
|
+
try {
|
|
99
|
+
while (true) {
|
|
100
|
+
const { value, done } = await reader.read();
|
|
101
|
+
if (done)
|
|
102
|
+
break;
|
|
103
|
+
const parsed = parseSseLines(remainder + decoder.decode(value, { stream: true }));
|
|
104
|
+
remainder = parsed.remainder;
|
|
105
|
+
for (const event of parsed.events) {
|
|
106
|
+
for (const mapped of normalizer.convert(event)) {
|
|
107
|
+
res.write(encodeSseEvent(mapped));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const tail = decoder.decode();
|
|
112
|
+
if (tail || remainder) {
|
|
113
|
+
const parsed = parseSseLines(remainder + tail + "\n");
|
|
114
|
+
for (const event of parsed.events) {
|
|
115
|
+
for (const mapped of normalizer.convert(event)) {
|
|
116
|
+
res.write(encodeSseEvent(mapped));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
res.end();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function mountMessagesCrossProviderRoute(app, opts) {
|
|
126
|
+
const forwardOpenAI = opts.forwardOpenAI ?? forwardOpenAICodexResponse;
|
|
127
|
+
const prepareOpenAIAccount = opts.prepareOpenAIAccount ?? (async () => true);
|
|
128
|
+
app.post("/v1/messages", express.json({
|
|
129
|
+
limit: "10mb",
|
|
130
|
+
verify: (req, _res, buf) => {
|
|
131
|
+
req._ccRawBody = Buffer.from(buf);
|
|
132
|
+
},
|
|
133
|
+
}), async (req, res, next) => {
|
|
134
|
+
if (!isAnthropicMessagesRequest(req.body)) {
|
|
135
|
+
res.status(400).json({
|
|
136
|
+
type: "error",
|
|
137
|
+
error: {
|
|
138
|
+
type: "invalid_request_error",
|
|
139
|
+
message: "Expected Anthropic Messages request with messages array",
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const route = selectRoute(req.body.model, opts.modelRouting);
|
|
145
|
+
if (route.provider !== "openai_subscription") {
|
|
146
|
+
next();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const account = opts.getOpenAIAccount();
|
|
150
|
+
if (!account) {
|
|
151
|
+
res.status(503).json({
|
|
152
|
+
type: "error",
|
|
153
|
+
error: {
|
|
154
|
+
type: "no_accounts",
|
|
155
|
+
message: "No OpenAI subscription accounts are configured",
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const ready = await prepareOpenAIAccount(account);
|
|
161
|
+
if (!ready) {
|
|
162
|
+
res.status(401).json({
|
|
163
|
+
type: "error",
|
|
164
|
+
error: {
|
|
165
|
+
type: "authentication_error",
|
|
166
|
+
message: "OpenAI subscription token refresh failed",
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const body = anthropicToOpenAIResponses(req.body, opts.modelRouting);
|
|
172
|
+
const upstream = await forwardOpenAI({
|
|
173
|
+
account,
|
|
174
|
+
body,
|
|
175
|
+
stream: body.stream === true,
|
|
176
|
+
});
|
|
177
|
+
await sendOpenAIAsAnthropic(upstream, res, req.body.stream === true);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { fetchAnthropicModels, fetchOpenAICodexModels, } from "../providers/model-discovery.js";
|
|
3
|
+
import { buildModelRoutingUpdate } from "../protocol/model-routing-config.js";
|
|
4
|
+
export function mountModelsRoute(app, opts) {
|
|
5
|
+
const fetchAnthropic = opts.fetchAnthropicModels ?? fetchAnthropicModels;
|
|
6
|
+
const fetchOpenAI = opts.fetchOpenAIModels ?? fetchOpenAICodexModels;
|
|
7
|
+
const prepareOpenAIAccount = opts.prepareOpenAIAccount ?? (async () => true);
|
|
8
|
+
app.get("/v1/models", async (_req, res) => {
|
|
9
|
+
const models = await discoverModelList(opts, prepareOpenAIAccount, fetchAnthropic, fetchOpenAI);
|
|
10
|
+
const body = {
|
|
11
|
+
object: "list",
|
|
12
|
+
data: models,
|
|
13
|
+
models: models.map(toCodexCliModel),
|
|
14
|
+
};
|
|
15
|
+
res.json(body);
|
|
16
|
+
});
|
|
17
|
+
app.get("/cc-router/models", async (_req, res) => {
|
|
18
|
+
res.json({
|
|
19
|
+
routing: currentModelRouting(opts),
|
|
20
|
+
models: await discoverModelList(opts, prepareOpenAIAccount, fetchAnthropic, fetchOpenAI),
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
app.patch("/cc-router/models", express.json({ limit: "16kb" }), async (req, res) => {
|
|
24
|
+
if (!opts.setModelRouting) {
|
|
25
|
+
res.status(501).json({ error: "Model routing updates are not available" });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const body = (req.body ?? {});
|
|
29
|
+
if (body.claudeModel !== undefined && typeof body.claudeModel !== "string") {
|
|
30
|
+
res.status(400).json({ error: "claudeModel must be a string" });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (body.openAIModel !== undefined && typeof body.openAIModel !== "string") {
|
|
34
|
+
res.status(400).json({ error: "openAIModel must be a string" });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const next = buildModelRoutingUpdate(currentModelRouting(opts), {
|
|
38
|
+
claudeModel: body.claudeModel,
|
|
39
|
+
openAIModel: body.openAIModel,
|
|
40
|
+
});
|
|
41
|
+
await opts.setModelRouting(next);
|
|
42
|
+
res.json({ routing: next });
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
async function discoverModelList(opts, prepareOpenAIAccount, fetchAnthropic, fetchOpenAI) {
|
|
46
|
+
const discovered = await Promise.all([
|
|
47
|
+
discoverAnthropicModels(opts.getAnthropicAccounts(), fetchAnthropic),
|
|
48
|
+
discoverOpenAIModels(opts.getOpenAIAccounts(), prepareOpenAIAccount, fetchOpenAI),
|
|
49
|
+
]);
|
|
50
|
+
const models = new Map();
|
|
51
|
+
for (const model of discovered.flat()) {
|
|
52
|
+
models.set(model.id, model);
|
|
53
|
+
}
|
|
54
|
+
addConfiguredAliases(models, currentModelRouting(opts));
|
|
55
|
+
return [...models.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
56
|
+
}
|
|
57
|
+
function currentModelRouting(opts) {
|
|
58
|
+
return opts.getModelRouting?.() ?? opts.modelRouting ?? {};
|
|
59
|
+
}
|
|
60
|
+
async function discoverAnthropicModels(accounts, fetchAnthropic) {
|
|
61
|
+
const enabledAccounts = accounts.filter(account => account.enabled !== false);
|
|
62
|
+
const results = await Promise.allSettled(enabledAccounts.map(account => fetchAnthropic(account)));
|
|
63
|
+
return results.flatMap(result => {
|
|
64
|
+
if (result.status !== "fulfilled")
|
|
65
|
+
return [];
|
|
66
|
+
return result.value.map(id => modelEntry(`anthropic/${id}`, "anthropic_subscription"));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
async function discoverOpenAIModels(accounts, prepareOpenAIAccount, fetchOpenAI) {
|
|
70
|
+
const enabledAccounts = accounts.filter(account => account.enabled !== false);
|
|
71
|
+
const results = await Promise.allSettled(enabledAccounts.map(async (account) => {
|
|
72
|
+
const ready = await prepareOpenAIAccount(account);
|
|
73
|
+
if (!ready)
|
|
74
|
+
return [];
|
|
75
|
+
return fetchOpenAI(account);
|
|
76
|
+
}));
|
|
77
|
+
return results.flatMap(result => {
|
|
78
|
+
if (result.status !== "fulfilled")
|
|
79
|
+
return [];
|
|
80
|
+
return result.value.map(id => modelEntry(`openai/${id}`, "openai_subscription"));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function addConfiguredAliases(models, config) {
|
|
84
|
+
for (const [alias, upstream] of Object.entries(config?.openAIAliases ?? {})) {
|
|
85
|
+
if (models.has(`openai/${upstream}`)) {
|
|
86
|
+
models.set(`openai/${alias}`, modelEntry(`openai/${alias}`, "openai_subscription"));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const [alias, upstream] of Object.entries(config?.anthropicAliases ?? {})) {
|
|
90
|
+
if (models.has(`anthropic/${upstream}`)) {
|
|
91
|
+
models.set(alias, modelEntry(alias, "anthropic_subscription"));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (config?.anthropicDefaultModel && models.has(`anthropic/${config.anthropicDefaultModel}`)) {
|
|
95
|
+
models.set("claude/default", modelEntry("claude/default", "anthropic_subscription"));
|
|
96
|
+
}
|
|
97
|
+
if (config?.openAIDefaultModel && models.has(`openai/${config.openAIDefaultModel}`)) {
|
|
98
|
+
models.set("openai/default", modelEntry("openai/default", "openai_subscription"));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function modelEntry(id, ownedBy) {
|
|
102
|
+
return { id, object: "model", owned_by: ownedBy };
|
|
103
|
+
}
|
|
104
|
+
function toCodexCliModel(model) {
|
|
105
|
+
return {
|
|
106
|
+
prefer_websockets: true,
|
|
107
|
+
support_verbosity: true,
|
|
108
|
+
default_verbosity: "medium",
|
|
109
|
+
apply_patch_tool_type: "freeform",
|
|
110
|
+
web_search_tool_type: "text_and_image",
|
|
111
|
+
input_modalities: ["text"],
|
|
112
|
+
supports_image_detail_original: false,
|
|
113
|
+
truncation_policy: { mode: "tokens", limit: 10_000 },
|
|
114
|
+
supports_parallel_tool_calls: true,
|
|
115
|
+
tool_mode: null,
|
|
116
|
+
multi_agent_version: null,
|
|
117
|
+
use_responses_lite: false,
|
|
118
|
+
auto_review_model_override: null,
|
|
119
|
+
context_window: 128_000,
|
|
120
|
+
max_context_window: 128_000,
|
|
121
|
+
auto_compact_token_limit: null,
|
|
122
|
+
reasoning_summary_format: "experimental",
|
|
123
|
+
default_reasoning_summary: "none",
|
|
124
|
+
slug: model.id,
|
|
125
|
+
display_name: model.id,
|
|
126
|
+
description: `${model.owned_by} model routed by CC-Router`,
|
|
127
|
+
default_reasoning_level: "medium",
|
|
128
|
+
supported_reasoning_levels: [
|
|
129
|
+
{ effort: "low", description: "Fast responses with lighter reasoning" },
|
|
130
|
+
{ effort: "medium", description: "Balanced reasoning for everyday tasks" },
|
|
131
|
+
{ effort: "high", description: "Deeper reasoning for complex tasks" },
|
|
132
|
+
],
|
|
133
|
+
shell_type: "shell_command",
|
|
134
|
+
visibility: "list",
|
|
135
|
+
minimal_client_version: "0.98.0",
|
|
136
|
+
supported_in_api: true,
|
|
137
|
+
availability_nux: null,
|
|
138
|
+
upgrade: null,
|
|
139
|
+
priority: model.owned_by === "openai_subscription" ? 20 : 10,
|
|
140
|
+
base_instructions: "",
|
|
141
|
+
model_messages: {},
|
|
142
|
+
experimental_supported_tools: [],
|
|
143
|
+
available_in_plans: [],
|
|
144
|
+
supports_search_tool: false,
|
|
145
|
+
default_service_tier: null,
|
|
146
|
+
service_tiers: [],
|
|
147
|
+
additional_speed_tiers: [],
|
|
148
|
+
supports_reasoning_summaries: true,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persist a provider toggle before discarding any Anthropic affinity. If
|
|
3
|
+
* persistence throws, the caller can roll back runtime enablement without
|
|
4
|
+
* losing bindings that still point at valid accounts.
|
|
5
|
+
*/
|
|
6
|
+
export function persistProviderEnabledState(options) {
|
|
7
|
+
const result = options.persist();
|
|
8
|
+
if (options.provider === "anthropic_subscription" && !options.enabled) {
|
|
9
|
+
for (const accountId of options.accountIds) {
|
|
10
|
+
options.invalidateAccount(accountId);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { selectRoute } from "../providers/route-selector.js";
|
|
3
|
+
import { forwardOpenAICodexResponse } from "../providers/openai/codex-transport.js";
|
|
4
|
+
function isResponsesRequest(value) {
|
|
5
|
+
return (typeof value === "object" &&
|
|
6
|
+
value !== null &&
|
|
7
|
+
typeof value.model === "string" &&
|
|
8
|
+
Array.isArray(value.input));
|
|
9
|
+
}
|
|
10
|
+
async function sendUpstreamResponse(upstream, res) {
|
|
11
|
+
const contentType = upstream.headers.get("content-type");
|
|
12
|
+
if (contentType)
|
|
13
|
+
res.setHeader("content-type", contentType);
|
|
14
|
+
res.status(upstream.status);
|
|
15
|
+
if (!upstream.body) {
|
|
16
|
+
res.end();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (contentType?.includes("text/event-stream")) {
|
|
20
|
+
res.setHeader("cache-control", "no-cache");
|
|
21
|
+
res.flushHeaders?.();
|
|
22
|
+
}
|
|
23
|
+
const reader = upstream.body.getReader();
|
|
24
|
+
try {
|
|
25
|
+
while (true) {
|
|
26
|
+
const { value, done } = await reader.read();
|
|
27
|
+
if (done)
|
|
28
|
+
break;
|
|
29
|
+
if (value)
|
|
30
|
+
res.write(Buffer.from(value));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
res.end();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function mountResponsesRoutes(app, opts) {
|
|
38
|
+
const forwardOpenAI = opts.forwardOpenAI ?? forwardOpenAICodexResponse;
|
|
39
|
+
const prepareOpenAIAccount = opts.prepareOpenAIAccount ?? (async () => true);
|
|
40
|
+
app.post("/v1/responses", express.json({ limit: "10mb" }), async (req, res) => {
|
|
41
|
+
if (!isResponsesRequest(req.body)) {
|
|
42
|
+
res.status(400).json({
|
|
43
|
+
error: {
|
|
44
|
+
type: "invalid_request_error",
|
|
45
|
+
message: "Expected Responses request with string model and input array",
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const route = selectRoute(req.body.model, opts.modelRouting);
|
|
51
|
+
if (route.provider !== "openai_subscription") {
|
|
52
|
+
res.status(501).json({
|
|
53
|
+
error: {
|
|
54
|
+
type: "unsupported_provider",
|
|
55
|
+
message: `Responses ingress for ${route.provider} is not implemented yet`,
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const account = opts.getOpenAIAccount();
|
|
61
|
+
if (!account) {
|
|
62
|
+
res.status(503).json({
|
|
63
|
+
error: {
|
|
64
|
+
type: "no_accounts",
|
|
65
|
+
message: "No OpenAI subscription accounts are configured",
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const ready = await prepareOpenAIAccount(account);
|
|
71
|
+
if (!ready) {
|
|
72
|
+
res.status(401).json({
|
|
73
|
+
error: {
|
|
74
|
+
type: "authentication_error",
|
|
75
|
+
message: "OpenAI subscription token refresh failed",
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const body = {
|
|
81
|
+
...req.body,
|
|
82
|
+
model: route.upstreamModel,
|
|
83
|
+
};
|
|
84
|
+
const upstream = await forwardOpenAI({
|
|
85
|
+
account,
|
|
86
|
+
body,
|
|
87
|
+
stream: body.stream === true,
|
|
88
|
+
});
|
|
89
|
+
await sendUpstreamResponse(upstream, res);
|
|
90
|
+
});
|
|
91
|
+
}
|