@timo972/cc-router 0.11.0 → 0.12.0-rc.1

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +33 -4
  3. package/dist/cli/cmd-accounts.js +91 -13
  4. package/dist/cli/cmd-cli-targets.js +177 -0
  5. package/dist/cli/cmd-client.js +3 -2
  6. package/dist/cli/cmd-configure.js +18 -4
  7. package/dist/cli/cmd-status.js +4 -1
  8. package/dist/cli/cmd-stop.js +18 -0
  9. package/dist/cli/index.js +10 -3
  10. package/dist/config/manager.js +19 -3
  11. package/dist/config/paths.js +1 -0
  12. package/dist/protocol/anthropic-to-openai.js +31 -19
  13. package/dist/protocol/model-ref.js +1 -1
  14. package/dist/protocol/openai-function-call.js +16 -0
  15. package/dist/protocol/openai-response-to-anthropic.js +34 -6
  16. package/dist/protocol/openai-stream-to-anthropic.js +116 -16
  17. package/dist/protocol/openai-to-anthropic.js +54 -29
  18. package/dist/providers/openai/account-state.js +4 -1
  19. package/dist/providers/openai/usage.js +14 -1
  20. package/dist/providers/xai/account-record.js +42 -0
  21. package/dist/providers/xai/device-oauth.js +116 -0
  22. package/dist/providers/xai/import-auth.js +70 -0
  23. package/dist/providers/xai/overview.js +279 -0
  24. package/dist/providers/xai/subscription-fetch.js +56 -0
  25. package/dist/proxy/allowance.js +142 -0
  26. package/dist/proxy/logger.js +4 -3
  27. package/dist/proxy/messages-cross-route.js +113 -8
  28. package/dist/proxy/models-server.js +30 -2
  29. package/dist/proxy/openai-ingress.js +7 -1
  30. package/dist/proxy/openai-routing.js +12 -7
  31. package/dist/proxy/server.js +110 -6
  32. package/dist/ui/Dashboard.js +383 -124
  33. package/dist/ui/accountsApi.js +3 -1
  34. package/dist/utils/cli-routing.js +80 -0
  35. package/dist/utils/codex-config.js +337 -15
  36. package/package.json +3 -2
@@ -2,6 +2,7 @@ import express from "express";
2
2
  import { selectRoute } from "../providers/route-selector.js";
3
3
  import { anthropicToOpenAIResponses } from "../protocol/anthropic-to-openai.js";
4
4
  import { openAIResponseToAnthropicMessage } from "../protocol/openai-response-to-anthropic.js";
5
+ import { OpenAIProtocolError } from "../protocol/openai-function-call.js";
5
6
  import { createOpenAIStreamToAnthropicNormalizer } from "../protocol/openai-stream-to-anthropic.js";
6
7
  import { encodeSseEvent, parseSseLines } from "../protocol/sse.js";
7
8
  import { forwardOpenAICodexResponse } from "../providers/openai/codex-transport.js";
