@juspay/neurolink 9.65.2 → 9.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +12 -12
  3. package/dist/avatar/index.d.ts +13 -0
  4. package/dist/avatar/index.js +72 -0
  5. package/dist/browser/neurolink.min.js +355 -347
  6. package/dist/cli/commands/proxy.js +154 -5
  7. package/dist/core/baseProvider.js +49 -8
  8. package/dist/factories/providerRegistry.js +23 -0
  9. package/dist/index.d.ts +10 -1
  10. package/dist/index.js +36 -1
  11. package/dist/lib/avatar/index.d.ts +13 -0
  12. package/dist/lib/avatar/index.js +72 -0
  13. package/dist/lib/core/baseProvider.js +49 -8
  14. package/dist/lib/factories/providerRegistry.js +23 -0
  15. package/dist/lib/files/fileTools.d.ts +1 -1
  16. package/dist/lib/index.d.ts +10 -1
  17. package/dist/lib/index.js +36 -1
  18. package/dist/lib/music/index.d.ts +14 -0
  19. package/dist/lib/music/index.js +80 -0
  20. package/dist/lib/proxy/modelRouter.d.ts +5 -1
  21. package/dist/lib/proxy/modelRouter.js +8 -0
  22. package/dist/lib/proxy/openaiFormat.d.ts +137 -0
  23. package/dist/lib/proxy/openaiFormat.js +801 -0
  24. package/dist/lib/proxy/proxyTranslationEngine.d.ts +124 -0
  25. package/dist/lib/proxy/proxyTranslationEngine.js +679 -0
  26. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +6 -5
  27. package/dist/lib/server/routes/claudeProxyRoutes.js +22 -355
  28. package/dist/lib/server/routes/index.d.ts +1 -0
  29. package/dist/lib/server/routes/index.js +10 -2
  30. package/dist/lib/server/routes/openaiProxyRoutes.d.ts +30 -0
  31. package/dist/lib/server/routes/openaiProxyRoutes.js +337 -0
  32. package/dist/lib/types/avatar.d.ts +8 -1
  33. package/dist/lib/types/multimodal.d.ts +20 -7
  34. package/dist/lib/types/music.d.ts +8 -1
  35. package/dist/lib/types/proxy.d.ts +179 -0
  36. package/dist/lib/types/server.d.ts +3 -0
  37. package/dist/lib/types/tts.d.ts +9 -1
  38. package/dist/lib/utils/avatarProcessor.d.ts +7 -1
  39. package/dist/lib/utils/avatarProcessor.js +6 -0
  40. package/dist/lib/utils/musicProcessor.d.ts +7 -1
  41. package/dist/lib/utils/musicProcessor.js +6 -0
  42. package/dist/lib/utils/parameterValidation.js +5 -1
  43. package/dist/lib/utils/sttProcessor.d.ts +5 -3
  44. package/dist/lib/utils/sttProcessor.js +4 -2
  45. package/dist/lib/utils/ttsProcessor.d.ts +6 -3
  46. package/dist/lib/utils/ttsProcessor.js +5 -2
  47. package/dist/lib/voice/RealtimeVoiceAPI.d.ts +5 -2
  48. package/dist/lib/voice/RealtimeVoiceAPI.js +4 -1
  49. package/dist/lib/voice/index.d.ts +23 -0
  50. package/dist/lib/voice/index.js +124 -2
  51. package/dist/lib/voice/providers/CartesiaTTS.d.ts +31 -0
  52. package/dist/lib/voice/providers/CartesiaTTS.js +189 -0
  53. package/dist/lib/workflow/config.d.ts +3 -3
  54. package/dist/music/index.d.ts +14 -0
  55. package/dist/music/index.js +80 -0
  56. package/dist/proxy/modelRouter.d.ts +5 -1
  57. package/dist/proxy/modelRouter.js +8 -0
  58. package/dist/proxy/openaiFormat.d.ts +137 -0
  59. package/dist/proxy/openaiFormat.js +800 -0
  60. package/dist/proxy/proxyTranslationEngine.d.ts +124 -0
  61. package/dist/proxy/proxyTranslationEngine.js +678 -0
  62. package/dist/server/routes/claudeProxyRoutes.d.ts +6 -5
  63. package/dist/server/routes/claudeProxyRoutes.js +22 -355
  64. package/dist/server/routes/index.d.ts +1 -0
  65. package/dist/server/routes/index.js +10 -2
  66. package/dist/server/routes/openaiProxyRoutes.d.ts +30 -0
  67. package/dist/server/routes/openaiProxyRoutes.js +336 -0
  68. package/dist/types/avatar.d.ts +8 -1
  69. package/dist/types/multimodal.d.ts +20 -7
  70. package/dist/types/music.d.ts +8 -1
  71. package/dist/types/proxy.d.ts +179 -0
  72. package/dist/types/server.d.ts +3 -0
  73. package/dist/types/tts.d.ts +9 -1
  74. package/dist/utils/avatarProcessor.d.ts +7 -1
  75. package/dist/utils/avatarProcessor.js +6 -0
  76. package/dist/utils/musicProcessor.d.ts +7 -1
  77. package/dist/utils/musicProcessor.js +6 -0
  78. package/dist/utils/parameterValidation.js +5 -1
  79. package/dist/utils/sttProcessor.d.ts +5 -3
  80. package/dist/utils/sttProcessor.js +4 -2
  81. package/dist/utils/ttsProcessor.d.ts +6 -3
  82. package/dist/utils/ttsProcessor.js +5 -2
  83. package/dist/voice/RealtimeVoiceAPI.d.ts +5 -2
  84. package/dist/voice/RealtimeVoiceAPI.js +4 -1
  85. package/dist/voice/index.d.ts +23 -0
  86. package/dist/voice/index.js +124 -2
  87. package/dist/voice/providers/CartesiaTTS.d.ts +31 -0
  88. package/dist/voice/providers/CartesiaTTS.js +188 -0
  89. package/package.json +65 -2
