@timo972/cc-router 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +139 -0
- package/README.md +12 -87
- package/dist/cli/cmd-accounts.js +114 -11
- package/dist/cli/index.js +0 -0
- package/dist/protocol/openai-responses-collect.js +71 -0
- package/dist/providers/anthropic/usage-refresher.js +195 -0
- package/dist/providers/anthropic/usage.js +217 -0
- package/dist/proxy/account-add.js +30 -0
- package/dist/proxy/account-deletion.js +16 -0
- package/dist/proxy/anthropic-routing.js +31 -2
- package/dist/proxy/lease-lifecycle.js +182 -22
- package/dist/proxy/logger.js +3 -0
- package/dist/proxy/messages-cross-route.js +4 -1
- package/dist/proxy/request-model.js +17 -0
- package/dist/proxy/responses-server.js +43 -1
- package/dist/proxy/server.js +198 -24
- package/dist/proxy/session-router.js +12 -8
- package/dist/proxy/stats.js +11 -0
- package/dist/proxy/token-pool.js +379 -108
- package/dist/ui/Dashboard.js +90 -4
- package/dist/ui/accountsApi.js +136 -20
- package/package.json +12 -11
package/dist/proxy/server.js
CHANGED
|
@@ -9,7 +9,7 @@ import { checkForUpdate, performUpdate, restartSelf, printUpdateBanner } from ".
|
|
|
9
9
|
import { trackEvent, startHeartbeat } from "../utils/telemetry.js";
|
|
10
10
|
import { loadTelemetryState } from "../config/telemetry.js";
|
|
11
11
|
import { logRoute, logError, logStartup } from "./logger.js";
|
|
12
|
-
import { stats } from "./stats.js";
|
|
12
|
+
import { createLocalRoutingErrorLog, stats } from "./stats.js";
|
|
13
13
|
import { PROXY_PORT, LITELLM_URL } from "../config/paths.js";
|
|
14
14
|
import { writePid, removePid } from "../daemon/pid.js";
|
|
15
15
|
import { createOpenAIAccountPicker } from "../providers/openai/account-pool.js";
|
|
@@ -20,15 +20,19 @@ import { mountModelsRoute } from "./models-server.js";
|
|
|
20
20
|
import chalk from "chalk";
|
|
21
21
|
import { SessionRouter } from "./session-router.js";
|
|
22
22
|
import { createAnthropicProxy } from "./anthropic-proxy.js";
|
|
23
|
-
import {
|
|
23
|
+
import { AnthropicUsageRefresher } from "../providers/anthropic/usage-refresher.js";
|
|
24
|
+
import { canUseExtraUsage } from "../providers/anthropic/usage.js";
|
|
25
|
+
import { applyUpstreamFailureRoutingDetailed, reconcileAmbiguousRateLimitCooldown, routeFailureDetails, routeReasonDetails, } from "./lease-lifecycle.js";
|
|
24
26
|
import { persistProviderEnabledState } from "./provider-routing.js";
|
|
25
|
-
import { accountDeletionStatusCode, deleteAnthropicAccountTransaction, } from "./account-deletion.js";
|
|
27
|
+
import { accountDeletionStatusCode, deleteAnthropicAccountTransaction, deleteOpenAIAccountTransaction, } from "./account-deletion.js";
|
|
28
|
+
import { addOpenAIAccountTransaction } from "./account-add.js";
|
|
26
29
|
import { createAnthropicRefreshMiddleware, createAnthropicRoutingMiddleware, } from "./anthropic-routing.js";
|
|
27
30
|
import { createStreamLifecycleTracker } from "./stream-lifecycle.js";
|
|
28
31
|
const zeroRoutingMetrics = () => ({
|
|
29
32
|
inFlightRequests: 0,
|
|
30
33
|
activeSessions: 0,
|
|
31
34
|
coolingDown: false,
|
|
35
|
+
cooldownUntilMs: 0,
|
|
32
36
|
});
|
|
33
37
|
export function createOperationalStatus(opts) {
|
|
34
38
|
const anthropicAccounts = opts.accounts.filter(a => a.provider === "anthropic_subscription");
|
|
@@ -79,6 +83,9 @@ function publicAnthropicAccountView(a, metrics) {
|
|
|
79
83
|
weeklyLimitPercent: a.weeklyLimitPercent,
|
|
80
84
|
healthy: a.enabled !== false && a.healthy,
|
|
81
85
|
busy: a.busy || metrics.coolingDown,
|
|
86
|
+
cooldownUntilMs: metrics.cooldownUntilMs ?? 0,
|
|
87
|
+
globalCooldownUntilMs: metrics.globalCooldownUntilMs ?? 0,
|
|
88
|
+
modelCooldowns: publicModelCooldowns(metrics.modelCooldowns),
|
|
82
89
|
inFlightRequests: metrics.inFlightRequests,
|
|
83
90
|
activeSessions: metrics.activeSessions,
|
|
84
91
|
requestCount: a.requestCount,
|
|
@@ -86,9 +93,95 @@ function publicAnthropicAccountView(a, metrics) {
|
|
|
86
93
|
expiresInMs: a.tokens.expiresAt - Date.now(),
|
|
87
94
|
lastUsedMs: a.lastUsed,
|
|
88
95
|
lastRefreshMs: a.lastRefresh,
|
|
89
|
-
rateLimits: a.rateLimits,
|
|
96
|
+
rateLimits: publicRateLimits(a.rateLimits),
|
|
90
97
|
};
|
|
91
98
|
}
|
|
99
|
+
function publicRateLimits(rateLimits) {
|
|
100
|
+
return {
|
|
101
|
+
status: rateLimits.status,
|
|
102
|
+
fiveHourUtil: publicUtilization(rateLimits.fiveHourUtil),
|
|
103
|
+
fiveHourReset: publicTimestamp(rateLimits.fiveHourReset),
|
|
104
|
+
sevenDayUtil: publicUtilization(rateLimits.sevenDayUtil),
|
|
105
|
+
sevenDayReset: publicTimestamp(rateLimits.sevenDayReset),
|
|
106
|
+
claim: publicRepresentativeClaim(rateLimits.claim),
|
|
107
|
+
plan: publicPlan(rateLimits.plan),
|
|
108
|
+
requestsLimit: publicNonNegativeInteger(rateLimits.requestsLimit),
|
|
109
|
+
lastUpdated: publicTimestamp(rateLimits.lastUpdated),
|
|
110
|
+
...(rateLimits.usage ? { usage: publicUsageSnapshot(rateLimits.usage) } : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function publicUsageSnapshot(usage) {
|
|
114
|
+
return {
|
|
115
|
+
...(usage.fiveHour ? { fiveHour: publicWindow(usage.fiveHour) } : {}),
|
|
116
|
+
...(usage.sevenDay ? { sevenDay: publicWindow(usage.sevenDay) } : {}),
|
|
117
|
+
modelLimits: usage.modelLimits.slice(0, 12).map(limit => ({
|
|
118
|
+
modelFamily: publicModelFamily(limit.modelFamily),
|
|
119
|
+
displayName: publicDisplayName(limit.displayName),
|
|
120
|
+
utilization: publicUtilization(limit.utilization),
|
|
121
|
+
resetAt: publicTimestamp(limit.resetAt),
|
|
122
|
+
active: limit.active === true,
|
|
123
|
+
severity: publicSeverity(limit.severity),
|
|
124
|
+
})),
|
|
125
|
+
...(usage.extraUsage ? {
|
|
126
|
+
extraUsage: {
|
|
127
|
+
enabled: usage.extraUsage.enabled === true,
|
|
128
|
+
spendLimitReached: usage.extraUsage.spendLimitReached === true,
|
|
129
|
+
usable: usage.fetchStatus === "fresh" && canUseExtraUsage(usage.extraUsage),
|
|
130
|
+
},
|
|
131
|
+
} : {}),
|
|
132
|
+
fetchedAt: publicTimestamp(usage.fetchedAt),
|
|
133
|
+
fetchStatus: usage.fetchStatus,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
function publicWindow(window) {
|
|
137
|
+
return { utilization: publicUtilization(window.utilization), resetAt: publicTimestamp(window.resetAt) };
|
|
138
|
+
}
|
|
139
|
+
function publicModelCooldowns(cooldowns) {
|
|
140
|
+
return (cooldowns ?? []).slice(0, 12).map(cooldown => ({
|
|
141
|
+
modelFamily: publicModelFamily(cooldown.modelFamily),
|
|
142
|
+
untilMs: publicTimestamp(cooldown.untilMs),
|
|
143
|
+
})).filter(cooldown => cooldown.untilMs > 0);
|
|
144
|
+
}
|
|
145
|
+
function publicUtilization(value) {
|
|
146
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
|
|
147
|
+
}
|
|
148
|
+
function publicTimestamp(value) {
|
|
149
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
150
|
+
}
|
|
151
|
+
function publicNonNegativeInteger(value) {
|
|
152
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
153
|
+
}
|
|
154
|
+
function publicModelFamily(value) {
|
|
155
|
+
return typeof value === "string" && /^[a-z0-9-]{1,64}$/.test(value) ? value : "unknown";
|
|
156
|
+
}
|
|
157
|
+
function publicDisplayName(value) {
|
|
158
|
+
if (typeof value !== "string")
|
|
159
|
+
return "Unknown model";
|
|
160
|
+
const normalized = value.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 80);
|
|
161
|
+
return normalized || "Unknown model";
|
|
162
|
+
}
|
|
163
|
+
function publicSeverity(value) {
|
|
164
|
+
return value === "warning" || value === "critical" ? value : value ? "unknown" : "";
|
|
165
|
+
}
|
|
166
|
+
function publicPlan(value) {
|
|
167
|
+
return value === "Pro" || value === "Max 5x" || value === "Max 20x" ? value : "";
|
|
168
|
+
}
|
|
169
|
+
function publicRepresentativeClaim(claim) {
|
|
170
|
+
if (typeof claim !== "string")
|
|
171
|
+
return "unknown";
|
|
172
|
+
const normalized = claim.trim().toLowerCase();
|
|
173
|
+
if (!normalized)
|
|
174
|
+
return "";
|
|
175
|
+
if (normalized === "five_hour" ||
|
|
176
|
+
normalized === "seven_day" ||
|
|
177
|
+
normalized === "seven_day_oauth_apps" ||
|
|
178
|
+
normalized === "seven_day_overage_included")
|
|
179
|
+
return normalized;
|
|
180
|
+
if (normalized.startsWith("seven_day_") && normalized.length > "seven_day_".length) {
|
|
181
|
+
return "seven_day_model";
|
|
182
|
+
}
|
|
183
|
+
return "unknown";
|
|
184
|
+
}
|
|
92
185
|
function publicOpenAIAccountView(a) {
|
|
93
186
|
const expiresInMs = a.expiresAt - Date.now();
|
|
94
187
|
return {
|
|
@@ -157,6 +250,14 @@ function extractRateLimits(headers) {
|
|
|
157
250
|
lastUpdated: Date.now(),
|
|
158
251
|
};
|
|
159
252
|
}
|
|
253
|
+
/** Apply upstream rate-limit headers without discarding the usage snapshot. */
|
|
254
|
+
export function applyRateLimitHeaders(account, headers) {
|
|
255
|
+
const rateLimits = extractRateLimits(headers);
|
|
256
|
+
if (!rateLimits)
|
|
257
|
+
return false;
|
|
258
|
+
account.rateLimits = { ...account.rateLimits, ...rateLimits };
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
160
261
|
export async function startServer(opts = {}) {
|
|
161
262
|
const port = opts.port ?? PROXY_PORT;
|
|
162
263
|
// Direct-to-Anthropic (standalone) or via LiteLLM (full mode).
|
|
@@ -182,11 +283,17 @@ export async function startServer(opts = {}) {
|
|
|
182
283
|
const sessionRouter = new SessionRouter(pool);
|
|
183
284
|
const createRoutingMetricsResolver = () => {
|
|
184
285
|
const activeSessionCounts = sessionRouter.getActiveSessionCountsSnapshot();
|
|
185
|
-
return accountId =>
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
286
|
+
return accountId => {
|
|
287
|
+
const cooldowns = pool.getCooldownSummary(accountId);
|
|
288
|
+
return {
|
|
289
|
+
inFlightRequests: pool.getInFlight(accountId),
|
|
290
|
+
activeSessions: activeSessionCounts.get(accountId) ?? 0,
|
|
291
|
+
coolingDown: pool.isCoolingDown(accountId),
|
|
292
|
+
cooldownUntilMs: pool.getEarliestCooldownUntil(accountId),
|
|
293
|
+
globalCooldownUntilMs: cooldowns.globalUntilMs,
|
|
294
|
+
modelCooldowns: cooldowns.modelCooldowns,
|
|
295
|
+
};
|
|
296
|
+
};
|
|
190
297
|
};
|
|
191
298
|
const pickOpenAIAccount = createOpenAIAccountPicker(openAIAccounts);
|
|
192
299
|
const initialConfig = readConfig();
|
|
@@ -206,6 +313,8 @@ export async function startServer(opts = {}) {
|
|
|
206
313
|
};
|
|
207
314
|
startRefreshLoop(accounts);
|
|
208
315
|
startOpenAIRefreshLoop(openAIAccounts, saveOpenAIAccounts);
|
|
316
|
+
const usageRefresher = new AnthropicUsageRefresher(pool);
|
|
317
|
+
usageRefresher.start();
|
|
209
318
|
const app = express();
|
|
210
319
|
const proxyRequestTimeoutMs = getProxyRequestTimeoutMs();
|
|
211
320
|
// ─── Proxy auth middleware ─────────────────────────────────────────────────
|
|
@@ -440,10 +549,36 @@ export async function startServer(opts = {}) {
|
|
|
440
549
|
res.status(400).json({ error: "Invalid field types on account record" });
|
|
441
550
|
return;
|
|
442
551
|
}
|
|
443
|
-
|
|
552
|
+
// IDs are unique across providers, so a new account may not collide with an
|
|
553
|
+
// existing account in either the Claude pool or the OpenAI picker.
|
|
554
|
+
if (pool.findById(body.id) || openAIAccounts.some(a => a.id === body.id)) {
|
|
444
555
|
res.status(409).json({ error: `Account "${body.id}" already exists` });
|
|
445
556
|
return;
|
|
446
557
|
}
|
|
558
|
+
if (body.provider === "openai_subscription") {
|
|
559
|
+
let addedOpenAI;
|
|
560
|
+
try {
|
|
561
|
+
addedOpenAI = addOpenAIAccountTransaction({
|
|
562
|
+
record: {
|
|
563
|
+
id: body.id,
|
|
564
|
+
accessToken: body.accessToken,
|
|
565
|
+
refreshToken: body.refreshToken,
|
|
566
|
+
expiresAt: body.expiresAt,
|
|
567
|
+
enabled: body.enabled,
|
|
568
|
+
},
|
|
569
|
+
accounts: openAIAccounts,
|
|
570
|
+
persist: saveOpenAIAccounts,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
catch (err) {
|
|
574
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
575
|
+
logError("accounts", 0, `Failed to persist accounts.json: ${message}`);
|
|
576
|
+
res.status(500).json({ error: `Failed to persist accounts.json: ${message}` });
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
res.status(201).json({ account: publicOpenAIAccountView(addedOpenAI) });
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
447
582
|
const record = {
|
|
448
583
|
id: body.id,
|
|
449
584
|
accessToken: body.accessToken,
|
|
@@ -475,18 +610,41 @@ export async function startServer(opts = {}) {
|
|
|
475
610
|
});
|
|
476
611
|
accountsRouter.delete("/:id", async (req, res) => {
|
|
477
612
|
const { id } = req.params;
|
|
478
|
-
// Refuse to remove the last account — downstream /v1/* would have no
|
|
479
|
-
// token to route with and the pool would throw EmptyPoolError on the
|
|
480
|
-
// next request. Users who want an empty pool should `cc-router stop`.
|
|
481
|
-
if (pool.getAll().length <= 1) {
|
|
482
|
-
res.status(409).json({ error: "Cannot remove the last account — at least one must remain" });
|
|
483
|
-
return;
|
|
484
|
-
}
|
|
485
613
|
const existing = pool.findById(id);
|
|
486
|
-
|
|
614
|
+
const openAIExisting = openAIAccounts.find(account => account.id === id);
|
|
615
|
+
if (!existing && !openAIExisting) {
|
|
487
616
|
res.status(404).json({ error: `Account "${id}" not found` });
|
|
488
617
|
return;
|
|
489
618
|
}
|
|
619
|
+
if (openAIExisting && !existing) {
|
|
620
|
+
try {
|
|
621
|
+
deleteOpenAIAccountTransaction({
|
|
622
|
+
id,
|
|
623
|
+
accounts: openAIAccounts,
|
|
624
|
+
otherAccountCount: pool.getAll().length,
|
|
625
|
+
persist: saveOpenAIAccounts,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
catch (err) {
|
|
629
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
630
|
+
const status = accountDeletionStatusCode(err);
|
|
631
|
+
if (status === 409) {
|
|
632
|
+
res.status(409).json({ error: message });
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
logError("accounts", 0, `Failed to persist accounts.json: ${message}`);
|
|
636
|
+
res.status(500).json({ error: `Failed to persist accounts.json: ${message}` });
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
res.json({ ok: true, id });
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
// Preserve the existing Anthropic invariant: a running Anthropic pool
|
|
643
|
+
// always retains at least one account.
|
|
644
|
+
if (pool.getAll().length <= 1) {
|
|
645
|
+
res.status(409).json({ error: "Cannot remove the last account — at least one must remain" });
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
490
648
|
try {
|
|
491
649
|
await deleteAnthropicAccountTransaction({
|
|
492
650
|
id,
|
|
@@ -590,9 +748,10 @@ export async function startServer(opts = {}) {
|
|
|
590
748
|
pendingLog.statusCode = status;
|
|
591
749
|
if (durationMs !== undefined)
|
|
592
750
|
pendingLog.durationMs = durationMs;
|
|
593
|
-
const
|
|
594
|
-
?
|
|
751
|
+
const failureRouting = route
|
|
752
|
+
? applyUpstreamFailureRoutingDetailed(status, proxyRes.headers, route, sessionRouter, pool)
|
|
595
753
|
: undefined;
|
|
754
|
+
const cooldownSeconds = failureRouting?.cooldownSeconds;
|
|
596
755
|
if (status === 401) {
|
|
597
756
|
// Token invalid or expired mid-request.
|
|
598
757
|
// Forward the 401 to the client (Claude Code will retry on 401).
|
|
@@ -613,9 +772,19 @@ export async function startServer(opts = {}) {
|
|
|
613
772
|
const retryAfter = cooldownSeconds ?? 60;
|
|
614
773
|
pendingLog.type = "error";
|
|
615
774
|
pendingLog.details = route
|
|
616
|
-
? routeFailureDetails(route, "rate-limited")
|
|
775
|
+
? routeFailureDetails(route, "rate-limited", failureRouting?.limitingScope)
|
|
617
776
|
: "rate-limited";
|
|
618
777
|
logError(account.id, 429, `Rate limited — cooldown ${retryAfter}s`);
|
|
778
|
+
// Refresh in the background to narrow only ambiguity-owned global
|
|
779
|
+
// state when fresh usage proves a requested-model exhaustion. The
|
|
780
|
+
// current upstream response remains on the native proxy stream.
|
|
781
|
+
queueMicrotask(() => {
|
|
782
|
+
void usageRefresher.refreshAfterCurrent(account).then(result => {
|
|
783
|
+
if (result.ok && route) {
|
|
784
|
+
reconcileAmbiguousRateLimitCooldown(route, pool, failureRouting?.ambiguousCooldownToken);
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
});
|
|
619
788
|
}
|
|
620
789
|
else if (status === 529) {
|
|
621
790
|
// Anthropic service overloaded — short cooldown on this account.
|
|
@@ -628,9 +797,7 @@ export async function startServer(opts = {}) {
|
|
|
628
797
|
logError(account.id, 529, "Service overloaded — cooldown 30s");
|
|
629
798
|
}
|
|
630
799
|
// ── Capture rate limit utilization from response headers ────────────
|
|
631
|
-
|
|
632
|
-
if (rl)
|
|
633
|
-
account.rateLimits = rl;
|
|
800
|
+
applyRateLimitHeaders(account, proxyRes.headers);
|
|
634
801
|
const entry = pendingLog;
|
|
635
802
|
stats.addLog(entry);
|
|
636
803
|
// ── Capture token usage from Anthropic response body ─────────────────
|
|
@@ -737,6 +904,12 @@ export async function startServer(opts = {}) {
|
|
|
737
904
|
error: { type: "no_accounts", message: err.message },
|
|
738
905
|
});
|
|
739
906
|
},
|
|
907
|
+
onNoEligibleAccount: (err, req) => {
|
|
908
|
+
stats.totalErrors++;
|
|
909
|
+
const entry = createLocalRoutingErrorLog(err.reason, req._ccRouteContext?.modelFamily);
|
|
910
|
+
stats.addLog(entry);
|
|
911
|
+
logError(entry.accountId, entry.statusCode ?? 0, entry.details ?? "no-eligible");
|
|
912
|
+
},
|
|
740
913
|
}), createAnthropicRefreshMiddleware({
|
|
741
914
|
needsRefresh,
|
|
742
915
|
refresh: account => refreshAccountIfCurrent(account, pool),
|
|
@@ -775,6 +948,7 @@ export async function startServer(opts = {}) {
|
|
|
775
948
|
// ─── Graceful shutdown ────────────────────────────────────────────────────
|
|
776
949
|
const shutdown = () => {
|
|
777
950
|
console.log(chalk.yellow("\nShutting down — saving tokens..."));
|
|
951
|
+
usageRefresher.stop();
|
|
778
952
|
saveAccounts(pool.getAll());
|
|
779
953
|
if (process.env["CC_ROUTER_DAEMON"] === "1") {
|
|
780
954
|
removePid();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeModelFamily } from "../providers/anthropic/usage.js";
|
|
1
2
|
const DEFAULT_TTL_MS = 60 * 60 * 1000;
|
|
2
3
|
const DEFAULT_MAX_ENTRIES = 10_000;
|
|
3
4
|
const MAX_SESSION_ID_BYTES = 256;
|
|
@@ -41,25 +42,26 @@ export class SessionRouter {
|
|
|
41
42
|
throw new RangeError("session binding capacity must be a positive integer");
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
|
-
acquire(sessionHeader) {
|
|
45
|
+
acquire(sessionHeader, context) {
|
|
45
46
|
const sessionId = normalizeSessionId(sessionHeader);
|
|
47
|
+
const modelFamily = normalizeModelFamily(context?.modelFamily);
|
|
46
48
|
const now = this.now();
|
|
47
49
|
this.sweepExpiredBindings(now);
|
|
48
50
|
if (!sessionId) {
|
|
49
|
-
return this.wrapUnscoped(this.pool.acquireBest(this.activeSessionCounts));
|
|
51
|
+
return this.wrapUnscoped(this.pool.acquireBest(this.activeSessionCounts, context), modelFamily);
|
|
50
52
|
}
|
|
51
53
|
const existing = this.bindings.get(sessionId);
|
|
52
54
|
if (existing) {
|
|
53
|
-
const stickyLease = this.pool.tryAcquire(existing.accountId);
|
|
55
|
+
const stickyLease = this.pool.tryAcquire(existing.accountId, context);
|
|
54
56
|
if (stickyLease) {
|
|
55
57
|
existing.lastSeen = now;
|
|
56
|
-
return this.wrapScoped(stickyLease, "sticky", sessionId, existing.generation);
|
|
58
|
+
return this.wrapScoped(stickyLease, "sticky", sessionId, existing.generation, modelFamily);
|
|
57
59
|
}
|
|
58
60
|
this.removeBinding(sessionId);
|
|
59
61
|
}
|
|
60
|
-
const lease = this.pool.acquireBest(this.activeSessionCounts);
|
|
62
|
+
const lease = this.pool.acquireBest(this.activeSessionCounts, context);
|
|
61
63
|
const binding = this.insertBinding(sessionId, lease.account.id, now);
|
|
62
|
-
return this.wrapScoped(lease, existing ? "failover" : "new-session", sessionId, binding.generation);
|
|
64
|
+
return this.wrapScoped(lease, existing ? "failover" : "new-session", sessionId, binding.generation, modelFamily);
|
|
63
65
|
}
|
|
64
66
|
/**
|
|
65
67
|
* Remove a binding only if it still belongs to the expected account. This
|
|
@@ -103,15 +105,16 @@ export class SessionRouter {
|
|
|
103
105
|
this.sweepExpiredBindings(this.now());
|
|
104
106
|
return new Map(this.activeSessionCounts);
|
|
105
107
|
}
|
|
106
|
-
wrapUnscoped(lease) {
|
|
108
|
+
wrapUnscoped(lease, modelFamily) {
|
|
107
109
|
return {
|
|
108
110
|
account: lease.account,
|
|
109
111
|
fallback: lease.fallback,
|
|
110
112
|
release: lease.release,
|
|
111
113
|
reason: "unscoped",
|
|
114
|
+
...(modelFamily ? { modelFamily } : {}),
|
|
112
115
|
};
|
|
113
116
|
}
|
|
114
|
-
wrapScoped(lease, reason, sessionId, bindingGeneration) {
|
|
117
|
+
wrapScoped(lease, reason, sessionId, bindingGeneration, modelFamily) {
|
|
115
118
|
return {
|
|
116
119
|
account: lease.account,
|
|
117
120
|
fallback: lease.fallback,
|
|
@@ -119,6 +122,7 @@ export class SessionRouter {
|
|
|
119
122
|
reason,
|
|
120
123
|
sessionId,
|
|
121
124
|
bindingGeneration,
|
|
125
|
+
...(modelFamily ? { modelFamily } : {}),
|
|
122
126
|
};
|
|
123
127
|
}
|
|
124
128
|
insertBinding(sessionId, accountId, lastSeen) {
|
package/dist/proxy/stats.js
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/** Build a bounded diagnostic for a request rejected before account selection. */
|
|
2
|
+
export function createLocalRoutingErrorLog(reason, modelFamily, now = Date.now()) {
|
|
3
|
+
return {
|
|
4
|
+
ts: now,
|
|
5
|
+
accountId: "proxy",
|
|
6
|
+
model: modelFamily ?? "-",
|
|
7
|
+
type: "error",
|
|
8
|
+
details: `no-eligible:${reason.replace("_", "-")}`,
|
|
9
|
+
statusCode: reason === "rate_limited" ? 429 : 503,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
1
12
|
const MAX_LOG_ENTRIES = 100;
|
|
2
13
|
class ProxyStats {
|
|
3
14
|
totalRequests = 0;
|