@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.
- package/CHANGELOG.md +12 -0
- package/README.md +12 -12
- package/dist/avatar/index.d.ts +13 -0
- package/dist/avatar/index.js +72 -0
- package/dist/browser/neurolink.min.js +355 -347
- package/dist/cli/commands/proxy.js +154 -5
- package/dist/core/baseProvider.js +49 -8
- package/dist/factories/providerRegistry.js +23 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +36 -1
- package/dist/lib/avatar/index.d.ts +13 -0
- package/dist/lib/avatar/index.js +72 -0
- package/dist/lib/core/baseProvider.js +49 -8
- package/dist/lib/factories/providerRegistry.js +23 -0
- package/dist/lib/files/fileTools.d.ts +1 -1
- package/dist/lib/index.d.ts +10 -1
- package/dist/lib/index.js +36 -1
- package/dist/lib/music/index.d.ts +14 -0
- package/dist/lib/music/index.js +80 -0
- package/dist/lib/proxy/modelRouter.d.ts +5 -1
- package/dist/lib/proxy/modelRouter.js +8 -0
- package/dist/lib/proxy/openaiFormat.d.ts +137 -0
- package/dist/lib/proxy/openaiFormat.js +801 -0
- package/dist/lib/proxy/proxyTranslationEngine.d.ts +124 -0
- package/dist/lib/proxy/proxyTranslationEngine.js +679 -0
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +6 -5
- package/dist/lib/server/routes/claudeProxyRoutes.js +22 -355
- package/dist/lib/server/routes/index.d.ts +1 -0
- package/dist/lib/server/routes/index.js +10 -2
- package/dist/lib/server/routes/openaiProxyRoutes.d.ts +30 -0
- package/dist/lib/server/routes/openaiProxyRoutes.js +337 -0
- package/dist/lib/types/avatar.d.ts +8 -1
- package/dist/lib/types/multimodal.d.ts +20 -7
- package/dist/lib/types/music.d.ts +8 -1
- package/dist/lib/types/proxy.d.ts +179 -0
- package/dist/lib/types/server.d.ts +3 -0
- package/dist/lib/types/tts.d.ts +9 -1
- package/dist/lib/utils/avatarProcessor.d.ts +7 -1
- package/dist/lib/utils/avatarProcessor.js +6 -0
- package/dist/lib/utils/musicProcessor.d.ts +7 -1
- package/dist/lib/utils/musicProcessor.js +6 -0
- package/dist/lib/utils/parameterValidation.js +5 -1
- package/dist/lib/utils/sttProcessor.d.ts +5 -3
- package/dist/lib/utils/sttProcessor.js +4 -2
- package/dist/lib/utils/ttsProcessor.d.ts +6 -3
- package/dist/lib/utils/ttsProcessor.js +5 -2
- package/dist/lib/voice/RealtimeVoiceAPI.d.ts +5 -2
- package/dist/lib/voice/RealtimeVoiceAPI.js +4 -1
- package/dist/lib/voice/index.d.ts +23 -0
- package/dist/lib/voice/index.js +124 -2
- package/dist/lib/voice/providers/CartesiaTTS.d.ts +31 -0
- package/dist/lib/voice/providers/CartesiaTTS.js +189 -0
- package/dist/lib/workflow/config.d.ts +3 -3
- package/dist/music/index.d.ts +14 -0
- package/dist/music/index.js +80 -0
- package/dist/proxy/modelRouter.d.ts +5 -1
- package/dist/proxy/modelRouter.js +8 -0
- package/dist/proxy/openaiFormat.d.ts +137 -0
- package/dist/proxy/openaiFormat.js +800 -0
- package/dist/proxy/proxyTranslationEngine.d.ts +124 -0
- package/dist/proxy/proxyTranslationEngine.js +678 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +6 -5
- package/dist/server/routes/claudeProxyRoutes.js +22 -355
- package/dist/server/routes/index.d.ts +1 -0
- package/dist/server/routes/index.js +10 -2
- package/dist/server/routes/openaiProxyRoutes.d.ts +30 -0
- package/dist/server/routes/openaiProxyRoutes.js +336 -0
- package/dist/types/avatar.d.ts +8 -1
- package/dist/types/multimodal.d.ts +20 -7
- package/dist/types/music.d.ts +8 -1
- package/dist/types/proxy.d.ts +179 -0
- package/dist/types/server.d.ts +3 -0
- package/dist/types/tts.d.ts +9 -1
- package/dist/utils/avatarProcessor.d.ts +7 -1
- package/dist/utils/avatarProcessor.js +6 -0
- package/dist/utils/musicProcessor.d.ts +7 -1
- package/dist/utils/musicProcessor.js +6 -0
- package/dist/utils/parameterValidation.js +5 -1
- package/dist/utils/sttProcessor.d.ts +5 -3
- package/dist/utils/sttProcessor.js +4 -2
- package/dist/utils/ttsProcessor.d.ts +6 -3
- package/dist/utils/ttsProcessor.js +5 -2
- package/dist/voice/RealtimeVoiceAPI.d.ts +5 -2
- package/dist/voice/RealtimeVoiceAPI.js +4 -1
- package/dist/voice/index.d.ts +23 -0
- package/dist/voice/index.js +124 -2
- package/dist/voice/providers/CartesiaTTS.d.ts +31 -0
- package/dist/voice/providers/CartesiaTTS.js +188 -0
- package/package.json +65 -2
|
@@ -0,0 +1,678 @@
|
|
|
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
|
+
}
|