@timo972/cc-router 0.7.0 → 0.9.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 +139 -0
- package/README.md +12 -87
- package/dist/cli/cmd-accounts.js +114 -11
- package/dist/cli/index.js +0 -0
- package/dist/protocol/openai-responses-collect.js +71 -0
- package/dist/providers/anthropic/usage-refresher.js +195 -0
- package/dist/providers/anthropic/usage.js +217 -0
- package/dist/proxy/account-add.js +30 -0
- package/dist/proxy/account-deletion.js +16 -0
- package/dist/proxy/anthropic-routing.js +31 -2
- package/dist/proxy/lease-lifecycle.js +182 -22
- package/dist/proxy/logger.js +3 -0
- package/dist/proxy/messages-cross-route.js +4 -1
- package/dist/proxy/request-model.js +17 -0
- package/dist/proxy/responses-server.js +43 -1
- package/dist/proxy/server.js +198 -24
- package/dist/proxy/session-router.js +12 -8
- package/dist/proxy/stats.js +11 -0
- package/dist/proxy/token-pool.js +379 -108
- package/dist/ui/Dashboard.js +90 -4
- package/dist/ui/accountsApi.js +136 -20
- package/package.json +12 -11
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { canUseExtraUsage, normalizeModelFamily } from "../providers/anthropic/usage.js";
|
|
1
2
|
/**
|
|
2
3
|
* Tie an account lease to every terminal HTTP response path while retaining
|
|
3
4
|
* one explicit cleanup callback for failures that happen before forwarding.
|
|
@@ -19,50 +20,209 @@ export function routeReasonDetails(route) {
|
|
|
19
20
|
return route.fallback ? `${route.reason}:fallback` : route.reason;
|
|
20
21
|
}
|
|
21
22
|
/** Retain bounded routing context when a later failure updates the log. */
|
|
22
|
-
export function routeFailureDetails(route, failure) {
|
|
23
|
-
return `${routeReasonDetails(route)}:${failure}`;
|
|
23
|
+
export function routeFailureDetails(route, failure, limitingScope) {
|
|
24
|
+
return `${routeReasonDetails(route)}:${failure}${limitingScope ? `:${limitingScope}` : ""}`;
|
|
24
25
|
}
|
|
25
26
|
/** Acquire and immediately bind a routed lease to its response lifecycle. */
|
|
26
|
-
export function acquireRequestRoute(sessionHeader, response, router) {
|
|
27
|
-
const route = router.acquire(sessionHeader);
|
|
27
|
+
export function acquireRequestRoute(sessionHeader, response, router, context) {
|
|
28
|
+
const route = context === undefined ? router.acquire(sessionHeader) : router.acquire(sessionHeader, context);
|
|
28
29
|
return {
|
|
29
30
|
route,
|
|
30
31
|
release: attachLeaseLifecycle(response, route),
|
|
31
32
|
details: routeReasonDetails(route),
|
|
32
33
|
};
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
+
const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60_000;
|
|
36
|
+
const OVERLOAD_COOLDOWN_MS = 30_000;
|
|
37
|
+
const MAX_RATE_LIMIT_COOLDOWN_MS = 8 * 24 * 60 * 60 * 1_000;
|
|
38
|
+
function asHeaders(value) {
|
|
39
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
return { "retry-after": value };
|
|
43
|
+
}
|
|
44
|
+
function header(headers, name) {
|
|
45
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
46
|
+
if (key.toLowerCase() !== name)
|
|
47
|
+
continue;
|
|
48
|
+
return typeof value === "string" || typeof value === "number" ? value : undefined;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
function futureExpiry(expiryMs, nowMs) {
|
|
53
|
+
if (!Number.isFinite(expiryMs) || expiryMs <= nowMs)
|
|
54
|
+
return undefined;
|
|
55
|
+
return expiryMs - nowMs <= MAX_RATE_LIMIT_COOLDOWN_MS ? expiryMs : undefined;
|
|
56
|
+
}
|
|
57
|
+
function retryAfterExpiry(value, nowMs) {
|
|
58
|
+
if (typeof value !== "string" && typeof value !== "number")
|
|
59
|
+
return undefined;
|
|
60
|
+
if (typeof value === "string" && value.trim().length === 0)
|
|
61
|
+
return undefined;
|
|
62
|
+
const numeric = Number(value);
|
|
63
|
+
if (Number.isFinite(numeric)) {
|
|
64
|
+
if (numeric <= 0)
|
|
65
|
+
return undefined;
|
|
66
|
+
return futureExpiry(nowMs + numeric * 1_000, nowMs);
|
|
67
|
+
}
|
|
68
|
+
if (typeof value !== "string")
|
|
69
|
+
return undefined;
|
|
70
|
+
return futureExpiry(Date.parse(value), nowMs);
|
|
71
|
+
}
|
|
72
|
+
function resetHeaderExpiry(value, nowMs) {
|
|
35
73
|
if (typeof value !== "string" && typeof value !== "number")
|
|
36
|
-
return
|
|
74
|
+
return undefined;
|
|
37
75
|
if (typeof value === "string" && value.trim().length === 0)
|
|
38
|
-
return
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
76
|
+
return undefined;
|
|
77
|
+
const numeric = Number(value);
|
|
78
|
+
if (Number.isFinite(numeric)) {
|
|
79
|
+
const milliseconds = numeric < 10_000_000_000 ? numeric * 1_000 : numeric;
|
|
80
|
+
return futureExpiry(milliseconds, nowMs);
|
|
81
|
+
}
|
|
82
|
+
if (typeof value !== "string")
|
|
83
|
+
return undefined;
|
|
84
|
+
return futureExpiry(Date.parse(value), nowMs);
|
|
85
|
+
}
|
|
86
|
+
function matchingModelLimit(account, modelFamily) {
|
|
87
|
+
if (!modelFamily)
|
|
88
|
+
return undefined;
|
|
89
|
+
const usage = account.rateLimits?.usage;
|
|
90
|
+
if (!usage || usage.fetchStatus === "unavailable")
|
|
91
|
+
return undefined;
|
|
92
|
+
return usage.modelLimits.find(limit => normalizeModelFamily(limit.modelFamily) === modelFamily);
|
|
93
|
+
}
|
|
94
|
+
function classifyCooldown(claimValue, route) {
|
|
95
|
+
const claim = typeof claimValue === "string" ? claimValue.trim().toLowerCase() : "";
|
|
96
|
+
const requestedFamily = normalizeModelFamily(route.modelFamily);
|
|
97
|
+
const usage = route.account.rateLimits?.usage;
|
|
98
|
+
if (claim === "five_hour" || claim === "seven_day" || claim === "seven_day_oauth_apps") {
|
|
99
|
+
const usageWindow = claim === "five_hour" ? usage?.fiveHour : claim === "seven_day" ? usage?.sevenDay : undefined;
|
|
100
|
+
return {
|
|
101
|
+
kind: "global",
|
|
102
|
+
ambiguous: false,
|
|
103
|
+
...(usageWindow ? { usageResetAtMs: usageWindow.resetAt * 1_000 } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (claim === "seven_day_overage_included") {
|
|
107
|
+
const matching = matchingModelLimit(route.account, requestedFamily);
|
|
108
|
+
if (requestedFamily && matching?.active === true && matching.utilization >= 1) {
|
|
109
|
+
return {
|
|
110
|
+
kind: "model",
|
|
111
|
+
ambiguous: false,
|
|
112
|
+
modelFamily: requestedFamily,
|
|
113
|
+
usageResetAtMs: matching.resetAt * 1_000,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return { kind: "global", ambiguous: true };
|
|
117
|
+
}
|
|
118
|
+
if (claim.startsWith("seven_day_")) {
|
|
119
|
+
const family = normalizeModelFamily(claim.slice("seven_day_".length));
|
|
120
|
+
if (family) {
|
|
121
|
+
const matching = matchingModelLimit(route.account, family);
|
|
122
|
+
return {
|
|
123
|
+
kind: "model",
|
|
124
|
+
ambiguous: false,
|
|
125
|
+
modelFamily: family,
|
|
126
|
+
...(matching ? { usageResetAtMs: matching.resetAt * 1_000 } : {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { kind: "global", ambiguous: true };
|
|
131
|
+
}
|
|
132
|
+
function cooldownDurationMs(headers, classification, nowMs) {
|
|
133
|
+
const expiries = [
|
|
134
|
+
retryAfterExpiry(header(headers, "retry-after"), nowMs),
|
|
135
|
+
resetHeaderExpiry(header(headers, "anthropic-ratelimit-unified-reset"), nowMs),
|
|
136
|
+
classification.usageResetAtMs === undefined
|
|
137
|
+
? undefined
|
|
138
|
+
: futureExpiry(classification.usageResetAtMs, nowMs),
|
|
139
|
+
].filter((expiry) => expiry !== undefined);
|
|
140
|
+
return expiries.length > 0
|
|
141
|
+
? Math.max(...expiries) - nowMs
|
|
142
|
+
: DEFAULT_RATE_LIMIT_COOLDOWN_MS;
|
|
143
|
+
}
|
|
144
|
+
function setGlobalCooldown(pool, account, durationMs, ambiguous, modelFamily) {
|
|
145
|
+
if (ambiguous && pool.setAmbiguousGlobalCooldownForAccount) {
|
|
146
|
+
return pool.setAmbiguousGlobalCooldownForAccount(account, durationMs, modelFamily);
|
|
147
|
+
}
|
|
148
|
+
else if (pool.setGlobalCooldownForAccount) {
|
|
149
|
+
pool.setGlobalCooldownForAccount(account, durationMs);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
pool.setCooldownForAccount(account, durationMs);
|
|
153
|
+
}
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
/** Narrow an ambiguity-created 429 cooldown only from a fresh, conclusive snapshot. */
|
|
157
|
+
export function reconcileAmbiguousRateLimitCooldown(route, pool, token, now = Date.now) {
|
|
158
|
+
const family = normalizeModelFamily(route.modelFamily);
|
|
159
|
+
const usage = route.account.rateLimits?.usage;
|
|
160
|
+
if (token === undefined || !family || !usage || usage.fetchStatus !== "fresh")
|
|
161
|
+
return false;
|
|
162
|
+
if (!usage.fiveHour || !usage.sevenDay)
|
|
163
|
+
return false;
|
|
164
|
+
if (!Number.isFinite(usage.fiveHour.utilization) ||
|
|
165
|
+
!Number.isFinite(usage.sevenDay.utilization) ||
|
|
166
|
+
usage.fiveHour.utilization < 0 ||
|
|
167
|
+
usage.sevenDay.utilization < 0 ||
|
|
168
|
+
usage.fiveHour.utilization >= 1 ||
|
|
169
|
+
usage.sevenDay.utilization >= 1)
|
|
170
|
+
return false;
|
|
171
|
+
if (canUseExtraUsage(usage.extraUsage))
|
|
172
|
+
return false;
|
|
173
|
+
const matching = matchingModelLimit(route.account, family);
|
|
174
|
+
if (!matching || !matching.active || !Number.isFinite(matching.utilization) || matching.utilization < 1) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
if (!pool.reconcileAmbiguousGlobalCooldownForAccount)
|
|
178
|
+
return false;
|
|
179
|
+
const nowMs = now();
|
|
180
|
+
const resetExpiry = futureExpiry(matching.resetAt * 1_000, nowMs);
|
|
181
|
+
return pool.reconcileAmbiguousGlobalCooldownForAccount(route.account, token, family, resetExpiry === undefined ? 0 : resetExpiry - nowMs);
|
|
46
182
|
}
|
|
47
183
|
/**
|
|
48
184
|
* Apply only routing state changes implied by an upstream failure. The
|
|
49
185
|
* current response remains owned by the proxy's native byte stream; callers
|
|
50
186
|
* use the returned seconds solely for status logging.
|
|
51
187
|
*/
|
|
52
|
-
export function
|
|
188
|
+
export function applyUpstreamFailureRoutingDetailed(status, failureHeaders, route, router, pool, now = Date.now) {
|
|
53
189
|
if (status !== 401 && status !== 429 && status !== 529)
|
|
54
|
-
return
|
|
190
|
+
return {};
|
|
55
191
|
if (route.sessionId !== undefined && route.bindingGeneration !== undefined) {
|
|
56
192
|
router.invalidate(route.sessionId, route.account.id, route.bindingGeneration);
|
|
57
193
|
}
|
|
58
194
|
if (status === 429) {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
195
|
+
const headers = asHeaders(failureHeaders);
|
|
196
|
+
const classification = classifyCooldown(header(headers, "anthropic-ratelimit-unified-representative-claim"), route);
|
|
197
|
+
const durationMs = cooldownDurationMs(headers, classification, now());
|
|
198
|
+
let ambiguousCooldownToken;
|
|
199
|
+
if (classification.kind === "model" && classification.modelFamily) {
|
|
200
|
+
if (pool.setModelCooldownForAccount) {
|
|
201
|
+
pool.setModelCooldownForAccount(route.account, classification.modelFamily, durationMs);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
pool.setCooldownForAccount(route.account, durationMs);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
ambiguousCooldownToken = setGlobalCooldown(pool, route.account, durationMs, classification.ambiguous, route.modelFamily);
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
cooldownSeconds: durationMs / 1_000,
|
|
212
|
+
...(ambiguousCooldownToken === undefined ? {} : { ambiguousCooldownToken }),
|
|
213
|
+
...(classification.ambiguous
|
|
214
|
+
? {}
|
|
215
|
+
: { limitingScope: classification.kind === "model" && classification.modelFamily
|
|
216
|
+
? `model:${classification.modelFamily}`
|
|
217
|
+
: "global" }),
|
|
218
|
+
};
|
|
62
219
|
}
|
|
63
220
|
if (status === 529) {
|
|
64
|
-
pool
|
|
65
|
-
return
|
|
221
|
+
setGlobalCooldown(pool, route.account, OVERLOAD_COOLDOWN_MS, false);
|
|
222
|
+
return { cooldownSeconds: OVERLOAD_COOLDOWN_MS / 1_000 };
|
|
66
223
|
}
|
|
67
|
-
return
|
|
224
|
+
return {};
|
|
225
|
+
}
|
|
226
|
+
export function applyUpstreamFailureRouting(status, failureHeaders, route, router, pool, now = Date.now) {
|
|
227
|
+
return applyUpstreamFailureRoutingDetailed(status, failureHeaders, route, router, pool, now).cooldownSeconds;
|
|
68
228
|
}
|
package/dist/proxy/logger.js
CHANGED
|
@@ -20,6 +20,9 @@ export function logError(accountId, status, message) {
|
|
|
20
20
|
const statusStr = status > 0 ? ` HTTP ${status}` : "";
|
|
21
21
|
console.log(chalk.red(`[${ts()}] [ERROR] ${accountId}:${statusStr} ${message}`));
|
|
22
22
|
}
|
|
23
|
+
export function logWarn(context, message) {
|
|
24
|
+
console.log(chalk.yellow(`[${ts()}] [WARN] ${context}: ${message}`));
|
|
25
|
+
}
|
|
23
26
|
function formatStartupAccountCounts(counts) {
|
|
24
27
|
const total = counts.anthropic + counts.openai;
|
|
25
28
|
return `${total} (Claude ${counts.anthropic}, OpenAI ${counts.openai})`;
|
|
@@ -5,6 +5,7 @@ import { openAIResponseToAnthropicMessage } from "../protocol/openai-response-to
|
|
|
5
5
|
import { createOpenAIStreamToAnthropicNormalizer } from "../protocol/openai-stream-to-anthropic.js";
|
|
6
6
|
import { encodeSseEvent, parseSseLines } from "../protocol/sse.js";
|
|
7
7
|
import { forwardOpenAICodexResponse } from "../providers/openai/codex-transport.js";
|
|
8
|
+
import { extractAnthropicRouteContext } from "./request-model.js";
|
|
8
9
|
function isAnthropicMessagesRequest(value) {
|
|
9
10
|
return (typeof value === "object" &&
|
|
10
11
|
value !== null &&
|
|
@@ -141,8 +142,10 @@ export function mountMessagesCrossProviderRoute(app, opts) {
|
|
|
141
142
|
});
|
|
142
143
|
return;
|
|
143
144
|
}
|
|
144
|
-
const
|
|
145
|
+
const requestedModel = typeof req.body.model === "string" ? req.body.model : undefined;
|
|
146
|
+
const route = selectRoute(requestedModel, opts.modelRouting);
|
|
145
147
|
if (route.provider !== "openai_subscription") {
|
|
148
|
+
req._ccRouteContext = extractAnthropicRouteContext(requestedModel, opts.modelRouting);
|
|
146
149
|
next();
|
|
147
150
|
return;
|
|
148
151
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { normalizeModelFamily } from "../providers/anthropic/usage.js";
|
|
2
|
+
import { parseModelRef } from "../protocol/model-ref.js";
|
|
3
|
+
/**
|
|
4
|
+
* Extract bounded Anthropic routing context from an incoming Messages model.
|
|
5
|
+
* OpenAI-routed requests deliberately return no context because they never
|
|
6
|
+
* enter the Anthropic account-selection middleware.
|
|
7
|
+
*/
|
|
8
|
+
export function extractAnthropicRouteContext(model, config = {}) {
|
|
9
|
+
const parsed = parseModelRef(typeof model === "string" ? model : undefined, config);
|
|
10
|
+
if (parsed.provider !== "anthropic_subscription")
|
|
11
|
+
return undefined;
|
|
12
|
+
const modelFamily = normalizeModelFamily(parsed.upstreamModel);
|
|
13
|
+
return {
|
|
14
|
+
requestedModel: parsed.upstreamModel,
|
|
15
|
+
...(modelFamily ? { modelFamily } : {}),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import express from "express";
|
|
2
2
|
import { selectRoute } from "../providers/route-selector.js";
|
|
3
3
|
import { forwardOpenAICodexResponse } from "../providers/openai/codex-transport.js";
|
|
4
|
+
import { collectCodexResponseStream } from "../protocol/openai-responses-collect.js";
|
|
5
|
+
import { stats } from "./stats.js";
|
|
6
|
+
import { logWarn } from "./logger.js";
|
|
4
7
|
function isResponsesRequest(value) {
|
|
5
8
|
return (typeof value === "object" &&
|
|
6
9
|
value !== null &&
|
|
@@ -37,6 +40,7 @@ async function sendUpstreamResponse(upstream, res) {
|
|
|
37
40
|
export function mountResponsesRoutes(app, opts) {
|
|
38
41
|
const forwardOpenAI = opts.forwardOpenAI ?? forwardOpenAICodexResponse;
|
|
39
42
|
const prepareOpenAIAccount = opts.prepareOpenAIAccount ?? (async () => true);
|
|
43
|
+
const recordActivity = opts.recordActivity ?? ((entry) => stats.addLog(entry));
|
|
40
44
|
app.post("/v1/responses", express.json({ limit: "10mb" }), async (req, res) => {
|
|
41
45
|
if (!isResponsesRequest(req.body)) {
|
|
42
46
|
res.status(400).json({
|
|
@@ -47,6 +51,34 @@ export function mountResponsesRoutes(app, opts) {
|
|
|
47
51
|
});
|
|
48
52
|
return;
|
|
49
53
|
}
|
|
54
|
+
if (req.body.store === true) {
|
|
55
|
+
recordActivity({
|
|
56
|
+
ts: Date.now(),
|
|
57
|
+
accountId: "-",
|
|
58
|
+
model: req.body.model,
|
|
59
|
+
type: "warn",
|
|
60
|
+
statusCode: 400,
|
|
61
|
+
details: "store:true rejected — Codex backend is stateless (store:false only)",
|
|
62
|
+
});
|
|
63
|
+
logWarn("responses", "store:true is not supported by the Codex backend; rejecting request");
|
|
64
|
+
res.status(400).json({
|
|
65
|
+
error: {
|
|
66
|
+
type: "invalid_request_error",
|
|
67
|
+
message: "store:true is not supported: the Codex subscription backend operates only in stateless (store:false) mode.",
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (req.body.max_output_tokens !== undefined) {
|
|
73
|
+
recordActivity({
|
|
74
|
+
ts: Date.now(),
|
|
75
|
+
accountId: "-",
|
|
76
|
+
model: req.body.model,
|
|
77
|
+
type: "warn",
|
|
78
|
+
details: "max_output_tokens ignored — unsupported by the Codex backend",
|
|
79
|
+
});
|
|
80
|
+
logWarn("responses", "max_output_tokens is unsupported by the Codex backend and was dropped");
|
|
81
|
+
}
|
|
50
82
|
const route = selectRoute(req.body.model, opts.modelRouting);
|
|
51
83
|
if (route.provider !== "openai_subscription") {
|
|
52
84
|
res.status(501).json({
|
|
@@ -86,6 +118,16 @@ export function mountResponsesRoutes(app, opts) {
|
|
|
86
118
|
body,
|
|
87
119
|
stream: body.stream === true,
|
|
88
120
|
});
|
|
89
|
-
|
|
121
|
+
if (body.stream === true) {
|
|
122
|
+
await sendUpstreamResponse(upstream, res);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const collected = await collectCodexResponseStream(upstream);
|
|
126
|
+
if (collected.kind === "json") {
|
|
127
|
+
res.status(collected.status).json(collected.body);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
res.status(collected.status).type(collected.contentType ?? "text/plain").send(collected.body);
|
|
131
|
+
}
|
|
90
132
|
});
|
|
91
133
|
}
|