saros-proxy 0.7.4 → 0.10.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 (114) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +116 -64
  3. package/dist/cli/autostart.d.ts +0 -0
  4. package/dist/cli/autostart.js +0 -0
  5. package/dist/cli/autostart.js.map +0 -0
  6. package/dist/cli/daemon.d.ts +0 -0
  7. package/dist/cli/daemon.js +4 -0
  8. package/dist/cli/daemon.js.map +1 -1
  9. package/dist/cli/dsh-config.d.ts +32 -0
  10. package/dist/cli/dsh-config.js +122 -0
  11. package/dist/cli/dsh-config.js.map +1 -0
  12. package/dist/cli/harness-models.d.ts +26 -2
  13. package/dist/cli/harness-models.js +192 -2
  14. package/dist/cli/harness-models.js.map +1 -1
  15. package/dist/cli/harness-sync.d.ts +3 -6
  16. package/dist/cli/harness-sync.js +14 -7
  17. package/dist/cli/harness-sync.js.map +1 -1
  18. package/dist/cli/help.d.ts +0 -0
  19. package/dist/cli/help.js +2 -2
  20. package/dist/cli/help.js.map +0 -0
  21. package/dist/cli/omp-config.d.ts +0 -0
  22. package/dist/cli/omp-config.js +11 -1
  23. package/dist/cli/omp-config.js.map +1 -1
  24. package/dist/cli/opencode-config.d.ts +0 -0
  25. package/dist/cli/opencode-config.js +0 -0
  26. package/dist/cli/opencode-config.js.map +0 -0
  27. package/dist/cli/overview.d.ts +0 -0
  28. package/dist/cli/overview.js +2 -1
  29. package/dist/cli/overview.js.map +1 -1
  30. package/dist/cli/pi-config.d.ts +0 -0
  31. package/dist/cli/pi-config.js +11 -1
  32. package/dist/cli/pi-config.js.map +1 -1
  33. package/dist/cli/setup.d.ts +6 -10
  34. package/dist/cli/setup.js +100 -232
  35. package/dist/cli/setup.js.map +1 -1
  36. package/dist/cli/ui.d.ts +1 -2
  37. package/dist/cli/ui.js +0 -16
  38. package/dist/cli/ui.js.map +1 -1
  39. package/dist/cli/update-check.d.ts +0 -0
  40. package/dist/cli/update-check.js +0 -0
  41. package/dist/cli/update-check.js.map +0 -0
  42. package/dist/cli/usage.d.ts +12 -0
  43. package/dist/cli/usage.js +182 -0
  44. package/dist/cli/usage.js.map +1 -0
  45. package/dist/config.d.ts +13 -17
  46. package/dist/config.js +29 -78
  47. package/dist/config.js.map +1 -1
  48. package/dist/constants.d.ts +9 -5
  49. package/dist/constants.js +14 -5
  50. package/dist/constants.js.map +1 -1
  51. package/dist/index.d.ts +0 -0
  52. package/dist/index.js +9 -25
  53. package/dist/index.js.map +1 -1
  54. package/dist/key-encryption.d.ts +0 -0
  55. package/dist/key-encryption.js +0 -0
  56. package/dist/key-encryption.js.map +0 -0
  57. package/dist/logger.d.ts +0 -0
  58. package/dist/logger.js +0 -0
  59. package/dist/logger.js.map +0 -0
  60. package/dist/model-probe.d.ts +0 -0
  61. package/dist/model-probe.js +0 -0
  62. package/dist/model-probe.js.map +0 -0
  63. package/dist/model-stats.d.ts +20 -0
  64. package/dist/model-stats.js +135 -0
  65. package/dist/model-stats.js.map +1 -0
  66. package/dist/models-fetcher.d.ts +0 -0
  67. package/dist/models-fetcher.js +0 -0
  68. package/dist/models-fetcher.js.map +0 -0
  69. package/dist/models-sync.d.ts +0 -0
  70. package/dist/models-sync.js +0 -0
  71. package/dist/models-sync.js.map +0 -0
  72. package/dist/probe-cache.d.ts +0 -0
  73. package/dist/probe-cache.js +0 -0
  74. package/dist/probe-cache.js.map +0 -0
  75. package/dist/providers/commandcode.d.ts +17 -0
  76. package/dist/providers/commandcode.js +237 -0
  77. package/dist/providers/commandcode.js.map +1 -0
  78. package/dist/providers/index.d.ts +43 -0
  79. package/dist/providers/index.js +75 -0
  80. package/dist/providers/index.js.map +1 -0
  81. package/dist/providers/infer.d.ts +23 -0
  82. package/dist/providers/infer.js +13 -0
  83. package/dist/providers/infer.js.map +1 -0
  84. package/dist/providers/opencode-go.d.ts +4 -0
  85. package/dist/providers/opencode-go.js +190 -0
  86. package/dist/providers/opencode-go.js.map +1 -0
  87. package/dist/providers/types.d.ts +135 -0
  88. package/dist/providers/types.js +10 -0
  89. package/dist/providers/types.js.map +1 -0
  90. package/dist/proxy-logic.d.ts +61 -22
  91. package/dist/proxy-logic.js +163 -106
  92. package/dist/proxy-logic.js.map +1 -1
  93. package/dist/proxy.d.ts +43 -2
  94. package/dist/proxy.js +327 -62
  95. package/dist/proxy.js.map +1 -1
  96. package/dist/token-usage.d.ts +53 -0
  97. package/dist/token-usage.js +198 -0
  98. package/dist/token-usage.js.map +1 -0
  99. package/dist/usage-client.d.ts +62 -0
  100. package/dist/usage-client.js +155 -0
  101. package/dist/usage-client.js.map +1 -0
  102. package/dist/validation.d.ts +5 -5
  103. package/dist/validation.js +7 -9
  104. package/dist/validation.js.map +1 -1
  105. package/package.json +11 -7
  106. package/dist/dashboard-parser.d.ts +0 -47
  107. package/dist/dashboard-parser.js +0 -88
  108. package/dist/dashboard-parser.js.map +0 -1
  109. package/dist/firefox-cookies.d.ts +0 -42
  110. package/dist/firefox-cookies.js +0 -170
  111. package/dist/firefox-cookies.js.map +0 -1
  112. package/dist/scraper.d.ts +0 -70
  113. package/dist/scraper.js +0 -239
  114. package/dist/scraper.js.map +0 -1
