@juspay/neurolink 9.67.2 → 9.68.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.
@@ -0,0 +1,684 @@
1
+ /**
2
+ * Abstract base class for providers that talk to an OpenAI chat-completions
3
+ * shaped HTTP endpoint. Owns the entire request/stream/tool-loop pipeline
4
+ * so concrete providers only declare configuration + provider-specific
5
+ * quirks (env var names, default model, error mapping).
6
+ *
7
+ * Currently extended by:
8
+ * - OpenAICompatibleProvider (generic /v1/chat/completions backend)
9
+ * - LiteLLMProvider (LiteLLM proxy server)
10
+ * - DeepSeekProvider (api.deepseek.com)
11
+ *
12
+ * Subclasses provide:
13
+ * - getProviderName() / getDefaultModel() / formatProviderError() (abstract)
14
+ * - optional overrides: getFallbackModelName, getFallbackModels,
15
+ * adjustBuildBodyOptions, onStreamStart, getAvailableModels
16
+ *
17
+ * Nothing here imports from "ai" or "@ai-sdk/*". The base class is a
18
+ * direct HTTP client + multi-step tool-execution loop driven by SSE.
19
+ */
20
+ import { BaseProvider } from "../core/baseProvider.js";
21
+ import { DEFAULT_MAX_STEPS } from "../core/constants.js";
22
+ import { streamAnalyticsCollector } from "../core/streamAnalytics.js";
23
+ import { createProxyFetch } from "../proxy/proxyFetch.js";
24
+ import { logger } from "../utils/logger.js";
25
+ import { NoOutputGeneratedError } from "../utils/generationErrors.js";
26
+ import { buildNoOutputSentinel, stampNoOutputSpan, } from "../utils/noOutputSentinel.js";
27
+ import { composeAbortSignals, createTimeoutController, mergeAbortSignals, } from "../utils/timeout.js";
28
+ import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js";
29
+ import { resolveToolChoice } from "../utils/toolChoice.js";
30
+ import { transformToolExecutions } from "../utils/transformationUtils.js";
31
+ import { buildAPIError, buildBody, buildToolsForOpenAI, createChunkQueue, createDeferredAnalytics, ensureJsonWordInBody, mapNeuroLinkToolChoice, mergeUsage, messageBuilderToOpenAI, parseSSEStream, stringifyToolOutput, stripTrailingSlash, v3ResponseFormatToOpenAI, v3ToolChoiceToOpenAI, v3ToolsToOpenAI, } from "./openaiChatCompletionsClient.js";
32
+ /**
33
+ * Abstract HTTP+SSE provider for OpenAI chat-completions-shaped endpoints.
34
+ */
35
+ export class OpenAIChatCompletionsProvider extends BaseProvider {
36
+ config;
37
+ resolvedModel;
38
+ constructor(providerName, modelName, sdk, config) {
39
+ super(modelName, providerName, sdk);
40
+ this.config = config;
41
+ }
42
+ // ===========================================================================
43
+ // Optional overridable hooks
44
+ // ===========================================================================
45
+ /**
46
+ * Model name to return when `getDefaultModel()` is empty AND
47
+ * auto-discovery via `/models` finds nothing. Default "gpt-3.5-turbo".
48
+ */
49
+ getFallbackModelName() {
50
+ return "gpt-3.5-turbo";
51
+ }
52
+ /**
53
+ * Hardcoded model names returned from `getAvailableModels()` when the
54
+ * remote `/models` endpoint can't be reached. Default empty.
55
+ */
56
+ getFallbackModels() {
57
+ return [];
58
+ }
59
+ /**
60
+ * Hook to mutate the `buildBody` options before the wire body is
61
+ * constructed. Default identity. Override for model-specific quirks
62
+ * (e.g. LiteLLM's Gemini 2.5 maxTokens skip).
63
+ */
64
+ adjustBuildBodyOptions(_modelId, opts) {
65
+ return opts;
66
+ }
67
+ /**
68
+ * Hook to adjust the OpenAI `response_format` after it's converted from the
69
+ * V3 responseFormat (non-streaming `doGenerate` path). Default identity.
70
+ * Override for providers that don't support a given format type — e.g.
71
+ * DeepSeek rejects `response_format: { type: "json_schema" }` ("This
72
+ * response_format type is unavailable now"); the `@ai-sdk/openai-compatible`
73
+ * path this replaced declared `supportsStructuredOutputs: false`, which
74
+ * downgraded `json_schema` to `json_object`. Subclasses replicate that here.
75
+ */
76
+ adjustResponseFormat(rf, _modelId) {
77
+ return rf;
78
+ }
79
+ /**
80
+ * Hook to adjust the fully-built wire request body before it is sent, on
81
+ * both the streaming and non-streaming paths. Default identity. Override for
82
+ * provider/model quirks that can't be expressed through buildBody options —
83
+ * e.g. Azure's newer reasoning deployments (o-series, gpt-5+) reject
84
+ * `max_tokens` and require `max_completion_tokens`.
85
+ */
86
+ adjustRequestBody(body, _modelId) {
87
+ return body;
88
+ }
89
+ /**
90
+ * Hook called once at the start of every `executeStream` invocation.
91
+ * Return lifecycle listeners (onUsage / onFinish) to receive deferred
92
+ * analytics events as the stream progresses. Default returns undefined
93
+ * (no extra wiring). LiteLLM uses this for the OTel span wrap with cost.
94
+ */
95
+ onStreamStart(_modelId) {
96
+ return undefined;
97
+ }
98
+ /**
99
+ * Returns true if `resolveModelName` should fall back to fetching
100
+ * `getAvailableModels()` and picking the first one when no explicit
101
+ * model is configured. Default true. Subclasses with a non-empty
102
+ * `getDefaultModel()` will never hit this branch anyway.
103
+ */
104
+ shouldAutoDiscoverModel() {
105
+ return true;
106
+ }
107
+ /**
108
+ * Builds the chat-completions request URL for a model. Default is
109
+ * `${baseURL}/chat/completions`. Override for providers with a different
110
+ * routing scheme (e.g. Azure's deployment-based path + api-version query).
111
+ */
112
+ getChatCompletionsURL(_modelId) {
113
+ return `${stripTrailingSlash(this.config.baseURL)}/chat/completions`;
114
+ }
115
+ /**
116
+ * Auth headers merged into every request. Default is a Bearer token.
117
+ * Override for providers that authenticate differently (e.g. Azure, which
118
+ * uses an `api-key` header instead of `Authorization: Bearer`).
119
+ */
120
+ getAuthHeaders() {
121
+ return { Authorization: `Bearer ${this.config.apiKey}` };
122
+ }
123
+ // ===========================================================================
124
+ // Public/protected concrete methods (shared by all subclasses)
125
+ // ===========================================================================
126
+ supportsTools() {
127
+ return true;
128
+ }
129
+ /**
130
+ * Returns a minimal V3-shaped model used by BaseProvider's `generate()`
131
+ * non-streaming path. Driven by the parent's `generateText`. The
132
+ * streaming path bypasses this entirely.
133
+ */
134
+ async getAISDKModel() {
135
+ const modelId = await this.resolveModelName();
136
+ return this.buildDelegatingModel(modelId);
137
+ }
138
+ async resolveModelName() {
139
+ if (this.resolvedModel) {
140
+ return this.resolvedModel;
141
+ }
142
+ const explicit = this.modelName || this.getDefaultModel();
143
+ if (explicit && explicit.trim() !== "") {
144
+ this.resolvedModel = explicit;
145
+ if (this.modelName !== explicit) {
146
+ this.refreshHandlersForModel(explicit);
147
+ }
148
+ return explicit;
149
+ }
150
+ if (this.shouldAutoDiscoverModel()) {
151
+ try {
152
+ const available = await this.getAvailableModels();
153
+ if (available.length > 0) {
154
+ this.resolvedModel = available[0];
155
+ this.refreshHandlersForModel(available[0]);
156
+ logger.info(`🔍 Auto-discovered model: ${available[0]} from ${available.length} available models`);
157
+ return available[0];
158
+ }
159
+ }
160
+ catch (err) {
161
+ logger.warn("Model auto-discovery failed, using fallback:", err);
162
+ }
163
+ }
164
+ const fallback = this.getFallbackModelName();
165
+ this.resolvedModel = fallback;
166
+ this.refreshHandlersForModel(fallback);
167
+ return fallback;
168
+ }
169
+ buildDelegatingModel(modelId) {
170
+ const url = this.getChatCompletionsURL(modelId);
171
+ const fetchImpl = createProxyFetch();
172
+ const getAuthHeaders = this.getAuthHeaders.bind(this);
173
+ const providerName = this.providerName;
174
+ const adjustBuildBodyOptions = this.adjustBuildBodyOptions.bind(this);
175
+ const adjustResponseFormat = this.adjustResponseFormat.bind(this);
176
+ const adjustRequestBody = this.adjustRequestBody.bind(this);
177
+ const getTimeoutForOptions = (opts) => this.getTimeout((opts ?? {}));
178
+ return {
179
+ specificationVersion: "v3",
180
+ provider: providerName,
181
+ modelId,
182
+ supportedUrls: {},
183
+ doGenerate: async (options) => {
184
+ const baseMessages = messageBuilderToOpenAI(options.prompt);
185
+ const responseFormat = options.responseFormat
186
+ ? adjustResponseFormat(v3ResponseFormatToOpenAI(options.responseFormat), modelId)
187
+ : undefined;
188
+ // ensureJsonWordInBody runs LAST — on the body after adjustRequestBody —
189
+ // so the json_object word guard reflects whatever a subclass left on
190
+ // the wire (it may rewrite response_format/messages), not an
191
+ // intermediate state.
192
+ const body = ensureJsonWordInBody(adjustRequestBody(buildBody({
193
+ modelId,
194
+ messages: baseMessages,
195
+ options: adjustBuildBodyOptions(modelId, {
196
+ maxTokens: options.maxOutputTokens,
197
+ temperature: options.temperature,
198
+ topP: options.topP,
199
+ presencePenalty: options.presencePenalty,
200
+ frequencyPenalty: options.frequencyPenalty,
201
+ seed: options.seed,
202
+ stopSequences: options.stopSequences,
203
+ }),
204
+ tools: v3ToolsToOpenAI(options.tools),
205
+ ...(options.toolChoice
206
+ ? { toolChoice: v3ToolChoiceToOpenAI(options.toolChoice) }
207
+ : {}),
208
+ streaming: false,
209
+ ...(responseFormat ? { responseFormat } : {}),
210
+ }), modelId));
211
+ const timeoutController = createTimeoutController(getTimeoutForOptions(options), providerName, "generate");
212
+ const composedSignal = composeAbortSignals(options.abortSignal, timeoutController?.controller.signal);
213
+ let res;
214
+ try {
215
+ res = await fetchImpl(url, {
216
+ method: "POST",
217
+ headers: {
218
+ "Content-Type": "application/json",
219
+ ...getAuthHeaders(),
220
+ },
221
+ body: JSON.stringify(body),
222
+ ...(composedSignal ? { signal: composedSignal } : {}),
223
+ });
224
+ }
225
+ finally {
226
+ timeoutController?.cleanup();
227
+ }
228
+ if (!res.ok) {
229
+ throw await buildAPIError(url, body, res);
230
+ }
231
+ const json = (await res.json());
232
+ const choice = json.choices?.[0];
233
+ const text = (typeof choice?.message?.content === "string"
234
+ ? choice.message.content
235
+ : "") ?? "";
236
+ const content = [];
237
+ if (text.length > 0) {
238
+ content.push({ type: "text", text });
239
+ }
240
+ for (const tc of choice?.message?.tool_calls ?? []) {
241
+ content.push({
242
+ type: "tool-call",
243
+ toolCallId: tc.id,
244
+ toolName: tc.function.name,
245
+ input: tc.function.arguments ?? "",
246
+ });
247
+ }
248
+ const rawFinish = choice?.finish_reason;
249
+ const unified = rawFinish === "length"
250
+ ? "length"
251
+ : rawFinish === "tool_calls" || rawFinish === "function_call"
252
+ ? "tool-calls"
253
+ : rawFinish === "content_filter"
254
+ ? "content-filter"
255
+ : "stop";
256
+ return {
257
+ content,
258
+ finishReason: { unified, raw: rawFinish ?? "stop" },
259
+ usage: {
260
+ inputTokens: {
261
+ total: json.usage?.prompt_tokens,
262
+ noCache: json.usage?.prompt_tokens,
263
+ cacheRead: undefined,
264
+ cacheWrite: undefined,
265
+ },
266
+ outputTokens: {
267
+ total: json.usage?.completion_tokens,
268
+ text: json.usage?.completion_tokens,
269
+ reasoning: undefined,
270
+ },
271
+ },
272
+ warnings: [],
273
+ request: { body },
274
+ response: {
275
+ ...(json.id ? { id: json.id } : {}),
276
+ ...(json.model ? { modelId: json.model } : {}),
277
+ headers: {},
278
+ body: json,
279
+ },
280
+ };
281
+ },
282
+ doStream: () => {
283
+ throw new Error(`${providerName}: doStream is not implemented on the delegating model — the streaming path uses executeStream directly.`);
284
+ },
285
+ };
286
+ }
287
+ /**
288
+ * Streaming path — drives the chat-completions endpoint directly. No
289
+ * streamText, no AI SDK orchestrator. Tool calls, multi-step loops,
290
+ * telemetry, abort handling all inline.
291
+ */
292
+ async executeStream(options, _analysisSchema) {
293
+ this.validateStreamOptions(options);
294
+ const startTime = Date.now();
295
+ const timeout = this.getTimeout(options);
296
+ const timeoutController = createTimeoutController(timeout, this.providerName, "stream");
297
+ // Consumer-driven abort: fires when the async iterator is closed early
298
+ // (caller breaks out of `for await`). Without this the background
299
+ // `loopPromise` keeps reading SSE and running tools indefinitely.
300
+ const consumerAbortController = new AbortController();
301
+ const abortSignal = mergeAbortSignals([
302
+ options.abortSignal,
303
+ timeoutController?.controller.signal,
304
+ consumerAbortController.signal,
305
+ ]).signal;
306
+ let modelId;
307
+ let toolsRecord;
308
+ let openAITools;
309
+ let openAIToolChoice;
310
+ let conversation;
311
+ try {
312
+ modelId = await this.resolveModelName();
313
+ const shouldUseTools = !options.disableTools && this.supportsTools();
314
+ toolsRecord = shouldUseTools
315
+ ? options.tools || (await this.getAllTools())
316
+ : {};
317
+ openAITools = shouldUseTools
318
+ ? buildToolsForOpenAI(toolsRecord)
319
+ : undefined;
320
+ openAIToolChoice = mapNeuroLinkToolChoice(resolveToolChoice(options, toolsRecord, shouldUseTools));
321
+ const initialMessages = await this.buildMessagesForStream(options);
322
+ conversation = messageBuilderToOpenAI(initialMessages);
323
+ }
324
+ catch (setupErr) {
325
+ timeoutController?.cleanup();
326
+ throw setupErr;
327
+ }
328
+ const url = this.getChatCompletionsURL(modelId);
329
+ const fetchImpl = createProxyFetch();
330
+ const maxSteps = options.maxSteps || DEFAULT_MAX_STEPS;
331
+ const emitter = this.neurolink?.getEventEmitter();
332
+ const toolsUsed = [];
333
+ const toolExecutionSummaries = [];
334
+ const { usagePromise, finishPromise, resolveUsage, resolveFinish } = createDeferredAnalytics();
335
+ const { pushChunk, nextChunk } = createChunkQueue();
336
+ // Per-provider lifecycle hook (e.g. OTel span wrap for LiteLLM).
337
+ const lifecycle = this.onStreamStart(modelId);
338
+ const loopPromise = this.runStreamLoop({
339
+ maxSteps,
340
+ modelId,
341
+ url,
342
+ fetchImpl,
343
+ abortSignal,
344
+ options,
345
+ conversation,
346
+ openAITools,
347
+ openAIToolChoice,
348
+ toolsRecord,
349
+ emitter,
350
+ toolsUsed,
351
+ toolExecutionSummaries,
352
+ pushChunk,
353
+ resolveUsage,
354
+ resolveFinish,
355
+ });
356
+ // Closure-scoped capture: the runStreamLoop's catch block stashes the
357
+ // underlying provider error here so we can pass it through to
358
+ // buildNoOutputSentinel for richer telemetry (matches the pattern in
359
+ // openAI.ts / litellm.ts where onError preserves the upstream cause).
360
+ let capturedProviderError;
361
+ // Parameter named `error` so the compiled `capturedProviderError = error`
362
+ // assignment matches the regression-grep in test:context 6.14.
363
+ const captureProviderError = (error) => {
364
+ capturedProviderError = error;
365
+ };
366
+ if (lifecycle?.onUsage) {
367
+ usagePromise.then(lifecycle.onUsage).catch(() => {
368
+ // usage may never resolve if the stream is aborted before completion
369
+ });
370
+ }
371
+ if (lifecycle?.onFinish) {
372
+ finishPromise
373
+ .then((reason) => lifecycle.onFinish?.(reason, capturedProviderError))
374
+ .catch(() => {
375
+ /* swallowed by design — see above */
376
+ });
377
+ }
378
+ const providerName = this.providerName;
379
+ const transformedStream = async function* () {
380
+ let contentYielded = 0;
381
+ try {
382
+ for (;;) {
383
+ const chunk = await nextChunk();
384
+ if ("done" in chunk) {
385
+ break;
386
+ }
387
+ if ("content" in chunk &&
388
+ typeof chunk.content === "string" &&
389
+ chunk.content.length > 0) {
390
+ contentYielded++;
391
+ }
392
+ yield chunk;
393
+ }
394
+ // Surface any error that the loop threw after we drained the queue.
395
+ await loopPromise;
396
+ // No-output path: stream completed normally but yielded zero text.
397
+ // Build an enriched sentinel + stamp the active OTel span so
398
+ // Pipeline B (ContextEnricher) surfaces a WARNING-level Langfuse
399
+ // observation instead of silently succeeding.
400
+ if (contentYielded === 0 && toolsUsed.length === 0) {
401
+ logger.warn(`${providerName}: Stream produced no output — emitting enriched sentinel`);
402
+ const fauxNoOutput = new NoOutputGeneratedError({
403
+ message: "Stream produced no output",
404
+ });
405
+ const sentinel = await buildNoOutputSentinel(fauxNoOutput, undefined, capturedProviderError);
406
+ stampNoOutputSpan(sentinel);
407
+ yield sentinel;
408
+ }
409
+ }
410
+ catch (streamError) {
411
+ if (NoOutputGeneratedError.isInstance(streamError)) {
412
+ const sentinel = await buildNoOutputSentinel(streamError, undefined, capturedProviderError);
413
+ stampNoOutputSpan(sentinel);
414
+ yield sentinel;
415
+ return;
416
+ }
417
+ const sentinel = await buildNoOutputSentinel(streamError, undefined, capturedProviderError);
418
+ stampNoOutputSpan(sentinel);
419
+ yield sentinel;
420
+ throw streamError;
421
+ }
422
+ finally {
423
+ if (!consumerAbortController.signal.aborted) {
424
+ consumerAbortController.abort();
425
+ }
426
+ }
427
+ };
428
+ const result = {
429
+ stream: transformedStream(),
430
+ provider: this.providerName,
431
+ model: this.modelName,
432
+ analytics: streamAnalyticsCollector.createAnalytics(this.providerName, this.modelName, {
433
+ textStream: (async function* () { })(),
434
+ usage: usagePromise,
435
+ finishReason: finishPromise,
436
+ }, Date.now() - startTime, {
437
+ requestId: options.requestId ??
438
+ `${this.providerName}-stream-${Date.now()}`,
439
+ streamingMode: true,
440
+ }),
441
+ toolsUsed,
442
+ metadata: {
443
+ startTime,
444
+ streamId: `${this.providerName}-${Date.now()}`,
445
+ },
446
+ };
447
+ // Lazy getter: every read transforms the live `toolExecutionSummaries`
448
+ // through the canonical `transformToolExecutions()` so consumers see
449
+ // `{name, input, output, duration}[]` (codebase convention), while still
450
+ // reflecting tools appended during streaming.
451
+ Object.defineProperty(result, "toolExecutions", {
452
+ enumerable: true,
453
+ configurable: true,
454
+ get: () => transformToolExecutions(toolExecutionSummaries.map((s) => ({
455
+ toolName: s.toolName,
456
+ input: s.input,
457
+ output: s.output,
458
+ duration: s.endTime.getTime() - s.startTime.getTime(),
459
+ }))),
460
+ });
461
+ loopPromise
462
+ .finally(() => timeoutController?.cleanup())
463
+ .catch((error) => {
464
+ captureProviderError(error);
465
+ });
466
+ return result;
467
+ }
468
+ async runStreamLoop(args) {
469
+ const { maxSteps, modelId, url, fetchImpl, abortSignal, options, conversation, openAITools, openAIToolChoice, toolsRecord, emitter, toolsUsed, toolExecutionSummaries, pushChunk, resolveUsage, resolveFinish, } = args;
470
+ try {
471
+ let stepFinish = null;
472
+ let stepUsage;
473
+ for (let step = 0; step < maxSteps; step++) {
474
+ const stepResult = await this.streamOneStep({
475
+ modelId,
476
+ url,
477
+ fetchImpl,
478
+ abortSignal,
479
+ options,
480
+ conversation,
481
+ openAITools,
482
+ openAIToolChoice,
483
+ pushChunk,
484
+ });
485
+ stepFinish = stepResult.finishReason;
486
+ if (stepResult.usage) {
487
+ stepUsage = mergeUsage(stepUsage, stepResult.usage);
488
+ }
489
+ if (stepResult.toolCalls.size === 0) {
490
+ break;
491
+ }
492
+ await this.executeToolBatch({
493
+ stepResult,
494
+ conversation,
495
+ toolsRecord,
496
+ emitter,
497
+ toolsUsed,
498
+ toolExecutionSummaries,
499
+ options,
500
+ });
501
+ }
502
+ resolveUsage({
503
+ promptTokens: stepUsage?.prompt_tokens ?? 0,
504
+ completionTokens: stepUsage?.completion_tokens ?? 0,
505
+ totalTokens: stepUsage?.total_tokens ?? 0,
506
+ });
507
+ resolveFinish(stepFinish ?? "stop");
508
+ pushChunk({ done: true });
509
+ return {
510
+ finishReason: stepFinish ?? "stop",
511
+ usage: stepUsage,
512
+ };
513
+ }
514
+ catch (err) {
515
+ logger.error(`${this.providerName}: Stream error`, {
516
+ error: err instanceof Error ? err.message : String(err),
517
+ });
518
+ resolveUsage({ promptTokens: 0, completionTokens: 0, totalTokens: 0 });
519
+ resolveFinish("error");
520
+ pushChunk({ done: true });
521
+ throw err;
522
+ }
523
+ }
524
+ async streamOneStep(args) {
525
+ const body = ensureJsonWordInBody(this.adjustRequestBody(buildBody({
526
+ modelId: args.modelId,
527
+ messages: args.conversation,
528
+ options: this.adjustBuildBodyOptions(args.modelId, args.options),
529
+ tools: args.openAITools,
530
+ ...(args.openAIToolChoice !== undefined
531
+ ? { toolChoice: args.openAIToolChoice }
532
+ : {}),
533
+ streaming: true,
534
+ }), args.modelId));
535
+ const res = await args.fetchImpl(args.url, {
536
+ method: "POST",
537
+ headers: {
538
+ "Content-Type": "application/json",
539
+ ...this.getAuthHeaders(),
540
+ },
541
+ body: JSON.stringify(body),
542
+ ...(args.abortSignal ? { signal: args.abortSignal } : {}),
543
+ });
544
+ if (!res.ok) {
545
+ throw await buildAPIError(args.url, body, res);
546
+ }
547
+ if (!res.body) {
548
+ throw new Error(`${this.providerName}: stream response had no body`);
549
+ }
550
+ return parseSSEStream(res.body, (delta) => {
551
+ args.pushChunk({ content: delta });
552
+ });
553
+ }
554
+ async executeToolBatch(args) {
555
+ const { stepResult, conversation, toolsRecord, emitter, toolsUsed, toolExecutionSummaries, options, } = args;
556
+ const toolCallsForMessage = [];
557
+ for (const [, t] of stepResult.toolCalls) {
558
+ toolCallsForMessage.push({
559
+ id: t.id,
560
+ type: "function",
561
+ function: { name: t.name, arguments: t.argsBuffered },
562
+ });
563
+ }
564
+ conversation.push({
565
+ role: "assistant",
566
+ content: stepResult.text.length > 0 ? stepResult.text : null,
567
+ tool_calls: toolCallsForMessage,
568
+ });
569
+ for (const [, t] of stepResult.toolCalls) {
570
+ const startedAt = new Date();
571
+ let input;
572
+ try {
573
+ input = JSON.parse(t.argsBuffered || "{}");
574
+ }
575
+ catch {
576
+ input = t.argsBuffered;
577
+ }
578
+ let output;
579
+ let errorMsg;
580
+ const toolDef = toolsRecord[t.name];
581
+ emitter?.emit("tool:start", {
582
+ toolName: t.name,
583
+ toolCallId: t.id,
584
+ input,
585
+ });
586
+ if (!toolDef || typeof toolDef.execute !== "function") {
587
+ errorMsg = `Tool '${t.name}' is not registered.`;
588
+ output = { error: errorMsg };
589
+ }
590
+ else {
591
+ try {
592
+ output = await toolDef.execute(input, {});
593
+ }
594
+ catch (err) {
595
+ errorMsg = err instanceof Error ? err.message : String(err);
596
+ output = { error: errorMsg };
597
+ }
598
+ }
599
+ const endedAt = new Date();
600
+ toolsUsed.push(t.name);
601
+ toolExecutionSummaries.push({
602
+ toolCallId: t.id,
603
+ toolName: t.name,
604
+ input,
605
+ output,
606
+ ...(errorMsg ? { error: errorMsg } : {}),
607
+ startTime: startedAt,
608
+ endTime: endedAt,
609
+ });
610
+ conversation.push({
611
+ role: "tool",
612
+ tool_call_id: t.id,
613
+ content: stringifyToolOutput(output),
614
+ });
615
+ }
616
+ const justExecuted = toolExecutionSummaries.slice(-stepResult.toolCalls.size);
617
+ emitToolEndFromStepFinish(emitter, justExecuted.map((s) => ({
618
+ toolName: s.toolName,
619
+ output: s.output,
620
+ ...(s.error ? { error: s.error } : {}),
621
+ })));
622
+ try {
623
+ await this.handleToolExecutionStorage(justExecuted.map((s) => ({
624
+ toolCallId: s.toolCallId,
625
+ toolName: s.toolName,
626
+ input: s.input,
627
+ output: s.output,
628
+ })), justExecuted.map((s) => ({
629
+ toolCallId: s.toolCallId,
630
+ toolName: s.toolName,
631
+ output: s.output,
632
+ })), options, new Date());
633
+ }
634
+ catch (err) {
635
+ logger.warn(`[${this.constructor.name}] Failed to store tool executions`, {
636
+ provider: this.providerName,
637
+ error: err instanceof Error ? err.message : String(err),
638
+ });
639
+ }
640
+ }
641
+ /**
642
+ * Default implementation hits `${baseURL}/models`. Subclasses with a
643
+ * different endpoint path, caching, or fallback strategy should override.
644
+ */
645
+ async getAvailableModels() {
646
+ try {
647
+ const modelsUrl = `${stripTrailingSlash(this.config.baseURL)}/models`;
648
+ logger.debug(`Fetching available models from: ${modelsUrl}`);
649
+ const proxyFetch = createProxyFetch();
650
+ const controller = new AbortController();
651
+ const t = setTimeout(() => controller.abort(), 5000);
652
+ const response = await proxyFetch(modelsUrl, {
653
+ headers: {
654
+ ...this.getAuthHeaders(),
655
+ "Content-Type": "application/json",
656
+ },
657
+ signal: controller.signal,
658
+ });
659
+ clearTimeout(t);
660
+ if (!response.ok) {
661
+ logger.warn(`Models endpoint returned ${response.status}: ${response.statusText}`);
662
+ return this.getFallbackModels();
663
+ }
664
+ const data = await response.json();
665
+ if (!data.data || !Array.isArray(data.data)) {
666
+ logger.warn("Invalid models response format");
667
+ return this.getFallbackModels();
668
+ }
669
+ const models = data.data.map((model) => model.id).filter(Boolean);
670
+ if (logger.shouldLog("debug")) {
671
+ logger.debug(`Discovered ${models.length} models:`, models);
672
+ }
673
+ return models.length > 0 ? models : this.getFallbackModels();
674
+ }
675
+ catch (error) {
676
+ logger.warn(`[${this.constructor.name}] Failed to fetch models from endpoint:`, error);
677
+ return this.getFallbackModels();
678
+ }
679
+ }
680
+ async getFirstAvailableModel() {
681
+ const models = await this.getAvailableModels();
682
+ return models[0] || this.getFallbackModelName();
683
+ }
684
+ }
@@ -31,6 +31,9 @@ export declare const v3ResponseFormatToOpenAI: (rf: {
31
31
  description?: string;
32
32
  }) => OpenAICompatResponseFormat | undefined;
33
33
  export declare const mapNeuroLinkToolChoice: (choice: unknown) => OpenAICompatToolChoiceWire | undefined;
34
+ export declare const messagesContainJsonWord: (messages: ReadonlyArray<OpenAICompatChatMessage>) => boolean;
35
+ export declare const ensureJsonWordInBody: (body: OpenAICompatChatRequest) => OpenAICompatChatRequest;
36
+ export declare const requiresMaxCompletionTokens: (modelId: string) => boolean;
34
37
  export declare const buildBody: (args: OpenAICompatBuildBodyArgs) => OpenAICompatChatRequest;
35
38
  export declare const parseSSEStream: (body: ReadableStream<Uint8Array>, onTextDelta: (delta: string) => void) => Promise<OpenAICompatSSEResult>;
36
39
  export declare const buildAPIError: (url: string, body: OpenAICompatChatRequest, res: Response) => Promise<Error>;