@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.
- package/CHANGELOG.md +12 -0
- package/README.md +33 -4
- 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/anthropic-to-openai.js +31 -19
- package/dist/protocol/model-ref.js +1 -1
- package/dist/protocol/openai-function-call.js +16 -0
- package/dist/protocol/openai-response-to-anthropic.js +34 -6
- package/dist/protocol/openai-stream-to-anthropic.js +116 -16
- package/dist/protocol/openai-to-anthropic.js +54 -29
- 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/messages-cross-route.js +113 -8
- 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
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|