@timo972/cc-router 0.11.0 → 0.12.0-rc.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.
@@ -0,0 +1,142 @@
1
+ // The 7-day (weekly) window is the headline constraint for both providers —
2
+ // it is the one that actually forces a provider switch. The 5-hour window
3
+ // resets hourly-scale and is only a secondary, tie-breaking signal. This
4
+ // module is a PURE function over the already-in-memory HealthAccountView[]
5
+ // (no upstream fetch, no I/O) so polling it can never itself rate-limit an
6
+ // account.
7
+ const CONSTRAINED_THRESHOLD = 0.85;
8
+ const NO_WINDOWS = { sevenDayUtil: null, sevenDayResetMs: null, fiveHourUtil: null };
9
+ export function createAllowanceView(views, now) {
10
+ return {
11
+ ts: now,
12
+ providers: {
13
+ anthropic: computeProviderAllowance(views.filter(v => v.provider === "anthropic_subscription"), now, anthropicWindows, isAnthropicRateLimited),
14
+ openai: computeProviderAllowance(views.filter(v => v.provider === "openai_subscription"), now, openAIWindows, isOpenAIRateLimited),
15
+ },
16
+ };
17
+ }
18
+ function isCandidate(view) {
19
+ return view.enabled !== false;
20
+ }
21
+ function isCooling(view, now) {
22
+ return Math.max(view.cooldownUntilMs ?? 0, view.globalCooldownUntilMs ?? 0) > now;
23
+ }
24
+ function isAnthropicRateLimited(view) {
25
+ return view.rateLimits?.status === "rate_limited";
26
+ }
27
+ function isOpenAIRateLimited(view) {
28
+ return view.codexRateLimits?.status === "rate_limited";
29
+ }
30
+ function anthropicWindows(view) {
31
+ const rl = view.rateLimits;
32
+ if (!rl)
33
+ return NO_WINDOWS;
34
+ return {
35
+ sevenDayUtil: typeof rl.sevenDayUtil === "number" ? rl.sevenDayUtil : null,
36
+ sevenDayResetMs: rl.sevenDayReset > 0 ? rl.sevenDayReset * 1000 : null,
37
+ fiveHourUtil: typeof rl.fiveHourUtil === "number" ? rl.fiveHourUtil : null,
38
+ };
39
+ }
40
+ // The default Codex bucket carries two windows: a short one (primary,
41
+ // typically the 5h window) and a long one (secondary, typically the 7d /
42
+ // weekly window). We identify the weekly window by whichever has the LARGER
43
+ // windowMinutes rather than by name, since primary/secondary are positional,
44
+ // not semantic. If only one window is present it is treated as the weekly
45
+ // (7d-primary) signal and there is no secondary 5h reading.
46
+ function openAIWindows(view) {
47
+ const bucket = view.codexRateLimits?.buckets?.[0];
48
+ if (!bucket)
49
+ return NO_WINDOWS;
50
+ const windows = [bucket.primary, bucket.secondary]
51
+ .filter((w) => w != null);
52
+ if (windows.length === 0)
53
+ return NO_WINDOWS;
54
+ const weekly = windows.reduce((best, w) => (w.windowMinutes > best.windowMinutes ? w : best));
55
+ const short = windows.length > 1 ? windows.find(w => w !== weekly) ?? null : null;
56
+ return {
57
+ sevenDayUtil: weekly.utilization,
58
+ sevenDayResetMs: weekly.resetAt > 0 ? weekly.resetAt * 1000 : null,
59
+ fiveHourUtil: short ? short.utilization : null,
60
+ };
61
+ }
62
+ function computeProviderAllowance(views, now, windowsOf, isRateLimited) {
63
+ const candidates = views.filter(isCandidate);
64
+ const available = candidates.filter(v => !isCooling(v, now) && !isRateLimited(v));
65
+ const availableWindows = available.map(windowsOf);
66
+ const sevenDayUtils = availableWindows
67
+ .map(w => w.sevenDayUtil)
68
+ .filter((u) => u != null);
69
+ const fiveHourUtils = availableWindows
70
+ .map(w => w.fiveHourUtil)
71
+ .filter((u) => u != null);
72
+ const worstSevenDayUtil = sevenDayUtils.length > 0 ? Math.max(...sevenDayUtils) : null;
73
+ const bestSevenDayUtil = sevenDayUtils.length > 0 ? Math.min(...sevenDayUtils) : null;
74
+ const sevenDayHeadroom = bestSevenDayUtil != null ? 1 - bestSevenDayUtil : null;
75
+ const sevenDaySpread = worstSevenDayUtil != null && bestSevenDayUtil != null
76
+ ? worstSevenDayUtil - bestSevenDayUtil
77
+ : null;
78
+ const fiveHourUtil = fiveHourUtils.length > 0 ? Math.min(...fiveHourUtils) : null;
79
+ const accountsTotal = candidates.length;
80
+ const accountsAvailable = available.length;
81
+ let status;
82
+ if (accountsAvailable === 0) {
83
+ status = "exhausted";
84
+ }
85
+ else if ((bestSevenDayUtil != null && bestSevenDayUtil >= CONSTRAINED_THRESHOLD)
86
+ || (fiveHourUtil != null && fiveHourUtil >= CONSTRAINED_THRESHOLD)) {
87
+ status = "constrained";
88
+ }
89
+ else {
90
+ status = "ok";
91
+ }
92
+ const earliestResetAt = computeEarliestResetAt({
93
+ status,
94
+ accountsAvailable,
95
+ available,
96
+ candidates,
97
+ windowsOf,
98
+ now,
99
+ });
100
+ const coolingModelFamilies = Array.from(new Set(candidates.flatMap(v => (v.modelCooldowns ?? [])
101
+ .filter(c => c.untilMs > now)
102
+ .map(c => c.modelFamily)))).sort();
103
+ return {
104
+ status,
105
+ accountsTotal,
106
+ accountsAvailable,
107
+ sevenDayUtil: worstSevenDayUtil,
108
+ sevenDayHeadroom,
109
+ sevenDaySpread,
110
+ fiveHourUtil,
111
+ earliestResetAt,
112
+ coolingModelFamilies,
113
+ };
114
+ }
115
+ function computeEarliestResetAt(args) {
116
+ const { status, accountsAvailable, available, candidates, windowsOf, now } = args;
117
+ if (accountsAvailable > 0) {
118
+ if (status !== "constrained")
119
+ return null;
120
+ const resets = available
121
+ .map(windowsOf)
122
+ .map(w => w.sevenDayResetMs)
123
+ .filter((r) => r != null);
124
+ return resets.length > 0 ? Math.min(...resets) : null;
125
+ }
126
+ // Exhausted: the earliest moment capacity could plausibly return, per
127
+ // candidate — whichever cooldown (account or global) is currently
128
+ // enforced, or otherwise its weekly reset if that lies in the future.
129
+ const candidateResets = [];
130
+ for (const view of candidates) {
131
+ const cooldownUntil = Math.max(view.cooldownUntilMs ?? 0, view.globalCooldownUntilMs ?? 0);
132
+ if (cooldownUntil > now) {
133
+ candidateResets.push(cooldownUntil);
134
+ continue;
135
+ }
136
+ const sevenDayResetMs = windowsOf(view).sevenDayResetMs;
137
+ if (sevenDayResetMs != null && sevenDayResetMs > now) {
138
+ candidateResets.push(sevenDayResetMs);
139
+ }
140
+ }
141
+ return candidateResets.length > 0 ? Math.min(...candidateResets) : null;
142
+ }
@@ -1,12 +1,13 @@
1
1
  import chalk from "chalk";
2
2
  function ts() {
3
- return new Date().toISOString().slice(11, 19); // HH:MM:SS
3
+ return new Date().toISOString().slice(0, 19).replace("T", " "); // YYYY-MM-DD HH:MM:SS
4
4
  }
5
- export function logRoute(accountId, requestCount, expiresInMin) {
5
+ export function logRoute(accountId, requestCount, expiresInMin, reason) {
6
6
  console.log(chalk.gray(`[${ts()}]`) +
7
7
  chalk.green(` → ${accountId}`) +
8
8
  chalk.gray(` req#${requestCount}`) +
9
- chalk.yellow(` exp=${expiresInMin}min`));
9
+ chalk.yellow(` exp=${expiresInMin}min`) +
10
+ (reason ? chalk.cyan(` ${reason}`) : ""));
10
11
  }
11
12
  export function logRefresh(accountId, ok, expiresInMin) {
12
13
  if (ok) {
@@ -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
  }