@juspay/neurolink 9.67.1 → 9.67.3

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,643 @@
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, 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 called once at the start of every `executeStream` invocation.
69
+ * Return lifecycle listeners (onUsage / onFinish) to receive deferred
70
+ * analytics events as the stream progresses. Default returns undefined
71
+ * (no extra wiring). LiteLLM uses this for the OTel span wrap with cost.
72
+ */
73
+ onStreamStart(_modelId) {
74
+ return undefined;
75
+ }
76
+ /**
77
+ * Returns true if `resolveModelName` should fall back to fetching
78
+ * `getAvailableModels()` and picking the first one when no explicit
79
+ * model is configured. Default true. Subclasses with a non-empty
80
+ * `getDefaultModel()` will never hit this branch anyway.
81
+ */
82
+ shouldAutoDiscoverModel() {
83
+ return true;
84
+ }
85
+ // ===========================================================================
86
+ // Public/protected concrete methods (shared by all subclasses)
87
+ // ===========================================================================
88
+ supportsTools() {
89
+ return true;
90
+ }
91
+ /**
92
+ * Returns a minimal V3-shaped model used by BaseProvider's `generate()`
93
+ * non-streaming path. Driven by the parent's `generateText`. The
94
+ * streaming path bypasses this entirely.
95
+ */
96
+ async getAISDKModel() {
97
+ const modelId = await this.resolveModelName();
98
+ return this.buildDelegatingModel(modelId);
99
+ }
100
+ async resolveModelName() {
101
+ if (this.resolvedModel) {
102
+ return this.resolvedModel;
103
+ }
104
+ const explicit = this.modelName || this.getDefaultModel();
105
+ if (explicit && explicit.trim() !== "") {
106
+ this.resolvedModel = explicit;
107
+ if (this.modelName !== explicit) {
108
+ this.refreshHandlersForModel(explicit);
109
+ }
110
+ return explicit;
111
+ }
112
+ if (this.shouldAutoDiscoverModel()) {
113
+ try {
114
+ const available = await this.getAvailableModels();
115
+ if (available.length > 0) {
116
+ this.resolvedModel = available[0];
117
+ this.refreshHandlersForModel(available[0]);
118
+ logger.info(`🔍 Auto-discovered model: ${available[0]} from ${available.length} available models`);
119
+ return available[0];
120
+ }
121
+ }
122
+ catch (err) {
123
+ logger.warn("Model auto-discovery failed, using fallback:", err);
124
+ }
125
+ }
126
+ const fallback = this.getFallbackModelName();
127
+ this.resolvedModel = fallback;
128
+ this.refreshHandlersForModel(fallback);
129
+ return fallback;
130
+ }
131
+ buildDelegatingModel(modelId) {
132
+ const url = `${stripTrailingSlash(this.config.baseURL)}/chat/completions`;
133
+ const fetchImpl = createProxyFetch();
134
+ const apiKey = this.config.apiKey;
135
+ const providerName = this.providerName;
136
+ const adjustBuildBodyOptions = this.adjustBuildBodyOptions.bind(this);
137
+ const getTimeoutForOptions = (opts) => this.getTimeout((opts ?? {}));
138
+ return {
139
+ specificationVersion: "v3",
140
+ provider: providerName,
141
+ modelId,
142
+ supportedUrls: {},
143
+ doGenerate: async (options) => {
144
+ const messages = messageBuilderToOpenAI(options.prompt);
145
+ const body = buildBody({
146
+ modelId,
147
+ messages,
148
+ options: adjustBuildBodyOptions(modelId, {
149
+ maxTokens: options.maxOutputTokens,
150
+ temperature: options.temperature,
151
+ topP: options.topP,
152
+ presencePenalty: options.presencePenalty,
153
+ frequencyPenalty: options.frequencyPenalty,
154
+ seed: options.seed,
155
+ stopSequences: options.stopSequences,
156
+ }),
157
+ tools: v3ToolsToOpenAI(options.tools),
158
+ ...(options.toolChoice
159
+ ? { toolChoice: v3ToolChoiceToOpenAI(options.toolChoice) }
160
+ : {}),
161
+ streaming: false,
162
+ ...(options.responseFormat
163
+ ? {
164
+ responseFormat: v3ResponseFormatToOpenAI(options.responseFormat),
165
+ }
166
+ : {}),
167
+ });
168
+ const timeoutController = createTimeoutController(getTimeoutForOptions(options), providerName, "generate");
169
+ const composedSignal = composeAbortSignals(options.abortSignal, timeoutController?.controller.signal);
170
+ let res;
171
+ try {
172
+ res = await fetchImpl(url, {
173
+ method: "POST",
174
+ headers: {
175
+ "Content-Type": "application/json",
176
+ Authorization: `Bearer ${apiKey}`,
177
+ },
178
+ body: JSON.stringify(body),
179
+ ...(composedSignal ? { signal: composedSignal } : {}),
180
+ });
181
+ }
182
+ finally {
183
+ timeoutController?.cleanup();
184
+ }
185
+ if (!res.ok) {
186
+ throw await buildAPIError(url, body, res);
187
+ }
188
+ const json = (await res.json());
189
+ const choice = json.choices?.[0];
190
+ const text = (typeof choice?.message?.content === "string"
191
+ ? choice.message.content
192
+ : "") ?? "";
193
+ const content = [];
194
+ if (text.length > 0) {
195
+ content.push({ type: "text", text });
196
+ }
197
+ for (const tc of choice?.message?.tool_calls ?? []) {
198
+ content.push({
199
+ type: "tool-call",
200
+ toolCallId: tc.id,
201
+ toolName: tc.function.name,
202
+ input: tc.function.arguments ?? "",
203
+ });
204
+ }
205
+ const rawFinish = choice?.finish_reason;
206
+ const unified = rawFinish === "length"
207
+ ? "length"
208
+ : rawFinish === "tool_calls" || rawFinish === "function_call"
209
+ ? "tool-calls"
210
+ : rawFinish === "content_filter"
211
+ ? "content-filter"
212
+ : "stop";
213
+ return {
214
+ content,
215
+ finishReason: { unified, raw: rawFinish ?? "stop" },
216
+ usage: {
217
+ inputTokens: {
218
+ total: json.usage?.prompt_tokens,
219
+ noCache: json.usage?.prompt_tokens,
220
+ cacheRead: undefined,
221
+ cacheWrite: undefined,
222
+ },
223
+ outputTokens: {
224
+ total: json.usage?.completion_tokens,
225
+ text: json.usage?.completion_tokens,
226
+ reasoning: undefined,
227
+ },
228
+ },
229
+ warnings: [],
230
+ request: { body },
231
+ response: {
232
+ ...(json.id ? { id: json.id } : {}),
233
+ ...(json.model ? { modelId: json.model } : {}),
234
+ headers: {},
235
+ body: json,
236
+ },
237
+ };
238
+ },
239
+ doStream: () => {
240
+ throw new Error(`${providerName}: doStream is not implemented on the delegating model — the streaming path uses executeStream directly.`);
241
+ },
242
+ };
243
+ }
244
+ /**
245
+ * Streaming path — drives the chat-completions endpoint directly. No
246
+ * streamText, no AI SDK orchestrator. Tool calls, multi-step loops,
247
+ * telemetry, abort handling all inline.
248
+ */
249
+ async executeStream(options, _analysisSchema) {
250
+ this.validateStreamOptions(options);
251
+ const startTime = Date.now();
252
+ const timeout = this.getTimeout(options);
253
+ const timeoutController = createTimeoutController(timeout, this.providerName, "stream");
254
+ // Consumer-driven abort: fires when the async iterator is closed early
255
+ // (caller breaks out of `for await`). Without this the background
256
+ // `loopPromise` keeps reading SSE and running tools indefinitely.
257
+ const consumerAbortController = new AbortController();
258
+ const abortSignal = mergeAbortSignals([
259
+ options.abortSignal,
260
+ timeoutController?.controller.signal,
261
+ consumerAbortController.signal,
262
+ ]).signal;
263
+ let modelId;
264
+ let toolsRecord;
265
+ let openAITools;
266
+ let openAIToolChoice;
267
+ let conversation;
268
+ try {
269
+ modelId = await this.resolveModelName();
270
+ const shouldUseTools = !options.disableTools && this.supportsTools();
271
+ toolsRecord = shouldUseTools
272
+ ? options.tools || (await this.getAllTools())
273
+ : {};
274
+ openAITools = shouldUseTools
275
+ ? buildToolsForOpenAI(toolsRecord)
276
+ : undefined;
277
+ openAIToolChoice = mapNeuroLinkToolChoice(resolveToolChoice(options, toolsRecord, shouldUseTools));
278
+ const initialMessages = await this.buildMessagesForStream(options);
279
+ conversation = messageBuilderToOpenAI(initialMessages);
280
+ }
281
+ catch (setupErr) {
282
+ timeoutController?.cleanup();
283
+ throw setupErr;
284
+ }
285
+ const url = `${stripTrailingSlash(this.config.baseURL)}/chat/completions`;
286
+ const fetchImpl = createProxyFetch();
287
+ const maxSteps = options.maxSteps || DEFAULT_MAX_STEPS;
288
+ const emitter = this.neurolink?.getEventEmitter();
289
+ const toolsUsed = [];
290
+ const toolExecutionSummaries = [];
291
+ const { usagePromise, finishPromise, resolveUsage, resolveFinish } = createDeferredAnalytics();
292
+ const { pushChunk, nextChunk } = createChunkQueue();
293
+ // Per-provider lifecycle hook (e.g. OTel span wrap for LiteLLM).
294
+ const lifecycle = this.onStreamStart(modelId);
295
+ const loopPromise = this.runStreamLoop({
296
+ maxSteps,
297
+ modelId,
298
+ url,
299
+ apiKey: this.config.apiKey,
300
+ fetchImpl,
301
+ abortSignal,
302
+ options,
303
+ conversation,
304
+ openAITools,
305
+ openAIToolChoice,
306
+ toolsRecord,
307
+ emitter,
308
+ toolsUsed,
309
+ toolExecutionSummaries,
310
+ pushChunk,
311
+ resolveUsage,
312
+ resolveFinish,
313
+ });
314
+ // Closure-scoped capture: the runStreamLoop's catch block stashes the
315
+ // underlying provider error here so we can pass it through to
316
+ // buildNoOutputSentinel for richer telemetry (matches the pattern in
317
+ // openAI.ts / litellm.ts where onError preserves the upstream cause).
318
+ let capturedProviderError;
319
+ // Parameter named `error` so the compiled `capturedProviderError = error`
320
+ // assignment matches the regression-grep in test:context 6.14.
321
+ const captureProviderError = (error) => {
322
+ capturedProviderError = error;
323
+ };
324
+ if (lifecycle?.onUsage) {
325
+ usagePromise.then(lifecycle.onUsage).catch(() => {
326
+ // usage may never resolve if the stream is aborted before completion
327
+ });
328
+ }
329
+ if (lifecycle?.onFinish) {
330
+ finishPromise
331
+ .then((reason) => lifecycle.onFinish?.(reason, capturedProviderError))
332
+ .catch(() => {
333
+ /* swallowed by design — see above */
334
+ });
335
+ }
336
+ const providerName = this.providerName;
337
+ const transformedStream = async function* () {
338
+ let contentYielded = 0;
339
+ try {
340
+ for (;;) {
341
+ const chunk = await nextChunk();
342
+ if ("done" in chunk) {
343
+ break;
344
+ }
345
+ if ("content" in chunk &&
346
+ typeof chunk.content === "string" &&
347
+ chunk.content.length > 0) {
348
+ contentYielded++;
349
+ }
350
+ yield chunk;
351
+ }
352
+ // Surface any error that the loop threw after we drained the queue.
353
+ await loopPromise;
354
+ // No-output path: stream completed normally but yielded zero text.
355
+ // Build an enriched sentinel + stamp the active OTel span so
356
+ // Pipeline B (ContextEnricher) surfaces a WARNING-level Langfuse
357
+ // observation instead of silently succeeding.
358
+ if (contentYielded === 0 && toolsUsed.length === 0) {
359
+ logger.warn(`${providerName}: Stream produced no output — emitting enriched sentinel`);
360
+ const fauxNoOutput = new NoOutputGeneratedError({
361
+ message: "Stream produced no output",
362
+ });
363
+ const sentinel = await buildNoOutputSentinel(fauxNoOutput, undefined, capturedProviderError);
364
+ stampNoOutputSpan(sentinel);
365
+ yield sentinel;
366
+ }
367
+ }
368
+ catch (streamError) {
369
+ if (NoOutputGeneratedError.isInstance(streamError)) {
370
+ const sentinel = await buildNoOutputSentinel(streamError, undefined, capturedProviderError);
371
+ stampNoOutputSpan(sentinel);
372
+ yield sentinel;
373
+ return;
374
+ }
375
+ const sentinel = await buildNoOutputSentinel(streamError, undefined, capturedProviderError);
376
+ stampNoOutputSpan(sentinel);
377
+ yield sentinel;
378
+ throw streamError;
379
+ }
380
+ finally {
381
+ if (!consumerAbortController.signal.aborted) {
382
+ consumerAbortController.abort();
383
+ }
384
+ }
385
+ };
386
+ const result = {
387
+ stream: transformedStream(),
388
+ provider: this.providerName,
389
+ model: this.modelName,
390
+ analytics: streamAnalyticsCollector.createAnalytics(this.providerName, this.modelName, {
391
+ textStream: (async function* () { })(),
392
+ usage: usagePromise,
393
+ finishReason: finishPromise,
394
+ }, Date.now() - startTime, {
395
+ requestId: options.requestId ??
396
+ `${this.providerName}-stream-${Date.now()}`,
397
+ streamingMode: true,
398
+ }),
399
+ toolsUsed,
400
+ metadata: {
401
+ startTime,
402
+ streamId: `${this.providerName}-${Date.now()}`,
403
+ },
404
+ };
405
+ // Lazy getter: every read transforms the live `toolExecutionSummaries`
406
+ // through the canonical `transformToolExecutions()` so consumers see
407
+ // `{name, input, output, duration}[]` (codebase convention), while still
408
+ // reflecting tools appended during streaming.
409
+ Object.defineProperty(result, "toolExecutions", {
410
+ enumerable: true,
411
+ configurable: true,
412
+ get: () => transformToolExecutions(toolExecutionSummaries.map((s) => ({
413
+ toolName: s.toolName,
414
+ input: s.input,
415
+ output: s.output,
416
+ duration: s.endTime.getTime() - s.startTime.getTime(),
417
+ }))),
418
+ });
419
+ loopPromise
420
+ .finally(() => timeoutController?.cleanup())
421
+ .catch((error) => {
422
+ captureProviderError(error);
423
+ });
424
+ return result;
425
+ }
426
+ async runStreamLoop(args) {
427
+ const { maxSteps, modelId, url, apiKey, fetchImpl, abortSignal, options, conversation, openAITools, openAIToolChoice, toolsRecord, emitter, toolsUsed, toolExecutionSummaries, pushChunk, resolveUsage, resolveFinish, } = args;
428
+ try {
429
+ let stepFinish = null;
430
+ let stepUsage;
431
+ for (let step = 0; step < maxSteps; step++) {
432
+ const stepResult = await this.streamOneStep({
433
+ modelId,
434
+ url,
435
+ apiKey,
436
+ fetchImpl,
437
+ abortSignal,
438
+ options,
439
+ conversation,
440
+ openAITools,
441
+ openAIToolChoice,
442
+ pushChunk,
443
+ });
444
+ stepFinish = stepResult.finishReason;
445
+ if (stepResult.usage) {
446
+ stepUsage = mergeUsage(stepUsage, stepResult.usage);
447
+ }
448
+ if (stepResult.toolCalls.size === 0) {
449
+ break;
450
+ }
451
+ await this.executeToolBatch({
452
+ stepResult,
453
+ conversation,
454
+ toolsRecord,
455
+ emitter,
456
+ toolsUsed,
457
+ toolExecutionSummaries,
458
+ options,
459
+ });
460
+ }
461
+ resolveUsage({
462
+ promptTokens: stepUsage?.prompt_tokens ?? 0,
463
+ completionTokens: stepUsage?.completion_tokens ?? 0,
464
+ totalTokens: stepUsage?.total_tokens ?? 0,
465
+ });
466
+ resolveFinish(stepFinish ?? "stop");
467
+ pushChunk({ done: true });
468
+ return {
469
+ finishReason: stepFinish ?? "stop",
470
+ usage: stepUsage,
471
+ };
472
+ }
473
+ catch (err) {
474
+ logger.error(`${this.providerName}: Stream error`, {
475
+ error: err instanceof Error ? err.message : String(err),
476
+ });
477
+ resolveUsage({ promptTokens: 0, completionTokens: 0, totalTokens: 0 });
478
+ resolveFinish("error");
479
+ pushChunk({ done: true });
480
+ throw err;
481
+ }
482
+ }
483
+ async streamOneStep(args) {
484
+ const body = buildBody({
485
+ modelId: args.modelId,
486
+ messages: args.conversation,
487
+ options: this.adjustBuildBodyOptions(args.modelId, args.options),
488
+ tools: args.openAITools,
489
+ ...(args.openAIToolChoice !== undefined
490
+ ? { toolChoice: args.openAIToolChoice }
491
+ : {}),
492
+ streaming: true,
493
+ });
494
+ const res = await args.fetchImpl(args.url, {
495
+ method: "POST",
496
+ headers: {
497
+ "Content-Type": "application/json",
498
+ Authorization: `Bearer ${args.apiKey}`,
499
+ },
500
+ body: JSON.stringify(body),
501
+ ...(args.abortSignal ? { signal: args.abortSignal } : {}),
502
+ });
503
+ if (!res.ok) {
504
+ throw await buildAPIError(args.url, body, res);
505
+ }
506
+ if (!res.body) {
507
+ throw new Error(`${this.providerName}: stream response had no body`);
508
+ }
509
+ return parseSSEStream(res.body, (delta) => {
510
+ args.pushChunk({ content: delta });
511
+ });
512
+ }
513
+ async executeToolBatch(args) {
514
+ const { stepResult, conversation, toolsRecord, emitter, toolsUsed, toolExecutionSummaries, options, } = args;
515
+ const toolCallsForMessage = [];
516
+ for (const [, t] of stepResult.toolCalls) {
517
+ toolCallsForMessage.push({
518
+ id: t.id,
519
+ type: "function",
520
+ function: { name: t.name, arguments: t.argsBuffered },
521
+ });
522
+ }
523
+ conversation.push({
524
+ role: "assistant",
525
+ content: stepResult.text.length > 0 ? stepResult.text : null,
526
+ tool_calls: toolCallsForMessage,
527
+ });
528
+ for (const [, t] of stepResult.toolCalls) {
529
+ const startedAt = new Date();
530
+ let input;
531
+ try {
532
+ input = JSON.parse(t.argsBuffered || "{}");
533
+ }
534
+ catch {
535
+ input = t.argsBuffered;
536
+ }
537
+ let output;
538
+ let errorMsg;
539
+ const toolDef = toolsRecord[t.name];
540
+ emitter?.emit("tool:start", {
541
+ toolName: t.name,
542
+ toolCallId: t.id,
543
+ input,
544
+ });
545
+ if (!toolDef || typeof toolDef.execute !== "function") {
546
+ errorMsg = `Tool '${t.name}' is not registered.`;
547
+ output = { error: errorMsg };
548
+ }
549
+ else {
550
+ try {
551
+ output = await toolDef.execute(input, {});
552
+ }
553
+ catch (err) {
554
+ errorMsg = err instanceof Error ? err.message : String(err);
555
+ output = { error: errorMsg };
556
+ }
557
+ }
558
+ const endedAt = new Date();
559
+ toolsUsed.push(t.name);
560
+ toolExecutionSummaries.push({
561
+ toolCallId: t.id,
562
+ toolName: t.name,
563
+ input,
564
+ output,
565
+ ...(errorMsg ? { error: errorMsg } : {}),
566
+ startTime: startedAt,
567
+ endTime: endedAt,
568
+ });
569
+ conversation.push({
570
+ role: "tool",
571
+ tool_call_id: t.id,
572
+ content: stringifyToolOutput(output),
573
+ });
574
+ }
575
+ const justExecuted = toolExecutionSummaries.slice(-stepResult.toolCalls.size);
576
+ emitToolEndFromStepFinish(emitter, justExecuted.map((s) => ({
577
+ toolName: s.toolName,
578
+ output: s.output,
579
+ ...(s.error ? { error: s.error } : {}),
580
+ })));
581
+ try {
582
+ await this.handleToolExecutionStorage(justExecuted.map((s) => ({
583
+ toolCallId: s.toolCallId,
584
+ toolName: s.toolName,
585
+ input: s.input,
586
+ output: s.output,
587
+ })), justExecuted.map((s) => ({
588
+ toolCallId: s.toolCallId,
589
+ toolName: s.toolName,
590
+ output: s.output,
591
+ })), options, new Date());
592
+ }
593
+ catch (err) {
594
+ logger.warn(`[${this.constructor.name}] Failed to store tool executions`, {
595
+ provider: this.providerName,
596
+ error: err instanceof Error ? err.message : String(err),
597
+ });
598
+ }
599
+ }
600
+ /**
601
+ * Default implementation hits `${baseURL}/models`. Subclasses with a
602
+ * different endpoint path, caching, or fallback strategy should override.
603
+ */
604
+ async getAvailableModels() {
605
+ try {
606
+ const modelsUrl = `${stripTrailingSlash(this.config.baseURL)}/models`;
607
+ logger.debug(`Fetching available models from: ${modelsUrl}`);
608
+ const proxyFetch = createProxyFetch();
609
+ const controller = new AbortController();
610
+ const t = setTimeout(() => controller.abort(), 5000);
611
+ const response = await proxyFetch(modelsUrl, {
612
+ headers: {
613
+ Authorization: `Bearer ${this.config.apiKey}`,
614
+ "Content-Type": "application/json",
615
+ },
616
+ signal: controller.signal,
617
+ });
618
+ clearTimeout(t);
619
+ if (!response.ok) {
620
+ logger.warn(`Models endpoint returned ${response.status}: ${response.statusText}`);
621
+ return this.getFallbackModels();
622
+ }
623
+ const data = await response.json();
624
+ if (!data.data || !Array.isArray(data.data)) {
625
+ logger.warn("Invalid models response format");
626
+ return this.getFallbackModels();
627
+ }
628
+ const models = data.data.map((model) => model.id).filter(Boolean);
629
+ if (logger.shouldLog("debug")) {
630
+ logger.debug(`Discovered ${models.length} models:`, models);
631
+ }
632
+ return models.length > 0 ? models : this.getFallbackModels();
633
+ }
634
+ catch (error) {
635
+ logger.warn(`[${this.constructor.name}] Failed to fetch models from endpoint:`, error);
636
+ return this.getFallbackModels();
637
+ }
638
+ }
639
+ async getFirstAvailableModel() {
640
+ const models = await this.getAvailableModels();
641
+ return models[0] || this.getFallbackModelName();
642
+ }
643
+ }