@@ -0,0 +1,679 @@
1
+ /**
2
+ * Proxy Translation Engine
3
+ *
4
+ * Shared translation logic used by both Claude and OpenAI proxy routes.
5
+ * Both formats follow the same pipeline:
6
+ * 1. Parse request (format-specific, done by the caller)
7
+ * 2. Loop through fallback attempts calling ctx.neurolink.stream()
8
+ * 3. Serialize response (format-specific, via serializer/response builder)
9
+ *
10
+ * This module exports the helpers that were previously duplicated between
11
+ * claudeProxyRoutes.ts and openaiProxyRoutes.ts, plus unified stream and
12
+ * JSON handlers that accept a format discriminator.
13
+ */
14
+ import { ClaudeStreamSerializer, generateToolUseId, serializeClaudeResponse, } from "./claudeFormat.js";
15
+ import { generateOpenAIToolCallId, OpenAIStreamSerializer, serializeOpenAIResponse, } from "./openaiFormat.js";
16
+ import { logRequest } from "./requestLogger.js";
17
+ import { recordAttempt, recordAttemptError, recordFinalError, recordFinalSuccess, } from "./usageStats.js";
18
+ import { ErrorCategory, ErrorSeverity } from "../constants/enums.js";
19
+ import { withTimeout } from "../utils/async/withTimeout.js";
20
+ import { ERROR_CODES, NeuroLinkError } from "../utils/errorHandling.js";
21
+ import { logger } from "../utils/logger.js";
22
+ // Upper bound on a single translation attempt. Long enough for slow upstreams
23
+ // (Vertex/LiteLLM can take 60–90s on big requests) but short enough that a
24
+ // hung provider can't stall the request handler indefinitely.
25
+ const TRANSLATION_ATTEMPT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
26
+ // ---------------------------------------------------------------------------
27
+ // Shared helpers
28
+ // ---------------------------------------------------------------------------
29
+ /**
30
+ * Extract text content from a stream chunk (handles various chunk formats).
31
+ */
32
+ export function extractText(chunk) {
33
+ if (typeof chunk === "string") {
34
+ return chunk;
35
+ }
36
+ if (chunk && typeof chunk === "object") {
37
+ const c = chunk;
38
+ // NeuroLink StreamResult chunk format: { content: string }
39
+ if (typeof c.content === "string") {
40
+ return c.content;
41
+ }
42
+ // Vercel AI SDK text delta format
43
+ if (c.type === "text-delta" && typeof c.textDelta === "string") {
44
+ return c.textDelta;
45
+ }
46
+ // Direct text field
47
+ if (typeof c.text === "string") {
48
+ return c.text;
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+ /** Extract tool call arguments from various shapes. */
54
+ export function extractToolArgs(toolCall) {
55
+ return (toolCall.args ??
56
+ toolCall.parameters ??
57
+ toolCall.input ??
58
+ {});
59
+ }
60
+ /** Check if there's meaningful output from translation. */
61
+ export function hasTranslatedOutput(collectedText, toolCalls) {
62
+ return collectedText.trim().length > 0 || (toolCalls?.length ?? 0) > 0;
63
+ }
64
+ /**
65
+ * Normalize usage from various AI SDK / NeuroLink shapes.
66
+ *
67
+ * Handles:
68
+ * - AI SDK v6: inputTokens / outputTokens
69
+ * - AI SDK v4: promptTokens / completionTokens
70
+ * - NeuroLink internal: input / output
71
+ */
72
+ export function extractUsageFromStreamResult(usage) {
73
+ if (!usage || typeof usage !== "object") {
74
+ return { input: 0, output: 0, total: 0 };
75
+ }
76
+ const u = usage;
77
+ const input = (typeof u.inputTokens === "number" ? u.inputTokens : 0) ||
78
+ (typeof u.promptTokens === "number" ? u.promptTokens : 0) ||
79
+ (typeof u.input === "number" ? u.input : 0);
80
+ const output = (typeof u.outputTokens === "number" ? u.outputTokens : 0) ||
81
+ (typeof u.completionTokens === "number" ? u.completionTokens : 0) ||
82
+ (typeof u.output === "number" ? u.output : 0);
83
+ return { input, output, total: input + output };
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // Format detection
87
+ // ---------------------------------------------------------------------------
88
+ /**
89
+ * Detect which proxy format a request is using based on path and headers.
90
+ */
91
+ export function detectProxyFormat(path, headers) {
92
+ // Path-based detection (primary, most reliable)
93
+ if (path.includes("/chat/completions")) {
94
+ return "openai";
95
+ }
96
+ if (path.includes("/messages")) {
97
+ return "claude";
98
+ }
99
+ // Header-based fallback
100
+ if (headers["anthropic-version"]) {
101
+ return "claude";
102
+ }
103
+ if (headers["x-claude-code-session-id"]) {
104
+ return "claude";
105
+ }
106
+ // Default to openai (more universal)
107
+ return "openai";
108
+ }
109
+ // ---------------------------------------------------------------------------
110
+ // Provider/model-specific overrides for translated requests
111
+ // ---------------------------------------------------------------------------
112
+ function shouldOmitImagesForTarget(provider, model) {
113
+ // `open-large` in our LiteLLM setup handles text and tools, but returns an
114
+ // empty completion when binary images are forwarded. Claude Code already
115
+ // includes textual image markers in the prompt, so dropping only the binary
116
+ // image payload keeps the request usable instead of breaking fallback.
117
+ return provider === "litellm" && model === "open-large";
118
+ }
119
+ function shouldOmitThinkingConfigForTarget(provider, model) {
120
+ // LiteLLM speaks an OpenAI-shaped API and does not understand the Anthropic
121
+ // `thinkingConfig` block — always strip it for litellm targets.
122
+ if (provider === "litellm") {
123
+ return true;
124
+ }
125
+ // For Vertex, only Gemini 2.5+ and 3.x support thinking on the wire.
126
+ // Other Vertex models (text-bison, older Gemini, Claude-on-Vertex) reject
127
+ // it, so omit for anything outside that allow-list.
128
+ if (provider === "vertex") {
129
+ const m = model?.toLowerCase() ?? "";
130
+ return !/gemini-(2\.5|3)/.test(m);
131
+ }
132
+ // Other providers (anthropic, openai, etc.) either support it natively or
133
+ // ignore unknown fields — pass it through.
134
+ return false;
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // Unified options builder
138
+ // ---------------------------------------------------------------------------
139
+ /**
140
+ * Build options for ctx.neurolink.stream() from a parsed request
141
+ * and an optional provider/model override.
142
+ *
143
+ * Works for both ParsedClaudeRequest and ParsedOpenAIRequest — the
144
+ * only differences are Claude-specific fields (topK, thinkingConfig)
145
+ * which are safely absent on OpenAI parsed requests.
146
+ */
147
+ export function buildTranslationOptions(parsed, overrides = {}) {
148
+ const historyMessages = parsed.conversationMessages.slice(0, -1);
149
+ const toolNames = Object.keys(parsed.tools);
150
+ // Claude-specific fields: topK and thinkingConfig
151
+ const claudeParsed = parsed;
152
+ const images = shouldOmitImagesForTarget(overrides.provider, overrides.model)
153
+ ? []
154
+ : parsed.images;
155
+ const thinkingConfig = claudeParsed.thinkingConfig &&
156
+ !shouldOmitThinkingConfigForTarget(overrides.provider, overrides.model)
157
+ ? claudeParsed.thinkingConfig
158
+ : undefined;
159
+ const toolChoice = parsed.toolChoiceName
160
+ ? { type: "tool", toolName: parsed.toolChoiceName }
161
+ : parsed.toolChoice;
162
+ return {
163
+ input: {
164
+ text: parsed.prompt,
165
+ ...(images.length > 0 ? { images } : {}),
166
+ },
167
+ ...(overrides.provider ? { provider: overrides.provider } : {}),
168
+ ...(overrides.model ? { model: overrides.model } : {}),
169
+ systemPrompt: parsed.systemPrompt,
170
+ ...(parsed.maxTokens !== undefined ? { maxTokens: parsed.maxTokens } : {}),
171
+ ...(parsed.temperature !== undefined
172
+ ? { temperature: parsed.temperature }
173
+ : {}),
174
+ ...(parsed.topP !== undefined ? { topP: parsed.topP } : {}),
175
+ ...(claudeParsed.topK !== undefined ? { topK: claudeParsed.topK } : {}),
176
+ ...(parsed.stopSequences?.length
177
+ ? { stopSequences: parsed.stopSequences }
178
+ : {}),
179
+ ...(thinkingConfig ? { thinkingConfig } : {}),
180
+ ...(toolNames.length === 0 ? { disableTools: true } : {}),
181
+ ...(toolNames.length > 0
182
+ ? {
183
+ tools: parsed.tools,
184
+ toolFilter: toolNames,
185
+ }
186
+ : {}),
187
+ ...(toolChoice ? { toolChoice } : {}),
188
+ ...(historyMessages.length > 0
189
+ ? { conversationMessages: historyMessages }
190
+ : {}),
191
+ disableInternalFallback: true,
192
+ skipToolPromptInjection: true,
193
+ maxSteps: 1,
194
+ };
195
+ }
196
+ // ---------------------------------------------------------------------------
197
+ // Serializer adapter — normalizes differences between Claude and OpenAI
198
+ // stream serializers behind a common interface.
199
+ // ---------------------------------------------------------------------------
200
+ function createClaudeSerializerAdapter(model) {
201
+ const inner = new ClaudeStreamSerializer(model, 0);
202
+ return {
203
+ start: () => inner.start(),
204
+ pushDelta: (text) => inner.pushDelta(text),
205
+ pushToolUse: (id, name, input) => inner.pushToolUse(id, name, input),
206
+ finish: (finishReason, usage) => inner.finish(usage.output, finishReason),
207
+ emitError: (message) => inner.emitError(500, message),
208
+ };
209
+ }
210
+ function createOpenAISerializerAdapter(model) {
211
+ const inner = new OpenAIStreamSerializer(model);
212
+ return {
213
+ start: () => inner.start(),
214
+ pushDelta: (text) => inner.pushDelta(text),
215
+ pushToolUse: (id, name, input) => inner.pushToolUse(id, name, input),
216
+ finish: (finishReason, usage) => inner.finish(finishReason, usage),
217
+ emitError: (message) => inner.emitError(message),
218
+ };
219
+ }
220
+ function generateToolId(format) {
221
+ return format === "claude" ? generateToolUseId() : generateOpenAIToolCallId();
222
+ }
223
+ function defaultFinishReason(format) {
224
+ return format === "claude" ? "end_turn" : "stop";
225
+ }
226
+ function logTag(format) {
227
+ return format === "claude" ? "[proxy]" : "[proxy:openai]";
228
+ }
229
+ // ---------------------------------------------------------------------------
230
+ // Unified streaming handler
231
+ // ---------------------------------------------------------------------------
232
+ /**
233
+ * Handles a translated stream request for either Claude or OpenAI format.
234
+ *
235
+ * The streaming loop logic (iterate attempts, call neurolink.stream, collect
236
+ * text, handle tool calls, keepalive timer) is identical across formats.
237
+ * Only the serializer differs.
238
+ */
239
+ export async function handleTranslatedStreamRequest(args) {
240
+ const { ctx, format, requestModel, parsed, attempts, tracer, requestStartTime, } = args;
241
+ const tag = logTag(format);
242
+ const serializer = format === "claude"
243
+ ? createClaudeSerializerAdapter(requestModel)
244
+ : createOpenAISerializerAdapter(requestModel);
245
+ const KEEPALIVE_INTERVAL_MS = 15_000;
246
+ const encoder = new TextEncoder();
247
+ let keepAliveTimer;
248
+ let cancelled = false;
249
+ let succeeded = false;
250
+ let translatedModel;
251
+ let finalStreamError = "No translation providers succeeded";
252
+ let upstreamIterator;
253
+ let lastAttemptLabel = "translation";
254
+ const stream = new ReadableStream({
255
+ async start(controller) {
256
+ // Emit opening frame
257
+ for (const frame of serializer.start()) {
258
+ controller.enqueue(encoder.encode(frame));
259
+ }
260
+ keepAliveTimer = setInterval(() => {
261
+ try {
262
+ controller.enqueue(encoder.encode(": keep-alive\n\n"));
263
+ }
264
+ catch {
265
+ // Controller already closed.
266
+ }
267
+ }, KEEPALIVE_INTERVAL_MS);
268
+ try {
269
+ for (let attemptIndex = 0; attemptIndex < attempts.length; attemptIndex++) {
270
+ const attempt = attempts[attemptIndex];
271
+ lastAttemptLabel = attempt.label ?? "translation";
272
+ logger.always(`[proxy:${format}] attempt ${attemptIndex + 1}/${attempts.length}: ${attempt.label}`);
273
+ recordAttempt(lastAttemptLabel, "translation");
274
+ let collectedText = "";
275
+ try {
276
+ const options = buildTranslationOptions(parsed, attempt.provider
277
+ ? { provider: attempt.provider, model: attempt.model }
278
+ : {});
279
+ const streamResult = await withTimeout(ctx.neurolink.stream(options), TRANSLATION_ATTEMPT_TIMEOUT_MS, `Translation attempt ${attempt.label} timed out after ${TRANSLATION_ATTEMPT_TIMEOUT_MS}ms`);
280
+ const iterable = streamResult.stream;
281
+ upstreamIterator = iterable[Symbol.asyncIterator]();
282
+ while (true) {
283
+ if (cancelled) {
284
+ break;
285
+ }
286
+ const { value: chunk, done } = await upstreamIterator.next();
287
+ if (done || cancelled) {
288
+ break;
289
+ }
290
+ const text = extractText(chunk);
291
+ if (text) {
292
+ collectedText += text;
293
+ for (const frame of serializer.pushDelta(text)) {
294
+ controller.enqueue(encoder.encode(frame));
295
+ }
296
+ }
297
+ }
298
+ const toolCalls = streamResult.toolCalls ?? [];
299
+ if (!hasTranslatedOutput(collectedText, toolCalls)) {
300
+ finalStreamError = `Translated provider ${attempt.label} returned no content or tool calls`;
301
+ logger.debug(`${tag} translation attempt ${attempt.label} returned no content or tool calls`);
302
+ continue;
303
+ }
304
+ if (!cancelled && toolCalls.length) {
305
+ for (const toolCall of toolCalls) {
306
+ const toolName = toolCall.toolName ??
307
+ toolCall.name ??
308
+ "unknown";
309
+ const toolId = toolCall.toolCallId ||
310
+ generateToolId(format);
311
+ for (const frame of serializer.pushToolUse(toolId, toolName, extractToolArgs(toolCall))) {
312
+ controller.enqueue(encoder.encode(frame));
313
+ }
314
+ }
315
+ }
316
+ if (!cancelled) {
317
+ const reason = streamResult.finishReason ?? defaultFinishReason(format);
318
+ const resolvedUsage = extractUsageFromStreamResult(streamResult.usage);
319
+ for (const frame of serializer.finish(reason, resolvedUsage)) {
320
+ controller.enqueue(encoder.encode(frame));
321
+ }
322
+ }
323
+ // Track usage and metrics
324
+ const resolvedUsageForTracer = extractUsageFromStreamResult(streamResult.usage);
325
+ tracer?.setUsage({
326
+ inputTokens: resolvedUsageForTracer.input,
327
+ outputTokens: resolvedUsageForTracer.output,
328
+ cacheCreationTokens: 0,
329
+ cacheReadTokens: 0,
330
+ });
331
+ tracer?.recordMetrics();
332
+ translatedModel = streamResult.model;
333
+ succeeded = true;
334
+ recordFinalSuccess(lastAttemptLabel, "translation");
335
+ return;
336
+ }
337
+ catch (streamErr) {
338
+ if (cancelled) {
339
+ return;
340
+ }
341
+ finalStreamError =
342
+ streamErr instanceof Error
343
+ ? streamErr.message
344
+ : String(streamErr);
345
+ if (collectedText.trim().length > 0) {
346
+ logger.always(`${tag} mid-stream error: ${finalStreamError}`);
347
+ for (const frame of serializer.emitError(`Upstream stream interrupted: ${finalStreamError}`)) {
348
+ controller.enqueue(encoder.encode(frame));
349
+ }
350
+ return;
351
+ }
352
+ logger.debug(`${tag} translation attempt ${attempt.label} failed: ${finalStreamError}`);
353
+ recordAttemptError(lastAttemptLabel, "translation", 500);
354
+ }
355
+ }
356
+ // All attempts exhausted
357
+ recordFinalError(500, lastAttemptLabel, "translation");
358
+ if (!cancelled) {
359
+ logger.always(`${tag} all translation attempts failed: ${finalStreamError}`);
360
+ for (const frame of serializer.emitError(finalStreamError)) {
361
+ controller.enqueue(encoder.encode(frame));
362
+ }
363
+ }
364
+ }
365
+ finally {
366
+ if (keepAliveTimer) {
367
+ clearInterval(keepAliveTimer);
368
+ }
369
+ if (!cancelled) {
370
+ controller.close();
371
+ }
372
+ if (tracer && translatedModel && translatedModel !== requestModel) {
373
+ tracer.setModelSubstitution(requestModel, translatedModel);
374
+ }
375
+ if (!succeeded) {
376
+ tracer?.setError("generation_error", finalStreamError.slice(0, 500));
377
+ }
378
+ // Use the real outcome status so trace data matches the logged
379
+ // responseStatus below (success path is 200, exhausted-attempts path is 500).
380
+ tracer?.end(succeeded ? 200 : 500, Date.now() - requestStartTime);
381
+ const traceCtx = tracer?.getTraceContext();
382
+ logRequest({
383
+ timestamp: new Date().toISOString(),
384
+ requestId: ctx.requestId,
385
+ method: ctx.method,
386
+ path: ctx.path,
387
+ model: requestModel,
388
+ stream: true,
389
+ toolCount: Object.keys(parsed.tools).length,
390
+ account: "translation",
391
+ accountType: "translation",
392
+ responseStatus: succeeded ? 200 : 500,
393
+ responseTimeMs: Date.now() - requestStartTime,
394
+ ...(traceCtx?.traceId ? { traceId: traceCtx.traceId } : {}),
395
+ ...(traceCtx?.spanId ? { spanId: traceCtx.spanId } : {}),
396
+ });
397
+ }
398
+ },
399
+ cancel() {
400
+ cancelled = true;
401
+ if (keepAliveTimer) {
402
+ clearInterval(keepAliveTimer);
403
+ keepAliveTimer = undefined;
404
+ }
405
+ if (upstreamIterator?.return) {
406
+ upstreamIterator.return(undefined).catch((cancelErr) => {
407
+ logger.debug(`${tag} upstream cancel error: ${cancelErr instanceof Error ? cancelErr.message : String(cancelErr)}`);
408
+ });
409
+ }
410
+ },
411
+ });
412
+ return new Response(stream, {
413
+ headers: {
414
+ "content-type": "text/event-stream",
415
+ "cache-control": "no-cache",
416
+ connection: "keep-alive",
417
+ },
418
+ });
419
+ }
420
+ // ---------------------------------------------------------------------------
421
+ // Unified JSON (non-streaming) handler
422
+ // ---------------------------------------------------------------------------
423
+ /**
424
+ * Handles a translated non-streaming request for either Claude or OpenAI format.
425
+ */
426
+ export async function handleTranslatedJsonRequest(args) {
427
+ const { ctx, format, requestModel, parsed, attempts, tracer, requestStartTime, } = args;
428
+ const tag = logTag(format);
429
+ let lastAttemptError = "No translation providers succeeded";
430
+ let lastAttemptLabel = "translation";
431
+ for (let attemptIndex = 0; attemptIndex < attempts.length; attemptIndex++) {
432
+ const attempt = attempts[attemptIndex];
433
+ lastAttemptLabel = attempt.label ?? "translation";
434
+ logger.always(`[proxy:${format}] attempt ${attemptIndex + 1}/${attempts.length}: ${attempt.label}`);
435
+ recordAttempt(lastAttemptLabel, "translation");
436
+ try {
437
+ const options = buildTranslationOptions(parsed, attempt.provider
438
+ ? { provider: attempt.provider, model: attempt.model }
439
+ : {});
440
+ const streamResult = await withTimeout(ctx.neurolink.stream(options), TRANSLATION_ATTEMPT_TIMEOUT_MS, `Translation attempt ${attempt.label} timed out after ${TRANSLATION_ATTEMPT_TIMEOUT_MS}ms`);
441
+ let collectedText = "";
442
+ for await (const chunk of streamResult.stream) {
443
+ const text = extractText(chunk);
444
+ if (text) {
445
+ collectedText += text;
446
+ }
447
+ }
448
+ if (!hasTranslatedOutput(collectedText, streamResult.toolCalls)) {
449
+ lastAttemptError = `Translated provider ${attempt.label} returned no content or tool calls`;
450
+ logger.debug(`${tag} translation attempt ${attempt.label} returned no content or tool calls`);
451
+ continue;
452
+ }
453
+ const internal = {
454
+ content: collectedText,
455
+ model: streamResult.model,
456
+ finishReason: streamResult.finishReason ?? defaultFinishReason(format),
457
+ reasoning: undefined,
458
+ usage: streamResult.usage
459
+ ? extractUsageFromStreamResult(streamResult.usage)
460
+ : undefined,
461
+ toolCalls: streamResult.toolCalls,
462
+ };
463
+ // Track usage and metrics
464
+ const resolvedUsage = extractUsageFromStreamResult(streamResult.usage);
465
+ tracer?.setUsage({
466
+ inputTokens: resolvedUsage.input,
467
+ outputTokens: resolvedUsage.output,
468
+ cacheCreationTokens: 0,
469
+ cacheReadTokens: 0,
470
+ });
471
+ tracer?.recordMetrics();
472
+ if (tracer && streamResult.model && streamResult.model !== requestModel) {
473
+ tracer.setModelSubstitution(requestModel, streamResult.model);
474
+ }
475
+ tracer?.end(200, Date.now() - requestStartTime);
476
+ recordFinalSuccess(lastAttemptLabel, "translation");
477
+ const traceCtx = tracer?.getTraceContext();
478
+ logRequest({
479
+ timestamp: new Date().toISOString(),
480
+ requestId: ctx.requestId,
481
+ method: ctx.method,
482
+ path: ctx.path,
483
+ model: requestModel,
484
+ stream: false,
485
+ toolCount: Object.keys(parsed.tools).length,
486
+ account: "translation",
487
+ accountType: "translation",
488
+ responseStatus: 200,
489
+ responseTimeMs: Date.now() - requestStartTime,
490
+ inputTokens: resolvedUsage.input,
491
+ outputTokens: resolvedUsage.output,
492
+ ...(traceCtx?.traceId ? { traceId: traceCtx.traceId } : {}),
493
+ ...(traceCtx?.spanId ? { spanId: traceCtx.spanId } : {}),
494
+ });
495
+ return format === "claude"
496
+ ? serializeClaudeResponse(internal, requestModel)
497
+ : serializeOpenAIResponse(internal, requestModel);
498
+ }
499
+ catch (attemptError) {
500
+ lastAttemptError =
501
+ attemptError instanceof Error
502
+ ? attemptError.message
503
+ : String(attemptError);
504
+ logger.debug(`${tag} translation attempt ${attempt.label} failed: ${lastAttemptError}`);
505
+ recordAttemptError(lastAttemptLabel, "translation", 500);
506
+ }
507
+ }
508
+ recordFinalError(500, lastAttemptLabel, "translation");
509
+ tracer?.setError("generation_error", lastAttemptError.slice(0, 500));
510
+ tracer?.end(500, Date.now() - requestStartTime);
511
+ const traceCtx = tracer?.getTraceContext();
512
+ logRequest({
513
+ timestamp: new Date().toISOString(),
514
+ requestId: ctx.requestId,
515
+ method: ctx.method,
516
+ path: ctx.path,
517
+ model: requestModel,
518
+ stream: false,
519
+ toolCount: Object.keys(parsed.tools).length,
520
+ account: "translation",
521
+ accountType: "translation",
522
+ responseStatus: 500,
523
+ responseTimeMs: Date.now() - requestStartTime,
524
+ errorType: "generation_error",
525
+ errorMessage: lastAttemptError.slice(0, 500),
526
+ ...(traceCtx?.traceId ? { traceId: traceCtx.traceId } : {}),
527
+ ...(traceCtx?.spanId ? { spanId: traceCtx.spanId } : {}),
528
+ });
529
+ throw new NeuroLinkError({
530
+ code: ERROR_CODES.PROVIDER_NOT_AVAILABLE,
531
+ message: lastAttemptError,
532
+ category: ErrorCategory.EXECUTION,
533
+ severity: ErrorSeverity.HIGH,
534
+ retriable: false,
535
+ context: { attemptLabel: lastAttemptLabel, format },
536
+ });
537
+ }
538
+ // ---------------------------------------------------------------------------
539
+ // Models list handler
540
+ // ---------------------------------------------------------------------------
541
+ /**
542
+ * Build the /v1/models response in OpenAI list format.
543
+ * Used by both Claude and OpenAI proxy routes.
544
+ */
545
+ export function buildModelsListResponse(modelRouter) {
546
+ const models = [];
547
+ if (modelRouter) {
548
+ const mappings = modelRouter.getModelMappings?.() ?? [];
549
+ for (const m of mappings) {
550
+ models.push({
551
+ id: m.from,
552
+ object: "model",
553
+ created: 0,
554
+ owned_by: "neurolink",
555
+ });
556
+ }
557
+ const passthroughModels = modelRouter.getPassthroughModels?.() ?? [];
558
+ for (const id of passthroughModels) {
559
+ models.push({
560
+ id,
561
+ object: "model",
562
+ created: 0,
563
+ owned_by: "neurolink",
564
+ });
565
+ }
566
+ }
567
+ // Always include a default entry if nothing else is configured
568
+ if (models.length === 0) {
569
+ for (const id of DEFAULT_MODEL_IDS) {
570
+ models.push({
571
+ id,
572
+ object: "model",
573
+ created: 0,
574
+ owned_by: "neurolink",
575
+ });
576
+ }
577
+ }
578
+ return {
579
+ object: "list",
580
+ data: models,
581
+ };
582
+ }
583
+ /**
584
+ * Canonical default model IDs surfaced when no router is configured. Format
585
+ * matches the IDs used throughout `src/lib/models/` and `src/lib/constants/`
586
+ * (e.g. `claude-3-5-haiku-20241022`, not `claude-haiku-3.5-20241022`).
587
+ */
588
+ const DEFAULT_MODEL_IDS = [
589
+ // Claude 4-series (current generation, hyphen-suffix family)
590
+ "claude-opus-4-6",
591
+ "claude-sonnet-4-6",
592
+ "claude-haiku-4-5",
593
+ // Claude 4 dated variant
594
+ "claude-sonnet-4-20250514",
595
+ // Claude 3.5-series (canonical Anthropic form: claude-3-5-{variant}-{date})
596
+ "claude-3-5-sonnet-20241022",
597
+ "claude-3-5-haiku-20241022",
598
+ // OpenAI / Google for translated-fallback users
599
+ "gpt-4o",
600
+ "gemini-2.5-pro",
601
+ "gemini-2.5-flash",
602
+ ];
603
+ /**
604
+ * Build an Anthropic-shaped `/v1/models` list response.
605
+ *
606
+ * Used by the Claude-compatible route so Anthropic SDK consumers receive
607
+ * the schema they expect:
608
+ * { data: [{type, id, display_name, created_at}], first_id, last_id, has_more }
609
+ *
610
+ * The OpenAI route continues to use {@link buildModelsListResponse} for the
611
+ * OpenAI list shape.
612
+ */
613
+ export function buildAnthropicModelsListResponse(modelRouter) {
614
+ const ids = [];
615
+ if (modelRouter) {
616
+ const mappings = modelRouter.getModelMappings?.() ?? [];
617
+ for (const m of mappings) {
618
+ if (m.from) {
619
+ ids.push(m.from);
620
+ }
621
+ }
622
+ const passthrough = modelRouter.getPassthroughModels?.() ?? [];
623
+ for (const id of passthrough) {
624
+ ids.push(id);
625
+ }
626
+ }
627
+ if (ids.length === 0) {
628
+ ids.push(...DEFAULT_MODEL_IDS);
629
+ }
630
+ // Deduplicate while preserving order — multiple router sources can publish
631
+ // the same id (e.g. both an explicit mapping and a passthrough entry).
632
+ const seen = new Set();
633
+ const unique = ids.filter((id) => {
634
+ if (seen.has(id)) {
635
+ return false;
636
+ }
637
+ seen.add(id);
638
+ return true;
639
+ });
640
+ const data = unique.map((id) => ({
641
+ type: "model",
642
+ id,
643
+ display_name: humanizeModelId(id),
644
+ // Anthropic uses ISO-8601 timestamps. We don't have a real creation date
645
+ // for proxy-published models, so anchor to the epoch — clients that care
646
+ // about ordering have `first_id`/`last_id` instead.
647
+ created_at: new Date(0).toISOString(),
648
+ }));
649
+ return {
650
+ data,
651
+ first_id: data[0]?.id ?? null,
652
+ last_id: data[data.length - 1]?.id ?? null,
653
+ has_more: false,
654
+ };
655
+ }
656
+ /** Best-effort pretty name for a model id (e.g. `claude-3-5-haiku-20241022` -> `Claude 3.5 Haiku`). */
657
+ function humanizeModelId(id) {
658
+ // Drop the trailing date suffix if present (e.g. -20241022).
659
+ const base = id.replace(/-\d{8}$/, "");
660
+ // claude-3-5-haiku → ["claude", "3", "5", "haiku"] → "Claude 3.5 Haiku"
661
+ const parts = base.split("-");
662
+ const numericVersion = [];
663
+ const words = [];
664
+ for (const p of parts) {
665
+ if (/^\d+$/.test(p) &&
666
+ words.length > 0 &&
667
+ words[0].toLowerCase() === "claude") {
668
+ numericVersion.push(p);
669
+ }
670
+ else {
671
+ words.push(p.charAt(0).toUpperCase() + p.slice(1));
672
+ }
673
+ }
674
+ const versionPart = numericVersion.length > 0 ? ` ${numericVersion.join(".")}` : "";
675
+ return words.length > 0
676
+ ? `${words[0]}${versionPart}${words.slice(1).length ? " " + words.slice(1).join(" ") : ""}`
677
+ : id;
678
+ }
679
+ //# sourceMappingURL=proxyTranslationEngine.js.map