package/dist/proxy.js CHANGED
@@ -8,11 +8,177 @@ import { Hono } from 'hono';
8
8
  import { cors } from 'hono/cors';
9
9
  import { rateLimiter } from 'hono-rate-limiter';
10
10
  import crypto from 'node:crypto';
11
+ import { mkdirSync, writeFileSync } from 'node:fs';
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
11
14
  import { createProxyState, selectKeyForRequest, failoverRequest, completeRequest, markKeyFailed, markKeySucceeded, classifyHttpError, } from './proxy-logic.js';
12
15
  import { logger, maskKey } from './logger.js';
13
- import { getAllUsage, isScraperRunning } from './scraper.js';
14
16
  import { MAX_BODY_SIZE, MAX_RETRIES, RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX, } from './constants.js';
15
17
  import { getModelsList } from './models-fetcher.js';
18
+ import { maybeRefreshUsage } from './usage-client.js';
19
+ import { recordModelRequest } from './model-stats.js';
20
+ import { recordKeyTokens } from './token-usage.js';
21
+ import { getProvider, inferProvider } from './providers/index.js';
22
+ /** Canonical client-facing chat route prefix (the opencode-go shape). */
23
+ const CANONICAL_CHAT_BASE = '/zen/go/v1';
24
+ /**
25
+ * Resolve the upstream URL for one attempt: per-provider base URL + path
26
+ * remapping. Clients always speak canonical saros routes (/zen/go/v1/<rest>,
27
+ * the opencode-go shape); requests routed to a provider with a different
28
+ * chat surface get the prefix remapped (e.g. /provider/v1 for commandcode).
29
+ */
30
+ export function resolveUpstreamUrl(config, provider, path) {
31
+ const base = (config.upstreams?.[provider.id] ?? (provider.id === 'opencode-go' ? config.upstreamBaseUrl : provider.baseUrl))
32
+ .replace(/\/+$/, '');
33
+ if (provider.chatBasePath === CANONICAL_CHAT_BASE) {
34
+ return buildUpstreamUrl(base, path);
35
+ }
36
+ const remapped = path.startsWith(CANONICAL_CHAT_BASE)
37
+ ? provider.chatBasePath + path.slice(CANONICAL_CHAT_BASE.length)
38
+ : path;
39
+ return buildUpstreamUrl(base, remapped);
40
+ }
41
+ /**
42
+ * Extract a provider-suffix routing hint from a model id
43
+ * (e.g. 'claude-…@commandcode' → 'commandcode'). Returns null when absent.
44
+ */
45
+ export function parseProviderSuffix(modelId) {
46
+ if (!modelId)
47
+ return null;
48
+ const at = modelId.lastIndexOf('@');
49
+ if (at <= 0 || at === modelId.length - 1)
50
+ return null;
51
+ return modelId.slice(at + 1);
52
+ }
53
+ /** Strip the @provider routing suffix from a model id for upstream forwarding. */
54
+ export function stripProviderSuffix(modelId) {
55
+ const at = modelId.lastIndexOf('@');
56
+ return at > 0 ? modelId.slice(0, at) : modelId;
57
+ }
58
+ function generateRequestId() {
59
+ return crypto.randomUUID();
60
+ }
61
+ /**
62
+ * 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.
66
+ */
67
+ function captureFailedRequest(ctx, status, errorBody) {
68
+ if (process.env.SAROS_CAPTURE_4XX !== '1')
69
+ return;
70
+ if (status < 400 || status >= 500)
71
+ return;
72
+ try {
73
+ const dir = join(homedir(), '.config', 'saros', 'captures');
74
+ mkdirSync(dir, { recursive: true });
75
+ const file = join(dir, `${Date.now()}-${ctx.requestId.slice(0, 8)}.json`);
76
+ writeFileSync(file, JSON.stringify({
77
+ ts: new Date().toISOString(),
78
+ requestId: ctx.requestId,
79
+ status,
80
+ method: ctx.method,
81
+ path: ctx.path,
82
+ model: ctx.modelId ?? null,
83
+ kind: ctx.kind,
84
+ upstreamError: errorBody.slice(0, 2000),
85
+ requestBody: ctx.bodyText.slice(0, 100_000),
86
+ }, null, 2), 'utf-8');
87
+ logger.warn({ requestId: ctx.requestId, file }, 'captured failing request (SAROS_CAPTURE_4XX)');
88
+ }
89
+ catch {
90
+ // diagnostics must never break the proxy
91
+ }
92
+ }
93
+ /**
94
+ * Resolve the model id to send upstream for one attempt. An '@provider'
95
+ * suffix is saros's routing shim and is always stripped; otherwise, when the
96
+ * serving provider names the same model differently (bare name matches its
97
+ * catalog under a vendor-prefixed id — e.g. 'deepseek-v4-flash' →
98
+ * 'deepseek/deepseek-v4-flash' on commandcode), the provider's native id is
99
+ * used so the upstream recognizes it. null when no rewrite is needed.
100
+ */
101
+ function targetModelId(modelId, provider) {
102
+ if (!modelId)
103
+ return null;
104
+ if (parseProviderSuffix(modelId))
105
+ return stripProviderSuffix(modelId);
106
+ const bare = modelId.split('/').pop() ?? modelId;
107
+ const native = provider.resolveNativeId?.(bare.toLowerCase());
108
+ return native && native !== modelId ? native : null;
109
+ }
110
+ /**
111
+ * Stable conversation fingerprint. The same conversation (same model, same
112
+ * first user turn) maps to the same id on every request, so upstream routing
113
+ * and prompt caching stay coherent, while a different conversation gets a
114
+ * different id. The system prompt is deliberately excluded: harnesses
115
+ * rewrite it on every turn (timestamps, injected context), which would make
116
+ * the id change per request and defeat the purpose.
117
+ */
118
+ function conversationSessionId(parsed, modelId) {
119
+ const messages = Array.isArray(parsed.messages) ? parsed.messages : [];
120
+ const firstUser = messages.find((message) => typeof message === 'object' && message !== null && !Array.isArray(message)
121
+ && 'role' in message && message.role === 'user');
122
+ const seed = firstUser ?? messages.slice(0, 2);
123
+ return crypto
124
+ .createHash('sha256')
125
+ .update(`${modelId ?? String(parsed.model ?? '')}\n${JSON.stringify(seed).slice(0, 4_000)}`)
126
+ .digest('hex')
127
+ .slice(0, 32);
128
+ }
129
+ /**
130
+ * Prepare the outbound body for one attempt in a single JSON pass:
131
+ * - rewrite the model id for the serving provider (see targetModelId);
132
+ * - ask for streamed token usage (`stream_options.include_usage`) on chat
133
+ * completions, unless the client set the option itself — an explicit
134
+ * `false` is respected, otherwise the upstream may withhold the final
135
+ * usage chunk and per-key token accounting goes blind;
136
+ * - derive a conversation session id when the provider needs one and the
137
+ * client didn't send it (see conversationSessionId).
138
+ *
139
+ * The original text is returned untouched when nothing changes (or the body
140
+ * isn't a JSON object), so most requests pay no re-serialization.
141
+ */
142
+ export function prepareOutboundRequest(bodyText, modelId, provider, options) {
143
+ const parsed = parseRequestBody(bodyText);
144
+ if (!parsed)
145
+ return { bodyText };
146
+ let changed = false;
147
+ const target = targetModelId(modelId, provider);
148
+ if (target && target !== parsed.model && typeof parsed.model === 'string') {
149
+ parsed.model = target;
150
+ changed = true;
151
+ }
152
+ if (options.wantsStreamUsage && provider.supportsStreamUsage) {
153
+ const streamOptions = parsed.stream_options;
154
+ const existing = typeof streamOptions === 'object' && streamOptions !== null && !Array.isArray(streamOptions)
155
+ ? streamOptions
156
+ : undefined;
157
+ if (existing?.include_usage !== true && existing?.include_usage !== false) {
158
+ parsed.stream_options = { ...existing, include_usage: true };
159
+ changed = true;
160
+ }
161
+ }
162
+ const sessionId = provider.sessionHeader && !options.clientSessionId
163
+ ? conversationSessionId(parsed, modelId)
164
+ : undefined;
165
+ return { bodyText: changed ? JSON.stringify(parsed) : bodyText, sessionId };
166
+ }
167
+ /**
168
+ * Provider for a key snapshot. Suffix-routed requests (​@provider model ids)
169
+ * pin the provider; otherwise structural inference from the key itself.
170
+ */
171
+ function providerForKey(key, config, modelId) {
172
+ const suffixProvider = parseProviderSuffix(modelId);
173
+ if (suffixProvider) {
174
+ const pinned = getProvider(suffixProvider);
175
+ if (pinned)
176
+ return pinned;
177
+ }
178
+ const inferred = inferProvider({ label: key.label, key: key.key });
179
+ void config;
180
+ return getProvider(inferred) ?? getProvider('opencode-go');
181
+ }
16
182
  // ---------------------------------------------------------------------------