@@ -143,16 +144,21 @@ async function collectOpenAIStreamAsAnthropicMessage(upstream, report) {
143
144
  let remainder = "";
144
145
  let id = "";
145
146
  let model = "";
146
- let text = "";
147
147
  let failure;
148
148
  let completed = false;
149
149
  let usage = {};
150
150
  let status;
151
151
  let incompleteDetails;
152
+ const textByIndex = new Map();
153
+ const refusalByIndex = new Map();
154
+ const argumentsByIndex = new Map();
155
+ const pendingCallsByIndex = new Map();
156
+ const callsByIndex = new Map();
152
157
  const applyEvent = (event) => {
153
158
  if (typeof event !== "object" || event === null)
154
159
  return;
155
160
  const openAIEvent = event;
161
+ const outputIndex = openAIEvent.output_index ?? 0;
156
162
  // Reported the moment it is seen, not when this function returns: the
157
163
  // read after it can be cut short by a client disconnect, and losing the
158
164
  // verdict there turns a real backend failure into a benign cancellation.
@@ -172,7 +178,57 @@ async function collectOpenAIStreamAsAnthropicMessage(upstream, report) {
172
178
  return;
173
179
  }
174
180
  if (openAIEvent.type === "response.output_text.delta") {
175
- text += openAIEvent.delta ?? "";
181
+ textByIndex.set(outputIndex, (textByIndex.get(outputIndex) ?? "") + (openAIEvent.delta ?? ""));
182
+ return;
183
+ }
184
+ if (openAIEvent.type === "response.refusal.delta") {
185
+ refusalByIndex.set(outputIndex, (refusalByIndex.get(outputIndex) ?? "") + (openAIEvent.delta ?? ""));
186
+ return;
187
+ }
188
+ if (openAIEvent.type === "response.output_item.added") {
189
+ const item = openAIEvent.item;
190
+ if (item?.type === "function_call") {
191
+ if (!item.call_id?.trim() || !item.name?.trim()) {
192
+ throw new OpenAIProtocolError("Invalid OpenAI function call metadata");
193
+ }
194
+ pendingCallsByIndex.set(outputIndex, {
195
+ type: "function_call",
196
+ call_id: item.call_id,
197
+ name: item.name,
198
+ arguments: item.arguments ?? "",
199
+ });
200
+ }
201
+ return;
202
+ }
203
+ if (openAIEvent.type === "response.function_call_arguments.delta") {
204
+ if (!pendingCallsByIndex.has(outputIndex))
205
+ return;
206
+ argumentsByIndex.set(outputIndex, (argumentsByIndex.get(outputIndex) ?? "") + (openAIEvent.delta ?? ""));
207
+ return;
208
+ }
209
+ if (openAIEvent.type === "response.function_call_arguments.done") {
210
+ if (pendingCallsByIndex.has(outputIndex) && !argumentsByIndex.has(outputIndex) && openAIEvent.arguments) {
211
+ argumentsByIndex.set(outputIndex, openAIEvent.arguments);
212
+ }
213
+ return;
214
+ }
215
+ if (openAIEvent.type === "response.output_item.done") {
216
+ const item = openAIEvent.item;
217
+ const pending = pendingCallsByIndex.get(outputIndex);
218
+ if (item?.type === "function_call" || pending) {
219
+ const callId = item?.call_id || pending?.call_id;
220
+ const name = item?.name || pending?.name;
221
+ if (!callId?.trim() || !name?.trim()) {
222
+ throw new OpenAIProtocolError("Invalid OpenAI function call metadata");
223
+ }
224
+ callsByIndex.set(outputIndex, {
225
+ type: "function_call",
226
+ call_id: callId,
227
+ name,
228
+ arguments: item?.arguments || argumentsByIndex.get(outputIndex) || pending?.arguments || "",
229
+ });
230
+ pendingCallsByIndex.delete(outputIndex);
231
+ }
176
232
  return;
177
233
  }
178
234
  if (terminalResponsePayload(event) !== undefined) {
@@ -199,15 +255,34 @@ async function collectOpenAIStreamAsAnthropicMessage(upstream, report) {
199
255
  if (tail || remainder) {
200
256
  parseSseLines(remainder + tail + "\n", { tolerant: true }).events.forEach(applyEvent);
201
257
  }
258
+ const output = [...new Set([
259
+ ...textByIndex.keys(),
260
+ ...refusalByIndex.keys(),
261
+ ...callsByIndex.keys(),
262
+ ])]
263
+ .sort((a, b) => a - b)
264
+ .flatMap((index) => {
265
+ const call = callsByIndex.get(index);
266
+ if (call)
267
+ return [{ ...call, arguments: call.arguments || argumentsByIndex.get(index) || "" }];
268
+ const text = textByIndex.get(index);
269
+ const refusal = refusalByIndex.get(index);
270
+ const content = [
271
+ ...(text ? [{ type: "output_text", text }] : []),
272
+ ...(refusal ? [{ type: "refusal", refusal }] : []),
273
+ ];
274
+ return content.length > 0 ? [{ type: "message", role: "assistant", content }] : [];
275
+ });
276
+ const protocolFailure = pendingCallsByIndex.size > 0
277
+ ? "OpenAI function call ended before completion"
278
+ : undefined;
279
+ if (protocolFailure)
280
+ report.upstreamReportedFailure = true;
202
281
  return {
203
282
  message: openAIResponseToAnthropicMessage({
204
283
  id,
205
284
  model,
206
- output: text ? [{
207
- type: "message",
208
- role: "assistant",
209
- content: [{ type: "output_text", text }],
210
- }] : [],
285
+ output,
211
286
  usage,
212
287
  ...(status ? { status } : {}),
213
288
  ...(incompleteDetails ? { incomplete_details: incompleteDetails } : {}),
@@ -222,7 +297,9 @@ async function collectOpenAIStreamAsAnthropicMessage(upstream, report) {
222
297
  // mid-flight) is a failure rather than an empty success. An explicit
223
298
  // `response.failed`/`error` message wins, since it says more about what
224
299
  // went wrong.
225
- failure: failure ?? (completed ? undefined : "Upstream stream ended without a terminal response event"),
300
+ failure: failure
301
+ ?? protocolFailure
302
+ ?? (completed ? undefined : "Upstream stream ended without a terminal response event"),
226
303
  };
227
304
  }
228
305
  /** Returns the upstream failure message when the stream ended in one. */
@@ -250,6 +327,16 @@ async function sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report) {
250
327
  let totals;
251
328
  let failure;
252
329
  let completed = false;
330
+ const pendingToolCalls = new Set();
331
+ const writeProtocolError = () => {
332
+ res.write(encodeSseEvent({
333
+ type: "error",
334
+ error: {
335
+ type: "api_error",
336
+ message: "Invalid or incomplete response from OpenAI",
337
+ },
338
+ }));
339
+ };
253
340
  const inspect = (event) => {
254
341
  totals = usageFromTerminalEvent(event) ?? totals;
255
342
  if (typeof event !== "object" || event === null)
@@ -268,6 +355,12 @@ async function sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report) {
268
355
  else if (terminalResponsePayload(event) !== undefined) {
269
356
  completed = true;
270
357
  }
358
+ if (typed.type === "response.output_item.added" && typed.item?.type === "function_call") {
359
+ pendingToolCalls.add(typed.output_index ?? 0);
360
+ }
361
+ else if (typed.type === "response.output_item.done") {
362
+ pendingToolCalls.delete(typed.output_index ?? 0);
363
+ }
271
364
  };
272
365
  const relayEvents = (events) => {
273
366
  for (const event of events) {
@@ -298,6 +391,18 @@ async function sendOpenAIStreamAsAnthropic(upstream, res, onUsage, report) {
298
391
  if (tail || remainder) {
299
392
  relayEvents(parseSseLines(remainder + tail + "\n", { tolerant: true }).events);
300
393
  }
394
+ if (!completed && pendingToolCalls.size > 0 && failure === undefined) {
395
+ failure = "OpenAI function call ended before completion";
396
+ report.upstreamReportedFailure = true;
397
+ writeProtocolError();
398
+ }
399
+ }
400
+ catch (error) {
401
+ if (!(error instanceof OpenAIProtocolError))
402
+ throw error;
403
+ failure = error.message;
404
+ report.upstreamReportedFailure = true;
405
+ writeProtocolError();
301
406
  }
302
407
  finally {
303
408
  res.end();
@@ -1,5 +1,6 @@
1
1
  import express from "express";
2
2
  import { fetchAnthropicModels, fetchOpenAICodexModels, } from "../providers/model-discovery.js";
3
+ import { isBareOpenAIModel } from "../protocol/model-ref.js";
3
4
  import { buildModelRoutingUpdate } from "../protocol/model-routing-config.js";
4
5
  export function mountModelsRoute(app, opts) {
5
6
  const fetchAnthropic = opts.fetchAnthropicModels ?? fetchAnthropicModels;
@@ -52,6 +53,7 @@ async function discoverModelList(opts, prepareOpenAIAccount, fetchAnthropic, fet
52
53
  models.set(model.id, model);
53
54
  }
54
55
  addConfiguredAliases(models, currentModelRouting(opts));
56
+ addBareOpenAIEntries(models);
55
57
  return [...models.values()].sort((a, b) => a.id.localeCompare(b.id));
56
58
  }
57
59
  function currentModelRouting(opts) {
@@ -98,9 +100,36 @@ function addConfiguredAliases(models, config) {
98
100
  models.set("openai/default", modelEntry("openai/default", "openai_subscription"));
99
101
  }
100
102
  }
103
+ /**
104
+ * Routing already claims bare `gpt-*` slugs for OpenAI (isBareOpenAIModel in
105
+ * model-ref.ts), because the Codex CLI writes those bare slugs into its own
106
+ * config. Without a matching metadata entry here, Codex warns "Model
107
+ * metadata not found" and falls back to generic defaults for a model the
108
+ * router routes correctly.
109
+ */
110
+ function addBareOpenAIEntries(models) {
111
+ for (const model of [...models.values()]) {
112
+ if (model.owned_by !== "openai_subscription")
113
+ continue;
114
+ const bare = model.id.startsWith("openai/") ? model.id.slice("openai/".length) : model.id;
115
+ if (isBareOpenAIModel(bare) && !models.has(bare)) {
116
+ models.set(bare, modelEntry(bare, "openai_subscription"));
117
+ }
118
+ }
119
+ }
101
120
  function modelEntry(id, ownedBy) {
102
121
  return { id, object: "model", owned_by: ownedBy };
103
122
  }
123
+ /**
124
+ * Codex's hardcoded skills-context budget (2%) is carved out of
125
+ * context_window, so an undersized value here starves it. Use the
126
+ * official per-family windows instead of one shared guess.
127
+ */
128
+ function contextWindowFor(ownedBy) {
129
+ return ownedBy === "openai_subscription"
130
+ ? { context_window: 272_000, max_context_window: 1_050_000 }
131
+ : { context_window: 200_000, max_context_window: 200_000 };
132
+ }
104
133
  function toCodexCliModel(model) {
105
134
  return {
106
135
  prefer_websockets: true,
@@ -116,8 +145,7 @@ function toCodexCliModel(model) {
116
145
  multi_agent_version: null,
117
146
  use_responses_lite: false,
118
147
  auto_review_model_override: null,
119
- context_window: 128_000,
120
- max_context_window: 128_000,
148
+ ...contextWindowFor(model.owned_by),
121
149
  auto_compact_token_limit: null,
122
150
  reasoning_summary_format: "experimental",
123
151
  default_reasoning_summary: "none",
@@ -3,7 +3,7 @@ import { headersToRecord, parseCodexRateLimits } from "../providers/openai/usage
3
3
  import { applyCodexFailureRouting } from "../providers/openai/failure-routing.js";
4
4
  import { needsOpenAIRefresh } from "../providers/openai/token-refresher.js";
5
5
  import { stats, boundModelId, createLocalRoutingErrorLog } from "./stats.js";
6
- import { logError } from "./logger.js";
6
+ import { logError, logRoute } from "./logger.js";
7
7
  import { EmptyPoolError, NoEligibleAccountError } from "./account-pool.js";
8
8
  import { acquireRequestRoute, routeReasonDetails, routeFailureDetails } from "./lease-lifecycle.js";
9
9
  import { MAX_UPSTREAM_ATTEMPTS, RETRY_REFRESH_TIMEOUT_MS, SAME_ACCOUNT_RETRY_DELAY_MS, boundedWait, isRetryableUpstreamStatus, retryDelay, } from "./upstream-retry.js";
@@ -159,6 +159,12 @@ export async function runOpenAIIngress(opts) {
159
159
  res.status(500).json(envelope.wrap("proxy_error", "Unexpected routing error"));
160
160
  return;
161
161
  }
162
+ // Mirrors the Anthropic path's route log (server.ts) — without this the
163
+ // OpenAI/Responses ingress made every routing decision (sticky/new-session/
164
+ // failover) invisible, unlike the Anthropic path which logs every routed
165
+ // request. `selected.details` is the pool's preformatted, session-id-free
166
+ // reason string — keep it that way.
167
+ logRoute(selected.route.account.id, selected.route.account.requestCount, Math.round((selected.route.account.expiresAt - now()) / 60_000), selected.details);
162
168
  const startedAt = now();
163
169
  const maxAttempts = Math.max(1, opts.maxAttempts ?? MAX_UPSTREAM_ATTEMPTS);
164
170
  const sameAccountDelayMs = opts.sameAccountRetryDelayMs ?? SAME_ACCOUNT_RETRY_DELAY_MS;
@@ -1,6 +1,7 @@
1
1
  import { extractClaudeSessionId } from "./anthropic-routing.js";
2
2
  import { normalizeSessionId } from "./session-router.js";
3
- const CODEX_SESSION_HEADER = "session_id";
3
+ const CODEX_SESSION_HEADER_DASHED = "session-id";
4
+ const CODEX_SESSION_HEADER_UNDERSCORE = "session_id";
4
5
  /** Extract exactly one native HTTP header field without joined duplicates. */
5
6
  function extractSingleHeader(request, name) {
6
7
  const distinct = request.headersDistinct;
@@ -21,14 +22,18 @@ function extractSingleHeader(request, name) {
21
22
  return normalizeSessionId(values[0]);
22
23
  }
23
24
  /**
24
- * Resolve the OpenAI affinity key in priority order: Codex session_id header,
25
- * Claude Code session header, then the request body's prompt_cache_key
26
- * (Codex thread id). Returns undefined for unscoped requests.
25
+ * Resolve the OpenAI affinity key in priority order: Codex `session-id` header
26
+ * (current Codex CLI spelling), legacy `session_id` header, Claude Code session
27
+ * header, then the request body's prompt_cache_key (Codex thread id). Returns
28
+ * undefined for unscoped requests.
27
29
  */
28
30
  export function extractCodexSessionKey(request, body) {
29
- const codexSession = extractSingleHeader(request, CODEX_SESSION_HEADER);
30
- if (codexSession !== undefined)
31
- return codexSession;
31
+ const codexSessionDashed = extractSingleHeader(request, CODEX_SESSION_HEADER_DASHED);
32
+ if (codexSessionDashed !== undefined)
33
+ return codexSessionDashed;
34
+ const codexSessionUnderscore = extractSingleHeader(request, CODEX_SESSION_HEADER_UNDERSCORE);
35
+ if (codexSessionUnderscore !== undefined)
36
+ return codexSessionUnderscore;
32
37
  const claudeSession = extractClaudeSessionId(request);
33
38
  if (claudeSession !== undefined)
34
39
  return claudeSession;
@@ -4,7 +4,7 @@ import { ServerResponse } from "http";
4
4
  import { timingSafeEqual } from "crypto";
5
5
  import { TokenPool } from "./token-pool.js";
6
6
  import { needsRefresh, refreshAccountIfCurrent, saveAccounts, startRefreshLoop } from "./token-refresher.js";
7
- import { loadAccounts, loadOpenAIAccounts, saveOpenAIAccountsToPath, accountsFileExists, readAccountsFromPath, readConfig, writeConfig, getAutoFailoverEnabled, getProxyRequestTimeoutMs, migrateLegacyAccountProviders, setProviderAccountsEnabled } from "../config/manager.js";
7
+ import { loadAccounts, loadOpenAIAccounts, saveOpenAIAccountsToPath, accountsFileExists, readAccountsFromPath, readConfig, writeConfig, getAutoFailoverEnabled, getProxyRequestTimeoutMs, migrateLegacyAccountProviders, setProviderAccountsEnabled, upsertAccountRecord, removeAccountRecordById } from "../config/manager.js";
8
8
  import { checkForUpdate, performUpdate, restartSelf, printUpdateBanner, getCurrentVersion } from "../utils/self-update.js";
9
9
  import { trackEvent, startHeartbeat } from "../utils/telemetry.js";
10
10
  import { loadTelemetryState } from "../config/telemetry.js";
@@ -13,6 +13,7 @@ import { createLocalRoutingErrorLog, stats } from "./stats.js";
13
13
  import { applyRateLimitHeaders } from "../providers/anthropic/rate-limit-headers.js";
14
14
  import { mountAnthropicMessagesRoute, withOAuthBeta } from "./anthropic-messages-route.js";
15
15
  import { PROXY_PORT, LITELLM_URL, ACCOUNTS_PATH } from "../config/paths.js";
16
+ import { loadGrokHealthSnapshots } from "../providers/xai/overview.js";
16
17
  import { writePid, removePid, managesPidFile } from "../daemon/pid.js";
17
18
  import { applyOpenAIAccountPatch, validateAccountPatchBody } from "./account-patch.js";
18
19
  import { AccountRenameConflictError, renameAccountTransaction } from "./account-rename.js";
@@ -35,6 +36,7 @@ import { persistProviderEnabledState } from "./provider-routing.js";
35
36
  import { accountDeletionStatusCode, deleteAnthropicAccountTransaction, deleteOpenAIAccountTransaction, } from "./account-deletion.js";
36
37
  import { addOpenAIAccountTransaction } from "./account-add.js";
37
38
  import { createAnthropicRefreshMiddleware, createAnthropicRoutingMiddleware, } from "./anthropic-routing.js";
39
+ import { createAllowanceView } from "./allowance.js";
38
40
  const zeroRoutingMetrics = () => ({
39
41
  inFlightRequests: 0,
40
42
  activeSessions: 0,
@@ -44,6 +46,7 @@ const zeroRoutingMetrics = () => ({
44
46
  export function createOperationalStatus(opts) {
45
47
  const anthropicAccounts = opts.accounts.filter(a => a.provider === "anthropic_subscription");
46
48
  const openAIAccounts = opts.accounts.filter(a => a.provider === "openai_subscription");
49
+ const xaiAccounts = opts.accounts.filter(a => a.provider === "xai_subscription");
47
50
  const modelRouting = opts.modelRouting ?? {};
48
51
  return {
49
52
  mode: opts.mode,
@@ -52,10 +55,12 @@ export function createOperationalStatus(opts) {
52
55
  providers: {
53
56
  anthropic: providerStatus(anthropicAccounts),
54
57
  openai: providerStatus(openAIAccounts),
58
+ xai: providerStatus(xaiAccounts),
55
59
  },
56
60
  endpoints: {
57
61
  health: "/cc-router/health",
58
62
  accounts: "/cc-router/accounts",
63
+ allowance: "/cc-router/allowance",
59
64
  messages: "/v1/messages",
60
65
  responses: "/v1/responses",
61
66
  models: "/v1/models",
@@ -75,12 +80,30 @@ export function createOperationalStatus(opts) {
75
80
  },
76
81
  };
77
82
  }
78
- export function createHealthAccountViews(anthropicAccounts, openAIAccounts, resolveRoutingMetrics = zeroRoutingMetrics, resolveOpenAIRouting) {
83
+ export function createHealthAccountViews(anthropicAccounts, openAIAccounts, resolveRoutingMetrics = zeroRoutingMetrics, resolveOpenAIRouting, xaiAccounts = []) {
79
84
  return [
80
85
  ...anthropicAccounts.map(account => (publicAnthropicAccountView(account, resolveRoutingMetrics(account.id)))),
81
86
  ...openAIAccounts.map(account => publicOpenAIAccountView(account, resolveOpenAIRouting?.(account.id) ?? { metrics: zeroRoutingMetrics(account.id), cooldowns: { globalUntilMs: 0, bucketCooldowns: [] } })),
87
+ ...xaiAccounts.map(publicXaiAccountView),
82
88
  ];
83
89
  }
90
+ function publicXaiAccountView(account) {
91
+ return {
92
+ id: account.id,
93
+ provider: "xai_subscription",
94
+ enabled: true,
95
+ healthy: account.healthy,
96
+ busy: account.busy,
97
+ inFlightRequests: 0,
98
+ activeSessions: account.activeSessions,
99
+ requestCount: account.requestCount,
100
+ errorCount: 0,
101
+ expiresInMs: account.expiresInMs,
102
+ lastUsedMs: 0,
103
+ lastRefreshMs: 0,
104
+ ...(account.tier !== undefined ? { xai: { tier: account.tier } } : {}),
105
+ };
106
+ }
84
107
  function publicAnthropicAccountView(a, metrics) {
85
108
  return {
86
109
  id: a.id,
@@ -241,6 +264,7 @@ function publicCodexRateLimits(a, cooldowns) {
241
264
  const balance = typeof credits?.balance === "string"
242
265
  ? credits.balance.replace(/[\x00-\x1f\x7f]/g, "").trim().slice(0, 32)
243
266
  : "";
267
+ const resetAvailable = rl.resetCredits?.available;
244
268
  return {
245
269
  status: rl.status === "rate_limited" ? "rate_limited" : "ok",
246
270
  plan: publicCodexPlan(rl.plan),
@@ -252,6 +276,9 @@ function publicCodexRateLimits(a, cooldowns) {
252
276
  ...(balance ? { balance } : {}),
253
277
  },
254
278
  } : {}),
279
+ ...(typeof resetAvailable === "number" && Number.isFinite(resetAvailable) ? {
280
+ resetCredits: { available: Math.max(0, Math.min(99, Math.floor(resetAvailable))) },
281
+ } : {}),
255
282
  lastUpdated: publicTimestamp(rl.lastUpdated),
256
283
  };
257
284
  }
@@ -453,7 +480,7 @@ export async function startServer(opts = {}) {
453
480
  pool.sweepExpiredCooldowns();
454
481
  openAIPool.sweepExpiredCooldowns();
455
482
  const resolveRoutingMetrics = createRoutingMetricsResolver();
456
- const accountViews = createHealthAccountViews(pool.getAll(), openAIAccounts, resolveRoutingMetrics, createOpenAIRoutingResolver());
483
+ const accountViews = createHealthAccountViews(pool.getAll(), openAIAccounts, resolveRoutingMetrics, createOpenAIRoutingResolver(), loadGrokHealthSnapshots());
457
484
  const status = accountViews.some(a => a.healthy) ? "ok" : "degraded";
458
485
  if (secretBuf && !secretMatches(presentedSecret(req))) {
459
486
  res.json({ status });
@@ -487,6 +514,24 @@ export async function startServer(opts = {}) {
487
514
  recentLogs: stats.getRecentLogs(50),
488
515
  });
489
516
  });
517
+ // ─── Allowance endpoint (cc-router internal, NOT proxied) ─────────────────
518
+ // Operational-status sibling of /cc-router/health, not an account
519
+ // operation — hence a top-level route rather than living under
520
+ // accountsRouter. Read-only allowance signal (anthropic + openai only) —
521
+ // createAllowanceView is a PURE function over the already-in-memory account
522
+ // views, so polling it can never itself rate-limit an account. See
523
+ // ./allowance.ts for the 7d-primary logic. Behind the same secret gate as
524
+ // every other path except /cc-router/health (see ~line 757).
525
+ app.get("/cc-router/allowance", (_req, res) => {
526
+ // Sweep expired cooldowns on each poll, mirroring the health route, so an
527
+ // account that cooled down during idle time reads as available rather
528
+ // than stale.
529
+ pool.sweepExpiredCooldowns();
530
+ openAIPool.sweepExpiredCooldowns();
531
+ const resolveRoutingMetrics = createRoutingMetricsResolver();
532
+ const views = createHealthAccountViews(pool.getAll(), openAIAccounts, resolveRoutingMetrics, createOpenAIRoutingResolver(), loadGrokHealthSnapshots());
533
+ res.json(createAllowanceView(views, Date.now()));
534
+ });
490
535
  // ─── Account management endpoints (authenticated) ─────────────────────────
491
536
  // These are mounted BEFORE the /v1/* proxy middleware so they don't get
492
537
  // forwarded to Anthropic. express.json() is scoped to this sub-router so
@@ -497,13 +542,31 @@ export async function startServer(opts = {}) {
497
542
  accountsRouter.get("/", (_req, res) => {
498
543
  const resolveRoutingMetrics = createRoutingMetricsResolver();
499
544
  res.json({
500
- accounts: createHealthAccountViews(pool.getAll(), openAIAccounts, resolveRoutingMetrics, createOpenAIRoutingResolver()),
545
+ accounts: createHealthAccountViews(pool.getAll(), openAIAccounts, resolveRoutingMetrics, createOpenAIRoutingResolver(), loadGrokHealthSnapshots()),
501
546
  });
502
547
  });
503
548
  accountsRouter.patch("/providers/:provider", (req, res) => {
504
549
  const providerParam = req.params.provider;
505
- if (providerParam !== "anthropic_subscription" && providerParam !== "openai_subscription") {
506
- res.status(400).json({ error: "provider must be anthropic_subscription or openai_subscription" });
550
+ if (providerParam !== "anthropic_subscription"
551
+ && providerParam !== "openai_subscription"
552
+ && providerParam !== "xai_subscription") {
553
+ res.status(400).json({ error: "provider must be anthropic_subscription, openai_subscription, or xai_subscription" });
554
+ return;
555
+ }
556
+ if (providerParam === "xai_subscription") {
557
+ const body = (req.body ?? {});
558
+ if (typeof body.enabled !== "boolean") {
559
+ res.status(400).json({ error: "enabled must be boolean" });
560
+ return;
561
+ }
562
+ try {
563
+ const changed = setProviderAccountsEnabled("xai_subscription", body.enabled, accountsPath);
564
+ res.json({ provider: providerParam, enabled: body.enabled, changed });
565
+ }
566
+ catch (err) {
567
+ const message = err instanceof Error ? err.message : String(err);
568
+ res.status(500).json({ error: `Failed to persist accounts.json: ${message}` });
569
+ }
507
570
  return;
508
571
  }
509
572
  const body = (req.body ?? {});
@@ -743,6 +806,42 @@ export async function startServer(opts = {}) {
743
806
  res.status(409).json({ error: `Account "${body.id}" already exists` });
744
807
  return;
745
808
  }
809
+ if (body.provider === "xai_subscription") {
810
+ try {
811
+ upsertAccountRecord({
812
+ id: body.id,
813
+ provider: "xai_subscription",
814
+ accessToken: body.accessToken,
815
+ refreshToken: body.refreshToken,
816
+ expiresAt: body.expiresAt,
817
+ scopes: Array.isArray(body.scopes) ? body.scopes : [],
818
+ enabled: body.enabled !== false,
819
+ });
820
+ }
821
+ catch (err) {
822
+ const message = err instanceof Error ? err.message : String(err);
823
+ res.status(500).json({ error: `Failed to persist accounts.json: ${message}` });
824
+ return;
825
+ }
826
+ const now = Date.now();
827
+ res.status(201).json({
828
+ account: publicXaiAccountView({
829
+ id: body.id,
830
+ provider: "xai_subscription",
831
+ enabled: true,
832
+ healthy: body.expiresAt > now,
833
+ busy: false,
834
+ inFlightRequests: 0,
835
+ activeSessions: 0,
836
+ requestCount: 0,
837
+ errorCount: 0,
838
+ expiresInMs: body.expiresAt - now,
839
+ lastUsedMs: 0,
840
+ lastRefreshMs: 0,
841
+ }),
842
+ });
843
+ return;
844
+ }
746
845
  if (body.provider === "openai_subscription") {
747
846
  let addedOpenAI;
748
847
  try {
@@ -803,6 +902,11 @@ export async function startServer(opts = {}) {
803
902
  const existing = pool.findById(id);
804
903
  const openAIExisting = openAIAccounts.find(account => account.id === id);
805
904
  if (!existing && !openAIExisting) {
905
+ const removedXai = removeAccountRecordById(id);
906
+ if (removedXai?.provider === "xai_subscription") {
907
+ res.json({ deleted: id, remaining: pool.getAll().length + openAIAccounts.length });
908
+ return;
909
+ }
806
910
  res.status(404).json({ error: `Account "${id}" not found` });
807
911
  return;
808
912
  }