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.
Files changed (59) hide show
  1. package/README.md +175 -29
  2. package/dist/cli/daemon.d.ts +38 -6
  3. package/dist/cli/daemon.js +524 -109
  4. package/dist/cli/daemon.js.map +1 -1
  5. package/dist/cli/dsh-config.js +34 -9
  6. package/dist/cli/dsh-config.js.map +1 -1
  7. package/dist/cli/help.js +3 -0
  8. package/dist/cli/help.js.map +1 -1
  9. package/dist/cli/key-state.d.ts +21 -0
  10. package/dist/cli/key-state.js +57 -0
  11. package/dist/cli/key-state.js.map +1 -0
  12. package/dist/cli/keys.d.ts +28 -0
  13. package/dist/cli/keys.js +185 -0
  14. package/dist/cli/keys.js.map +1 -0
  15. package/dist/cli/overview.js +6 -1
  16. package/dist/cli/overview.js.map +1 -1
  17. package/dist/cli/secret.d.ts +31 -0
  18. package/dist/cli/secret.js +89 -0
  19. package/dist/cli/secret.js.map +1 -0
  20. package/dist/cli/usage.js +7 -0
  21. package/dist/cli/usage.js.map +1 -1
  22. package/dist/config.d.ts +15 -1
  23. package/dist/config.js +98 -25
  24. package/dist/config.js.map +1 -1
  25. package/dist/constants.d.ts +2 -0
  26. package/dist/constants.js +2 -0
  27. package/dist/constants.js.map +1 -1
  28. package/dist/debug-buffer.d.ts +58 -0
  29. package/dist/debug-buffer.js +42 -0
  30. package/dist/debug-buffer.js.map +1 -0
  31. package/dist/index.js +30 -4
  32. package/dist/index.js.map +1 -1
  33. package/dist/log-redact.d.ts +18 -0
  34. package/dist/log-redact.js +41 -0
  35. package/dist/log-redact.js.map +1 -0
  36. package/dist/logger.d.ts +6 -0
  37. package/dist/logger.js +30 -0
  38. package/dist/logger.js.map +1 -1
  39. package/dist/providers/infer.d.ts +11 -0
  40. package/dist/providers/infer.js.map +1 -1
  41. package/dist/proxy-logic.d.ts +74 -3
  42. package/dist/proxy-logic.js +111 -9
  43. package/dist/proxy-logic.js.map +1 -1
  44. package/dist/proxy.d.ts +33 -1
  45. package/dist/proxy.js +328 -45
  46. package/dist/proxy.js.map +1 -1
  47. package/dist/secrets/boot.d.ts +33 -0
  48. package/dist/secrets/boot.js +65 -0
  49. package/dist/secrets/boot.js.map +1 -0
  50. package/dist/secrets/envelope.d.ts +44 -0
  51. package/dist/secrets/envelope.js +132 -0
  52. package/dist/secrets/envelope.js.map +1 -0
  53. package/dist/secrets/source.d.ts +130 -0
  54. package/dist/secrets/source.js +332 -0
  55. package/dist/secrets/source.js.map +1 -0
  56. package/dist/validation.d.ts +3 -3
  57. package/dist/validation.js +3 -3
  58. package/dist/validation.js.map +1 -1
  59. 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, inferProvider } from './providers/index.js';
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 provider-side
64
- * errors (role validation, plan gating) can be replayed and debugged.
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
- const dir = join(homedir(), '.config', 'saros', 'captures');
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
- const file = join(dir, `${Date.now()}-${ctx.requestId.slice(0, 8)}.json`);
76
- writeFileSync(file, JSON.stringify({
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), 'utf-8');
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 structural inference from the key itself.
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, config, modelId) {
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
- const inferred = inferProvider({ label: key.label, key: key.key });
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.state, ctx.requestId, ctx.kind, attempt) };
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.config, ctx.modelId);
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
- markKeySucceeded(ctx.state, key.label);
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
- return {
385
- kind: 'done',
386
- response: await ctx.onSuccess(response, ctx.state, ctx.requestId, key.label),
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
- markKeyFailed(ctx.state, key.label, errorType);
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
- function buildNoKeysResponse(state, requestId, kind, attempt) {
457
- logger.warn({ requestId, attempt, kind }, kind === 'standard'
458
- ? 'No API keys available on attempt %d'
459
- : 'No API keys available for streaming on attempt %d', attempt);
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
- return new Response(JSON.stringify({ error: 'All API keys are temporarily unavailable', requestId }), { status: 503, headers: { 'content-type': 'application/json' } });
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
- return new Response(JSON.stringify({ error: 'Bad Gateway', message, requestId: ctx.requestId }), { status: 502, headers: { 'content-type': 'application/json' } });
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
- function logUpstreamError(requestId, key, status, errorType, errorBody, kind) {
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({ requestId, keyLabel: key.label, status, errorType, kind }, 'Upstream returned error: %s', errorBody.slice(0, 200));
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 after %dms, retrying with next key'
506
- : 'Streaming request timed out after %dms, retrying with next key', timeoutMs);
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
- markKeyFailed(state, keyLabel, 'ServerFault');
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
- markKeyFailed(state, keyLabel, 'ServerFault');
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