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.
Files changed (77) hide show
  1. package/README.md +261 -78
  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 +20 -10
  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/models-fetcher.js +2 -0
  40. package/dist/models-fetcher.js.map +1 -1
  41. package/dist/providers/commandcode.d.ts +20 -5
  42. package/dist/providers/commandcode.js +115 -7
  43. package/dist/providers/commandcode.js.map +1 -1
  44. package/dist/providers/index.d.ts +2 -2
  45. package/dist/providers/index.js +2 -2
  46. package/dist/providers/index.js.map +1 -1
  47. package/dist/providers/infer.d.ts +11 -0
  48. package/dist/providers/infer.js.map +1 -1
  49. package/dist/providers/opencode-go.d.ts +8 -0
  50. package/dist/providers/opencode-go.js +35 -7
  51. package/dist/providers/opencode-go.js.map +1 -1
  52. package/dist/providers/types.d.ts +10 -2
  53. package/dist/proxy-logic.d.ts +101 -7
  54. package/dist/proxy-logic.js +197 -41
  55. package/dist/proxy-logic.js.map +1 -1
  56. package/dist/proxy.d.ts +33 -1
  57. package/dist/proxy.js +369 -67
  58. package/dist/proxy.js.map +1 -1
  59. package/dist/secrets/boot.d.ts +33 -0
  60. package/dist/secrets/boot.js +65 -0
  61. package/dist/secrets/boot.js.map +1 -0
  62. package/dist/secrets/envelope.d.ts +44 -0
  63. package/dist/secrets/envelope.js +132 -0
  64. package/dist/secrets/envelope.js.map +1 -0
  65. package/dist/secrets/source.d.ts +130 -0
  66. package/dist/secrets/source.js +332 -0
  67. package/dist/secrets/source.js.map +1 -0
  68. package/dist/token-usage.d.ts +9 -2
  69. package/dist/token-usage.js +20 -4
  70. package/dist/token-usage.js.map +1 -1
  71. package/dist/usage-client.d.ts +43 -13
  72. package/dist/usage-client.js +37 -54
  73. package/dist/usage-client.js.map +1 -1
  74. package/dist/validation.d.ts +3 -3
  75. package/dist/validation.js +3 -3
  76. package/dist/validation.js.map +1 -1
  77. 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,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. Chat completions carry `usage` at the
586
- * top level; the OpenAI Responses schema nests it under `response.usage` on
587
- * `response.completed`, so a top-level-only reader books zero for those streams.
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 response = parsed.response;
593
- if (response && typeof response === 'object' && !Array.isArray(response)) {
594
- const nested = response.usage;
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
- // Upstreams may report the same `usage` on more than one chunk: CommandCode
613
- // sends it on the finish chunk (`choices[0].finish_reason`) and again on the
614
- // trailing usage-only chunk (`choices: []`), so booking per chunk would
615
- // count every streamed request twice. Keep the canonical usage-only chunk
616
- // (last one wins, OpenAI's `include_usage` contract) and fall back to the
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, usageOnlyChunk ?? lastUsage);
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
- markKeyFailed(state, keyLabel, 'ServerFault');
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 the usage-only chunk (see bookUsage). The
660
- // OpenAI Responses schema nests them under `response.usage` on the
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
- lastUsage = eventUsage;
665
- if (Array.isArray(parsed.choices) && parsed.choices.length === 0) {
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
- markKeyFailed(state, keyLabel, 'ServerFault');
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