saros-proxy 0.10.2 → 1.0.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/README.md +261 -78
- package/dist/cli/daemon.d.ts +38 -6
- package/dist/cli/daemon.js +524 -109
- package/dist/cli/daemon.js.map +1 -1
- package/dist/cli/dsh-config.js +34 -9
- package/dist/cli/dsh-config.js.map +1 -1
- package/dist/cli/help.js +3 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/key-state.d.ts +21 -0
- package/dist/cli/key-state.js +57 -0
- package/dist/cli/key-state.js.map +1 -0
- package/dist/cli/keys.d.ts +28 -0
- package/dist/cli/keys.js +185 -0
- package/dist/cli/keys.js.map +1 -0
- package/dist/cli/overview.js +6 -1
- package/dist/cli/overview.js.map +1 -1
- package/dist/cli/secret.d.ts +31 -0
- package/dist/cli/secret.js +89 -0
- package/dist/cli/secret.js.map +1 -0
- package/dist/cli/usage.js +20 -10
- package/dist/cli/usage.js.map +1 -1
- package/dist/config.d.ts +15 -1
- package/dist/config.js +98 -25
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +2 -0
- package/dist/constants.js.map +1 -1
- package/dist/debug-buffer.d.ts +58 -0
- package/dist/debug-buffer.js +42 -0
- package/dist/debug-buffer.js.map +1 -0
- package/dist/index.js +30 -4
- package/dist/index.js.map +1 -1
- package/dist/log-redact.d.ts +18 -0
- package/dist/log-redact.js +41 -0
- package/dist/log-redact.js.map +1 -0
- package/dist/logger.d.ts +6 -0
- package/dist/logger.js +30 -0
- package/dist/logger.js.map +1 -1
- package/dist/models-fetcher.js +2 -0
- package/dist/models-fetcher.js.map +1 -1
- package/dist/providers/commandcode.d.ts +20 -5
- package/dist/providers/commandcode.js +115 -7
- package/dist/providers/commandcode.js.map +1 -1
- package/dist/providers/index.d.ts +2 -2
- package/dist/providers/index.js +2 -2
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/infer.d.ts +11 -0
- package/dist/providers/infer.js.map +1 -1
- package/dist/providers/opencode-go.d.ts +8 -0
- package/dist/providers/opencode-go.js +35 -7
- package/dist/providers/opencode-go.js.map +1 -1
- package/dist/providers/types.d.ts +10 -2
- package/dist/proxy-logic.d.ts +101 -7
- package/dist/proxy-logic.js +197 -41
- package/dist/proxy-logic.js.map +1 -1
- package/dist/proxy.d.ts +33 -1
- package/dist/proxy.js +369 -67
- package/dist/proxy.js.map +1 -1
- package/dist/secrets/boot.d.ts +33 -0
- package/dist/secrets/boot.js +65 -0
- package/dist/secrets/boot.js.map +1 -0
- package/dist/secrets/envelope.d.ts +44 -0
- package/dist/secrets/envelope.js +132 -0
- package/dist/secrets/envelope.js.map +1 -0
- package/dist/secrets/source.d.ts +130 -0
- package/dist/secrets/source.js +332 -0
- package/dist/secrets/source.js.map +1 -0
- package/dist/token-usage.d.ts +9 -2
- package/dist/token-usage.js +20 -4
- package/dist/token-usage.js.map +1 -1
- package/dist/usage-client.d.ts +43 -13
- package/dist/usage-client.js +37 -54
- package/dist/usage-client.js.map +1 -1
- package/dist/validation.d.ts +3 -3
- package/dist/validation.js +3 -3
- package/dist/validation.js.map +1 -1
- package/package.json +1 -1
package/dist/proxy.js
CHANGED
|
@@ -9,16 +9,18 @@ import { cors } from 'hono/cors';
|
|
|
9
9
|
import { rateLimiter } from 'hono-rate-limiter';
|
|
10
10
|
import crypto from 'node:crypto';
|
|
11
11
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
12
|
-
import { homedir } from 'node:os';
|
|
13
12
|
import { join } from 'node:path';
|
|
14
|
-
import { createProxyState, selectKeyForRequest, failoverRequest, completeRequest, markKeyFailed, markKeySucceeded, classifyHttpError, } from './proxy-logic.js';
|
|
13
|
+
import { createProxyState, selectKeyForRequest, failoverRequest, completeRequest, markKeyFailed, markKeySucceeded, classifyHttpError, describeKeys, } from './proxy-logic.js';
|
|
14
|
+
import { getSarosDir } from './config.js';
|
|
15
15
|
import { logger, maskKey } from './logger.js';
|
|
16
|
+
import { redactSecrets } from './log-redact.js';
|
|
16
17
|
import { MAX_BODY_SIZE, MAX_RETRIES, RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX, } from './constants.js';
|
|
17
18
|
import { getModelsList } from './models-fetcher.js';
|
|
18
19
|
import { maybeRefreshUsage } from './usage-client.js';
|
|
19
20
|
import { recordModelRequest } from './model-stats.js';
|
|
20
21
|
import { recordKeyTokens } from './token-usage.js';
|
|
21
|
-
import { getProvider
|
|
22
|
+
import { getProvider } from './providers/index.js';
|
|
23
|
+
import { createRecentRequestBuffer, isDebugBufferEnabled, } from './debug-buffer.js';
|
|
22
24
|
/** Canonical client-facing chat route prefix (the opencode-go shape). */
|
|
23
25
|
const CANONICAL_CHAT_BASE = '/zen/go/v1';
|
|
24
26
|
/**
|
|
@@ -58,11 +60,18 @@ export function stripProviderSuffix(modelId) {
|
|
|
58
60
|
function generateRequestId() {
|
|
59
61
|
return crypto.randomUUID();
|
|
60
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Relative folder name for `SAROS_CAPTURE_4XX` diagnostics. Literal: no
|
|
65
|
+
* request, response or header value can reach it.
|
|
66
|
+
*/
|
|
67
|
+
const CAPTURE_DIR_NAME = 'captures';
|
|
68
|
+
/** Disambiguates captures that land in the same millisecond. */
|
|
69
|
+
let captureSequence = 0;
|
|
61
70
|
/**
|
|
62
71
|
* Opt-in diagnostics: when SAROS_CAPTURE_4XX=1, write the full request
|
|
63
|
-
* context of every upstream 4xx to <config dir>/captures/ so
|
|
64
|
-
* errors (role validation, plan gating) can be replayed and
|
|
65
|
-
* Never enabled by default — bodies may contain user content.
|
|
72
|
+
* context of every upstream 4xx to <saros config dir>/captures/ so
|
|
73
|
+
* provider-side errors (role validation, plan gating) can be replayed and
|
|
74
|
+
* debugged. Never enabled by default — bodies may contain user content.
|
|
66
75
|
*/
|
|
67
76
|
function captureFailedRequest(ctx, status, errorBody) {
|
|
68
77
|
if (process.env.SAROS_CAPTURE_4XX !== '1')
|
|
@@ -70,10 +79,16 @@ function captureFailedRequest(ctx, status, errorBody) {
|
|
|
70
79
|
if (status < 400 || status >= 500)
|
|
71
80
|
return;
|
|
72
81
|
try {
|
|
73
|
-
|
|
82
|
+
// Path = saros config dir + literal folder name. Nothing network-derived
|
|
83
|
+
// reaches this expression, so a capture stays under the user's config dir.
|
|
84
|
+
const dir = join(getSarosDir(), CAPTURE_DIR_NAME);
|
|
74
85
|
mkdirSync(dir, { recursive: true });
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
// Filename = timestamp + monotonic sequence. The request id is kept in
|
|
87
|
+
// the JSON payload instead, so no request-derived byte ever selects the
|
|
88
|
+
// path, and `wx` refuses a pre-created file.
|
|
89
|
+
captureSequence += 1;
|
|
90
|
+
const file = join(dir, `${Date.now()}-${captureSequence}.json`);
|
|
91
|
+
const payload = JSON.stringify({
|
|
77
92
|
ts: new Date().toISOString(),
|
|
78
93
|
requestId: ctx.requestId,
|
|
79
94
|
status,
|
|
@@ -83,7 +98,13 @@ function captureFailedRequest(ctx, status, errorBody) {
|
|
|
83
98
|
kind: ctx.kind,
|
|
84
99
|
upstreamError: errorBody.slice(0, 2000),
|
|
85
100
|
requestBody: ctx.bodyText.slice(0, 100_000),
|
|
86
|
-
}, null, 2)
|
|
101
|
+
}, null, 2);
|
|
102
|
+
// Opt-in diagnostic (SAROS_CAPTURE_4XX=1), not a general sink: the target
|
|
103
|
+
// is always <saros config dir>/captures/<timestamp>-<counter>.json, so no
|
|
104
|
+
// network-derived value selects the path. The file is created with mode
|
|
105
|
+
// 0600 and flag 'wx', which fails on a pre-existing file instead of
|
|
106
|
+
// following or overwriting it.
|
|
107
|
+
writeFileSync(file, payload, { encoding: 'utf-8', mode: 0o600, flag: 'wx' });
|
|
87
108
|
logger.warn({ requestId: ctx.requestId, file }, 'captured failing request (SAROS_CAPTURE_4XX)');
|
|
88
109
|
}
|
|
89
110
|
catch {
|
|
@@ -166,18 +187,21 @@ export function prepareOutboundRequest(bodyText, modelId, provider, options) {
|
|
|
166
187
|
}
|
|
167
188
|
/**
|
|
168
189
|
* Provider for a key snapshot. Suffix-routed requests (@provider model ids)
|
|
169
|
-
* pin the provider; otherwise
|
|
190
|
+
* pin the provider; otherwise the key's configured provider decides.
|
|
191
|
+
*
|
|
192
|
+
* The credential prefix is deliberately not re-inferred here: `key.provider`
|
|
193
|
+
* is normalized from config (an explicit `provider:` wins over the prefix) and
|
|
194
|
+
* it is what selection filters on. Routing on a different signal sent a key
|
|
195
|
+
* declared `provider: commandcode` through the OpenCode-Go URL.
|
|
170
196
|
*/
|
|
171
|
-
function providerForKey(key,
|
|
197
|
+
function providerForKey(key, modelId) {
|
|
172
198
|
const suffixProvider = parseProviderSuffix(modelId);
|
|
173
199
|
if (suffixProvider) {
|
|
174
200
|
const pinned = getProvider(suffixProvider);
|
|
175
201
|
if (pinned)
|
|
176
202
|
return pinned;
|
|
177
203
|
}
|
|
178
|
-
|
|
179
|
-
void config;
|
|
180
|
-
return getProvider(inferred) ?? getProvider('opencode-go');
|
|
204
|
+
return getProvider(key.provider) ?? getProvider('opencode-go');
|
|
181
205
|
}
|
|
182
206
|
// ---------------------------------------------------------------------------
|
|
183
207
|
// Helpers
|
|
@@ -335,6 +359,9 @@ async function executeWithRetry(opts) {
|
|
|
335
359
|
kind: opts.kind,
|
|
336
360
|
modelId: opts.modelId,
|
|
337
361
|
onSuccess: opts.onSuccess,
|
|
362
|
+
startedAt: Date.now(),
|
|
363
|
+
attempt: 0,
|
|
364
|
+
recentRequests: opts.recentRequests,
|
|
338
365
|
};
|
|
339
366
|
let lastError = null;
|
|
340
367
|
for (let attempt = 0; attempt < opts.maxRetries; attempt++) {
|
|
@@ -346,15 +373,25 @@ async function executeWithRetry(opts) {
|
|
|
346
373
|
return handleAllRetriesFailed(ctx, opts.maxRetries, lastError);
|
|
347
374
|
}
|
|
348
375
|
async function executeSingleAttempt(attempt, ctx) {
|
|
376
|
+
ctx.attempt = attempt;
|
|
349
377
|
const key = pickKey(ctx, attempt);
|
|
350
378
|
if (!key) {
|
|
351
|
-
return { kind: 'done', response: buildNoKeysResponse(ctx
|
|
379
|
+
return { kind: 'done', response: buildNoKeysResponse(ctx, attempt) };
|
|
352
380
|
}
|
|
353
381
|
logKeyAttempt(ctx.requestId, key, attempt, ctx.kind);
|
|
354
382
|
// Per-attempt upstream: keys may be mixed-provider within one failover chain.
|
|
355
383
|
// Suffix-routed models (@provider) pin the provider and lose the suffix
|
|
356
384
|
// in the forwarded body; the upstream never sees saros's routing shim.
|
|
357
|
-
const provider = providerForKey(key, ctx.
|
|
385
|
+
const provider = providerForKey(key, ctx.modelId);
|
|
386
|
+
ctx.lastProvider = provider.id;
|
|
387
|
+
logger.debug({
|
|
388
|
+
evt: 'key.selected',
|
|
389
|
+
requestId: ctx.requestId,
|
|
390
|
+
keyLabel: key.label,
|
|
391
|
+
provider: provider.id,
|
|
392
|
+
attempt,
|
|
393
|
+
pinned: Boolean(parseProviderSuffix(ctx.modelId)),
|
|
394
|
+
}, 'key selected');
|
|
358
395
|
const upstreamUrl = resolveUpstreamUrl(ctx.config, provider, ctx.path);
|
|
359
396
|
const sessionHeader = provider.sessionHeader;
|
|
360
397
|
const prepared = ctx.bodyText
|
|
@@ -376,17 +413,30 @@ async function executeSingleAttempt(attempt, ctx) {
|
|
|
376
413
|
provider,
|
|
377
414
|
sessionId: prepared.sessionId,
|
|
378
415
|
});
|
|
416
|
+
const attemptStarted = Date.now();
|
|
379
417
|
try {
|
|
380
418
|
const response = await fetchWithTimeout(upstreamUrl, fetchOptions, ctx.config.requestTimeoutMs);
|
|
381
419
|
if (response.ok) {
|
|
382
|
-
|
|
420
|
+
logger.info({
|
|
421
|
+
evt: 'upstream.response',
|
|
422
|
+
requestId: ctx.requestId,
|
|
423
|
+
keyLabel: key.label,
|
|
424
|
+
provider: provider.id,
|
|
425
|
+
status: response.status,
|
|
426
|
+
durationMs: Date.now() - attemptStarted,
|
|
427
|
+
stream: ctx.kind === 'streaming',
|
|
428
|
+
}, 'upstream response');
|
|
429
|
+
recordKeySuccess(ctx.state, key.label, ctx.requestId);
|
|
383
430
|
completeRequest(ctx.state, ctx.requestId, true);
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
431
|
+
const downstream = await ctx.onSuccess(response, ctx.state, ctx.requestId, key.label, provider.id);
|
|
432
|
+
// Logged after the success handler: if it throws, the request retries and
|
|
433
|
+
// this is not a completed request (the retry path logs its own outcome).
|
|
434
|
+
// The downstream status is what the client saw — the streaming handler
|
|
435
|
+
// synthesizes a 502 when the upstream 2xx carried no body.
|
|
436
|
+
logRequestComplete(ctx, downstream.status, key.label);
|
|
437
|
+
return { kind: 'done', response: downstream };
|
|
388
438
|
}
|
|
389
|
-
return await handleUpstreamErrorResponse(response, ctx, key);
|
|
439
|
+
return await handleUpstreamErrorResponse(response, ctx, key, attemptStarted);
|
|
390
440
|
}
|
|
391
441
|
catch (err) {
|
|
392
442
|
return handleNetworkError(err, ctx, key);
|
|
@@ -422,20 +472,24 @@ function buildFetchOptions(opts) {
|
|
|
422
472
|
options.duplex = 'half';
|
|
423
473
|
return options;
|
|
424
474
|
}
|
|
425
|
-
async function handleUpstreamErrorResponse(response, ctx, key) {
|
|
475
|
+
async function handleUpstreamErrorResponse(response, ctx, key, attemptStarted) {
|
|
426
476
|
const errorBody = await response.text();
|
|
427
477
|
const errorType = classifyHttpError(response.status, errorBody);
|
|
428
|
-
logUpstreamError(ctx.requestId, key, response.status, errorType, errorBody, ctx.kind);
|
|
478
|
+
logUpstreamError(ctx.requestId, key, response.status, errorType, errorBody, ctx.kind, Date.now() - attemptStarted);
|
|
429
479
|
captureFailedRequest(ctx, response.status, errorBody);
|
|
430
480
|
if (errorType === 'RequestFault') {
|
|
431
481
|
// Client error — don't retry, don't penalise the key
|
|
432
482
|
completeRequest(ctx.state, ctx.requestId, false);
|
|
483
|
+
logRequestComplete(ctx, response.status, key.label);
|
|
433
484
|
return { kind: 'done', response: buildClientErrorResponse(response.status, errorBody, ctx.requestId) };
|
|
434
485
|
}
|
|
435
486
|
// KeyFault or ServerFault — penalise the key and retry
|
|
436
|
-
|
|
487
|
+
recordKeyFailure(ctx.state, key.label, errorType, ctx.requestId, {
|
|
488
|
+
status: response.status,
|
|
489
|
+
body: errorBody,
|
|
490
|
+
});
|
|
437
491
|
const lastError = ctx.kind === 'standard'
|
|
438
|
-
? new Error(`Upstream error ${response.status}: ${errorBody.slice(0, 200)}`)
|
|
492
|
+
? new Error(`Upstream error ${response.status}: ${redactSecrets(errorBody).slice(0, 200)}`)
|
|
439
493
|
: null;
|
|
440
494
|
return { kind: 'retry', lastError };
|
|
441
495
|
}
|
|
@@ -453,12 +507,117 @@ function handleNetworkError(err, ctx, key) {
|
|
|
453
507
|
markKeyFailed(ctx.state, key.label, 'NetworkFault');
|
|
454
508
|
return { kind: 'retry', lastError };
|
|
455
509
|
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
510
|
+
/** Human explanation for each reason a key can be out of rotation. */
|
|
511
|
+
const FAILURE_CAUSES = {
|
|
512
|
+
auth: 'a key was rejected upstream (401): revoked or rotated, or it does not serve this model',
|
|
513
|
+
quota: 'the account is out of credit or quota for this provider',
|
|
514
|
+
rate_limit: 'the upstream rate-limited the key (429) — it retries after the cooldown',
|
|
515
|
+
server: 'the upstream returned 5xx or broke the stream',
|
|
516
|
+
};
|
|
517
|
+
/** Why a key cannot serve a request, in the 503/502 vocabulary. */
|
|
518
|
+
function unavailableState(report) {
|
|
519
|
+
if (report.disabledByConfig)
|
|
520
|
+
return 'disabled_by_config';
|
|
521
|
+
if (!report.entitled)
|
|
522
|
+
return 'not_entitled';
|
|
523
|
+
return 'disabled';
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Keys that cannot serve a request right now, with the reason and the upstream
|
|
527
|
+
* message. Labels are already public; the credential is never included.
|
|
528
|
+
* `enabled=false` and `consecutiveFailures >= threshold` are the same state —
|
|
529
|
+
* the circuit breaker flips `enabled` when the counter reaches it.
|
|
530
|
+
*/
|
|
531
|
+
function unavailableKeyReports(state, onlyLabels) {
|
|
532
|
+
const now = Date.now();
|
|
533
|
+
return describeKeys(state, now)
|
|
534
|
+
.filter((report) => !report.enabled || !report.entitled)
|
|
535
|
+
.filter((report) => !onlyLabels || onlyLabels.includes(report.label))
|
|
536
|
+
.map((report) => {
|
|
537
|
+
const lastError = state.keys.find((k) => k.label === report.label)?.lastError ?? null;
|
|
538
|
+
return {
|
|
539
|
+
label: report.label,
|
|
540
|
+
provider: report.provider,
|
|
541
|
+
state: unavailableState(report),
|
|
542
|
+
reason: report.lastError?.reason ?? null,
|
|
543
|
+
lastStatus: report.lastError?.status ?? null,
|
|
544
|
+
disabledAt: report.disabledAt,
|
|
545
|
+
retryAfterMs: report.retryAfterMs,
|
|
546
|
+
detail: lastError?.detail ?? null,
|
|
547
|
+
};
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Shared diagnosis for "no key can serve this": which keys are out, why, when
|
|
552
|
+
* one may return. Pure so it can be tested without an app instance.
|
|
553
|
+
*/
|
|
554
|
+
export function buildKeyDiagnosis(keys, modelId, triedLabels = []) {
|
|
555
|
+
const retryTimes = keys
|
|
556
|
+
.map((k) => k.retryAfterMs)
|
|
557
|
+
.filter((ms) => typeof ms === 'number' && ms > 0);
|
|
558
|
+
const retryAfterMs = retryTimes.length > 0 ? Math.min(...retryTimes) : null;
|
|
559
|
+
const causes = new Set();
|
|
560
|
+
for (const key of keys) {
|
|
561
|
+
if (key.reason)
|
|
562
|
+
causes.add(FAILURE_CAUSES[key.reason]);
|
|
563
|
+
}
|
|
564
|
+
if (keys.some((k) => k.state === 'not_entitled')) {
|
|
565
|
+
causes.add('the account has no plan for this provider (403 entitlement)');
|
|
566
|
+
}
|
|
567
|
+
if (keys.some((k) => k.state === 'disabled_by_config')) {
|
|
568
|
+
causes.add('a key is disabled in config (enabled: false) — re-enable it to put it back in rotation');
|
|
569
|
+
}
|
|
570
|
+
const pinned = modelId?.includes('@') ? modelId.slice(modelId.lastIndexOf('@') + 1) : null;
|
|
571
|
+
if (pinned) {
|
|
572
|
+
causes.add(`the request is pinned to @${pinned} and none of its keys are usable`);
|
|
573
|
+
}
|
|
574
|
+
if (triedLabels.length > 0) {
|
|
575
|
+
// No key was selectable, so every key this request touched failed — say
|
|
576
|
+
// which ones. Gating this on an empty keys[] hid the case where an
|
|
577
|
+
// unrelated retired key was listed while the tried keys were not.
|
|
578
|
+
causes.add(`keys tried for this request: ${triedLabels.join(', ')} — the upstream kept answering with a retryable error`);
|
|
579
|
+
}
|
|
580
|
+
return { retryAfterMs, keys, causes: [...causes] };
|
|
581
|
+
}
|
|
582
|
+
/** Build the 503 body sent when no key can be selected at all. */
|
|
583
|
+
export function buildNoKeysBody(keys, requestId, modelId, triedLabels = []) {
|
|
584
|
+
const diagnosis = buildKeyDiagnosis(keys, modelId, triedLabels);
|
|
585
|
+
const pinned = modelId?.includes('@') ? modelId.slice(modelId.lastIndexOf('@') + 1) : null;
|
|
586
|
+
const retryText = diagnosis.retryAfterMs === null ? '' : `; retry in ${Math.ceil(diagnosis.retryAfterMs / 1000)}s`;
|
|
587
|
+
return {
|
|
588
|
+
// Kept for clients that match on the legacy message.
|
|
589
|
+
error: 'All API keys are temporarily unavailable',
|
|
590
|
+
message: pinned
|
|
591
|
+
? `No usable key for provider ${pinned}${retryText}.`
|
|
592
|
+
: `No usable API key${retryText}.`,
|
|
593
|
+
requestId,
|
|
594
|
+
...diagnosis,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
function buildNoKeysResponse(ctx, attempt) {
|
|
598
|
+
const { state, requestId, kind, modelId } = ctx;
|
|
599
|
+
const triedLabels = [...(state.activeRequests.get(requestId)?.triedKeys ?? [])];
|
|
600
|
+
// A 503 raised between attempts still follows keys that failed: the body
|
|
601
|
+
// names them, so the access entry must too. Null stays reserved for the
|
|
602
|
+
// attempt-0 case, when no key was ever selected.
|
|
603
|
+
const lastTried = triedLabels.at(-1) ?? null;
|
|
604
|
+
const keys = unavailableKeyReports(state);
|
|
605
|
+
const body = buildNoKeysBody(keys, requestId, modelId, triedLabels);
|
|
606
|
+
logger.warn({
|
|
607
|
+
evt: 'request.no_keys',
|
|
608
|
+
requestId,
|
|
609
|
+
attempt,
|
|
610
|
+
kind,
|
|
611
|
+
modelId: modelId ?? null,
|
|
612
|
+
retryAfterMs: body.retryAfterMs,
|
|
613
|
+
unavailable: keys.map((k) => ({ label: k.label, reason: k.reason, status: k.lastStatus })),
|
|
614
|
+
}, 'No API key available for this request');
|
|
460
615
|
completeRequest(state, requestId, false);
|
|
461
|
-
|
|
616
|
+
logRequestComplete(ctx, 503, lastTried);
|
|
617
|
+
return new Response(JSON.stringify(body), {
|
|
618
|
+
status: 503,
|
|
619
|
+
headers: { 'content-type': 'application/json' },
|
|
620
|
+
});
|
|
462
621
|
}
|
|
463
622
|
function buildClientErrorResponse(status, errorBody, requestId) {
|
|
464
623
|
// Sanitize upstream error - don't leak internal details
|
|
@@ -477,43 +636,140 @@ function buildClientErrorResponse(status, errorBody, requestId) {
|
|
|
477
636
|
});
|
|
478
637
|
}
|
|
479
638
|
function handleAllRetriesFailed(ctx, maxRetries, lastError) {
|
|
639
|
+
// Read before completeRequest deletes the context. The last attempt never
|
|
640
|
+
// goes through failoverRequest (the loop ends), so its key is only in
|
|
641
|
+
// currentKey — omitting it hides the key that failed last.
|
|
642
|
+
const active = ctx.state.activeRequests.get(ctx.requestId);
|
|
643
|
+
const lastKeyLabel = active?.currentKey?.label ?? null;
|
|
644
|
+
const triedLabels = [
|
|
645
|
+
...new Set([...(active?.triedKeys ?? []), ...(active?.currentKey ? [active.currentKey.label] : [])]),
|
|
646
|
+
];
|
|
480
647
|
completeRequest(ctx.state, ctx.requestId, false);
|
|
481
|
-
logger.error({ requestId: ctx.requestId, err: lastError, kind: ctx.kind }, ctx.kind === 'standard'
|
|
648
|
+
logger.error({ evt: 'request.retries_exhausted', requestId: ctx.requestId, err: lastError, kind: ctx.kind }, ctx.kind === 'standard'
|
|
482
649
|
? 'All %d retries exhausted, returning 502'
|
|
483
650
|
: 'Streaming failed after %d attempts', maxRetries);
|
|
651
|
+
logRequestComplete(ctx, 502, lastKeyLabel);
|
|
484
652
|
const message = ctx.kind === 'standard'
|
|
485
653
|
? 'Upstream service unavailable after multiple attempts'
|
|
486
654
|
: 'Streaming service unavailable after multiple attempts';
|
|
487
|
-
|
|
655
|
+
// Status stays 502 (the upstream did answer with errors), but when every key
|
|
656
|
+
// is now out of rotation the body says which and why — otherwise the caller
|
|
657
|
+
// sees "unavailable" and has no way to tell a revoked key from a 429.
|
|
658
|
+
// Scoped to the keys this request tried: an unrelated key retired in config
|
|
659
|
+
// must not be blamed for the failure.
|
|
660
|
+
const unavailable = unavailableKeyReports(ctx.state, triedLabels);
|
|
661
|
+
// Always present: an empty keys[] with a cause is the honest answer when the
|
|
662
|
+
// pool is healthy but every key was tried.
|
|
663
|
+
const diagnosis = buildKeyDiagnosis(unavailable, ctx.modelId, triedLabels);
|
|
664
|
+
return new Response(JSON.stringify({
|
|
665
|
+
error: 'Bad Gateway',
|
|
666
|
+
message,
|
|
667
|
+
requestId: ctx.requestId,
|
|
668
|
+
...diagnosis,
|
|
669
|
+
}), { status: 502, headers: { 'content-type': 'application/json' } });
|
|
488
670
|
}
|
|
489
671
|
// --- Logging helpers (preserve original log message format) ---
|
|
672
|
+
/**
|
|
673
|
+
* One line per finished request — the access log. Exactly one per request:
|
|
674
|
+
* success, upstream client error, no usable key (503) and retries exhausted
|
|
675
|
+
* (502) are mutually exclusive paths.
|
|
676
|
+
*
|
|
677
|
+
* The same entry is pushed to the in-memory buffer served by `GET /debug/recent`
|
|
678
|
+
* (when enabled), so the two views cannot drift. `keyLabel` is the key that
|
|
679
|
+
* served the request, or the last key tried when the proxy answers 502 or a 503
|
|
680
|
+
* raised after keys were tried; null only when no key was ever selected.
|
|
681
|
+
*/
|
|
682
|
+
function logRequestComplete(ctx, status, keyLabel) {
|
|
683
|
+
const entry = {
|
|
684
|
+
requestId: ctx.requestId,
|
|
685
|
+
at: new Date().toISOString(),
|
|
686
|
+
method: ctx.method,
|
|
687
|
+
path: ctx.path,
|
|
688
|
+
model: ctx.modelId ?? null,
|
|
689
|
+
provider: ctx.lastProvider ?? null,
|
|
690
|
+
keyLabel,
|
|
691
|
+
status,
|
|
692
|
+
durationMs: Date.now() - ctx.startedAt,
|
|
693
|
+
stream: ctx.kind === 'streaming',
|
|
694
|
+
attempt: ctx.attempt + 1,
|
|
695
|
+
};
|
|
696
|
+
logger.info({ evt: 'request.complete', ...entry }, 'request complete');
|
|
697
|
+
ctx.recentRequests?.push(entry);
|
|
698
|
+
}
|
|
490
699
|
function logKeyAttempt(requestId, key, attempt, kind) {
|
|
491
700
|
logger.info({ requestId, keyLabel: key.label, attempt, kind }, kind === 'standard'
|
|
492
701
|
? 'Forwarding request to upstream using key %s'
|
|
493
702
|
: 'Streaming request with key %s', maskKey(key.key));
|
|
494
703
|
}
|
|
495
|
-
|
|
704
|
+
/**
|
|
705
|
+
* Record a key failure and log the transition. The line is the `key.state`
|
|
706
|
+
* event: reason + upstream status are what make a flapping key visible without
|
|
707
|
+
* reading the source.
|
|
708
|
+
*/
|
|
709
|
+
function recordKeyFailure(state, keyLabel, errorType, requestId, info) {
|
|
710
|
+
markKeyFailed(state, keyLabel, errorType, info);
|
|
711
|
+
const key = state.keys.find((k) => k.label === keyLabel);
|
|
712
|
+
if (!key)
|
|
713
|
+
return;
|
|
714
|
+
// Below the threshold the key keeps serving — nothing changed, no line.
|
|
715
|
+
if (key.enabled && key.consecutiveFailures < state.circuitBreakerThreshold)
|
|
716
|
+
return;
|
|
717
|
+
logger.warn({
|
|
718
|
+
evt: 'key.state',
|
|
719
|
+
requestId,
|
|
720
|
+
keyLabel,
|
|
721
|
+
provider: key.provider,
|
|
722
|
+
enabled: key.enabled,
|
|
723
|
+
reason: key.lastError?.reason,
|
|
724
|
+
status: key.lastError?.status,
|
|
725
|
+
consecutiveFailures: key.consecutiveFailures,
|
|
726
|
+
cooldownMs: state.circuitBreakerCooldownMs,
|
|
727
|
+
}, 'Key left rotation');
|
|
728
|
+
}
|
|
729
|
+
/** Mark a key as succeeded and log when it returns from a disable. */
|
|
730
|
+
function recordKeySuccess(state, keyLabel, requestId) {
|
|
731
|
+
const before = state.keys.find((k) => k.label === keyLabel);
|
|
732
|
+
if (!before)
|
|
733
|
+
return;
|
|
734
|
+
const wasOut = !before.enabled || before.disabledAt !== null;
|
|
735
|
+
markKeySucceeded(state, keyLabel);
|
|
736
|
+
if (!wasOut)
|
|
737
|
+
return;
|
|
738
|
+
logger.info({ evt: 'key.state', requestId, keyLabel, provider: before.provider, enabled: true }, 'Key back in rotation');
|
|
739
|
+
}
|
|
740
|
+
function logUpstreamError(requestId, key, status, errorType, errorBody, kind, durationMs) {
|
|
496
741
|
if (kind === 'standard') {
|
|
497
|
-
logger.warn({
|
|
742
|
+
logger.warn({
|
|
743
|
+
evt: 'upstream.error',
|
|
744
|
+
requestId,
|
|
745
|
+
keyLabel: key.label,
|
|
746
|
+
status,
|
|
747
|
+
errorType,
|
|
748
|
+
kind,
|
|
749
|
+
durationMs,
|
|
750
|
+
// Redact before truncating: a 401 that echoes the bearer must not write
|
|
751
|
+
// a working key into the access log or daemon.log.
|
|
752
|
+
bodyPrefix: redactSecrets(errorBody).slice(0, 200),
|
|
753
|
+
}, 'Upstream returned error');
|
|
498
754
|
}
|
|
499
755
|
else {
|
|
500
|
-
logger.warn({ requestId, keyLabel: key.label, status, kind }, 'Upstream streaming error');
|
|
756
|
+
logger.warn({ evt: 'upstream.error', requestId, keyLabel: key.label, status, kind, durationMs }, 'Upstream streaming error');
|
|
501
757
|
}
|
|
502
758
|
}
|
|
503
759
|
function logTimeout(requestId, key, timeoutMs, kind) {
|
|
504
|
-
logger.warn({ requestId, keyLabel: key.label, kind }, kind === 'standard'
|
|
505
|
-
? 'Request timed out
|
|
506
|
-
: 'Streaming request timed out
|
|
760
|
+
logger.warn({ evt: 'upstream.timeout', requestId, keyLabel: key.label, kind, timeoutMs }, kind === 'standard'
|
|
761
|
+
? 'Request timed out, retrying with next key'
|
|
762
|
+
: 'Streaming request timed out, retrying with next key');
|
|
507
763
|
}
|
|
508
764
|
function logNetworkError(requestId, key, err, kind) {
|
|
509
|
-
logger.error({ requestId, keyLabel: key.label, err, kind }, kind === 'standard'
|
|
765
|
+
logger.error({ evt: 'upstream.network_error', requestId, keyLabel: key.label, err, kind }, kind === 'standard'
|
|
510
766
|
? 'Network error forwarding to upstream'
|
|
511
767
|
: 'Network error during streaming');
|
|
512
768
|
}
|
|
513
769
|
// ---------------------------------------------------------------------------
|
|
514
770
|
// Success handlers
|
|
515
771
|
// ---------------------------------------------------------------------------
|
|
516
|
-
const standardSuccessHandler = async (response, state, requestId, keyLabel) => {
|
|
772
|
+
const standardSuccessHandler = async (response, state, requestId, keyLabel, provider) => {
|
|
517
773
|
logger.info({ requestId, keyLabel, status: response.status }, 'Upstream request succeeded');
|
|
518
774
|
const responseBody = await response.text();
|
|
519
775
|
// Non-streaming bodies report the request's token usage at the top level
|
|
@@ -523,12 +779,13 @@ const standardSuccessHandler = async (response, state, requestId, keyLabel) => {
|
|
|
523
779
|
const downstreamHeaders = buildDownstreamHeaders(response.headers);
|
|
524
780
|
downstreamHeaders.set('X-Proxy-Key-Label', keyLabel);
|
|
525
781
|
downstreamHeaders.set('X-Proxy-Request-Id', requestId);
|
|
782
|
+
downstreamHeaders.set('X-Proxy-Provider', provider);
|
|
526
783
|
return new Response(responseBody, {
|
|
527
784
|
status: response.status,
|
|
528
785
|
headers: downstreamHeaders,
|
|
529
786
|
});
|
|
530
787
|
};
|
|
531
|
-
const streamingSuccessHandler = async (response, state, requestId, keyLabel) => {
|
|
788
|
+
const streamingSuccessHandler = async (response, state, requestId, keyLabel, provider) => {
|
|
532
789
|
if (!response.body) {
|
|
533
790
|
logger.error({ requestId, keyLabel }, 'Streaming response missing body');
|
|
534
791
|
return new Response(JSON.stringify({ error: 'Bad Gateway', message: 'Upstream response missing body', requestId }), { status: 502, headers: { 'content-type': 'application/json' } });
|
|
@@ -540,6 +797,7 @@ const streamingSuccessHandler = async (response, state, requestId, keyLabel) =>
|
|
|
540
797
|
responseHeaders.set('connection', 'keep-alive');
|
|
541
798
|
responseHeaders.set('X-Proxy-Key-Label', keyLabel);
|
|
542
799
|
responseHeaders.set('X-Proxy-Request-Id', requestId);
|
|
800
|
+
responseHeaders.set('X-Proxy-Provider', provider);
|
|
543
801
|
return new Response(wrappedStream, { status: 200, headers: responseHeaders });
|
|
544
802
|
};
|
|
545
803
|
/**
|
|
@@ -559,6 +817,7 @@ async function handleWithFailover(opts) {
|
|
|
559
817
|
modelId: opts.modelId,
|
|
560
818
|
onSuccess: standardSuccessHandler,
|
|
561
819
|
maxRetries: opts.maxRetries ?? MAX_RETRIES,
|
|
820
|
+
recentRequests: opts.recentRequests,
|
|
562
821
|
});
|
|
563
822
|
}
|
|
564
823
|
/**
|
|
@@ -579,24 +838,50 @@ async function handleStreamingRequest(opts) {
|
|
|
579
838
|
modelId: opts.modelId,
|
|
580
839
|
onSuccess: streamingSuccessHandler,
|
|
581
840
|
maxRetries: opts.maxRetries ?? MAX_RETRIES,
|
|
841
|
+
recentRequests: opts.recentRequests,
|
|
582
842
|
});
|
|
583
843
|
}
|
|
584
844
|
/**
|
|
585
|
-
* Pull token counts out of one SSE event
|
|
586
|
-
*
|
|
587
|
-
* `response.
|
|
845
|
+
* Pull token counts out of one SSE event, across every streaming surface saros
|
|
846
|
+
* relays: Chat Completions (top-level `usage`), OpenAI Responses
|
|
847
|
+
* (`response.usage` on `response.completed`) and Anthropic Messages
|
|
848
|
+
* (`message.usage` on `message_start`). A reader that only knew the first
|
|
849
|
+
* shape booked zero for the other two. `message` is only consulted on
|
|
850
|
+
* `message_start`: other event types may carry an unrelated business object
|
|
851
|
+
* under that key (e.g. streamed decision payloads).
|
|
588
852
|
*/
|
|
589
853
|
function extractEventUsage(parsed) {
|
|
590
854
|
if (parsed.usage)
|
|
591
855
|
return parsed.usage;
|
|
592
|
-
const
|
|
593
|
-
if (
|
|
594
|
-
|
|
856
|
+
const containers = [parsed.response];
|
|
857
|
+
if (parsed.type === 'message_start')
|
|
858
|
+
containers.push(parsed.message);
|
|
859
|
+
for (const container of containers) {
|
|
860
|
+
if (!container || typeof container !== 'object' || Array.isArray(container))
|
|
861
|
+
continue;
|
|
862
|
+
const nested = container.usage;
|
|
595
863
|
if (nested)
|
|
596
864
|
return nested;
|
|
597
865
|
}
|
|
598
866
|
return null;
|
|
599
867
|
}
|
|
868
|
+
/**
|
|
869
|
+
* Merge one event's usage into the stream total. Anthropic splits the counts
|
|
870
|
+
* across events — `message_start` carries the prompt tokens, `message_delta`
|
|
871
|
+
* the cumulative output tokens — so last-one-wins would book zero prompt
|
|
872
|
+
* tokens for every Messages stream. Every documented surface reports
|
|
873
|
+
* cumulative per-field counts, so the largest value seen is the final one;
|
|
874
|
+
* identical repeats (CommandCode sends `usage` twice) are no-ops.
|
|
875
|
+
*/
|
|
876
|
+
function mergeStreamUsage(target, next) {
|
|
877
|
+
if (!next || typeof next !== 'object' || Array.isArray(next))
|
|
878
|
+
return;
|
|
879
|
+
for (const [field, value] of Object.entries(next)) {
|
|
880
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
881
|
+
continue;
|
|
882
|
+
target[field] = Math.max(target[field] ?? 0, value);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
600
885
|
/**
|
|
601
886
|
* Wrap an upstream SSE stream with error detection.
|
|
602
887
|
* Parses SSE events properly to avoid false positives on
|
|
@@ -609,20 +894,17 @@ function wrapStreamWithErrorDetection(upstreamStream, state, requestId, keyLabel
|
|
|
609
894
|
const decoder = new TextDecoder();
|
|
610
895
|
const encoder = new TextEncoder();
|
|
611
896
|
let buffer = '';
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
// trailing usage-only chunk
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
// finish chunk's copy; book exactly once, when the stream ends.
|
|
618
|
-
let lastUsage = null;
|
|
619
|
-
let usageOnlyChunk = null;
|
|
897
|
+
// Usage is booked exactly once, when the stream ends. Upstreams may repeat
|
|
898
|
+
// the same counts on more than one chunk (CommandCode sends them on the
|
|
899
|
+
// finish chunk and on the trailing usage-only chunk) and Anthropic splits
|
|
900
|
+
// them across events, so counts are merged per field instead of replaced.
|
|
901
|
+
let streamUsage = null;
|
|
620
902
|
let usageBooked = false;
|
|
621
903
|
const bookUsage = () => {
|
|
622
904
|
if (usageBooked)
|
|
623
905
|
return;
|
|
624
906
|
usageBooked = true;
|
|
625
|
-
recordKeyTokens(keyLabel,
|
|
907
|
+
recordKeyTokens(keyLabel, streamUsage);
|
|
626
908
|
};
|
|
627
909
|
return new ReadableStream({
|
|
628
910
|
async pull(controller) {
|
|
@@ -648,7 +930,9 @@ function wrapStreamWithErrorDetection(upstreamStream, state, requestId, keyLabel
|
|
|
648
930
|
const parsed = JSON.parse(dataLine.slice(5).trim());
|
|
649
931
|
if (parsed && (parsed.error || parsed.type === 'error')) {
|
|
650
932
|
logger.warn({ requestId, keyLabel }, 'Error detected in stream');
|
|
651
|
-
|
|
933
|
+
recordKeyFailure(state, keyLabel, 'ServerFault', requestId, {
|
|
934
|
+
body: JSON.stringify(parsed),
|
|
935
|
+
});
|
|
652
936
|
// Whatever the upstream reported before failing was consumed.
|
|
653
937
|
bookUsage();
|
|
654
938
|
controller.enqueue(encoder.encode('data: {"type":"proxy_error","message":"Upstream stream error"}\n\n'));
|
|
@@ -656,15 +940,12 @@ function wrapStreamWithErrorDetection(upstreamStream, state, requestId, keyLabel
|
|
|
656
940
|
return;
|
|
657
941
|
}
|
|
658
942
|
// Intermediate chunks send `usage: null`; the counts arrive on the
|
|
659
|
-
// finish chunk and/or
|
|
660
|
-
//
|
|
661
|
-
// `response.completed` event instead of at the top level.
|
|
943
|
+
// finish chunk and/or a usage-only chunk (see bookUsage), or split
|
|
944
|
+
// across `response.completed` / `message_start` / `message_delta`.
|
|
662
945
|
const eventUsage = extractEventUsage(parsed);
|
|
663
946
|
if (eventUsage) {
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
usageOnlyChunk = eventUsage;
|
|
667
|
-
}
|
|
947
|
+
streamUsage ??= {};
|
|
948
|
+
mergeStreamUsage(streamUsage, eventUsage);
|
|
668
949
|
}
|
|
669
950
|
}
|
|
670
951
|
catch {
|
|
@@ -676,7 +957,9 @@ function wrapStreamWithErrorDetection(upstreamStream, state, requestId, keyLabel
|
|
|
676
957
|
}
|
|
677
958
|
catch (err) {
|
|
678
959
|
logger.error({ requestId, keyLabel, err }, 'Stream read error');
|
|
679
|
-
|
|
960
|
+
recordKeyFailure(state, keyLabel, 'ServerFault', requestId, {
|
|
961
|
+
body: err instanceof Error ? err.message : String(err),
|
|
962
|
+
});
|
|
680
963
|
bookUsage();
|
|
681
964
|
controller.enqueue(encoder.encode('data: {"type":"proxy_error","message":"Stream interrupted"}\n\n'));
|
|
682
965
|
controller.close();
|
|
@@ -697,6 +980,9 @@ export function createProxyApp(config) {
|
|
|
697
980
|
circuitBreakerThreshold: config.circuitBreakerThreshold,
|
|
698
981
|
circuitBreakerCooldownMs: config.circuitBreakerCooldownMs,
|
|
699
982
|
});
|
|
983
|
+
// Last proxied requests, in memory only. `SAROS_DEBUG_BUFFER=0` removes the
|
|
984
|
+
// instance entirely: `/debug/recent` answers 404 and nothing is stored.
|
|
985
|
+
const recentRequests = isDebugBufferEnabled() ? createRecentRequestBuffer() : undefined;
|
|
700
986
|
// Model→provider affinity: requests naming a provider-specific model
|
|
701
987
|
// (e.g. commandcode's vendor-prefixed ids) are routed to a matching key
|
|
702
988
|
// first; 'maybe' providers remain as fallback. Bare-name matching lets
|
|
@@ -736,6 +1022,9 @@ export function createProxyApp(config) {
|
|
|
736
1022
|
}));
|
|
737
1023
|
// --- CORS (configurable origins) ---
|
|
738
1024
|
app.use('*', cors({
|
|
1025
|
+
// Browser JS cannot read a response header unless it is exposed: without
|
|
1026
|
+
// this the X-Proxy-* annotations are visible to curl but not to fetch().
|
|
1027
|
+
exposeHeaders: ['X-Proxy-Key-Label', 'X-Proxy-Request-Id', 'X-Proxy-Provider'],
|
|
739
1028
|
origin: (origin) => {
|
|
740
1029
|
if (!origin)
|
|
741
1030
|
return null; // Non-browser requests (no Origin header) — skip CORS
|
|
@@ -769,6 +1058,9 @@ export function createProxyApp(config) {
|
|
|
769
1058
|
activeRequests: activeCount,
|
|
770
1059
|
circuitBreakerThreshold: state.circuitBreakerThreshold,
|
|
771
1060
|
circuitBreakerCooldownMs: state.circuitBreakerCooldownMs,
|
|
1061
|
+
// Per-key state: what is out of rotation, why, and when it may return.
|
|
1062
|
+
// Never contains the credential.
|
|
1063
|
+
keys: describeKeys(state),
|
|
772
1064
|
// Per-provider key counts (multi-provider pools)
|
|
773
1065
|
providers: keys.reduce((acc, k) => {
|
|
774
1066
|
const entry = (acc[k.provider] ??= { total: 0, enabled: 0 });
|
|
@@ -779,6 +1071,14 @@ export function createProxyApp(config) {
|
|
|
779
1071
|
}, {}),
|
|
780
1072
|
});
|
|
781
1073
|
});
|
|
1074
|
+
// --- Recent proxied requests (in-memory, opt-out) ---
|
|
1075
|
+
// Same entries as the `request.complete` access lines: requestId, provider,
|
|
1076
|
+
// key label and outcome — never credentials, headers or bodies.
|
|
1077
|
+
app.get('/debug/recent', (c) => {
|
|
1078
|
+
if (!recentRequests)
|
|
1079
|
+
return c.json({ error: 'Not Found' }, 404);
|
|
1080
|
+
return c.json(recentRequests.snapshot());
|
|
1081
|
+
});
|
|
782
1082
|
// --- Models discovery endpoint (OpenAI-compatible) ---
|
|
783
1083
|
// Returns the full model list so the OpenCode client can discover all
|
|
784
1084
|
// available models. Without this, OpenCode falls back to a hardcoded
|
|
@@ -823,6 +1123,7 @@ export function createProxyApp(config) {
|
|
|
823
1123
|
incomingHeaders,
|
|
824
1124
|
bodyText,
|
|
825
1125
|
modelId: typeof parsedBody.model === 'string' ? parsedBody.model : undefined,
|
|
1126
|
+
recentRequests,
|
|
826
1127
|
});
|
|
827
1128
|
}
|
|
828
1129
|
// Non-streaming request with failover
|
|
@@ -835,6 +1136,7 @@ export function createProxyApp(config) {
|
|
|
835
1136
|
incomingHeaders,
|
|
836
1137
|
bodyText,
|
|
837
1138
|
modelId: typeof parsedBody?.model === 'string' ? parsedBody.model : undefined,
|
|
1139
|
+
recentRequests,
|
|
838
1140
|
});
|
|
839
1141
|
}
|
|
840
1142
|
// Unified catch-all: rewrites /v1/* → /zen/go/v1/* for upstream forwarding
|