@juspay/neurolink 9.67.2 → 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.
- package/CHANGELOG.md +2 -0
- package/dist/browser/neurolink.min.js +326 -326
- package/dist/lib/providers/litellm.d.ts +25 -32
- package/dist/lib/providers/litellm.js +132 -601
- package/dist/lib/providers/openaiChatCompletionsBase.d.ts +93 -0
- package/dist/lib/providers/openaiChatCompletionsBase.js +644 -0
- package/dist/lib/providers/openaiCompatible.d.ts +7 -63
- package/dist/lib/providers/openaiCompatible.js +27 -658
- package/dist/lib/types/openaiCompatible.d.ts +20 -0
- package/dist/providers/litellm.d.ts +25 -32
- package/dist/providers/litellm.js +132 -601
- package/dist/providers/openaiChatCompletionsBase.d.ts +93 -0
- package/dist/providers/openaiChatCompletionsBase.js +643 -0
- package/dist/providers/openaiCompatible.d.ts +7 -63
- package/dist/providers/openaiCompatible.js +27 -658
- package/dist/types/openaiCompatible.d.ts +20 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|