@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.
- package/CHANGELOG.md +12 -0
- package/README.md +32 -3
- package/dist/cli/cmd-accounts.js +91 -13
- package/dist/cli/cmd-cli-targets.js +177 -0
- package/dist/cli/cmd-client.js +3 -2
- package/dist/cli/cmd-configure.js +18 -4
- package/dist/cli/cmd-status.js +4 -1
- package/dist/cli/cmd-stop.js +18 -0
- package/dist/cli/index.js +10 -3
- package/dist/config/manager.js +19 -3
- package/dist/config/paths.js +1 -0
- package/dist/protocol/model-ref.js +1 -1
- package/dist/providers/openai/account-state.js +4 -1
- package/dist/providers/openai/usage.js +14 -1
- package/dist/providers/xai/account-record.js +42 -0
- package/dist/providers/xai/device-oauth.js +116 -0
- package/dist/providers/xai/import-auth.js +70 -0
- package/dist/providers/xai/overview.js +279 -0
- package/dist/providers/xai/subscription-fetch.js +56 -0
- package/dist/proxy/allowance.js +142 -0
- package/dist/proxy/logger.js +4 -3
- package/dist/proxy/models-server.js +30 -2
- package/dist/proxy/openai-ingress.js +7 -1
- package/dist/proxy/openai-routing.js +12 -7
- package/dist/proxy/server.js +110 -6
- package/dist/ui/Dashboard.js +383 -124
- package/dist/ui/accountsApi.js +3 -1
- package/dist/utils/cli-routing.js +80 -0
- package/dist/utils/codex-config.js +337 -15
- package/package.json +3 -2
|
@@ -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
|
+
}
|
package/dist/proxy/logger.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
function ts() {
|
|
3
|
-
return new Date().toISOString().slice(
|
|
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
|
-
|
|
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
|
|
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
|
|
25
|
-
*
|
|
26
|
-
* (Codex thread id). Returns
|
|
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
|
|
30
|
-
if (
|
|
31
|
-
return
|
|
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;
|
package/dist/proxy/server.js
CHANGED
|
@@ -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"
|
|
506
|
-
|
|
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
|
}
|