@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.
@@ -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
- function retryAfterSeconds(value) {
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 60;
74
+ return undefined;
37
75
  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;
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 applyUpstreamFailureRouting(status, retryAfterHeader, route, router, pool) {
188
+ export function applyUpstreamFailureRoutingDetailed(status, failureHeaders, route, router, pool, now = Date.now) {
53
189
  if (status !== 401 && status !== 429 && status !== 529)
54
- return undefined;
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 seconds = retryAfterSeconds(retryAfterHeader);
60
- pool.setCooldownForAccount(route.account, seconds * 1_000);
61
- return seconds;
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.setCooldownForAccount(route.account, 30_000);
65
- return 30;
221
+ setGlobalCooldown(pool, route.account, OVERLOAD_COOLDOWN_MS, false);
222
+ return { cooldownSeconds: OVERLOAD_COOLDOWN_MS / 1_000 };
66
223
  }
67
- return undefined;
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
  }
@@ -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 route = selectRoute(req.body.model, opts.modelRouting);
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
- await sendUpstreamResponse(upstream, res);
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
  }