17
183
  // Helpers
18
184
  // ---------------------------------------------------------------------------
@@ -45,9 +211,6 @@ class TimeoutError extends Error {
45
211
  this.name = 'TimeoutError';
46
212
  }
47
213
  }
48
- function generateRequestId() {
49
- return crypto.randomUUID();
50
- }
51
214
  /**
52
215
  * Build upstream URL from base + request path.
53
216
  */
@@ -62,8 +225,11 @@ export function buildUpstreamUrl(base, path) {
62
225
  return normalizedBase + normalizedPath;
63
226
  }
64
227
  /**
65
- * Clean headers for upstream forwarding: remove hop-by-hop headers
66
- * and any pre-existing Authorization.
228
+ * Clean headers for upstream forwarding: remove hop-by-hop headers,
229
+ * pre-existing Authorization, and content-length (the forwarded body may be
230
+ * rewritten — model-id aliasing / suffix strip — so a stale content-length
231
+ * makes the upstream wait for bytes that never arrive; undici recomputes it
232
+ * from the real body).
67
233
  */
68
234
  export function buildUpstreamHeaders(incoming, bearerToken) {
69
235
  const headers = new Headers();
@@ -77,7 +243,8 @@ export function buildUpstreamHeaders(incoming, bearerToken) {
77
243
  lower === 'te' ||
78
244
  lower === 'transfer-encoding' ||
79
245
  lower === 'upgrade' ||
80
- lower === 'authorization') {
246
+ lower === 'authorization' ||
247
+ lower === 'content-length') {
81
248
  continue;
82
249
  }
83
250
  headers.set(key, value);
@@ -106,6 +273,15 @@ export function buildDownstreamHeaders(upstreamHeaders) {
106
273
  lower === 'upgrade' ||
107
274
  // Security-sensitive
108
275
  lower === 'set-cookie' ||
276
+ // Body is already decoded: Node fetch transparently gunzips upstream
277
+ // responses, so forwarding content-encoding would make clients try to
278
+ // decompress plain text (found live against api.commandcode.ai).
279
+ lower === 'content-encoding' ||
280
+ // The forwarded body may be rewritten (model-id aliasing/suffix strip),
281
+ // so a forwarded content-length can disagree with the actual body and
282
+ // hang the upstream waiting for missing bytes (found live). Undici
283
+ // recomputes content-length from the real body.
284
+ lower === 'content-length' ||
109
285
  // Internal debug
110
286
  lower === 'x-request-id' ||
111
287
  // Response constructor recalculates content-length
@@ -128,28 +304,19 @@ export function isStreamingRequest(bodyText) {
128
304
  return false;
129
305
  }
130
306
  }
131
- /**
132
- * Build usage-based key selection options from scraped data.
133
- * Returns undefined when no usage data is available or scraping is disabled.
134
- */
135
- function buildUsageKeyOptions(config) {
136
- const allUsage = getAllUsage();
137
- if (allUsage.size === 0)
138
- return undefined;
139
- const usageMap = new Map();
140
- config.keys.forEach((key, i) => {
141
- const account = config.scraping?.accounts?.[i];
142
- if (account) {
143
- const accountUsage = allUsage.get(account.workspaceId);
144
- if (accountUsage) {
145
- usageMap.set(key.label, accountUsage.usage);
146
- }
147
- }
148
- });
149
- if (usageMap.size === 0)
150
- return undefined;
151
- // Caller ensures config.scraping is defined when scraping is enabled
152
- return { usageMap, usageThreshold: config.scraping?.usageThreshold ?? 50 };
307
+ /** Single JSON parse of a request body; null on invalid/missing input. */
308
+ function parseRequestBody(bodyText) {
309
+ if (!bodyText)
310
+ return null;
311
+ try {
312
+ const parsed = JSON.parse(bodyText);
313
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
314
+ ? parsed
315
+ : null;
316
+ }
317
+ catch {
318
+ return null;
319
+ }
153
320
  }
154
321
  /**
155
322
  * Shared retry loop for both streaming and non-streaming requests.
@@ -166,8 +333,7 @@ async function executeWithRetry(opts) {
166
333
  incomingHeaders: opts.incomingHeaders,
167
334
  bodyText: opts.bodyText,
168
335
  kind: opts.kind,
169
- upstreamUrl: buildUpstreamUrl(opts.config.upstreamBaseUrl, opts.path),
170
- keyOptions: opts.config.scraping?.enabled ? buildUsageKeyOptions(opts.config) : undefined,
336
+ modelId: opts.modelId,
171
337
  onSuccess: opts.onSuccess,
172
338
  };
173
339
  let lastError = null;
@@ -185,9 +351,33 @@ async function executeSingleAttempt(attempt, ctx) {
185
351
  return { kind: 'done', response: buildNoKeysResponse(ctx.state, ctx.requestId, ctx.kind, attempt) };
186
352
  }
187
353
  logKeyAttempt(ctx.requestId, key, attempt, ctx.kind);
188
- const fetchOptions = buildFetchOptions(ctx.method, ctx.bodyText, ctx.kind, ctx.incomingHeaders, key.key);
354
+ // Per-attempt upstream: keys may be mixed-provider within one failover chain.
355
+ // Suffix-routed models (@provider) pin the provider and lose the suffix
356
+ // in the forwarded body; the upstream never sees saros's routing shim.
357
+ const provider = providerForKey(key, ctx.config, ctx.modelId);
358
+ const upstreamUrl = resolveUpstreamUrl(ctx.config, provider, ctx.path);
359
+ const sessionHeader = provider.sessionHeader;
360
+ const prepared = ctx.bodyText
361
+ ? prepareOutboundRequest(ctx.bodyText, ctx.modelId, provider, {
362
+ // `stream_options` is a Chat Completions field — never inject it into
363
+ // other streaming surfaces (e.g. the Responses API).
364
+ wantsStreamUsage: ctx.kind === 'streaming' && ctx.path.endsWith('/chat/completions'),
365
+ clientSessionId: sessionHeader
366
+ ? ctx.incomingHeaders.get(sessionHeader) ?? undefined
367
+ : undefined,
368
+ })
369
+ : { bodyText: ctx.bodyText };
370
+ const fetchOptions = buildFetchOptions({
371
+ method: ctx.method,
372
+ bodyText: prepared.bodyText,
373
+ kind: ctx.kind,
374
+ incomingHeaders: ctx.incomingHeaders,
375
+ bearerToken: key.key,
376
+ provider,
377
+ sessionId: prepared.sessionId,
378
+ });
189
379
  try {
190
- const response = await fetchWithTimeout(ctx.upstreamUrl, fetchOptions, ctx.config.requestTimeoutMs);
380
+ const response = await fetchWithTimeout(upstreamUrl, fetchOptions, ctx.config.requestTimeoutMs);
191
381
  if (response.ok) {
192
382
  markKeySucceeded(ctx.state, key.label);
193
383
  completeRequest(ctx.state, ctx.requestId, true);
@@ -204,19 +394,31 @@ async function executeSingleAttempt(attempt, ctx) {
204
394
  }
205
395
  function pickKey(ctx, attempt) {
206
396
  return attempt === 0
207
- ? selectKeyForRequest(ctx.state, ctx.requestId, ctx.keyOptions)
208
- : failoverRequest(ctx.state, ctx.requestId, ctx.keyOptions);
397
+ ? selectKeyForRequest(ctx.state, ctx.requestId, ctx.modelId)
398
+ : failoverRequest(ctx.state, ctx.requestId);
209
399
  }
210
- function buildFetchOptions(method, bodyText, kind, incomingHeaders, bearerToken) {
211
- const headers = buildUpstreamHeaders(incomingHeaders, bearerToken);
212
- const options = { method, headers };
213
- if (!bodyText)
400
+ function buildFetchOptions(opts) {
401
+ const headers = buildUpstreamHeaders(opts.incomingHeaders, opts.bearerToken);
402
+ // Provider-specific identity headers (e.g. CommandCode CLI headers)
403
+ if (opts.provider?.extraUpstreamHeaders) {
404
+ for (const [name, value] of Object.entries(opts.provider.extraUpstreamHeaders())) {
405
+ headers.set(name, value);
406
+ }
407
+ }
408
+ // Some upstreams require a stable per-conversation session id and reject
409
+ // requests without one (OpenCode Go: 400 MissingSessionID). A client that
410
+ // sends its own value keeps it — it was forwarded verbatim above.
411
+ if (opts.sessionId && opts.provider?.sessionHeader) {
412
+ headers.set(opts.provider.sessionHeader, opts.sessionId);
413
+ }
414
+ const options = { method: opts.method, headers };
415
+ if (!opts.bodyText)
214
416
  return options;
215
417
  // Node.js fetch requires `duplex: 'half'` when body is a stream or string
216
418
  // that may be streamed. TypeScript's DOM RequestInit lacks this field.
217
- if (kind === 'standard' && (method === 'GET' || method === 'HEAD'))
419
+ if (opts.kind === 'standard' && (opts.method === 'GET' || opts.method === 'HEAD'))
218
420
  return options;
219
- options.body = bodyText;
421
+ options.body = opts.bodyText;
220
422
  options.duplex = 'half';
221
423
  return options;
222
424
  }
@@ -224,6 +426,7 @@ async function handleUpstreamErrorResponse(response, ctx, key) {
224
426
  const errorBody = await response.text();
225
427
  const errorType = classifyHttpError(response.status, errorBody);
226
428
  logUpstreamError(ctx.requestId, key, response.status, errorType, errorBody, ctx.kind);
429
+ captureFailedRequest(ctx, response.status, errorBody);
227
430
  if (errorType === 'RequestFault') {
228
431
  // Client error — don't retry, don't penalise the key
229
432
  completeRequest(ctx.state, ctx.requestId, false);
@@ -238,13 +441,16 @@ async function handleUpstreamErrorResponse(response, ctx, key) {
238
441
  }
239
442
  function handleNetworkError(err, ctx, key) {
240
443
  const lastError = err instanceof Error ? err : new Error(String(err));
444
+ // Timeouts / connection errors are shared-upstream failures, not per-key
445
+ // faults: penalising them disabled every key during upstream hangs and
446
+ // locked the proxy into "all keys unavailable" until cooldown expired.
241
447
  if (lastError instanceof TimeoutError) {
242
448
  logTimeout(ctx.requestId, key, ctx.config.requestTimeoutMs, ctx.kind);
243
- markKeyFailed(ctx.state, key.label, 'ServerFault');
244
- return { kind: 'retry', lastError };
245
449
  }
246
- logNetworkError(ctx.requestId, key, err, ctx.kind);
247
- markKeyFailed(ctx.state, key.label, 'ServerFault');
450
+ else {
451
+ logNetworkError(ctx.requestId, key, err, ctx.kind);
452
+ }
453
+ markKeyFailed(ctx.state, key.label, 'NetworkFault');
248
454
  return { kind: 'retry', lastError };
249
455
  }
250
456
  function buildNoKeysResponse(state, requestId, kind, attempt) {
@@ -310,6 +516,10 @@ function logNetworkError(requestId, key, err, kind) {
310
516
  const standardSuccessHandler = async (response, state, requestId, keyLabel) => {
311
517
  logger.info({ requestId, keyLabel, status: response.status }, 'Upstream request succeeded');
312
518
  const responseBody = await response.text();
519
+ // Non-streaming bodies report the request's token usage at the top level
520
+ // (OpenAI shape); recording it is what makes `saros-proxy usage` show real
521
+ // consumption for providers with no quota API (CommandCode).
522
+ recordKeyTokens(keyLabel, parseRequestBody(responseBody)?.usage);
313
523
  const downstreamHeaders = buildDownstreamHeaders(response.headers);
314
524
  downstreamHeaders.set('X-Proxy-Key-Label', keyLabel);
315
525
  downstreamHeaders.set('X-Proxy-Request-Id', requestId);
@@ -346,6 +556,7 @@ async function handleWithFailover(opts) {
346
556
  incomingHeaders: opts.incomingHeaders,
347
557
  bodyText: opts.bodyText,
348
558
  kind: 'standard',
559
+ modelId: opts.modelId,
349
560
  onSuccess: standardSuccessHandler,
350
561
  maxRetries: opts.maxRetries ?? MAX_RETRIES,
351
562
  });
@@ -365,6 +576,7 @@ async function handleStreamingRequest(opts) {
365
576
  incomingHeaders: opts.incomingHeaders,
366
577
  bodyText: opts.bodyText,
367
578
  kind: 'streaming',
579
+ modelId: opts.modelId,
368
580
  onSuccess: streamingSuccessHandler,
369
581
  maxRetries: opts.maxRetries ?? MAX_RETRIES,
370
582
  });
@@ -381,11 +593,27 @@ function wrapStreamWithErrorDetection(upstreamStream, state, requestId, keyLabel
381
593
  const decoder = new TextDecoder();
382
594
  const encoder = new TextEncoder();
383
595
  let buffer = '';
596
+ // Upstreams may report the same `usage` on more than one chunk: CommandCode
597
+ // sends it on the finish chunk (`choices[0].finish_reason`) and again on the
598
+ // trailing usage-only chunk (`choices: []`), so booking per chunk would
599
+ // count every streamed request twice. Keep the canonical usage-only chunk
600
+ // (last one wins, OpenAI's `include_usage` contract) and fall back to the
601
+ // finish chunk's copy; book exactly once, when the stream ends.
602
+ let lastUsage = null;
603
+ let usageOnlyChunk = null;
604
+ let usageBooked = false;
605
+ const bookUsage = () => {
606
+ if (usageBooked)
607
+ return;
608
+ usageBooked = true;
609
+ recordKeyTokens(keyLabel, usageOnlyChunk ?? lastUsage);
610
+ };
384
611
  return new ReadableStream({
385
612
  async pull(controller) {
386
613
  try {
387
614
  const { done, value } = await reader.read();
388
615
  if (done) {
616
+ bookUsage();
389
617
  controller.close();
390
618
  return;
391
619
  }
@@ -405,10 +633,20 @@ function wrapStreamWithErrorDetection(upstreamStream, state, requestId, keyLabel
405
633
  if (parsed && (parsed.error || parsed.type === 'error')) {
406
634
  logger.warn({ requestId, keyLabel }, 'Error detected in stream');
407
635
  markKeyFailed(state, keyLabel, 'ServerFault');
636
+ // Whatever the upstream reported before failing was consumed.
637
+ bookUsage();
408
638
  controller.enqueue(encoder.encode('data: {"type":"proxy_error","message":"Upstream stream error"}\n\n'));
409
639
  controller.close();
410
640
  return;
411
641
  }
642
+ // Intermediate chunks send `usage: null`; the counts arrive on the
643
+ // finish chunk and/or the usage-only chunk (see bookUsage).
644
+ if (parsed?.usage) {
645
+ lastUsage = parsed.usage;
646
+ if (Array.isArray(parsed.choices) && parsed.choices.length === 0) {
647
+ usageOnlyChunk = parsed.usage;
648
+ }
649
+ }
412
650
  }
413
651
  catch {
414
652
  // Invalid JSON in SSE event — not an error we can classify, ignore
@@ -420,11 +658,14 @@ function wrapStreamWithErrorDetection(upstreamStream, state, requestId, keyLabel
420
658
  catch (err) {
421
659
  logger.error({ requestId, keyLabel, err }, 'Stream read error');
422
660
  markKeyFailed(state, keyLabel, 'ServerFault');
661
+ bookUsage();
423
662
  controller.enqueue(encoder.encode('data: {"type":"proxy_error","message":"Stream interrupted"}\n\n'));
424
663
  controller.close();
425
664
  }
426
665
  },
427
666
  cancel() {
667
+ // The client hung up, but the upstream already spent the tokens.
668
+ bookUsage();
428
669
  reader.cancel();
429
670
  },
430
671
  });
@@ -437,6 +678,24 @@ export function createProxyApp(config) {
437
678
  circuitBreakerThreshold: config.circuitBreakerThreshold,
438
679
  circuitBreakerCooldownMs: config.circuitBreakerCooldownMs,
439
680
  });
681
+ // Model→provider affinity: requests naming a provider-specific model
682
+ // (e.g. commandcode's vendor-prefixed ids) are routed to a matching key
683
+ // first; 'maybe' providers remain as fallback. Bare-name matching lets
684
+ // shared models (deepseek-v4-flash etc.) rotate across ALL serving keys
685
+ // cross-provider. No-op for single-provider pools and when no provider
686
+ // claims the model.
687
+ state.affinityResolver = (providerId, modelId) => {
688
+ const provider = getProvider(providerId);
689
+ if (!provider)
690
+ return 'maybe';
691
+ const direct = provider.modelAffinity(modelId);
692
+ if (direct !== 'maybe')
693
+ return direct;
694
+ // Bare-name cross-check: 'deepseek-v4-flash' may be commandcode's
695
+ // 'deepseek/deepseek-v4-flash'.
696
+ const bare = (modelId.split('/').pop() ?? modelId).toLowerCase();
697
+ return provider.modelAffinityByName?.(bare) ?? 'maybe';
698
+ };
440
699
  const app = new Hono();
441
700
  // --- Rate limiting (prevent DoS and key exhaustion) ---
442
701
  app.use('*', rateLimiter({
@@ -479,31 +738,26 @@ export function createProxyApp(config) {
479
738
  const enabledCount = keys.filter((k) => k.enabled).length;
480
739
  const disabledCount = keys.length - enabledCount;
481
740
  const activeCount = state.activeRequests.size;
482
- // Build scraping status
483
- const scrapingStatus = config.scraping?.enabled
484
- ? {
485
- enabled: true,
486
- running: isScraperRunning(),
487
- intervalMs: config.scraping.intervalMs,
488
- usageThreshold: config.scraping.usageThreshold,
489
- accounts: Array.from(getAllUsage().entries()).map(([workspaceId, data]) => ({
490
- workspaceId,
491
- usage: data.usage,
492
- lastScrapedAt: data.lastScrapedAt.toISOString(),
493
- lastError: data.lastError ?? null,
494
- })),
495
- }
496
- : { enabled: false };
497
741
  return c.json({
498
742
  status: 'ok',
499
743
  uptime: process.uptime(),
500
744
  keyCount: keys.length,
501
745
  enabledCount,
502
746
  disabledCount,
747
+ // Keys whose account has no plan for their provider (403 entitlement):
748
+ // kept out of rotation until the usage refresh clears the flag.
749
+ entitledCount: keys.filter((k) => k.entitled).length,
503
750
  activeRequests: activeCount,
504
751
  circuitBreakerThreshold: state.circuitBreakerThreshold,
505
752
  circuitBreakerCooldownMs: state.circuitBreakerCooldownMs,
506
- scraping: scrapingStatus,
753
+ // Per-provider key counts (multi-provider pools)
754
+ providers: keys.reduce((acc, k) => {
755
+ const entry = (acc[k.provider] ??= { total: 0, enabled: 0 });
756
+ entry.total++;
757
+ if (k.enabled)
758
+ entry.enabled++;
759
+ return acc;
760
+ }, {}),
507
761
  });
508
762
  });
509
763
  // --- Models discovery endpoint (OpenAI-compatible) ---
@@ -529,8 +783,17 @@ export function createProxyApp(config) {
529
783
  if (bodyText.length > MAX_BODY_SIZE) {
530
784
  return c.json({ error: 'Request body too large', requestId }, 413);
531
785
  }
786
+ maybeRefreshUsage(state, config);
787
+ // Parse once: used for both streaming detection and model tracking
788
+ const parsedBody = parseRequestBody(bodyText);
789
+ // Track most-used models for `saros-proxy usage` (best-effort)
790
+ if (parsedBody) {
791
+ const model = parsedBody.model;
792
+ if (typeof model === 'string' && model.length > 0)
793
+ recordModelRequest(model);
794
+ }
532
795
  // Check for streaming mode
533
- if (bodyText && isStreamingRequest(bodyText)) {
796
+ if (parsedBody?.stream === true) {
534
797
  logger.info({ requestId, method, path }, 'Streaming request detected');
535
798
  return handleStreamingRequest({
536
799
  state,
@@ -540,6 +803,7 @@ export function createProxyApp(config) {
540
803
  path,
541
804
  incomingHeaders,
542
805
  bodyText,
806
+ modelId: typeof parsedBody.model === 'string' ? parsedBody.model : undefined,
543
807
  });
544
808
  }
545
809
  // Non-streaming request with failover
@@ -551,6 +815,7 @@ export function createProxyApp(config) {
551
815
  path,
552
816
  incomingHeaders,
553
817
  bodyText,
818
+ modelId: typeof parsedBody?.model === 'string' ? parsedBody.model : undefined,
554
819
  });
555
820
  }
556
821
  // Unified catch-all: rewrites /v1/* → /zen/go/v1/* for upstream forwarding