saros-proxy 0.10.2 → 0.11.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 +175 -29
- 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 +7 -0
- 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/providers/infer.d.ts +11 -0
- package/dist/providers/infer.js.map +1 -1
- package/dist/proxy-logic.d.ts +74 -3
- package/dist/proxy-logic.js +111 -9
- package/dist/proxy-logic.js.map +1 -1
- package/dist/proxy.d.ts +33 -1
- package/dist/proxy.js +328 -45
- 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/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,6 +838,7 @@ 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
|
/**
|
|
@@ -648,7 +908,9 @@ function wrapStreamWithErrorDetection(upstreamStream, state, requestId, keyLabel
|
|
|
648
908
|
const parsed = JSON.parse(dataLine.slice(5).trim());
|
|
649
909
|
if (parsed && (parsed.error || parsed.type === 'error')) {
|
|
650
910
|
logger.warn({ requestId, keyLabel }, 'Error detected in stream');
|
|
651
|
-
|
|
911
|
+
recordKeyFailure(state, keyLabel, 'ServerFault', requestId, {
|
|
912
|
+
body: JSON.stringify(parsed),
|
|
913
|
+
});
|
|
652
914
|
// Whatever the upstream reported before failing was consumed.
|
|
653
915
|
bookUsage();
|
|
654
916
|
controller.enqueue(encoder.encode('data: {"type":"proxy_error","message":"Upstream stream error"}\n\n'));
|
|
@@ -676,7 +938,9 @@ function wrapStreamWithErrorDetection(upstreamStream, state, requestId, keyLabel
|
|
|
676
938
|
}
|
|
677
939
|
catch (err) {
|
|
678
940
|
logger.error({ requestId, keyLabel, err }, 'Stream read error');
|
|
679
|
-
|
|
941
|
+
recordKeyFailure(state, keyLabel, 'ServerFault', requestId, {
|
|
942
|
+
body: err instanceof Error ? err.message : String(err),
|
|
943
|
+
});
|
|
680
944
|
bookUsage();
|
|
681
945
|
controller.enqueue(encoder.encode('data: {"type":"proxy_error","message":"Stream interrupted"}\n\n'));
|
|
682
946
|
controller.close();
|
|
@@ -697,6 +961,9 @@ export function createProxyApp(config) {
|
|
|
697
961
|
circuitBreakerThreshold: config.circuitBreakerThreshold,
|
|
698
962
|
circuitBreakerCooldownMs: config.circuitBreakerCooldownMs,
|
|
699
963
|
});
|
|
964
|
+
// Last proxied requests, in memory only. `SAROS_DEBUG_BUFFER=0` removes the
|
|
965
|
+
// instance entirely: `/debug/recent` answers 404 and nothing is stored.
|
|
966
|
+
const recentRequests = isDebugBufferEnabled() ? createRecentRequestBuffer() : undefined;
|
|
700
967
|
// Model→provider affinity: requests naming a provider-specific model
|
|
701
968
|
// (e.g. commandcode's vendor-prefixed ids) are routed to a matching key
|
|
702
969
|
// first; 'maybe' providers remain as fallback. Bare-name matching lets
|
|
@@ -736,6 +1003,9 @@ export function createProxyApp(config) {
|
|
|
736
1003
|
}));
|
|
737
1004
|
// --- CORS (configurable origins) ---
|
|
738
1005
|
app.use('*', cors({
|
|
1006
|
+
// Browser JS cannot read a response header unless it is exposed: without
|
|
1007
|
+
// this the X-Proxy-* annotations are visible to curl but not to fetch().
|
|
1008
|
+
exposeHeaders: ['X-Proxy-Key-Label', 'X-Proxy-Request-Id', 'X-Proxy-Provider'],
|
|
739
1009
|
origin: (origin) => {
|
|
740
1010
|
if (!origin)
|
|
741
1011
|
return null; // Non-browser requests (no Origin header) — skip CORS
|
|
@@ -769,6 +1039,9 @@ export function createProxyApp(config) {
|
|
|
769
1039
|
activeRequests: activeCount,
|
|
770
1040
|
circuitBreakerThreshold: state.circuitBreakerThreshold,
|
|
771
1041
|
circuitBreakerCooldownMs: state.circuitBreakerCooldownMs,
|
|
1042
|
+
// Per-key state: what is out of rotation, why, and when it may return.
|
|
1043
|
+
// Never contains the credential.
|
|
1044
|
+
keys: describeKeys(state),
|
|
772
1045
|
// Per-provider key counts (multi-provider pools)
|
|
773
1046
|
providers: keys.reduce((acc, k) => {
|
|
774
1047
|
const entry = (acc[k.provider] ??= { total: 0, enabled: 0 });
|
|
@@ -779,6 +1052,14 @@ export function createProxyApp(config) {
|
|
|
779
1052
|
}, {}),
|
|
780
1053
|
});
|
|
781
1054
|
});
|
|
1055
|
+
// --- Recent proxied requests (in-memory, opt-out) ---
|
|
1056
|
+
// Same entries as the `request.complete` access lines: requestId, provider,
|
|
1057
|
+
// key label and outcome — never credentials, headers or bodies.
|
|
1058
|
+
app.get('/debug/recent', (c) => {
|
|
1059
|
+
if (!recentRequests)
|
|
1060
|
+
return c.json({ error: 'Not Found' }, 404);
|
|
1061
|
+
return c.json(recentRequests.snapshot());
|
|
1062
|
+
});
|
|
782
1063
|
// --- Models discovery endpoint (OpenAI-compatible) ---
|
|
783
1064
|
// Returns the full model list so the OpenCode client can discover all
|
|
784
1065
|
// available models. Without this, OpenCode falls back to a hardcoded
|
|
@@ -823,6 +1104,7 @@ export function createProxyApp(config) {
|
|
|
823
1104
|
incomingHeaders,
|
|
824
1105
|
bodyText,
|
|
825
1106
|
modelId: typeof parsedBody.model === 'string' ? parsedBody.model : undefined,
|
|
1107
|
+
recentRequests,
|
|
826
1108
|
});
|
|
827
1109
|
}
|
|
828
1110
|
// Non-streaming request with failover
|
|
@@ -835,6 +1117,7 @@ export function createProxyApp(config) {
|
|
|
835
1117
|
incomingHeaders,
|
|
836
1118
|
bodyText,
|
|
837
1119
|
modelId: typeof parsedBody?.model === 'string' ? parsedBody.model : undefined,
|
|
1120
|
+
recentRequests,
|
|
838
1121
|
});
|
|
839
1122
|
}
|
|
840
1123
|
// Unified catch-all: rewrites /v1/* → /zen/go/v1/* for upstream forwarding
|