@evomap/evolver-proxy 2.0.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/dist/bin/envFile.d.ts +10 -0
- package/dist/bin/envFile.js +68 -0
- package/dist/bin/evolver-llm-proxy.d.ts +2 -0
- package/dist/bin/evolver-llm-proxy.js +111 -0
- package/dist/bin/evolver-proxy.d.ts +83 -0
- package/dist/bin/evolver-proxy.js +511 -0
- package/dist/bin/proxySettings.d.ts +15 -0
- package/dist/bin/proxySettings.js +84 -0
- package/dist/bin/proxyStorePath.d.ts +1 -0
- package/dist/bin/proxyStorePath.js +16 -0
- package/dist/daemon/atpConsent.d.ts +13 -0
- package/dist/daemon/atpConsent.js +60 -0
- package/dist/daemon/ipcConfig.d.ts +1 -0
- package/dist/daemon/ipcConfig.js +13 -0
- package/dist/daemon/proxyDaemon.d.ts +191 -0
- package/dist/daemon/proxyDaemon.js +1015 -0
- package/dist/daemon/selectHub.d.ts +16 -0
- package/dist/daemon/selectHub.js +30 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/lifecycle/deployGuard.d.ts +46 -0
- package/dist/lifecycle/deployGuard.js +53 -0
- package/dist/lifecycle/legacyNodeId.d.ts +96 -0
- package/dist/lifecycle/legacyNodeId.js +163 -0
- package/dist/lifecycle/manager.d.ts +106 -0
- package/dist/lifecycle/manager.js +390 -0
- package/dist/llm/bodyCapture.d.ts +31 -0
- package/dist/llm/bodyCapture.js +293 -0
- package/dist/llm/index.d.ts +3 -0
- package/dist/llm/index.js +3 -0
- package/dist/llm/server.d.ts +35 -0
- package/dist/llm/server.js +359 -0
- package/dist/llm/traceBackfill.d.ts +53 -0
- package/dist/llm/traceBackfill.js +525 -0
- package/dist/llm/traceConfig.d.ts +7 -0
- package/dist/llm/traceConfig.js +44 -0
- package/dist/llm/traceControl.d.ts +16 -0
- package/dist/llm/traceControl.js +85 -0
- package/dist/llm/traceEnvelope.d.ts +75 -0
- package/dist/llm/traceEnvelope.js +286 -0
- package/dist/llm/traceSink.d.ts +61 -0
- package/dist/llm/traceSink.js +278 -0
- package/dist/llm/traceUploadPayload.d.ts +14 -0
- package/dist/llm/traceUploadPayload.js +27 -0
- package/dist/llm/upstream.d.ts +68 -0
- package/dist/llm/upstream.js +491 -0
- package/dist/private/adapterLoader.d.ts +46 -0
- package/dist/private/adapterLoader.js +62 -0
- package/dist/private/privateRuntimeSmokeOptions.d.ts +15 -0
- package/dist/private/privateRuntimeSmokeOptions.js +132 -0
- package/dist/router/cachePassthrough.d.ts +3 -0
- package/dist/router/cachePassthrough.js +13 -0
- package/dist/router/features.d.ts +9 -0
- package/dist/router/features.js +52 -0
- package/dist/router/index.d.ts +6 -0
- package/dist/router/index.js +6 -0
- package/dist/router/messagesRoute.d.ts +138 -0
- package/dist/router/messagesRoute.js +753 -0
- package/dist/router/modelRouter.d.ts +49 -0
- package/dist/router/modelRouter.js +66 -0
- package/dist/router/providerRoutes.d.ts +42 -0
- package/dist/router/providerRoutes.js +1579 -0
- package/dist/router/sseScan.d.ts +56 -0
- package/dist/router/sseScan.js +543 -0
- package/dist/selfUpdate/executor.d.ts +90 -0
- package/dist/selfUpdate/executor.js +179 -0
- package/dist/selfUpdate/failureCodes.d.ts +33 -0
- package/dist/selfUpdate/failureCodes.js +84 -0
- package/dist/selfUpdate/index.d.ts +5 -0
- package/dist/selfUpdate/index.js +5 -0
- package/dist/selfUpdate/lastUpdate.d.ts +43 -0
- package/dist/selfUpdate/lastUpdate.js +195 -0
- package/dist/selfUpdate/policy.d.ts +3 -0
- package/dist/selfUpdate/policy.js +9 -0
- package/dist/selfUpdate/releaseBinary.d.ts +56 -0
- package/dist/selfUpdate/releaseBinary.js +498 -0
- package/dist/selfUpdate/version.d.ts +2 -0
- package/dist/selfUpdate/version.js +14 -0
- package/dist/sync/engine.d.ts +65 -0
- package/dist/sync/engine.js +461 -0
- package/package.json +41 -0
|
@@ -0,0 +1,1579 @@
|
|
|
1
|
+
import { captureTraceMetadata, extractThinkingEffort } from './messagesRoute.js';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { teeStreamForScan } from './sseScan.js';
|
|
4
|
+
import { bodyMaxChars, captureBodiesEnabled, captureBody, REDACTION_VERSION, positiveIntegerFromEnv, redactText, stableUserIdHash } from '../llm/bodyCapture.js';
|
|
5
|
+
import { rewriteModel } from './cachePassthrough.js';
|
|
6
|
+
import { PLAN_RE, SIMPLE_LOOKUP_MAX_CHARS } from './features.js';
|
|
7
|
+
import { pickForTurn } from './modelRouter.js';
|
|
8
|
+
export const OPENAI_RESPONSE_HEADER_ALLOWLIST = new Set([
|
|
9
|
+
'openai-processing-ms',
|
|
10
|
+
'openai-version',
|
|
11
|
+
'retry-after',
|
|
12
|
+
'x-request-id',
|
|
13
|
+
]);
|
|
14
|
+
export const GEMINI_RESPONSE_HEADER_ALLOWLIST = new Set([
|
|
15
|
+
'content-type',
|
|
16
|
+
'retry-after',
|
|
17
|
+
'x-request-id',
|
|
18
|
+
]);
|
|
19
|
+
const CREDENTIAL_QUERY_PARAMS = new Set([
|
|
20
|
+
'key',
|
|
21
|
+
'api_key',
|
|
22
|
+
'apikey',
|
|
23
|
+
'access_token',
|
|
24
|
+
'token',
|
|
25
|
+
'authorization',
|
|
26
|
+
'auth',
|
|
27
|
+
'client_secret',
|
|
28
|
+
'refresh_token',
|
|
29
|
+
'id_token',
|
|
30
|
+
'signature',
|
|
31
|
+
'sig',
|
|
32
|
+
]);
|
|
33
|
+
const GEMINI_VERTEX_ACTIONS = new Set([
|
|
34
|
+
'generateContent',
|
|
35
|
+
'streamGenerateContent',
|
|
36
|
+
'countTokens',
|
|
37
|
+
'embedContent',
|
|
38
|
+
'batchEmbedContents',
|
|
39
|
+
]);
|
|
40
|
+
const DEFAULT_PROVIDER_STREAM_MAX_EVENTS = 100_000;
|
|
41
|
+
const DEFAULT_PROVIDER_STREAM_SEMANTIC_TAIL_EVENTS = 100_000;
|
|
42
|
+
const MAX_PROVIDER_STREAM_LINE_SCAN_TAIL_BYTES = 128 * 1024;
|
|
43
|
+
// FIX-11: a single streamed SSE event can legitimately exceed the per-FIELD body cap (a big tool-call argument
|
|
44
|
+
// blob, a long reasoning event). When the whole line overflows we can no longer JSON-parse it and fall back to a
|
|
45
|
+
// lossy regex tail scan that drops tool calls / semantic events. Give the line scanner a higher floor than the
|
|
46
|
+
// body-field cap so far more events stay fully parseable; the captured body is still size-capped downstream by
|
|
47
|
+
// captureBody(), so this only widens what we can structurally extract, it does not bloat stored bodies.
|
|
48
|
+
const DEFAULT_PROVIDER_STREAM_LINE_MAX_BYTES = 2 * 1024 * 1024;
|
|
49
|
+
function providerStreamCaptureLimits(env = process.env) {
|
|
50
|
+
const bodyMax = bodyMaxChars(env);
|
|
51
|
+
return {
|
|
52
|
+
lineMaxBytes: positiveIntegerFromEnv(env, [
|
|
53
|
+
'EVOLVER_LLM_TRACE_PROVIDER_STREAM_LINE_MAX_BYTES',
|
|
54
|
+
'EVOMAP_PROXY_TRACE_PROVIDER_STREAM_LINE_MAX_BYTES',
|
|
55
|
+
'EVOLVER_LLM_TRACE_STREAM_LINE_MAX_BYTES',
|
|
56
|
+
'EVOMAP_PROXY_TRACE_STREAM_LINE_MAX_BYTES',
|
|
57
|
+
], Math.max(bodyMax, DEFAULT_PROVIDER_STREAM_LINE_MAX_BYTES)),
|
|
58
|
+
chunkMaxEvents: positiveIntegerFromEnv(env, [
|
|
59
|
+
'EVOLVER_LLM_TRACE_PROVIDER_STREAM_MAX_EVENTS',
|
|
60
|
+
'EVOMAP_PROXY_TRACE_PROVIDER_STREAM_MAX_EVENTS',
|
|
61
|
+
'EVOLVER_LLM_TRACE_STREAM_MAX_EVENTS',
|
|
62
|
+
'EVOMAP_PROXY_TRACE_STREAM_MAX_EVENTS',
|
|
63
|
+
], DEFAULT_PROVIDER_STREAM_MAX_EVENTS),
|
|
64
|
+
chunkMaxBytes: positiveIntegerFromEnv(env, [
|
|
65
|
+
'EVOLVER_LLM_TRACE_PROVIDER_STREAM_MAX_BYTES',
|
|
66
|
+
'EVOMAP_PROXY_TRACE_PROVIDER_STREAM_MAX_BYTES',
|
|
67
|
+
'EVOLVER_LLM_TRACE_STREAM_MAX_BYTES',
|
|
68
|
+
'EVOMAP_PROXY_TRACE_STREAM_MAX_BYTES',
|
|
69
|
+
], bodyMax),
|
|
70
|
+
rawStreamMaxChars: positiveIntegerFromEnv(env, [
|
|
71
|
+
'EVOLVER_LLM_TRACE_PROVIDER_RAW_STREAM_MAX_CHARS',
|
|
72
|
+
'EVOMAP_PROXY_TRACE_PROVIDER_RAW_STREAM_MAX_CHARS',
|
|
73
|
+
'EVOLVER_LLM_TRACE_STREAM_RAW_MAX_CHARS',
|
|
74
|
+
'EVOMAP_PROXY_TRACE_STREAM_RAW_MAX_CHARS',
|
|
75
|
+
], bodyMax),
|
|
76
|
+
semanticTailMaxEvents: positiveIntegerFromEnv(env, [
|
|
77
|
+
'EVOLVER_LLM_TRACE_PROVIDER_STREAM_SEMANTIC_TAIL_MAX_EVENTS',
|
|
78
|
+
'EVOMAP_PROXY_TRACE_PROVIDER_STREAM_SEMANTIC_TAIL_MAX_EVENTS',
|
|
79
|
+
'EVOLVER_LLM_TRACE_STREAM_SEMANTIC_TAIL_MAX_EVENTS',
|
|
80
|
+
'EVOMAP_PROXY_TRACE_STREAM_SEMANTIC_TAIL_MAX_EVENTS',
|
|
81
|
+
], DEFAULT_PROVIDER_STREAM_SEMANTIC_TAIL_EVENTS),
|
|
82
|
+
semanticTailMaxBytes: positiveIntegerFromEnv(env, [
|
|
83
|
+
'EVOLVER_LLM_TRACE_PROVIDER_STREAM_SEMANTIC_TAIL_MAX_BYTES',
|
|
84
|
+
'EVOMAP_PROXY_TRACE_PROVIDER_STREAM_SEMANTIC_TAIL_MAX_BYTES',
|
|
85
|
+
'EVOLVER_LLM_TRACE_STREAM_SEMANTIC_TAIL_MAX_BYTES',
|
|
86
|
+
'EVOMAP_PROXY_TRACE_STREAM_SEMANTIC_TAIL_MAX_BYTES',
|
|
87
|
+
], bodyMax),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const OPENAI_ROUTER_ENABLED_KEYS = ['EVOLVER_LLM_OPENAI_ROUTER_ENABLED', 'EVOMAP_OPENAI_ROUTER_ENABLED'];
|
|
91
|
+
const OPENAI_ROUTE_THREADED_KEYS = ['EVOLVER_LLM_OPENAI_ROUTE_THREADED', 'EVOMAP_OPENAI_ROUTE_THREADED'];
|
|
92
|
+
const OPENAI_ROUTED_RETRY_STATUSES = new Set([400, 404, 422]);
|
|
93
|
+
const ROUTER_FALSE_VALUES = new Set(['0', 'false', 'off', 'no', 'none']);
|
|
94
|
+
function jsonStringify(o) {
|
|
95
|
+
return JSON.stringify(o);
|
|
96
|
+
}
|
|
97
|
+
function copyResponseHeaders(headers = {}, allow) {
|
|
98
|
+
const out = {};
|
|
99
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
100
|
+
const lower = name.toLowerCase();
|
|
101
|
+
if (!allow(lower) || value === undefined || value === null)
|
|
102
|
+
continue;
|
|
103
|
+
if (/[\r\n]/.test(value))
|
|
104
|
+
continue;
|
|
105
|
+
out[lower] = value;
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
export function copyOpenAIResponseHeaders(headers = {}) {
|
|
110
|
+
return copyResponseHeaders(headers, (lower) => OPENAI_RESPONSE_HEADER_ALLOWLIST.has(lower) || lower.startsWith('x-ratelimit-'));
|
|
111
|
+
}
|
|
112
|
+
export function copyGeminiResponseHeaders(headers = {}) {
|
|
113
|
+
return copyResponseHeaders(headers, (lower) => GEMINI_RESPONSE_HEADER_ALLOWLIST.has(lower) || lower.startsWith('x-goog-'));
|
|
114
|
+
}
|
|
115
|
+
function responseToBody(raw, status, headers, log, event) {
|
|
116
|
+
if (!raw)
|
|
117
|
+
return {};
|
|
118
|
+
try {
|
|
119
|
+
return JSON.parse(raw);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
log.warn?.(jsonStringify({
|
|
123
|
+
event,
|
|
124
|
+
reason: 'upstream_non_json',
|
|
125
|
+
upstream_status: status,
|
|
126
|
+
content_type: headers?.['content-type'] || '',
|
|
127
|
+
response_bytes: Buffer.byteLength(raw),
|
|
128
|
+
}));
|
|
129
|
+
return { error: raw };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function attemptResponseToBody(raw) {
|
|
133
|
+
if (!raw)
|
|
134
|
+
return {};
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(raw);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return { error: raw };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function asUpstreamError(provider, err, fallback = 502) {
|
|
143
|
+
const sc = err && typeof err === 'object' && 'statusCode' in err ? Number(err.statusCode) : NaN;
|
|
144
|
+
return Object.assign(new Error(`${provider} upstream request failed`), {
|
|
145
|
+
statusCode: Number.isFinite(sc) ? sc : fallback,
|
|
146
|
+
cause: err,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
function badRequest(message) {
|
|
150
|
+
return Object.assign(new Error(message), { statusCode: 400 });
|
|
151
|
+
}
|
|
152
|
+
function isRecord(value) {
|
|
153
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
154
|
+
}
|
|
155
|
+
function envEnabled(env, keys) {
|
|
156
|
+
for (const key of keys) {
|
|
157
|
+
const raw = env[key];
|
|
158
|
+
if (raw === undefined)
|
|
159
|
+
continue;
|
|
160
|
+
const normalized = String(raw).trim().toLowerCase();
|
|
161
|
+
if (!normalized || ROUTER_FALSE_VALUES.has(normalized))
|
|
162
|
+
return false;
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
function routeSpecificOpenAIModelKeys(route, tier) {
|
|
168
|
+
if (route === '/v1/responses')
|
|
169
|
+
return [`EVOLVER_LLM_OPENAI_RESPONSES_MODEL_${tier}`, `EVOMAP_OPENAI_RESPONSES_MODEL_${tier}`];
|
|
170
|
+
return [`EVOLVER_LLM_OPENAI_CHAT_MODEL_${tier}`, `EVOMAP_OPENAI_CHAT_MODEL_${tier}`];
|
|
171
|
+
}
|
|
172
|
+
export function resolveOpenAITierModels(env = process.env, route) {
|
|
173
|
+
const out = {};
|
|
174
|
+
const first = (keys) => {
|
|
175
|
+
for (const key of keys) {
|
|
176
|
+
const value = env[key];
|
|
177
|
+
if (typeof value === 'string' && value.trim())
|
|
178
|
+
return value.trim();
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
};
|
|
182
|
+
const tierKeys = (tier) => [
|
|
183
|
+
...(route ? routeSpecificOpenAIModelKeys(route, tier) : []),
|
|
184
|
+
`EVOLVER_LLM_OPENAI_MODEL_${tier}`,
|
|
185
|
+
`EVOMAP_OPENAI_MODEL_${tier}`,
|
|
186
|
+
];
|
|
187
|
+
const cheap = first(tierKeys('CHEAP'));
|
|
188
|
+
const mid = first(tierKeys('MID'));
|
|
189
|
+
const expensive = first(tierKeys('EXPENSIVE'));
|
|
190
|
+
if (cheap)
|
|
191
|
+
out.cheap = cheap;
|
|
192
|
+
if (mid)
|
|
193
|
+
out.mid = mid;
|
|
194
|
+
if (expensive)
|
|
195
|
+
out.expensive = expensive;
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
function textFromContent(content) {
|
|
199
|
+
if (typeof content === 'string')
|
|
200
|
+
return content;
|
|
201
|
+
if (!Array.isArray(content))
|
|
202
|
+
return '';
|
|
203
|
+
return content.map((part) => {
|
|
204
|
+
if (typeof part === 'string')
|
|
205
|
+
return part;
|
|
206
|
+
if (!isRecord(part))
|
|
207
|
+
return '';
|
|
208
|
+
for (const key of ['text', 'input_text', 'output_text', 'content']) {
|
|
209
|
+
const value = part[key];
|
|
210
|
+
if (typeof value === 'string')
|
|
211
|
+
return value;
|
|
212
|
+
}
|
|
213
|
+
return '';
|
|
214
|
+
}).filter(Boolean).join('\n');
|
|
215
|
+
}
|
|
216
|
+
function isOpenAIToolOutput(value) {
|
|
217
|
+
if (!isRecord(value))
|
|
218
|
+
return false;
|
|
219
|
+
const type = typeof value['type'] === 'string' ? value['type'] : '';
|
|
220
|
+
const role = typeof value['role'] === 'string' ? value['role'] : '';
|
|
221
|
+
return type === 'function_call_output' || type === 'tool_result' || role === 'tool';
|
|
222
|
+
}
|
|
223
|
+
function openAIResponsesInputText(input) {
|
|
224
|
+
if (typeof input === 'string')
|
|
225
|
+
return input;
|
|
226
|
+
if (!Array.isArray(input))
|
|
227
|
+
return '';
|
|
228
|
+
for (let i = input.length - 1; i >= 0; i--) {
|
|
229
|
+
const item = input[i];
|
|
230
|
+
if (!isRecord(item) || isOpenAIToolOutput(item))
|
|
231
|
+
continue;
|
|
232
|
+
const role = typeof item['role'] === 'string' ? item['role'] : '';
|
|
233
|
+
if (role && role !== 'user')
|
|
234
|
+
continue;
|
|
235
|
+
const direct = textFromContent(item['content']);
|
|
236
|
+
if (direct)
|
|
237
|
+
return direct;
|
|
238
|
+
const text = textFromContent(item['text']);
|
|
239
|
+
if (text)
|
|
240
|
+
return text;
|
|
241
|
+
}
|
|
242
|
+
return '';
|
|
243
|
+
}
|
|
244
|
+
function openAIChatLastUserText(messages) {
|
|
245
|
+
if (!Array.isArray(messages))
|
|
246
|
+
return '';
|
|
247
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
248
|
+
const msg = messages[i];
|
|
249
|
+
if (!isRecord(msg) || msg['role'] !== 'user')
|
|
250
|
+
continue;
|
|
251
|
+
return textFromContent(msg['content']);
|
|
252
|
+
}
|
|
253
|
+
return '';
|
|
254
|
+
}
|
|
255
|
+
function openAIChatLastToolCallCount(messages) {
|
|
256
|
+
if (!Array.isArray(messages))
|
|
257
|
+
return 0;
|
|
258
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
259
|
+
const msg = messages[i];
|
|
260
|
+
if (!isRecord(msg) || msg['role'] !== 'assistant')
|
|
261
|
+
continue;
|
|
262
|
+
const toolCalls = msg['tool_calls'];
|
|
263
|
+
return Array.isArray(toolCalls) ? toolCalls.length : 0;
|
|
264
|
+
}
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
function featuresFromText(userText, toolOnly, toolCallCount) {
|
|
268
|
+
const trimmed = userText.trim();
|
|
269
|
+
const userRequestedPlanning = trimmed.length > 0 && PLAN_RE.test(trimmed);
|
|
270
|
+
return {
|
|
271
|
+
last_assistant_tool_call_count: toolCallCount,
|
|
272
|
+
last_assistant_had_tool_call: toolCallCount > 0,
|
|
273
|
+
last_user_is_tool_result_only: toolOnly,
|
|
274
|
+
user_requested_planning: userRequestedPlanning,
|
|
275
|
+
user_simple_lookup: trimmed.length > 0 && !toolOnly && !userRequestedPlanning && trimmed.length <= SIMPLE_LOOKUP_MAX_CHARS,
|
|
276
|
+
last_assistant_output_tokens: 0,
|
|
277
|
+
last_assistant_stop_reason: toolOnly || toolCallCount > 0 ? 'ToolUse' : null,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
function extractOpenAIFeatures(route, body) {
|
|
281
|
+
if (route === '/v1/chat/completions') {
|
|
282
|
+
const messages = body['messages'];
|
|
283
|
+
const tail = Array.isArray(messages) ? messages[messages.length - 1] : null;
|
|
284
|
+
const toolOnly = isOpenAIToolOutput(tail);
|
|
285
|
+
return featuresFromText(openAIChatLastUserText(messages), toolOnly, openAIChatLastToolCallCount(messages));
|
|
286
|
+
}
|
|
287
|
+
const input = body['input'];
|
|
288
|
+
const toolOnly = Array.isArray(input) && input.length > 0 && input.every(isOpenAIToolOutput);
|
|
289
|
+
const functionCallCount = Array.isArray(input)
|
|
290
|
+
? input.filter((item) => isRecord(item) && item['type'] === 'function_call').length
|
|
291
|
+
: 0;
|
|
292
|
+
return featuresFromText(openAIResponsesInputText(input), toolOnly, functionCallCount);
|
|
293
|
+
}
|
|
294
|
+
function hasOpenAIResponsesThreadState(body) {
|
|
295
|
+
return body['previous_response_id'] !== undefined || body['conversation'] !== undefined;
|
|
296
|
+
}
|
|
297
|
+
function shouldRetryRoutedOpenAI(upstream, opts) {
|
|
298
|
+
if (upstream.stream || opts.provider !== 'openai')
|
|
299
|
+
return false;
|
|
300
|
+
if (!opts.retryBody || !opts.chosenModel || opts.chosenModel === opts.model)
|
|
301
|
+
return false;
|
|
302
|
+
return upstream.status >= 500 || OPENAI_ROUTED_RETRY_STATUSES.has(upstream.status);
|
|
303
|
+
}
|
|
304
|
+
function routeOpenAIRequest(route, body, env, log) {
|
|
305
|
+
const originalModel = typeof body.model === 'string' ? body.model : null;
|
|
306
|
+
let chosenModel = originalModel;
|
|
307
|
+
let tier = null;
|
|
308
|
+
let reason = null;
|
|
309
|
+
let fallback = null;
|
|
310
|
+
let features;
|
|
311
|
+
const routerEnabled = envEnabled(env, OPENAI_ROUTER_ENABLED_KEYS);
|
|
312
|
+
if (!routerEnabled)
|
|
313
|
+
return { body, originalModel, chosenModel, tier, reason, fallback, routerEnabled };
|
|
314
|
+
if (route === '/v1/responses' && hasOpenAIResponsesThreadState(body) && !envEnabled(env, OPENAI_ROUTE_THREADED_KEYS)) {
|
|
315
|
+
return { body, originalModel, chosenModel, tier, reason, fallback: 'threaded_passthrough', routerEnabled };
|
|
316
|
+
}
|
|
317
|
+
if (!originalModel) {
|
|
318
|
+
return { body, originalModel, chosenModel, tier, reason, fallback: 'missing_model', routerEnabled };
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
features = extractOpenAIFeatures(route, body);
|
|
322
|
+
const decision = pickForTurn({
|
|
323
|
+
features,
|
|
324
|
+
router_state: { history: [], pinned: null },
|
|
325
|
+
config: { default_tier: 'mid', disable: false, hard_pin_after_plan: false },
|
|
326
|
+
});
|
|
327
|
+
tier = decision.tier;
|
|
328
|
+
reason = decision.reason;
|
|
329
|
+
const tierModel = resolveOpenAITierModels(env, route)[decision.tier];
|
|
330
|
+
if (tierModel)
|
|
331
|
+
chosenModel = tierModel;
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
fallback = 'classifier_error';
|
|
335
|
+
log.warn?.(jsonStringify({ event: 'router_fallback', route, reason: fallback, original_model: originalModel, error: err instanceof Error ? err.message : String(err) }));
|
|
336
|
+
}
|
|
337
|
+
let routedBody = body;
|
|
338
|
+
if (chosenModel && chosenModel !== originalModel) {
|
|
339
|
+
try {
|
|
340
|
+
routedBody = rewriteModel(body, chosenModel);
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
fallback = fallback ?? 'rewrite_error';
|
|
344
|
+
chosenModel = originalModel;
|
|
345
|
+
routedBody = body;
|
|
346
|
+
log.warn?.(jsonStringify({ event: 'router_fallback', route, reason: fallback, original_model: originalModel, error: err instanceof Error ? err.message : String(err) }));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
log.log?.(jsonStringify({ event: 'router_decision', route, tier, reason, original_model: originalModel, chosen_model: chosenModel, fallback }));
|
|
350
|
+
return { body: routedBody, originalModel, chosenModel, tier, reason, fallback, routerEnabled, ...(features ? { features } : {}) };
|
|
351
|
+
}
|
|
352
|
+
function setUsageNumber(usage, key, value) {
|
|
353
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
354
|
+
usage[key] = value;
|
|
355
|
+
}
|
|
356
|
+
function usageOrUndefined(usage) {
|
|
357
|
+
return Object.keys(usage).length > 0 ? usage : undefined;
|
|
358
|
+
}
|
|
359
|
+
function mergeTraceMeta(into, next) {
|
|
360
|
+
if (next.usage)
|
|
361
|
+
into.usage = { ...(into.usage ?? {}), ...next.usage };
|
|
362
|
+
if (next.stop_reason !== undefined)
|
|
363
|
+
into.stop_reason = next.stop_reason;
|
|
364
|
+
if (next.response_id !== undefined)
|
|
365
|
+
into.response_id = next.response_id;
|
|
366
|
+
if (next.error !== undefined)
|
|
367
|
+
into.error = next.error;
|
|
368
|
+
}
|
|
369
|
+
function responseIdFromBody(body) {
|
|
370
|
+
if (!isRecord(body))
|
|
371
|
+
return undefined;
|
|
372
|
+
const id = body['id'];
|
|
373
|
+
if (typeof id === 'string' && id.length > 0)
|
|
374
|
+
return id;
|
|
375
|
+
const response = body['response'];
|
|
376
|
+
if (isRecord(response) && typeof response['id'] === 'string' && response['id'].length > 0)
|
|
377
|
+
return response['id'];
|
|
378
|
+
return undefined;
|
|
379
|
+
}
|
|
380
|
+
function openAIResponsesMeta(body) {
|
|
381
|
+
const o = isRecord(body) && isRecord(body['response']) ? body['response'] : body;
|
|
382
|
+
if (!isRecord(o))
|
|
383
|
+
return {};
|
|
384
|
+
const out = {};
|
|
385
|
+
if (isRecord(o['usage'])) {
|
|
386
|
+
const u = o['usage'];
|
|
387
|
+
const usage = {};
|
|
388
|
+
setUsageNumber(usage, 'input_tokens', u['input_tokens']);
|
|
389
|
+
setUsageNumber(usage, 'output_tokens', u['output_tokens']);
|
|
390
|
+
setUsageNumber(usage, 'cache_creation_input_tokens', u['cache_creation_input_tokens']);
|
|
391
|
+
setUsageNumber(usage, 'cache_read_input_tokens', u['cache_read_input_tokens']);
|
|
392
|
+
const mapped = usageOrUndefined(usage);
|
|
393
|
+
if (mapped)
|
|
394
|
+
out.usage = mapped;
|
|
395
|
+
}
|
|
396
|
+
const incomplete = isRecord(o['incomplete_details']) ? o['incomplete_details']['reason'] : undefined;
|
|
397
|
+
if (typeof incomplete === 'string')
|
|
398
|
+
out.stop_reason = incomplete;
|
|
399
|
+
else if (typeof o['status'] === 'string')
|
|
400
|
+
out.stop_reason = o['status'];
|
|
401
|
+
if ((o['status'] === 'failed' || o['status'] === 'cancelled') && isRecord(o['error'])) {
|
|
402
|
+
const message = o['error']['message'];
|
|
403
|
+
if (typeof message === 'string' && message.trim())
|
|
404
|
+
out.error = message;
|
|
405
|
+
}
|
|
406
|
+
const responseId = responseIdFromBody(body) ?? responseIdFromBody(o);
|
|
407
|
+
if (responseId)
|
|
408
|
+
out.response_id = responseId;
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
function openAIChatMeta(body) {
|
|
412
|
+
if (!isRecord(body))
|
|
413
|
+
return {};
|
|
414
|
+
const out = {};
|
|
415
|
+
if (isRecord(body['usage'])) {
|
|
416
|
+
const u = body['usage'];
|
|
417
|
+
const usage = {};
|
|
418
|
+
setUsageNumber(usage, 'input_tokens', u['prompt_tokens']);
|
|
419
|
+
setUsageNumber(usage, 'output_tokens', u['completion_tokens']);
|
|
420
|
+
const mapped = usageOrUndefined(usage);
|
|
421
|
+
if (mapped)
|
|
422
|
+
out.usage = mapped;
|
|
423
|
+
}
|
|
424
|
+
const choices = body['choices'];
|
|
425
|
+
const first = Array.isArray(choices) && isRecord(choices[0]) ? choices[0] : null;
|
|
426
|
+
if (first && (typeof first['finish_reason'] === 'string' || first['finish_reason'] === null)) {
|
|
427
|
+
out.stop_reason = first['finish_reason'];
|
|
428
|
+
}
|
|
429
|
+
const responseId = responseIdFromBody(body);
|
|
430
|
+
if (responseId)
|
|
431
|
+
out.response_id = responseId;
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
function geminiMeta(body) {
|
|
435
|
+
if (!isRecord(body))
|
|
436
|
+
return {};
|
|
437
|
+
const out = {};
|
|
438
|
+
if (isRecord(body['usageMetadata'])) {
|
|
439
|
+
const u = body['usageMetadata'];
|
|
440
|
+
const usage = {};
|
|
441
|
+
setUsageNumber(usage, 'input_tokens', u['promptTokenCount']);
|
|
442
|
+
setUsageNumber(usage, 'output_tokens', u['candidatesTokenCount']);
|
|
443
|
+
setUsageNumber(usage, 'cache_read_input_tokens', u['cachedContentTokenCount']);
|
|
444
|
+
const mapped = usageOrUndefined(usage);
|
|
445
|
+
if (mapped)
|
|
446
|
+
out.usage = mapped;
|
|
447
|
+
}
|
|
448
|
+
const candidates = body['candidates'];
|
|
449
|
+
const first = Array.isArray(candidates) && isRecord(candidates[0]) ? candidates[0] : null;
|
|
450
|
+
if (first && typeof first['finishReason'] === 'string')
|
|
451
|
+
out.stop_reason = first['finishReason'];
|
|
452
|
+
return out;
|
|
453
|
+
}
|
|
454
|
+
function ollamaMeta(body) {
|
|
455
|
+
if (!isRecord(body))
|
|
456
|
+
return {};
|
|
457
|
+
const out = {};
|
|
458
|
+
const usage = {};
|
|
459
|
+
setUsageNumber(usage, 'input_tokens', body['prompt_eval_count']);
|
|
460
|
+
setUsageNumber(usage, 'output_tokens', body['eval_count']);
|
|
461
|
+
const mapped = usageOrUndefined(usage);
|
|
462
|
+
if (mapped)
|
|
463
|
+
out.usage = mapped;
|
|
464
|
+
if (typeof body['done_reason'] === 'string')
|
|
465
|
+
out.stop_reason = body['done_reason'];
|
|
466
|
+
return out;
|
|
467
|
+
}
|
|
468
|
+
function providerMeta(route, body) {
|
|
469
|
+
if (route.provider === 'openai' && route.upstreamPath === '/responses')
|
|
470
|
+
return openAIResponsesMeta(body);
|
|
471
|
+
if (route.provider === 'openai' && route.upstreamPath === '/chat/completions')
|
|
472
|
+
return openAIChatMeta(body);
|
|
473
|
+
if (route.provider === 'gemini' || route.provider === 'vertex')
|
|
474
|
+
return geminiMeta(body);
|
|
475
|
+
if (route.provider === 'ollama')
|
|
476
|
+
return ollamaMeta(body);
|
|
477
|
+
return {};
|
|
478
|
+
}
|
|
479
|
+
function setProviderTextUsage(into, key, match) {
|
|
480
|
+
if (!match?.[1])
|
|
481
|
+
return;
|
|
482
|
+
const n = Number(match[1]);
|
|
483
|
+
if (!Number.isFinite(n))
|
|
484
|
+
return;
|
|
485
|
+
const usage = into.usage ?? {};
|
|
486
|
+
usage[key] = n;
|
|
487
|
+
into.usage = usage;
|
|
488
|
+
}
|
|
489
|
+
function scanProviderTextMetadata(into, text) {
|
|
490
|
+
const id = /"id"\s*:\s*"((?:resp_|chatcmpl-|msg_)[^"]+)"/.exec(text);
|
|
491
|
+
if (id?.[1])
|
|
492
|
+
into.response_id = id[1];
|
|
493
|
+
const status = /"status"\s*:\s*"(completed|failed|incomplete|cancelled)"/.exec(text);
|
|
494
|
+
if (status?.[1])
|
|
495
|
+
into.stop_reason = status[1];
|
|
496
|
+
const finish = /"finish_reason"\s*:\s*("(?:[^"]+)"|null)/.exec(text);
|
|
497
|
+
if (finish?.[1])
|
|
498
|
+
into.stop_reason = finish[1] === 'null' ? null : finish[1].slice(1, -1);
|
|
499
|
+
setProviderTextUsage(into, 'input_tokens', /"input_tokens"\s*:\s*(\d+)/.exec(text));
|
|
500
|
+
setProviderTextUsage(into, 'output_tokens', /"output_tokens"\s*:\s*(\d+)/.exec(text));
|
|
501
|
+
setProviderTextUsage(into, 'input_tokens', /"prompt_tokens"\s*:\s*(\d+)/.exec(text));
|
|
502
|
+
setProviderTextUsage(into, 'output_tokens', /"completion_tokens"\s*:\s*(\d+)/.exec(text));
|
|
503
|
+
}
|
|
504
|
+
function responseBodyIndicatesTruncation(value) {
|
|
505
|
+
return isRecord(value) && (value['provider_stream_truncated'] === true
|
|
506
|
+
|| value['raw_stream_truncated'] === true
|
|
507
|
+
|| value['truncated'] === true);
|
|
508
|
+
}
|
|
509
|
+
function copyDefinedFields(source, keys) {
|
|
510
|
+
const out = {};
|
|
511
|
+
for (const key of keys)
|
|
512
|
+
if (source[key] !== undefined)
|
|
513
|
+
out[key] = source[key];
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
function semanticToolItem(item) {
|
|
517
|
+
if (!isRecord(item))
|
|
518
|
+
return undefined;
|
|
519
|
+
const type = typeof item['type'] === 'string' ? item['type'] : '';
|
|
520
|
+
const role = typeof item['role'] === 'string' ? item['role'] : '';
|
|
521
|
+
const fn = isRecord(item['function']) ? item['function'] : undefined;
|
|
522
|
+
const isSemantic = type === 'function_call'
|
|
523
|
+
|| type === 'tool_use'
|
|
524
|
+
|| type === 'function_call_output'
|
|
525
|
+
|| type === 'tool_result'
|
|
526
|
+
|| role === 'tool'
|
|
527
|
+
|| Array.isArray(item['tool_calls'])
|
|
528
|
+
|| fn !== undefined;
|
|
529
|
+
if (!isSemantic)
|
|
530
|
+
return undefined;
|
|
531
|
+
const out = copyDefinedFields(item, [
|
|
532
|
+
'id',
|
|
533
|
+
'type',
|
|
534
|
+
'role',
|
|
535
|
+
'call_id',
|
|
536
|
+
'tool_call_id',
|
|
537
|
+
'tool_use_id',
|
|
538
|
+
'name',
|
|
539
|
+
'arguments',
|
|
540
|
+
'input',
|
|
541
|
+
'output',
|
|
542
|
+
'content',
|
|
543
|
+
'status',
|
|
544
|
+
'index',
|
|
545
|
+
'output_index',
|
|
546
|
+
]);
|
|
547
|
+
if (Array.isArray(item['tool_calls']))
|
|
548
|
+
out['tool_calls'] = item['tool_calls'];
|
|
549
|
+
if (fn)
|
|
550
|
+
out['function'] = copyDefinedFields(fn, ['name', 'arguments']);
|
|
551
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
552
|
+
}
|
|
553
|
+
function semanticResponse(response) {
|
|
554
|
+
if (!isRecord(response))
|
|
555
|
+
return undefined;
|
|
556
|
+
const out = copyDefinedFields(response, ['id', 'status', 'usage', 'incomplete_details']);
|
|
557
|
+
const output = Array.isArray(response['output'])
|
|
558
|
+
? response['output'].map((item) => semanticToolItem(item)).filter((item) => item !== undefined)
|
|
559
|
+
: [];
|
|
560
|
+
if (output.length > 0)
|
|
561
|
+
out['output'] = output;
|
|
562
|
+
if (isRecord(response['error']))
|
|
563
|
+
out['error'] = copyDefinedFields(response['error'], ['message', 'type', 'code']);
|
|
564
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
565
|
+
}
|
|
566
|
+
function semanticOpenAIChatEvent(evt) {
|
|
567
|
+
const choices = Array.isArray(evt['choices'])
|
|
568
|
+
? evt['choices'].map((choice) => {
|
|
569
|
+
if (!isRecord(choice))
|
|
570
|
+
return undefined;
|
|
571
|
+
const out = copyDefinedFields(choice, ['index']);
|
|
572
|
+
if (choice['finish_reason'] !== undefined && choice['finish_reason'] !== null)
|
|
573
|
+
out['finish_reason'] = choice['finish_reason'];
|
|
574
|
+
const delta = isRecord(choice['delta']) ? choice['delta'] : undefined;
|
|
575
|
+
const message = isRecord(choice['message']) ? choice['message'] : undefined;
|
|
576
|
+
if (delta && Array.isArray(delta['tool_calls']))
|
|
577
|
+
out['delta'] = { tool_calls: delta['tool_calls'] };
|
|
578
|
+
if (message && Array.isArray(message['tool_calls']))
|
|
579
|
+
out['message'] = { tool_calls: message['tool_calls'] };
|
|
580
|
+
if (message && isRecord(message['function_call']))
|
|
581
|
+
out['message'] = { ...(isRecord(out['message']) ? out['message'] : {}), function_call: message['function_call'] };
|
|
582
|
+
return Object.keys(out).some((key) => key !== 'index') ? out : undefined;
|
|
583
|
+
}).filter((choice) => choice !== undefined)
|
|
584
|
+
: [];
|
|
585
|
+
const out = copyDefinedFields(evt, ['id', 'usage']);
|
|
586
|
+
if (choices.length > 0)
|
|
587
|
+
out['choices'] = choices;
|
|
588
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
589
|
+
}
|
|
590
|
+
function semanticOpenAIResponsesEvent(evt) {
|
|
591
|
+
const type = typeof evt['type'] === 'string' ? evt['type'] : '';
|
|
592
|
+
if (type === 'response.function_call_arguments.delta' || type === 'response.function_call_arguments.done') {
|
|
593
|
+
return copyDefinedFields(evt, ['type', 'item_id', 'output_index', 'call_id', 'id', 'delta', 'arguments']);
|
|
594
|
+
}
|
|
595
|
+
const item = semanticToolItem(evt['item']);
|
|
596
|
+
if (item) {
|
|
597
|
+
const out = copyDefinedFields(evt, ['type', 'output_index', 'item_id']);
|
|
598
|
+
out['item'] = item;
|
|
599
|
+
return out;
|
|
600
|
+
}
|
|
601
|
+
if (type === 'response.completed' || type === 'response.failed' || type === 'response.incomplete' || type === 'response.cancelled') {
|
|
602
|
+
const response = semanticResponse(evt['response']);
|
|
603
|
+
if (response)
|
|
604
|
+
return { type, response };
|
|
605
|
+
}
|
|
606
|
+
return undefined;
|
|
607
|
+
}
|
|
608
|
+
function semanticAnthropicEvent(evt) {
|
|
609
|
+
const type = typeof evt['type'] === 'string' ? evt['type'] : '';
|
|
610
|
+
if (type === 'content_block_start') {
|
|
611
|
+
const contentBlock = semanticToolItem(evt['content_block']);
|
|
612
|
+
if (contentBlock)
|
|
613
|
+
return { ...copyDefinedFields(evt, ['type', 'index']), content_block: contentBlock };
|
|
614
|
+
}
|
|
615
|
+
if (type === 'content_block_delta' && isRecord(evt['delta']) && evt['delta']['type'] === 'input_json_delta') {
|
|
616
|
+
return { ...copyDefinedFields(evt, ['type', 'index']), delta: copyDefinedFields(evt['delta'], ['type', 'partial_json']) };
|
|
617
|
+
}
|
|
618
|
+
if (type === 'message_delta') {
|
|
619
|
+
const out = copyDefinedFields(evt, ['type', 'usage']);
|
|
620
|
+
if (isRecord(evt['delta'])) {
|
|
621
|
+
const delta = copyDefinedFields(evt['delta'], ['stop_reason', 'stop_sequence']);
|
|
622
|
+
if (Object.keys(delta).length > 0)
|
|
623
|
+
out['delta'] = delta;
|
|
624
|
+
}
|
|
625
|
+
return out['usage'] !== undefined || out['delta'] !== undefined ? out : undefined;
|
|
626
|
+
}
|
|
627
|
+
return undefined;
|
|
628
|
+
}
|
|
629
|
+
// Gemini/Vertex tool calls live in candidates[].content.parts[] as
|
|
630
|
+
// { functionCall: { name, args } } / { functionResponse: { name, response } }.
|
|
631
|
+
// Keep only those tool-bearing parts so downstream tool-call reconstruction can
|
|
632
|
+
// rebuild them while still dropping bulky free-text/thought parts.
|
|
633
|
+
function semanticGeminiContentParts(content) {
|
|
634
|
+
if (!isRecord(content))
|
|
635
|
+
return undefined;
|
|
636
|
+
const parts = Array.isArray(content['parts']) ? content['parts'] : [];
|
|
637
|
+
const kept = parts
|
|
638
|
+
.map((part) => {
|
|
639
|
+
if (!isRecord(part))
|
|
640
|
+
return undefined;
|
|
641
|
+
if (isRecord(part['functionCall']))
|
|
642
|
+
return copyDefinedFields(part, ['functionCall']);
|
|
643
|
+
if (isRecord(part['functionResponse']))
|
|
644
|
+
return copyDefinedFields(part, ['functionResponse']);
|
|
645
|
+
return undefined;
|
|
646
|
+
})
|
|
647
|
+
.filter((part) => part !== undefined);
|
|
648
|
+
if (kept.length === 0)
|
|
649
|
+
return undefined;
|
|
650
|
+
const out = copyDefinedFields(content, ['role']);
|
|
651
|
+
out['parts'] = kept;
|
|
652
|
+
return out;
|
|
653
|
+
}
|
|
654
|
+
function semanticGenericEvent(evt) {
|
|
655
|
+
const out = copyDefinedFields(evt, [
|
|
656
|
+
'usage',
|
|
657
|
+
'usageMetadata',
|
|
658
|
+
'prompt_eval_count',
|
|
659
|
+
'eval_count',
|
|
660
|
+
'done',
|
|
661
|
+
'done_reason',
|
|
662
|
+
]);
|
|
663
|
+
const candidates = Array.isArray(evt['candidates'])
|
|
664
|
+
? evt['candidates'].map((candidate) => {
|
|
665
|
+
if (!isRecord(candidate))
|
|
666
|
+
return undefined;
|
|
667
|
+
const candidateOut = copyDefinedFields(candidate, ['finishReason']);
|
|
668
|
+
const content = semanticGeminiContentParts(candidate['content']);
|
|
669
|
+
if (content)
|
|
670
|
+
candidateOut['content'] = content;
|
|
671
|
+
return candidateOut;
|
|
672
|
+
})
|
|
673
|
+
.filter((candidate) => candidate !== undefined && Object.keys(candidate).length > 0)
|
|
674
|
+
: [];
|
|
675
|
+
if (candidates.length > 0)
|
|
676
|
+
out['candidates'] = candidates;
|
|
677
|
+
if (out['done'] !== true)
|
|
678
|
+
delete out['done'];
|
|
679
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
680
|
+
}
|
|
681
|
+
function semanticTailEvents(parsed) {
|
|
682
|
+
if (Array.isArray(parsed))
|
|
683
|
+
return parsed.flatMap((item) => semanticTailEvents(item));
|
|
684
|
+
if (!isRecord(parsed))
|
|
685
|
+
return [];
|
|
686
|
+
const event = semanticOpenAIResponsesEvent(parsed)
|
|
687
|
+
?? semanticOpenAIChatEvent(parsed)
|
|
688
|
+
?? semanticAnthropicEvent(parsed)
|
|
689
|
+
?? semanticGenericEvent(parsed);
|
|
690
|
+
return event ? [event] : [];
|
|
691
|
+
}
|
|
692
|
+
class ProviderStreamMetaScanner {
|
|
693
|
+
route;
|
|
694
|
+
env;
|
|
695
|
+
result = {};
|
|
696
|
+
responseChunks = [];
|
|
697
|
+
semanticTailEvents = [];
|
|
698
|
+
responseChunkBytes = 0;
|
|
699
|
+
semanticTailBytes = 0;
|
|
700
|
+
rawStreamBody = '';
|
|
701
|
+
rawStreamChars = 0;
|
|
702
|
+
responseChunksTruncated = false;
|
|
703
|
+
rawStreamTruncated = false;
|
|
704
|
+
semanticTailEventsTruncated = false;
|
|
705
|
+
droppedSemanticTailEventCount = 0;
|
|
706
|
+
buf = '';
|
|
707
|
+
overflowedLine = false;
|
|
708
|
+
overflowTail = '';
|
|
709
|
+
truncatedLineCount = 0;
|
|
710
|
+
droppedLineChars = 0;
|
|
711
|
+
decoder = new TextDecoder();
|
|
712
|
+
limits;
|
|
713
|
+
constructor(route, env = process.env) {
|
|
714
|
+
this.route = route;
|
|
715
|
+
this.env = env;
|
|
716
|
+
this.limits = providerStreamCaptureLimits(env);
|
|
717
|
+
}
|
|
718
|
+
push(chunk) {
|
|
719
|
+
let text;
|
|
720
|
+
if (typeof chunk === 'string')
|
|
721
|
+
text = chunk;
|
|
722
|
+
else if (chunk instanceof Uint8Array)
|
|
723
|
+
text = this.decoder.decode(chunk, { stream: true });
|
|
724
|
+
else
|
|
725
|
+
return;
|
|
726
|
+
if (captureBodiesEnabled(this.env))
|
|
727
|
+
this.captureRawStreamText(text);
|
|
728
|
+
this.pushText(text);
|
|
729
|
+
}
|
|
730
|
+
finish() {
|
|
731
|
+
const tail = this.decoder.decode();
|
|
732
|
+
if (tail) {
|
|
733
|
+
if (captureBodiesEnabled(this.env))
|
|
734
|
+
this.captureRawStreamText(tail);
|
|
735
|
+
this.pushText(tail);
|
|
736
|
+
}
|
|
737
|
+
if (this.overflowedLine) {
|
|
738
|
+
this.finishOverflowedLine();
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
const line = this.buf.trim();
|
|
742
|
+
this.buf = '';
|
|
743
|
+
if (line)
|
|
744
|
+
this.scanLine(line);
|
|
745
|
+
}
|
|
746
|
+
responseBody() {
|
|
747
|
+
if (this.responseChunks.length === 0
|
|
748
|
+
&& this.truncatedLineCount === 0
|
|
749
|
+
&& !this.responseChunksTruncated
|
|
750
|
+
&& !this.rawStreamBody
|
|
751
|
+
&& !this.rawStreamTruncated)
|
|
752
|
+
return undefined;
|
|
753
|
+
return {
|
|
754
|
+
reconstructed: true,
|
|
755
|
+
...(this.responseChunks.length > 0 ? { chunks: this.responseChunks } : {}),
|
|
756
|
+
...(this.semanticTailEvents.length > 0 ? { semantic_tail_events: this.semanticTailEvents } : {}),
|
|
757
|
+
...(this.rawStreamBody ? { raw_stream_body: this.rawStreamBody } : {}),
|
|
758
|
+
...(this.responseChunksTruncated ? { truncated: true } : {}),
|
|
759
|
+
...(this.rawStreamTruncated ? { raw_stream_truncated: true } : {}),
|
|
760
|
+
...(this.semanticTailEventsTruncated
|
|
761
|
+
? {
|
|
762
|
+
semantic_tail_events_truncated: true,
|
|
763
|
+
dropped_semantic_tail_event_count: this.droppedSemanticTailEventCount,
|
|
764
|
+
}
|
|
765
|
+
: {}),
|
|
766
|
+
...(this.truncatedLineCount > 0
|
|
767
|
+
? {
|
|
768
|
+
provider_stream_truncated: true,
|
|
769
|
+
truncated_line_count: this.truncatedLineCount,
|
|
770
|
+
dropped_line_chars: this.droppedLineChars,
|
|
771
|
+
}
|
|
772
|
+
: {}),
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
captureRawStreamText(text) {
|
|
776
|
+
if (!text)
|
|
777
|
+
return;
|
|
778
|
+
const redacted = redactText(text);
|
|
779
|
+
const available = Math.max(0, this.limits.rawStreamMaxChars - this.rawStreamChars);
|
|
780
|
+
if (available > 0) {
|
|
781
|
+
const piece = redacted.slice(0, available);
|
|
782
|
+
this.rawStreamBody += piece;
|
|
783
|
+
this.rawStreamChars += piece.length;
|
|
784
|
+
}
|
|
785
|
+
if (redacted.length > available)
|
|
786
|
+
this.rawStreamTruncated = true;
|
|
787
|
+
}
|
|
788
|
+
pushText(text) {
|
|
789
|
+
let remaining = text;
|
|
790
|
+
while (remaining.length > 0) {
|
|
791
|
+
if (this.overflowedLine) {
|
|
792
|
+
const nl = remaining.indexOf('\n');
|
|
793
|
+
const segment = nl >= 0 ? remaining.slice(0, nl) : remaining;
|
|
794
|
+
this.scanOverflowText(segment);
|
|
795
|
+
this.droppedLineChars += segment.length;
|
|
796
|
+
if (nl < 0)
|
|
797
|
+
return;
|
|
798
|
+
this.finishOverflowedLine();
|
|
799
|
+
remaining = remaining.slice(nl + 1);
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
const nl = remaining.indexOf('\n');
|
|
803
|
+
const segment = nl >= 0 ? remaining.slice(0, nl) : remaining;
|
|
804
|
+
if (this.buf.length + segment.length > this.limits.lineMaxBytes) {
|
|
805
|
+
this.startOverflowedLine(this.buf + segment);
|
|
806
|
+
this.buf = '';
|
|
807
|
+
if (nl < 0)
|
|
808
|
+
return;
|
|
809
|
+
this.finishOverflowedLine();
|
|
810
|
+
remaining = remaining.slice(nl + 1);
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
this.buf += segment;
|
|
814
|
+
if (nl < 0)
|
|
815
|
+
return;
|
|
816
|
+
const line = this.buf.trim();
|
|
817
|
+
this.buf = '';
|
|
818
|
+
if (line)
|
|
819
|
+
this.scanLine(line);
|
|
820
|
+
remaining = remaining.slice(nl + 1);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
startOverflowedLine(text) {
|
|
824
|
+
this.overflowedLine = true;
|
|
825
|
+
this.truncatedLineCount += 1;
|
|
826
|
+
this.scanOverflowText(text);
|
|
827
|
+
this.droppedLineChars += text.length;
|
|
828
|
+
}
|
|
829
|
+
finishOverflowedLine() {
|
|
830
|
+
if (this.overflowTail)
|
|
831
|
+
scanProviderTextMetadata(this.result, this.overflowTail);
|
|
832
|
+
this.overflowedLine = false;
|
|
833
|
+
this.overflowTail = '';
|
|
834
|
+
}
|
|
835
|
+
scanOverflowText(text) {
|
|
836
|
+
this.overflowTail = (this.overflowTail + text).slice(-MAX_PROVIDER_STREAM_LINE_SCAN_TAIL_BYTES);
|
|
837
|
+
scanProviderTextMetadata(this.result, text);
|
|
838
|
+
}
|
|
839
|
+
scanLine(line) {
|
|
840
|
+
let payload = line.startsWith('data:') ? line.slice(5).trim() : line.trim();
|
|
841
|
+
if (!line.startsWith('data:')) {
|
|
842
|
+
payload = payload
|
|
843
|
+
.replace(/^\[\s*/, '')
|
|
844
|
+
.replace(/^,\s*/, '')
|
|
845
|
+
.replace(/\s*,?\s*\]\s*$/, '')
|
|
846
|
+
.replace(/,\s*$/, '')
|
|
847
|
+
.trim();
|
|
848
|
+
}
|
|
849
|
+
if (!payload || payload === '[DONE]')
|
|
850
|
+
return;
|
|
851
|
+
let parsed;
|
|
852
|
+
try {
|
|
853
|
+
parsed = JSON.parse(payload);
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
if (captureBodiesEnabled(this.env)) {
|
|
859
|
+
this.captureParsedBodyChunk(parsed);
|
|
860
|
+
this.captureParsedSemanticTail(parsed);
|
|
861
|
+
}
|
|
862
|
+
this.scanParsed(parsed);
|
|
863
|
+
}
|
|
864
|
+
captureParsedBodyChunk(parsed) {
|
|
865
|
+
if (this.responseChunksTruncated)
|
|
866
|
+
return;
|
|
867
|
+
let bytes = 0;
|
|
868
|
+
try {
|
|
869
|
+
bytes = Buffer.byteLength(JSON.stringify(parsed), 'utf8');
|
|
870
|
+
}
|
|
871
|
+
catch {
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
if (this.responseChunks.length >= this.limits.chunkMaxEvents
|
|
875
|
+
|| this.responseChunkBytes + bytes > this.limits.chunkMaxBytes) {
|
|
876
|
+
this.responseChunksTruncated = true;
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
this.responseChunks.push(parsed);
|
|
880
|
+
this.responseChunkBytes += bytes;
|
|
881
|
+
}
|
|
882
|
+
captureParsedSemanticTail(parsed) {
|
|
883
|
+
if (!this.responseChunksTruncated)
|
|
884
|
+
return;
|
|
885
|
+
for (const event of semanticTailEvents(parsed))
|
|
886
|
+
this.pushSemanticTailEvent(event);
|
|
887
|
+
}
|
|
888
|
+
pushSemanticTailEvent(event) {
|
|
889
|
+
const captured = captureBody(event, this.env);
|
|
890
|
+
if (!captured)
|
|
891
|
+
return;
|
|
892
|
+
let redactedEvent;
|
|
893
|
+
try {
|
|
894
|
+
redactedEvent = JSON.parse(captured.body);
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
redactedEvent = captured.body;
|
|
898
|
+
}
|
|
899
|
+
let bytes = 0;
|
|
900
|
+
try {
|
|
901
|
+
bytes = Buffer.byteLength(JSON.stringify(redactedEvent), 'utf8');
|
|
902
|
+
}
|
|
903
|
+
catch {
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (bytes > this.limits.semanticTailMaxBytes) {
|
|
907
|
+
this.semanticTailEventsTruncated = true;
|
|
908
|
+
this.droppedSemanticTailEventCount += 1;
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
while (this.semanticTailEvents.length >= this.limits.semanticTailMaxEvents
|
|
912
|
+
|| this.semanticTailBytes + bytes > this.limits.semanticTailMaxBytes) {
|
|
913
|
+
const dropped = this.semanticTailEvents.shift();
|
|
914
|
+
if (dropped === undefined)
|
|
915
|
+
break;
|
|
916
|
+
this.semanticTailEventsTruncated = true;
|
|
917
|
+
this.droppedSemanticTailEventCount += 1;
|
|
918
|
+
try {
|
|
919
|
+
this.semanticTailBytes -= Buffer.byteLength(JSON.stringify(dropped), 'utf8');
|
|
920
|
+
}
|
|
921
|
+
catch {
|
|
922
|
+
this.semanticTailBytes = 0;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
this.semanticTailEvents.push(redactedEvent);
|
|
926
|
+
this.semanticTailBytes += bytes;
|
|
927
|
+
}
|
|
928
|
+
scanParsed(parsed) {
|
|
929
|
+
if (Array.isArray(parsed)) {
|
|
930
|
+
for (const item of parsed)
|
|
931
|
+
this.scanParsed(item);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
mergeTraceMeta(this.result, providerMeta(this.route, parsed));
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
function getHeader(headers, name) {
|
|
938
|
+
const want = name.toLowerCase();
|
|
939
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
940
|
+
if (key.toLowerCase() === want && value)
|
|
941
|
+
return value;
|
|
942
|
+
}
|
|
943
|
+
return '';
|
|
944
|
+
}
|
|
945
|
+
function clip(value, max = 128) {
|
|
946
|
+
return value.length <= max ? value : value.slice(0, max);
|
|
947
|
+
}
|
|
948
|
+
function safeTraceError(value, max = 256) {
|
|
949
|
+
return clip(redactText(value).replace(/\s+/g, ' ').trim(), max);
|
|
950
|
+
}
|
|
951
|
+
function safePlainSessionId(value) {
|
|
952
|
+
const s = value.trim();
|
|
953
|
+
if (s !== value)
|
|
954
|
+
return '';
|
|
955
|
+
if (s.length < 4 || s.length > 128)
|
|
956
|
+
return '';
|
|
957
|
+
if (s.includes('@') || /\s/.test(s))
|
|
958
|
+
return '';
|
|
959
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*[A-Za-z0-9]$/.test(s))
|
|
960
|
+
return '';
|
|
961
|
+
if (/^(?:bearer|basic|sk-|ghp_|github_pat_|gho_|ghu_|ghs_|glpat-|xox[baprs]-)/i.test(s))
|
|
962
|
+
return '';
|
|
963
|
+
if (/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(s))
|
|
964
|
+
return '';
|
|
965
|
+
if (/(?:^|[-_.])(?:token|secret|apikey|api[_-]?key|password|passwd|credential|auth)(?:$|[-_.])/i.test(s))
|
|
966
|
+
return '';
|
|
967
|
+
if (/^[a-f0-9]{32,}$/i.test(s))
|
|
968
|
+
return '';
|
|
969
|
+
if (/^[A-Za-z0-9_-]{40,}$/.test(s) && !/[-_.]/.test(s))
|
|
970
|
+
return '';
|
|
971
|
+
if (/(?:session|sess)/i.test(s))
|
|
972
|
+
return s;
|
|
973
|
+
return /[-_.]/.test(s) ? s : '';
|
|
974
|
+
}
|
|
975
|
+
function sessionIdFromPlainField(value) {
|
|
976
|
+
return typeof value === 'string' ? safePlainSessionId(value) : '';
|
|
977
|
+
}
|
|
978
|
+
function sessionIdFromClaudeUserId(value) {
|
|
979
|
+
const marker = '__session_';
|
|
980
|
+
const index = value.indexOf(marker);
|
|
981
|
+
if (index < 0)
|
|
982
|
+
return '';
|
|
983
|
+
return safePlainSessionId(value.slice(index + marker.length));
|
|
984
|
+
}
|
|
985
|
+
function sessionIdFromUserField(value) {
|
|
986
|
+
if (!value)
|
|
987
|
+
return '';
|
|
988
|
+
let parsed = value;
|
|
989
|
+
if (typeof value === 'string') {
|
|
990
|
+
const s = value.trim();
|
|
991
|
+
if (!s.startsWith('{'))
|
|
992
|
+
return sessionIdFromClaudeUserId(s) || safePlainSessionId(s);
|
|
993
|
+
try {
|
|
994
|
+
parsed = JSON.parse(s);
|
|
995
|
+
}
|
|
996
|
+
catch {
|
|
997
|
+
return '';
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
if (parsed && typeof parsed === 'object') {
|
|
1001
|
+
const sid = parsed['session_id'];
|
|
1002
|
+
if (typeof sid === 'string' && sid.length > 0)
|
|
1003
|
+
return safePlainSessionId(sid);
|
|
1004
|
+
}
|
|
1005
|
+
return '';
|
|
1006
|
+
}
|
|
1007
|
+
function extractSessionId(headers, body) {
|
|
1008
|
+
for (const name of ['x-session-id', 'x-cursor-session-id', 'x-conversation-id']) {
|
|
1009
|
+
const value = getHeader(headers, name).trim();
|
|
1010
|
+
const sid = sessionIdFromPlainField(value);
|
|
1011
|
+
if (sid)
|
|
1012
|
+
return clip(sid);
|
|
1013
|
+
}
|
|
1014
|
+
const metadata = body['metadata'];
|
|
1015
|
+
if (isRecord(metadata)) {
|
|
1016
|
+
const sid = sessionIdFromUserField(metadata['user_id']) || sessionIdFromPlainField(metadata['session_id']);
|
|
1017
|
+
if (sid)
|
|
1018
|
+
return clip(sid);
|
|
1019
|
+
}
|
|
1020
|
+
const sid = sessionIdFromUserField(body['user']);
|
|
1021
|
+
if (sid)
|
|
1022
|
+
return clip(sid);
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
function extractTopLevelUserIdHash(body) {
|
|
1026
|
+
const metadata = body['metadata'];
|
|
1027
|
+
if (isRecord(metadata)) {
|
|
1028
|
+
const userId = metadata['user_id'];
|
|
1029
|
+
if (typeof userId === 'string' || typeof userId === 'number')
|
|
1030
|
+
return stableUserIdHash(userId);
|
|
1031
|
+
}
|
|
1032
|
+
const user = body['user'];
|
|
1033
|
+
if (typeof user === 'string' || typeof user === 'number')
|
|
1034
|
+
return stableUserIdHash(user);
|
|
1035
|
+
return undefined;
|
|
1036
|
+
}
|
|
1037
|
+
function extractPreviousResponseId(body) {
|
|
1038
|
+
const value = body['previous_response_id'];
|
|
1039
|
+
return typeof value === 'string' && value.length > 0 ? clip(value) : null;
|
|
1040
|
+
}
|
|
1041
|
+
function retryHeadersForMutatedOpenAIRequest(headers) {
|
|
1042
|
+
const out = { ...headers };
|
|
1043
|
+
for (const name of Object.keys(out)) {
|
|
1044
|
+
const lower = name.toLowerCase();
|
|
1045
|
+
if (lower === 'idempotency-key' || lower.startsWith('x-stainless-retry-'))
|
|
1046
|
+
delete out[name];
|
|
1047
|
+
}
|
|
1048
|
+
return out;
|
|
1049
|
+
}
|
|
1050
|
+
function detectClient(headers) {
|
|
1051
|
+
const ua = getHeader(headers, 'user-agent').toLowerCase();
|
|
1052
|
+
if (ua.includes('claude'))
|
|
1053
|
+
return 'claude-code';
|
|
1054
|
+
if (ua.includes('cursor'))
|
|
1055
|
+
return 'cursor';
|
|
1056
|
+
if (ua.includes('codex'))
|
|
1057
|
+
return 'codex';
|
|
1058
|
+
return 'unknown';
|
|
1059
|
+
}
|
|
1060
|
+
function wireApiForTrace(opts) {
|
|
1061
|
+
if (opts.provider === 'openai' && opts.upstreamPath === '/responses')
|
|
1062
|
+
return 'openai_responses';
|
|
1063
|
+
if (opts.provider === 'openai' && opts.upstreamPath === '/chat/completions')
|
|
1064
|
+
return 'openai_chat_completions';
|
|
1065
|
+
if (opts.provider === 'gemini')
|
|
1066
|
+
return 'gemini_generate_content';
|
|
1067
|
+
if (opts.provider === 'vertex')
|
|
1068
|
+
return 'vertex_gemini';
|
|
1069
|
+
if (opts.provider === 'ollama')
|
|
1070
|
+
return 'ollama_api';
|
|
1071
|
+
return 'anthropic_messages';
|
|
1072
|
+
}
|
|
1073
|
+
function captureTraceAttempts(attempts, captureBodies, env) {
|
|
1074
|
+
return attempts.map((attempt) => {
|
|
1075
|
+
const record = {
|
|
1076
|
+
attempt_index: attempt.attempt_index,
|
|
1077
|
+
model: attempt.model,
|
|
1078
|
+
provider: attempt.provider,
|
|
1079
|
+
upstream_mode: attempt.upstream_mode,
|
|
1080
|
+
status: attempt.status,
|
|
1081
|
+
...(attempt.error !== undefined ? { error: safeTraceError(attempt.error) } : {}),
|
|
1082
|
+
...(attempt.body_truncated === true ? { body_truncated: true } : {}),
|
|
1083
|
+
};
|
|
1084
|
+
const reqCap = captureBodies ? captureBody(attempt.requestBody, env) : undefined;
|
|
1085
|
+
const respCap = captureBodies && attempt.responseBody !== undefined ? captureBody(attempt.responseBody, env) : undefined;
|
|
1086
|
+
if (reqCap)
|
|
1087
|
+
record.requestBody = reqCap.body;
|
|
1088
|
+
if (respCap)
|
|
1089
|
+
record.responseBody = respCap.body;
|
|
1090
|
+
if (reqCap?.truncated
|
|
1091
|
+
|| respCap?.truncated
|
|
1092
|
+
|| attempt.body_truncated === true
|
|
1093
|
+
|| responseBodyIndicatesTruncation(attempt.responseBody))
|
|
1094
|
+
record.body_truncated = true;
|
|
1095
|
+
return record;
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
function emitTrace(opts, t0, ttfb, last, bodyCaptureAllowed) {
|
|
1099
|
+
if (!opts.onTrace)
|
|
1100
|
+
return;
|
|
1101
|
+
const clock = opts.clock ?? (() => Date.now());
|
|
1102
|
+
const requestId = getHeader(opts.headers, 'x-request-id');
|
|
1103
|
+
const sessionId = extractSessionId(opts.headers, opts.body);
|
|
1104
|
+
const previousResponseId = extractPreviousResponseId(opts.body);
|
|
1105
|
+
const captureBodies = bodyCaptureAllowed && captureBodiesEnabled(opts.env ?? process.env);
|
|
1106
|
+
const record = {
|
|
1107
|
+
ts: new Date(t0).toISOString(),
|
|
1108
|
+
event: 'llm_turn',
|
|
1109
|
+
id: `llm_${randomUUID()}`,
|
|
1110
|
+
request_id: requestId ? clip(requestId) : null,
|
|
1111
|
+
route: opts.route ?? opts.upstreamPath,
|
|
1112
|
+
provider: opts.provider,
|
|
1113
|
+
wire_api: wireApiForTrace(opts),
|
|
1114
|
+
client: detectClient(opts.headers),
|
|
1115
|
+
...(getHeader(opts.headers, 'user-agent') ? { user_agent: clip(getHeader(opts.headers, 'user-agent')) } : {}),
|
|
1116
|
+
...(() => {
|
|
1117
|
+
try {
|
|
1118
|
+
const hash = extractTopLevelUserIdHash(opts.body);
|
|
1119
|
+
return hash ? { user_id_hash: hash } : {};
|
|
1120
|
+
}
|
|
1121
|
+
catch {
|
|
1122
|
+
return {};
|
|
1123
|
+
}
|
|
1124
|
+
})(),
|
|
1125
|
+
...(() => {
|
|
1126
|
+
try {
|
|
1127
|
+
const effort = extractThinkingEffort(opts.body);
|
|
1128
|
+
return effort ? { thinking_effort: effort } : {};
|
|
1129
|
+
}
|
|
1130
|
+
catch {
|
|
1131
|
+
return {};
|
|
1132
|
+
}
|
|
1133
|
+
})(),
|
|
1134
|
+
session_id: sessionId,
|
|
1135
|
+
original_model: opts.model,
|
|
1136
|
+
chosen_model: opts.chosenModel ?? opts.model,
|
|
1137
|
+
tier: opts.tier ?? null,
|
|
1138
|
+
reason: opts.reason ?? null,
|
|
1139
|
+
fallback: opts.fallback ?? null,
|
|
1140
|
+
router_enabled: opts.routerEnabled ?? false,
|
|
1141
|
+
upstream_mode: opts.provider,
|
|
1142
|
+
status: last.status,
|
|
1143
|
+
stream: last.stream,
|
|
1144
|
+
ttfb_ms: ttfb,
|
|
1145
|
+
latency_ms: clock() - t0,
|
|
1146
|
+
...(opts.features ? { features: opts.features } : {}),
|
|
1147
|
+
...(last.usage ? { usage: last.usage } : {}),
|
|
1148
|
+
...(last.stop_reason !== undefined ? { stop_reason: last.stop_reason } : {}),
|
|
1149
|
+
...(last.response_id ? { response_id: clip(last.response_id) } : {}),
|
|
1150
|
+
...(previousResponseId ? { previous_response_id: previousResponseId } : {}),
|
|
1151
|
+
...(last.error !== undefined ? { error: safeTraceError(last.error) } : {}),
|
|
1152
|
+
...(captureBodies ? { request_headers: captureTraceMetadata(opts.headers, opts.env ?? process.env) } : {}),
|
|
1153
|
+
...(captureBodies && last.responseHeaders ? { response_headers: captureTraceMetadata(last.responseHeaders, opts.env ?? process.env) } : {}),
|
|
1154
|
+
...(captureBodies && last.transportMetadata !== undefined ? { transport_metadata: captureTraceMetadata(last.transportMetadata, opts.env ?? process.env) } : {}),
|
|
1155
|
+
};
|
|
1156
|
+
if (opts.attempts && opts.attempts.length > 0) {
|
|
1157
|
+
try {
|
|
1158
|
+
record.attempts = captureTraceAttempts(opts.attempts, captureBodies, opts.env ?? process.env);
|
|
1159
|
+
}
|
|
1160
|
+
catch { /* attempt capture must never break trace emission */ }
|
|
1161
|
+
}
|
|
1162
|
+
// Native body capture (v1-compatible default full; see bodyCapture.ts). Redacted + size-capped. Only place
|
|
1163
|
+
// content can enter a provider trace. Non-streaming rows carry full parsed response JSON;
|
|
1164
|
+
// streaming rows carry best-effort reconstructed native event / NDJSON chunks from the transparent stream tee.
|
|
1165
|
+
if (captureBodies) {
|
|
1166
|
+
try {
|
|
1167
|
+
const reqCap = captureBody(opts.body, opts.env ?? process.env);
|
|
1168
|
+
const respCap = last.responseBody !== undefined ? captureBody(last.responseBody, opts.env ?? process.env) : undefined;
|
|
1169
|
+
if (reqCap)
|
|
1170
|
+
record.requestBody = reqCap.body;
|
|
1171
|
+
if (respCap)
|
|
1172
|
+
record.responseBody = respCap.body;
|
|
1173
|
+
record.redaction = REDACTION_VERSION;
|
|
1174
|
+
if (reqCap?.truncated
|
|
1175
|
+
|| respCap?.truncated
|
|
1176
|
+
|| responseBodyIndicatesTruncation(last.responseBody)
|
|
1177
|
+
|| record.attempts?.some((attempt) => attempt.body_truncated === true))
|
|
1178
|
+
record.body_truncated = true;
|
|
1179
|
+
}
|
|
1180
|
+
catch { /* capture must never break trace emission */ }
|
|
1181
|
+
}
|
|
1182
|
+
else if (record.attempts?.some((attempt) => attempt.body_truncated === true)) {
|
|
1183
|
+
record.body_truncated = true;
|
|
1184
|
+
}
|
|
1185
|
+
try {
|
|
1186
|
+
opts.onTrace(record);
|
|
1187
|
+
}
|
|
1188
|
+
catch { /* trace must never break serving */ }
|
|
1189
|
+
}
|
|
1190
|
+
function streamResponse(upstream, headers) {
|
|
1191
|
+
const ct = upstream.headers?.['content-type'];
|
|
1192
|
+
return { status: upstream.status, stream: upstream.stream, headers: ct ? { ...headers, 'Content-Type': ct } : headers };
|
|
1193
|
+
}
|
|
1194
|
+
async function passthrough(opts) {
|
|
1195
|
+
const clock = opts.clock ?? (() => Date.now());
|
|
1196
|
+
const t0 = clock();
|
|
1197
|
+
let upstream;
|
|
1198
|
+
let traceOpts = opts;
|
|
1199
|
+
const attempts = [];
|
|
1200
|
+
let ttfb = null;
|
|
1201
|
+
let bodyCaptureAllowed = false;
|
|
1202
|
+
try {
|
|
1203
|
+
upstream = await opts.proxy(opts.upstreamPath, opts.body, {
|
|
1204
|
+
inboundHeaders: opts.headers,
|
|
1205
|
+
upstreamMode: opts.provider,
|
|
1206
|
+
...(opts.method ? { method: opts.method } : {}),
|
|
1207
|
+
...(opts.baseUrl ? { baseUrl: opts.baseUrl } : {}),
|
|
1208
|
+
});
|
|
1209
|
+
bodyCaptureAllowed = true;
|
|
1210
|
+
ttfb = clock() - t0;
|
|
1211
|
+
}
|
|
1212
|
+
catch (err) {
|
|
1213
|
+
const wrapped = asUpstreamError(opts.provider, err);
|
|
1214
|
+
emitTrace(opts, t0, ttfb, { status: wrapped.statusCode, stream: false, error: wrapped.message }, bodyCaptureAllowed);
|
|
1215
|
+
throw wrapped;
|
|
1216
|
+
}
|
|
1217
|
+
if (shouldRetryRoutedOpenAI(upstream, opts) && opts.retryBody) {
|
|
1218
|
+
const retryBody = opts.retryBody;
|
|
1219
|
+
const retryFallback = `upstream_${upstream.status}_retry`;
|
|
1220
|
+
const retryFailedFallback = `upstream_${upstream.status}_retry_failed`;
|
|
1221
|
+
opts.logger.warn?.(jsonStringify({
|
|
1222
|
+
event: 'router_fallback',
|
|
1223
|
+
route: opts.route ?? opts.upstreamPath,
|
|
1224
|
+
reason: retryFallback,
|
|
1225
|
+
original_model: opts.model,
|
|
1226
|
+
would_have_been: opts.chosenModel,
|
|
1227
|
+
upstream_status: upstream.status,
|
|
1228
|
+
}));
|
|
1229
|
+
let drainedFirst = '';
|
|
1230
|
+
if (upstream.text) {
|
|
1231
|
+
try {
|
|
1232
|
+
drainedFirst = await Promise.resolve(upstream.text());
|
|
1233
|
+
}
|
|
1234
|
+
catch {
|
|
1235
|
+
drainedFirst = '';
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
if (opts.onTrace) {
|
|
1239
|
+
attempts.push({
|
|
1240
|
+
attempt_index: 0,
|
|
1241
|
+
model: opts.chosenModel ?? opts.model,
|
|
1242
|
+
provider: opts.provider,
|
|
1243
|
+
upstream_mode: opts.provider,
|
|
1244
|
+
status: upstream.status,
|
|
1245
|
+
error: safeTraceError(`upstream ${upstream.status}`),
|
|
1246
|
+
requestBody: opts.body,
|
|
1247
|
+
responseBody: attemptResponseToBody(drainedFirst),
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
try {
|
|
1251
|
+
const retryHeaders = retryHeadersForMutatedOpenAIRequest(opts.headers);
|
|
1252
|
+
upstream = await opts.proxy(opts.upstreamPath, retryBody, {
|
|
1253
|
+
inboundHeaders: retryHeaders,
|
|
1254
|
+
upstreamMode: opts.provider,
|
|
1255
|
+
...(opts.method ? { method: opts.method } : {}),
|
|
1256
|
+
...(opts.baseUrl ? { baseUrl: opts.baseUrl } : {}),
|
|
1257
|
+
});
|
|
1258
|
+
traceOpts = {
|
|
1259
|
+
...opts,
|
|
1260
|
+
body: retryBody,
|
|
1261
|
+
chosenModel: opts.model,
|
|
1262
|
+
fallback: retryFallback,
|
|
1263
|
+
headers: retryHeaders,
|
|
1264
|
+
attempts,
|
|
1265
|
+
};
|
|
1266
|
+
if (opts.onTrace) {
|
|
1267
|
+
attempts.push({
|
|
1268
|
+
attempt_index: 1,
|
|
1269
|
+
model: opts.model,
|
|
1270
|
+
provider: opts.provider,
|
|
1271
|
+
upstream_mode: opts.provider,
|
|
1272
|
+
status: upstream.status,
|
|
1273
|
+
requestBody: retryBody,
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
catch (err) {
|
|
1278
|
+
const wrapped = asUpstreamError(opts.provider, err);
|
|
1279
|
+
const retryError = err instanceof Error ? err.message : String(err);
|
|
1280
|
+
if (opts.onTrace) {
|
|
1281
|
+
attempts.push({
|
|
1282
|
+
attempt_index: 1,
|
|
1283
|
+
model: opts.model,
|
|
1284
|
+
provider: opts.provider,
|
|
1285
|
+
upstream_mode: opts.provider,
|
|
1286
|
+
status: wrapped.statusCode,
|
|
1287
|
+
error: retryError,
|
|
1288
|
+
requestBody: retryBody,
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
opts.logger.warn?.(jsonStringify({
|
|
1292
|
+
event: 'router_fallback',
|
|
1293
|
+
route: opts.route ?? opts.upstreamPath,
|
|
1294
|
+
reason: retryFailedFallback,
|
|
1295
|
+
original_model: opts.model,
|
|
1296
|
+
would_have_been: opts.chosenModel,
|
|
1297
|
+
error: retryError,
|
|
1298
|
+
}));
|
|
1299
|
+
upstream = {
|
|
1300
|
+
status: upstream.status,
|
|
1301
|
+
headers: upstream.headers,
|
|
1302
|
+
stream: null,
|
|
1303
|
+
text: () => drainedFirst,
|
|
1304
|
+
};
|
|
1305
|
+
traceOpts = { ...opts, fallback: retryFallback, attempts };
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
const forwardHeaders = opts.responseHeaders ? opts.responseHeaders(upstream.headers ?? {}) : {};
|
|
1309
|
+
if (upstream.stream) {
|
|
1310
|
+
const scanner = traceOpts.onTrace ? new ProviderStreamMetaScanner(traceOpts, traceOpts.env ?? process.env) : null;
|
|
1311
|
+
const stream = scanner
|
|
1312
|
+
? teeStreamForScan(upstream.stream, (chunk) => scanner.push(chunk), (info) => {
|
|
1313
|
+
scanner.finish();
|
|
1314
|
+
const streamError = scanner.result.error ?? info?.error ?? (info?.cancelled ? 'stream cancelled' : undefined);
|
|
1315
|
+
const responseBody = scanner.responseBody();
|
|
1316
|
+
const finalAttempt = attempts.find((attempt) => attempt.attempt_index === 1 && attempt.responseBody === undefined);
|
|
1317
|
+
if (finalAttempt && responseBody !== undefined) {
|
|
1318
|
+
finalAttempt.responseBody = responseBody;
|
|
1319
|
+
finalAttempt.body_truncated = responseBodyIndicatesTruncation(responseBody);
|
|
1320
|
+
}
|
|
1321
|
+
emitTrace(traceOpts, t0, ttfb, {
|
|
1322
|
+
status: upstream.status,
|
|
1323
|
+
stream: true,
|
|
1324
|
+
...scanner.result,
|
|
1325
|
+
...(streamError ? { error: streamError } : {}),
|
|
1326
|
+
...(responseBody !== undefined ? { responseBody } : {}),
|
|
1327
|
+
...(upstream.headers ? { responseHeaders: upstream.headers } : {}),
|
|
1328
|
+
...(upstream.transportMetadata !== undefined ? { transportMetadata: upstream.transportMetadata } : {}),
|
|
1329
|
+
}, bodyCaptureAllowed);
|
|
1330
|
+
})
|
|
1331
|
+
: upstream.stream;
|
|
1332
|
+
return streamResponse({ ...upstream, stream }, forwardHeaders);
|
|
1333
|
+
}
|
|
1334
|
+
let raw = '';
|
|
1335
|
+
if (upstream.text) {
|
|
1336
|
+
try {
|
|
1337
|
+
raw = await Promise.resolve(upstream.text());
|
|
1338
|
+
}
|
|
1339
|
+
catch (err) {
|
|
1340
|
+
const wrapped = asUpstreamError(opts.provider, err);
|
|
1341
|
+
emitTrace(traceOpts, t0, ttfb, { status: wrapped.statusCode, stream: false, error: wrapped.message }, bodyCaptureAllowed);
|
|
1342
|
+
throw wrapped;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
const body = responseToBody(raw, upstream.status, upstream.headers, opts.logger, `${opts.provider}_fallback`);
|
|
1346
|
+
const finalAttempt = attempts.find((attempt) => attempt.attempt_index === 1 && attempt.responseBody === undefined);
|
|
1347
|
+
if (finalAttempt)
|
|
1348
|
+
finalAttempt.responseBody = body;
|
|
1349
|
+
emitTrace(traceOpts, t0, ttfb, {
|
|
1350
|
+
status: upstream.status,
|
|
1351
|
+
stream: false,
|
|
1352
|
+
...providerMeta(traceOpts, body),
|
|
1353
|
+
responseBody: body,
|
|
1354
|
+
...(upstream.headers ? { responseHeaders: upstream.headers } : {}),
|
|
1355
|
+
...(upstream.transportMetadata !== undefined ? { transportMetadata: upstream.transportMetadata } : {}),
|
|
1356
|
+
}, bodyCaptureAllowed);
|
|
1357
|
+
return { status: upstream.status, body, headers: forwardHeaders };
|
|
1358
|
+
}
|
|
1359
|
+
export function parseModelAction(modelAction) {
|
|
1360
|
+
const idx = modelAction.lastIndexOf(':');
|
|
1361
|
+
if (idx === -1)
|
|
1362
|
+
return { model: modelAction, action: '' };
|
|
1363
|
+
return { model: modelAction.slice(0, idx), action: modelAction.slice(idx + 1) };
|
|
1364
|
+
}
|
|
1365
|
+
function decodePathParam(raw, name) {
|
|
1366
|
+
try {
|
|
1367
|
+
return decodeURIComponent(raw);
|
|
1368
|
+
}
|
|
1369
|
+
catch {
|
|
1370
|
+
throw badRequest(`${name} contains invalid percent encoding`);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
function validateDecodedPathComponent(value, name) {
|
|
1374
|
+
if (!value)
|
|
1375
|
+
throw badRequest(`${name} is required`);
|
|
1376
|
+
if (value.includes('/') || value.includes('\\'))
|
|
1377
|
+
throw badRequest(`${name} must not contain path separators`);
|
|
1378
|
+
if (value === '.' || value === '..')
|
|
1379
|
+
throw badRequest(`${name} must not be a dot segment`);
|
|
1380
|
+
return value;
|
|
1381
|
+
}
|
|
1382
|
+
function validatedModelAction(raw) {
|
|
1383
|
+
const decoded = validateDecodedPathComponent(decodePathParam(raw, 'modelAction'), 'modelAction');
|
|
1384
|
+
const { model, action } = parseModelAction(decoded);
|
|
1385
|
+
validateDecodedPathComponent(model, 'model');
|
|
1386
|
+
if (!GEMINI_VERTEX_ACTIONS.has(action))
|
|
1387
|
+
throw badRequest(`unsupported model action: ${action || '(missing)'}`);
|
|
1388
|
+
return { model, action };
|
|
1389
|
+
}
|
|
1390
|
+
function validatedVertexProject(raw) {
|
|
1391
|
+
const decoded = validateDecodedPathComponent(decodePathParam(raw, 'project'), 'project');
|
|
1392
|
+
if (!/^[A-Za-z0-9_-]+$/.test(decoded))
|
|
1393
|
+
throw badRequest('project contains unsupported characters');
|
|
1394
|
+
return decoded;
|
|
1395
|
+
}
|
|
1396
|
+
function validatedVertexLocation(raw) {
|
|
1397
|
+
const decoded = validateDecodedPathComponent(decodePathParam(raw, 'location'), 'location');
|
|
1398
|
+
if (decoded !== 'global' && !/^[a-z][a-z0-9-]*$/.test(decoded))
|
|
1399
|
+
throw badRequest('location contains unsupported characters');
|
|
1400
|
+
return decoded;
|
|
1401
|
+
}
|
|
1402
|
+
function providerQueryString(query) {
|
|
1403
|
+
if (!query || Object.keys(query).length === 0)
|
|
1404
|
+
return '';
|
|
1405
|
+
const params = new URLSearchParams();
|
|
1406
|
+
for (const [name, value] of Object.entries(query)) {
|
|
1407
|
+
if (CREDENTIAL_QUERY_PARAMS.has(name.toLowerCase()))
|
|
1408
|
+
continue;
|
|
1409
|
+
params.append(name, value);
|
|
1410
|
+
}
|
|
1411
|
+
const qs = params.toString();
|
|
1412
|
+
return qs ? `?${qs}` : '';
|
|
1413
|
+
}
|
|
1414
|
+
export function detectModelsProvider(headers = {}) {
|
|
1415
|
+
if (headers['anthropic-version'] || headers['anthropic-beta'])
|
|
1416
|
+
return 'anthropic';
|
|
1417
|
+
return 'openai';
|
|
1418
|
+
}
|
|
1419
|
+
export function vertexBaseUrl(location, env = process.env) {
|
|
1420
|
+
const override = (env['EVOMAP_VERTEX_BASE_URL'] || '').trim();
|
|
1421
|
+
if (override)
|
|
1422
|
+
return override.replace(/\/+$/, '');
|
|
1423
|
+
const raw = location.trim();
|
|
1424
|
+
if (!raw || raw === 'global')
|
|
1425
|
+
return 'https://aiplatform.googleapis.com';
|
|
1426
|
+
const loc = validatedVertexLocation(raw);
|
|
1427
|
+
return `https://${loc}-aiplatform.googleapis.com`;
|
|
1428
|
+
}
|
|
1429
|
+
export function buildOpenAIResponsesHandler(opts) {
|
|
1430
|
+
const log = opts.logger ?? console;
|
|
1431
|
+
const env = opts.env ?? process.env;
|
|
1432
|
+
return (req) => {
|
|
1433
|
+
const routed = routeOpenAIRequest('/v1/responses', req.body, env, log);
|
|
1434
|
+
return passthrough({
|
|
1435
|
+
proxy: opts.openAIProxy,
|
|
1436
|
+
provider: 'openai',
|
|
1437
|
+
route: '/v1/responses',
|
|
1438
|
+
upstreamPath: '/responses',
|
|
1439
|
+
model: routed.originalModel,
|
|
1440
|
+
chosenModel: routed.chosenModel,
|
|
1441
|
+
tier: routed.tier,
|
|
1442
|
+
reason: routed.reason,
|
|
1443
|
+
fallback: routed.fallback,
|
|
1444
|
+
routerEnabled: routed.routerEnabled,
|
|
1445
|
+
...(routed.features ? { features: routed.features } : {}),
|
|
1446
|
+
body: routed.body,
|
|
1447
|
+
retryBody: req.body,
|
|
1448
|
+
headers: req.headers ?? {},
|
|
1449
|
+
logger: log,
|
|
1450
|
+
...(opts.onTrace ? { onTrace: opts.onTrace } : {}),
|
|
1451
|
+
...(opts.clock ? { clock: opts.clock } : {}),
|
|
1452
|
+
env,
|
|
1453
|
+
responseHeaders: copyOpenAIResponseHeaders,
|
|
1454
|
+
});
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
export function buildOpenAIChatCompletionsHandler(opts) {
|
|
1458
|
+
const log = opts.logger ?? console;
|
|
1459
|
+
const env = opts.env ?? process.env;
|
|
1460
|
+
return (req) => {
|
|
1461
|
+
const routed = routeOpenAIRequest('/v1/chat/completions', req.body, env, log);
|
|
1462
|
+
return passthrough({
|
|
1463
|
+
proxy: opts.openAIProxy,
|
|
1464
|
+
provider: 'openai',
|
|
1465
|
+
route: '/v1/chat/completions',
|
|
1466
|
+
upstreamPath: '/chat/completions',
|
|
1467
|
+
model: routed.originalModel,
|
|
1468
|
+
chosenModel: routed.chosenModel,
|
|
1469
|
+
tier: routed.tier,
|
|
1470
|
+
reason: routed.reason,
|
|
1471
|
+
fallback: routed.fallback,
|
|
1472
|
+
routerEnabled: routed.routerEnabled,
|
|
1473
|
+
...(routed.features ? { features: routed.features } : {}),
|
|
1474
|
+
body: routed.body,
|
|
1475
|
+
retryBody: req.body,
|
|
1476
|
+
headers: req.headers ?? {},
|
|
1477
|
+
logger: log,
|
|
1478
|
+
...(opts.onTrace ? { onTrace: opts.onTrace } : {}),
|
|
1479
|
+
...(opts.clock ? { clock: opts.clock } : {}),
|
|
1480
|
+
env,
|
|
1481
|
+
responseHeaders: copyOpenAIResponseHeaders,
|
|
1482
|
+
});
|
|
1483
|
+
};
|
|
1484
|
+
}
|
|
1485
|
+
export function buildGeminiHandler(opts) {
|
|
1486
|
+
const log = opts.logger ?? console;
|
|
1487
|
+
const env = opts.env ?? process.env;
|
|
1488
|
+
return async (req) => {
|
|
1489
|
+
const { model, action } = validatedModelAction(req.params?.['modelAction'] ?? '');
|
|
1490
|
+
const qs = providerQueryString(req.query);
|
|
1491
|
+
return passthrough({
|
|
1492
|
+
proxy: opts.geminiProxy,
|
|
1493
|
+
provider: 'gemini',
|
|
1494
|
+
route: `/v1beta/models/${model}:${action}`,
|
|
1495
|
+
upstreamPath: `/v1beta/models/${encodeURIComponent(model)}:${action}${qs}`,
|
|
1496
|
+
model,
|
|
1497
|
+
body: req.body,
|
|
1498
|
+
headers: req.headers ?? {},
|
|
1499
|
+
logger: log,
|
|
1500
|
+
...(opts.onTrace ? { onTrace: opts.onTrace } : {}),
|
|
1501
|
+
...(opts.clock ? { clock: opts.clock } : {}),
|
|
1502
|
+
env,
|
|
1503
|
+
responseHeaders: copyGeminiResponseHeaders,
|
|
1504
|
+
});
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
export function buildOllamaHandler(opts) {
|
|
1508
|
+
const log = opts.logger ?? console;
|
|
1509
|
+
const env = opts.env ?? process.env;
|
|
1510
|
+
return (req) => passthrough({
|
|
1511
|
+
proxy: opts.ollamaProxy,
|
|
1512
|
+
provider: 'ollama',
|
|
1513
|
+
route: opts.apiPath,
|
|
1514
|
+
upstreamPath: opts.apiPath,
|
|
1515
|
+
model: typeof req.body.model === 'string' ? req.body.model : null,
|
|
1516
|
+
body: req.body,
|
|
1517
|
+
headers: req.headers ?? {},
|
|
1518
|
+
logger: log,
|
|
1519
|
+
...(opts.onTrace ? { onTrace: opts.onTrace } : {}),
|
|
1520
|
+
...(opts.clock ? { clock: opts.clock } : {}),
|
|
1521
|
+
env,
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
export function buildVertexHandler(opts) {
|
|
1525
|
+
const log = opts.logger ?? console;
|
|
1526
|
+
const env = opts.env ?? process.env;
|
|
1527
|
+
return async (req) => {
|
|
1528
|
+
const project = validatedVertexProject(req.params?.['project'] ?? '');
|
|
1529
|
+
const location = validatedVertexLocation(req.params?.['location'] ?? '');
|
|
1530
|
+
const { model, action } = validatedModelAction(req.params?.['modelAction'] ?? '');
|
|
1531
|
+
const qs = providerQueryString(req.query);
|
|
1532
|
+
return passthrough({
|
|
1533
|
+
proxy: opts.vertexProxy,
|
|
1534
|
+
provider: 'vertex',
|
|
1535
|
+
route: `/v1/projects/${project}/locations/${location}/publishers/google/models/${model}:${action}`,
|
|
1536
|
+
upstreamPath: `/v1/projects/${encodeURIComponent(project)}/locations/${encodeURIComponent(location)}/publishers/google/models/${encodeURIComponent(model)}:${action}${qs}`,
|
|
1537
|
+
model,
|
|
1538
|
+
body: req.body,
|
|
1539
|
+
headers: req.headers ?? {},
|
|
1540
|
+
logger: log,
|
|
1541
|
+
baseUrl: vertexBaseUrl(location, env),
|
|
1542
|
+
...(opts.onTrace ? { onTrace: opts.onTrace } : {}),
|
|
1543
|
+
...(opts.clock ? { clock: opts.clock } : {}),
|
|
1544
|
+
env,
|
|
1545
|
+
});
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
export function buildModelsHandler(opts) {
|
|
1549
|
+
const log = opts.logger ?? console;
|
|
1550
|
+
return async (req) => {
|
|
1551
|
+
const headers = req.headers ?? {};
|
|
1552
|
+
const provider = detectModelsProvider(headers);
|
|
1553
|
+
const proxy = provider === 'anthropic' ? opts.anthropicProxy : opts.openAIProxy;
|
|
1554
|
+
const upstreamPath = provider === 'anthropic' ? '/v1/models' : '/models';
|
|
1555
|
+
const upstream = await proxy(upstreamPath, null, { method: 'GET', inboundHeaders: headers, upstreamMode: provider });
|
|
1556
|
+
let raw = '';
|
|
1557
|
+
if (upstream.text) {
|
|
1558
|
+
try {
|
|
1559
|
+
raw = await Promise.resolve(upstream.text());
|
|
1560
|
+
}
|
|
1561
|
+
catch {
|
|
1562
|
+
raw = '';
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
const body = responseToBody(raw, upstream.status, upstream.headers, log, 'models_fallback');
|
|
1566
|
+
return { status: upstream.status, body };
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
export function buildProviderRoutes(opts) {
|
|
1570
|
+
return {
|
|
1571
|
+
'POST /v1/responses': buildOpenAIResponsesHandler(opts),
|
|
1572
|
+
'POST /v1/chat/completions': buildOpenAIChatCompletionsHandler(opts),
|
|
1573
|
+
'GET /v1/models': buildModelsHandler(opts),
|
|
1574
|
+
'POST /v1beta/models/:modelAction': buildGeminiHandler(opts),
|
|
1575
|
+
'POST /api/chat': buildOllamaHandler({ ...opts, apiPath: '/api/chat' }),
|
|
1576
|
+
'POST /api/generate': buildOllamaHandler({ ...opts, apiPath: '/api/generate' }),
|
|
1577
|
+
'POST /v1/projects/:project/locations/:location/publishers/google/models/:modelAction': buildVertexHandler(opts),
|
|
1578
|
+
};
|
|
1579
|
+
}
|