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.
- package/LICENSE +0 -0
- package/README.md +116 -64
- package/dist/cli/autostart.d.ts +0 -0
- package/dist/cli/autostart.js +0 -0
- package/dist/cli/autostart.js.map +0 -0
- package/dist/cli/daemon.d.ts +0 -0
- package/dist/cli/daemon.js +4 -0
- package/dist/cli/daemon.js.map +1 -1
- package/dist/cli/dsh-config.d.ts +32 -0
- package/dist/cli/dsh-config.js +122 -0
- package/dist/cli/dsh-config.js.map +1 -0
- package/dist/cli/harness-models.d.ts +26 -2
- package/dist/cli/harness-models.js +192 -2
- package/dist/cli/harness-models.js.map +1 -1
- package/dist/cli/harness-sync.d.ts +3 -6
- package/dist/cli/harness-sync.js +14 -7
- package/dist/cli/harness-sync.js.map +1 -1
- package/dist/cli/help.d.ts +0 -0
- package/dist/cli/help.js +2 -2
- package/dist/cli/help.js.map +0 -0
- package/dist/cli/omp-config.d.ts +0 -0
- package/dist/cli/omp-config.js +11 -1
- package/dist/cli/omp-config.js.map +1 -1
- package/dist/cli/opencode-config.d.ts +0 -0
- package/dist/cli/opencode-config.js +0 -0
- package/dist/cli/opencode-config.js.map +0 -0
- package/dist/cli/overview.d.ts +0 -0
- package/dist/cli/overview.js +2 -1
- package/dist/cli/overview.js.map +1 -1
- package/dist/cli/pi-config.d.ts +0 -0
- package/dist/cli/pi-config.js +11 -1
- package/dist/cli/pi-config.js.map +1 -1
- package/dist/cli/setup.d.ts +6 -10
- package/dist/cli/setup.js +100 -232
- package/dist/cli/setup.js.map +1 -1
- package/dist/cli/ui.d.ts +1 -2
- package/dist/cli/ui.js +0 -16
- package/dist/cli/ui.js.map +1 -1
- package/dist/cli/update-check.d.ts +0 -0
- package/dist/cli/update-check.js +0 -0
- package/dist/cli/update-check.js.map +0 -0
- package/dist/cli/usage.d.ts +12 -0
- package/dist/cli/usage.js +182 -0
- package/dist/cli/usage.js.map +1 -0
- package/dist/config.d.ts +13 -17
- package/dist/config.js +29 -78
- package/dist/config.js.map +1 -1
- package/dist/constants.d.ts +9 -5
- package/dist/constants.js +14 -5
- package/dist/constants.js.map +1 -1
- package/dist/index.d.ts +0 -0
- package/dist/index.js +9 -25
- package/dist/index.js.map +1 -1
- package/dist/key-encryption.d.ts +0 -0
- package/dist/key-encryption.js +0 -0
- package/dist/key-encryption.js.map +0 -0
- package/dist/logger.d.ts +0 -0
- package/dist/logger.js +0 -0
- package/dist/logger.js.map +0 -0
- package/dist/model-probe.d.ts +0 -0
- package/dist/model-probe.js +0 -0
- package/dist/model-probe.js.map +0 -0
- package/dist/model-stats.d.ts +20 -0
- package/dist/model-stats.js +135 -0
- package/dist/model-stats.js.map +1 -0
- package/dist/models-fetcher.d.ts +0 -0
- package/dist/models-fetcher.js +0 -0
- package/dist/models-fetcher.js.map +0 -0
- package/dist/models-sync.d.ts +0 -0
- package/dist/models-sync.js +0 -0
- package/dist/models-sync.js.map +0 -0
- package/dist/probe-cache.d.ts +0 -0
- package/dist/probe-cache.js +0 -0
- package/dist/probe-cache.js.map +0 -0
- package/dist/providers/commandcode.d.ts +17 -0
- package/dist/providers/commandcode.js +237 -0
- package/dist/providers/commandcode.js.map +1 -0
- package/dist/providers/index.d.ts +43 -0
- package/dist/providers/index.js +75 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/providers/infer.d.ts +23 -0
- package/dist/providers/infer.js +13 -0
- package/dist/providers/infer.js.map +1 -0
- package/dist/providers/opencode-go.d.ts +4 -0
- package/dist/providers/opencode-go.js +190 -0
- package/dist/providers/opencode-go.js.map +1 -0
- package/dist/providers/types.d.ts +135 -0
- package/dist/providers/types.js +10 -0
- package/dist/providers/types.js.map +1 -0
- package/dist/proxy-logic.d.ts +61 -22
- package/dist/proxy-logic.js +163 -106
- package/dist/proxy-logic.js.map +1 -1
- package/dist/proxy.d.ts +43 -2
- package/dist/proxy.js +327 -62
- package/dist/proxy.js.map +1 -1
- package/dist/token-usage.d.ts +53 -0
- package/dist/token-usage.js +198 -0
- package/dist/token-usage.js.map +1 -0
- package/dist/usage-client.d.ts +62 -0
- package/dist/usage-client.js +155 -0
- package/dist/usage-client.js.map +1 -0
- package/dist/validation.d.ts +5 -5
- package/dist/validation.js +7 -9
- package/dist/validation.js.map +1 -1
- package/package.json +11 -7
- package/dist/dashboard-parser.d.ts +0 -47
- package/dist/dashboard-parser.js +0 -88
- package/dist/dashboard-parser.js.map +0 -1
- package/dist/firefox-cookies.d.ts +0 -42
- package/dist/firefox-cookies.js +0 -170
- package/dist/firefox-cookies.js.map +0 -1
- package/dist/scraper.d.ts +0 -70
- package/dist/scraper.js +0 -239
- 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
|
-
*
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
208
|
-
: failoverRequest(ctx.state, ctx.requestId
|
|
397
|
+
? selectKeyForRequest(ctx.state, ctx.requestId, ctx.modelId)
|
|
398
|
+
: failoverRequest(ctx.state, ctx.requestId);
|
|
209
399
|
}
|
|
210
|
-
function buildFetchOptions(
|
|
211
|
-
const headers = buildUpstreamHeaders(incomingHeaders, bearerToken);
|
|
212
|
-
|
|
213
|
-
if (
|
|
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
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|