@juspay/neurolink 9.68.13 → 9.68.15

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.
@@ -1,234 +1,84 @@
1
- import { createOpenAI } from "@ai-sdk/openai";
2
1
  import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
3
- import { AIProviderName } from "../constants/enums.js";
4
- import { BaseProvider } from "../core/baseProvider.js";
5
- import { DEFAULT_MAX_STEPS } from "../core/constants.js";
6
- import { streamAnalyticsCollector } from "../core/streamAnalytics.js";
2
+ import { AIProviderName as AIProviderNameEnum } from "../constants/enums.js";
7
3
  import { createProxyFetch } from "../proxy/proxyFetch.js";
8
4
  import { AuthenticationError, InvalidModelError, NetworkError, ProviderError, RateLimitError, } from "../types/index.js";
9
5
  import { logger } from "../utils/logger.js";
10
- import { buildNoOutputSentinel, detectPostStreamNoOutput, stampNoOutputSpan, } from "../utils/noOutputSentinel.js";
11
6
  import { calculateCost } from "../utils/pricing.js";
12
7
  import { createOpenAIConfig, getProviderModel, validateApiKey, } from "../utils/providerConfig.js";
13
- import { isZodSchema } from "../utils/schemaConversion.js";
14
- import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
15
- import { resolveToolChoice } from "../utils/toolChoice.js";
16
- import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js";
17
8
  import { MAX_IMAGE_BYTES, readBoundedBuffer } from "../utils/sizeGuard.js";
18
9
  import { assertSafeUrl } from "../utils/ssrfGuard.js";
19
- import { getModelId } from "./providerTypeUtils.js";
20
- import { NoOutputGeneratedError } from "../utils/generationErrors.js";
21
- import { stepCountIs } from "../utils/tool.js";
22
- import { embed, embedMany, streamText } from "../utils/generation.js";
10
+ import { createTimeoutController, TimeoutError } from "../utils/timeout.js";
11
+ import { stripTrailingSlash } from "./openaiChatCompletionsClient.js";
12
+ import { OpenAIChatCompletionsProvider } from "./openaiChatCompletionsBase.js";
13
+ const OPENAI_DEFAULT_BASE_URL = "https://api.openai.com/v1";
23
14
  /**
24
- * Retrieve a tool's schema, handling both AI SDK v6 (`inputSchema`) and
25
- * legacy v4 (`parameters`) field names.
15
+ * Resolve the effective OpenAI base URL from optional credential / env
16
+ * overrides, falling back to the official API host.
17
+ *
18
+ * - Blank or whitespace-only overrides are treated as unset, so a bare
19
+ * `OPENAI_BASE_URL=` cannot silently override the default with "".
20
+ * - The official OpenAI REST API lives under `/v1`. Setup guidance has long
21
+ * shown `OPENAI_BASE_URL="https://api.openai.com"` (no path); consumed
22
+ * verbatim that builds `https://api.openai.com/chat/completions` and 404s.
23
+ * When the canonical host is supplied without a path, append `/v1`. Custom
24
+ * proxy hosts (LiteLLM, gateways, …) are left exactly as provided.
26
25
  */
27
- function getToolSchema(tool) {
28
- const t = tool;
29
- return t.inputSchema ?? t.parameters;
30
- }
31
- // Configuration helpers - now using consolidated utility
32
- const getOpenAIApiKey = () => {
33
- return validateApiKey(createOpenAIConfig());
34
- };
35
- const getOpenAIModel = () => {
36
- return getProviderModel("OPENAI_MODEL", "gpt-4o");
26
+ const resolveOpenAIBaseURL = (credentialBaseURL, envBaseURL) => {
27
+ const resolved = [credentialBaseURL, envBaseURL]
28
+ .map((v) => v?.trim())
29
+ .find((v) => !!v && v.length > 0) ?? OPENAI_DEFAULT_BASE_URL;
30
+ try {
31
+ const url = new URL(resolved);
32
+ const hasPath = url.pathname && url.pathname !== "/";
33
+ if (url.hostname === "api.openai.com" && !hasPath) {
34
+ url.pathname = "/v1";
35
+ return stripTrailingSlash(url.toString());
36
+ }
37
+ }
38
+ catch {
39
+ // Not a parseable absolute URL — return the override verbatim.
40
+ }
41
+ return resolved;
37
42
  };
43
+ const getOpenAIApiKey = () => validateApiKey(createOpenAIConfig());
44
+ const getOpenAIModel = () => getProviderModel("OPENAI_MODEL", "gpt-4o");
38
45
  const streamTracer = trace.getTracer("neurolink.provider.openai");
39
46
  /**
40
- * OpenAI Provider v2 - BaseProvider Implementation
41
- * Migrated to use factory pattern with exact Google AI provider pattern
47
+ * OpenAI Provider direct HTTP, no AI SDK.
48
+ *
49
+ * OpenAI chat completions at api.openai.com/v1. All request / stream /
50
+ * tool-loop orchestration lives in `OpenAIChatCompletionsProvider`; this
51
+ * class adds:
52
+ * - OTel span wrap with cost (`onStreamStart`)
53
+ * - Native `/v1/embeddings` (`embed` / `embedMany`)
54
+ * - Image generation via `/v1/images/generations` (`executeImageGeneration`)
55
+ * - OpenAI-specific error mapping (`formatProviderError`)
56
+ *
57
+ * @see https://platform.openai.com/docs/api-reference
42
58
  */
43
- export class OpenAIProvider extends BaseProvider {
44
- model;
45
- credentials;
46
- constructor(modelName, neurolink, _region, credentials) {
47
- super(modelName || getOpenAIModel(), AIProviderName.OPENAI, neurolink);
48
- this.credentials = credentials;
49
- // Initialize OpenAI provider with proxy support
50
- const openai = createOpenAI({
51
- apiKey: credentials?.apiKey ?? getOpenAIApiKey(),
52
- ...(credentials?.baseURL ? { baseURL: credentials.baseURL } : {}),
53
- fetch: createProxyFetch(),
54
- });
55
- // Initialize model
56
- this.model = openai(this.modelName);
57
- logger.debug("OpenAIProvider constructor called", {
59
+ export class OpenAIProvider extends OpenAIChatCompletionsProvider {
60
+ constructor(modelName, sdk, _region, credentials) {
61
+ const overrideApiKey = credentials?.apiKey?.trim();
62
+ const apiKey = overrideApiKey && overrideApiKey.length > 0
63
+ ? overrideApiKey
64
+ : getOpenAIApiKey();
65
+ const baseURL = resolveOpenAIBaseURL(credentials?.baseURL, process.env.OPENAI_BASE_URL);
66
+ super(AIProviderNameEnum.OPENAI, modelName, sdk, { baseURL, apiKey });
67
+ logger.debug("OpenAIProvider initialized", {
58
68
  model: this.modelName,
59
- provider: this.providerName,
60
- supportsTools: this.supportsTools(),
61
- className: this.constructor.name,
69
+ providerName: this.providerName,
70
+ baseURL: this.config.baseURL,
62
71
  });
63
72
  }
64
- // ===================
65
- // ABSTRACT METHOD IMPLEMENTATIONS
66
- // ===================
67
- /**
68
- * Check if this provider supports tool/function calling
69
- */
70
- supportsTools() {
71
- return true; // Re-enable tools now that we understand the issue
72
- }
73
+ // ===========================================================================
74
+ // Abstract hook implementations
75
+ // ===========================================================================
73
76
  getProviderName() {
74
- return AIProviderName.OPENAI;
77
+ return AIProviderNameEnum.OPENAI;
75
78
  }
76
79
  getDefaultModel() {
77
80
  return getOpenAIModel();
78
81
  }
79
- /**
80
- * Get the default embedding model for OpenAI
81
- * @returns The default OpenAI embedding model name
82
- */
83
- getDefaultEmbeddingModel() {
84
- return process.env.OPENAI_EMBEDDING_MODEL || "text-embedding-3-small";
85
- }
86
- /**
87
- * Returns the Vercel AI SDK model instance for OpenAI
88
- */
89
- getAISDKModel() {
90
- return this.model;
91
- }
92
- /**
93
- * OpenAI-specific tool validation and filtering
94
- * Filters out tools that might cause streaming issues
95
- */
96
- validateAndFilterToolsForOpenAI(tools) {
97
- const validTools = {};
98
- for (const [name, tool] of Object.entries(tools)) {
99
- try {
100
- // Basic validation - ensure tool has required structure
101
- if (tool && typeof tool === "object") {
102
- // Check if tool has description (required by OpenAI)
103
- if (tool.description && typeof tool.description === "string") {
104
- // Keep the original tool structure - AI SDK will handle Zod schema conversion internally
105
- const processedTool = { ...tool };
106
- // Validate that Zod schemas are properly structured for AI SDK processing
107
- const toolSchema = getToolSchema(tool);
108
- if (toolSchema && isZodSchema(toolSchema)) {
109
- logger.debug(`OpenAI: Tool ${name} has Zod schema - AI SDK will handle conversion`);
110
- // Basic validation that the Zod schema has the required structure
111
- this.validateZodSchema(name, toolSchema);
112
- }
113
- // Include the tool with original Zod schema for AI SDK processing
114
- if (this.isValidToolStructure(processedTool)) {
115
- validTools[name] = processedTool;
116
- }
117
- else {
118
- logger.warn(`OpenAI: Filtering out tool with invalid structure: ${name}`, {
119
- parametersType: typeof getToolSchema(processedTool),
120
- hasDescription: !!processedTool.description,
121
- hasExecute: !!processedTool.execute,
122
- });
123
- }
124
- }
125
- else {
126
- logger.warn(`OpenAI: Filtering out tool without description: ${name}`);
127
- }
128
- }
129
- else {
130
- logger.warn(`OpenAI: Filtering out invalid tool: ${name}`);
131
- }
132
- }
133
- catch (error) {
134
- logger.warn(`OpenAI: Error validating tool ${name}:`, error);
135
- }
136
- }
137
- return validTools;
138
- }
139
- /**
140
- * Validate Zod schema structure
141
- */
142
- validateZodSchema(toolName, schema) {
143
- try {
144
- const zodSchema = schema;
145
- if (zodSchema._def && zodSchema._def.typeName) {
146
- logger.debug(`OpenAI: Zod schema for ${toolName} appears valid`, {
147
- typeName: zodSchema._def.typeName,
148
- });
149
- }
150
- else {
151
- logger.warn(`OpenAI: Zod schema for ${toolName} missing typeName - may cause issues`);
152
- }
153
- }
154
- catch (zodValidationError) {
155
- logger.warn(`OpenAI: Zod schema validation failed for ${toolName}:`, zodValidationError);
156
- // Continue anyway - let AI SDK handle it
157
- }
158
- }
159
- /**
160
- * Validate tool structure for OpenAI compatibility
161
- * More lenient validation to avoid filtering out valid tools
162
- */
163
- /** Shared helper: mark a stream span as ERROR, record the exception, and end it. */
164
- endStreamSpanWithError(span, error) {
165
- span.setStatus({
166
- code: SpanStatusCode.ERROR,
167
- message: error instanceof Error ? error.message : String(error),
168
- });
169
- if (error instanceof Error) {
170
- span.recordException(error);
171
- }
172
- span.end();
173
- }
174
- isValidToolStructure(tool) {
175
- if (!tool || typeof tool !== "object") {
176
- return false;
177
- }
178
- const toolObj = tool;
179
- // Ensure tool has description and execute function
180
- if (!toolObj.description || typeof toolObj.description !== "string") {
181
- return false;
182
- }
183
- if (!toolObj.execute || typeof toolObj.execute !== "function") {
184
- return false;
185
- }
186
- // AI SDK v6 uses inputSchema; v4 used parameters — check both
187
- const schema = "inputSchema" in toolObj
188
- ? toolObj.inputSchema
189
- : "parameters" in toolObj
190
- ? toolObj.parameters
191
- : undefined;
192
- return this.isValidToolParameters(schema);
193
- }
194
- /**
195
- * Validate tool parameters for OpenAI compatibility
196
- * Ensures the tool has either valid Zod schema or valid JSON schema
197
- */
198
- isValidToolParameters(parameters) {
199
- if (!parameters) {
200
- // For OpenAI, tools without parameters need an empty object schema
201
- return true;
202
- }
203
- // Check if it's a Zod schema - these are valid
204
- if (isZodSchema(parameters)) {
205
- return true;
206
- }
207
- // Check if it's a JSON schema
208
- if (typeof parameters !== "object" || parameters === null) {
209
- return false;
210
- }
211
- const params = parameters;
212
- // If it's a JSON schema, it should have type "object" for OpenAI
213
- if (params.type && params.type !== "object") {
214
- return false;
215
- }
216
- // OpenAI requires schemas to have properties field, even if empty
217
- // If there's no properties field, the schema is incomplete
218
- if (params.type === "object" && !params.properties) {
219
- logger.warn(`Tool parameter schema missing properties field:`, params);
220
- return false;
221
- }
222
- // If properties exist, they should be an object
223
- if (params.properties && typeof params.properties !== "object") {
224
- return false;
225
- }
226
- // If required exists, it should be an array
227
- if (params.required && !Array.isArray(params.required)) {
228
- return false;
229
- }
230
- return true;
231
- }
232
82
  formatProviderError(error) {
233
83
  if (error instanceof TimeoutError) {
234
84
  return new NetworkError(error.message, this.providerName);
@@ -262,7 +112,9 @@ export class OpenAIProvider extends BaseProvider {
262
112
  ? message
263
113
  : "Invalid OpenAI API key. Please check your OPENAI_API_KEY environment variable.", this.providerName);
264
114
  }
265
- if (message.includes("rate limit") || errorType === "rate_limit_error") {
115
+ if (message.includes("rate limit") ||
116
+ errorType === "rate_limit_error" ||
117
+ statusCode === 429) {
266
118
  return new RateLimitError("OpenAI rate limit exceeded. Please try again later.", this.providerName);
267
119
  }
268
120
  if (message.includes("model_not_found")) {
@@ -271,377 +123,87 @@ export class OpenAIProvider extends BaseProvider {
271
123
  // Generic provider error
272
124
  return new ProviderError(`OpenAI error: ${message}`, this.providerName);
273
125
  }
126
+ // ===========================================================================
127
+ // Optional hook overrides
128
+ // ===========================================================================
274
129
  /**
275
- * executeGenerate method removed - generation is now handled by BaseProvider.
276
- * For details on the changes and migration steps, refer to the BaseProvider documentation
277
- * and the migration guide in the project repository.
130
+ * Wrap the stream in an OTel span to capture provider-level latency,
131
+ * token usage, finish reason, and cost. Mirrors the pre-migration
132
+ * `streamText`-span behaviour.
278
133
  */
279
- async executeStream(options, _analysisSchema) {
280
- this.validateStreamOptions(options);
281
- const startTime = Date.now();
282
- const timeout = this.getTimeout(options);
283
- const timeoutController = createTimeoutController(timeout, this.providerName, "stream");
284
- try {
285
- // Get tools - options.tools is pre-merged by BaseProvider.stream() with
286
- // base tools (MCP/built-in) + user-provided tools (RAG, etc.)
287
- const shouldUseTools = !options.disableTools && this.supportsTools();
288
- const allTools = shouldUseTools
289
- ? options.tools || (await this.getAllTools())
290
- : {};
291
- // OpenAI-specific fix: Validate tools format and filter out problematic ones
292
- let tools = this.validateAndFilterToolsForOpenAI(allTools);
293
- // OpenAI max tools limit - configurable via environment variable
294
- const MAX_TOOLS = parseInt(process.env.OPENAI_MAX_TOOLS || "150", 10);
295
- if (Object.keys(tools).length > MAX_TOOLS) {
296
- logger.warn(`OpenAI: Too many tools (${Object.keys(tools).length}), limiting to ${MAX_TOOLS} tools`);
297
- const toolEntries = Object.entries(tools);
298
- tools = Object.fromEntries(toolEntries.slice(0, MAX_TOOLS));
299
- }
300
- // Count tools with Zod schemas for debugging
301
- const zodToolsCount = Object.values(allTools).filter((tool) => {
302
- if (!tool || typeof tool !== "object") {
303
- return false;
304
- }
305
- const schema = getToolSchema(tool);
306
- return schema !== null && schema !== undefined && isZodSchema(schema);
307
- }).length;
308
- logger.info("OpenAI streaming tools", {
309
- shouldUseTools,
310
- allToolsCount: Object.keys(allTools).length,
311
- filteredToolsCount: Object.keys(tools).length,
312
- zodToolsCount,
313
- toolNames: Object.keys(tools),
314
- filteredOutTools: Object.keys(allTools).filter((name) => !tools[name]),
315
- });
316
- // Build message array from options with multimodal support
317
- // Using protected helper from BaseProvider to eliminate code duplication
318
- const messages = await this.buildMessagesForStream(options);
319
- let resolvedToolChoice = resolveToolChoice(options, tools, shouldUseTools);
320
- // Guard: if toolChoice names a specific tool that was filtered out, fall back to "auto"
321
- if (resolvedToolChoice !== null &&
322
- typeof resolvedToolChoice === "object" &&
323
- "toolName" in resolvedToolChoice &&
324
- typeof resolvedToolChoice.toolName === "string" &&
325
- !tools[resolvedToolChoice.toolName]) {
326
- logger.warn(`OpenAI: toolChoice references tool "${resolvedToolChoice.toolName}" which was removed during filtering; falling back to "auto"`);
327
- resolvedToolChoice = "auto";
328
- }
329
- // Debug the actual request being sent to OpenAI
330
- logger.debug(`OpenAI: streamText request parameters:`, {
331
- modelName: this.modelName,
332
- messagesCount: messages.length,
333
- temperature: options.temperature,
334
- maxTokens: options.maxTokens,
335
- toolsCount: Object.keys(tools).length,
336
- toolChoice: resolvedToolChoice,
337
- maxSteps: options.maxSteps || DEFAULT_MAX_STEPS,
338
- firstToolExample: Object.keys(tools).length > 0
339
- ? {
340
- name: Object.keys(tools)[0],
341
- description: tools[Object.keys(tools)[0]]?.description,
342
- parametersType: typeof getToolSchema(tools[Object.keys(tools)[0]]),
343
- }
344
- : "no-tools",
345
- });
346
- const model = await this.getAISDKModelWithMiddleware(options); // This is where network connection happens!
347
- // Wrap streamText in an OTel span to capture provider-level latency and token usage
348
- const streamSpan = streamTracer.startSpan("neurolink.provider.streamText", {
349
- kind: SpanKind.CLIENT,
350
- attributes: {
351
- "gen_ai.system": "openai",
352
- "gen_ai.request.model": getModelId(model) || this.modelName || "unknown",
353
- },
354
- });
355
- // Reviewer follow-up: capture upstream provider errors via onError
356
- // so the post-stream NoOutput detect can propagate the *real* cause
357
- // into the sentinel's providerError / modelResponseRaw.
358
- let capturedProviderError;
359
- let result;
360
- try {
361
- result = streamText({
362
- model,
363
- messages: messages,
364
- temperature: options.temperature,
365
- maxOutputTokens: options.maxTokens, // No default limit - unlimited unless specified
366
- maxRetries: 0, // NL11: Disable AI SDK's invisible internal retries; we handle retries with OTel instrumentation
367
- tools,
368
- stopWhen: stepCountIs(options.maxSteps || DEFAULT_MAX_STEPS),
369
- toolChoice: resolvedToolChoice,
370
- abortSignal: composeAbortSignals(options.abortSignal, timeoutController?.controller.signal),
371
- experimental_repairToolCall: this.getToolCallRepairFn(options),
372
- experimental_telemetry: this.telemetryHandler.getTelemetryConfig(options),
373
- onError: (event) => {
374
- capturedProviderError = event.error;
375
- logger.error("OpenAI: Stream error", {
376
- error: event.error instanceof Error
377
- ? event.error.message
378
- : String(event.error),
379
- });
380
- },
381
- onStepFinish: ({ toolCalls, toolResults }) => {
382
- logger.info("Tool execution completed", {
383
- toolResults,
384
- toolCalls,
385
- });
386
- // Emit tool:end for each completed tool result so Pipeline B
387
- // captures telemetry for AI-SDK-driven tool calls (gap S2).
388
- emitToolEndFromStepFinish(this.neurolink?.getEventEmitter(), toolResults);
389
- // Handle tool execution storage
390
- this.handleToolExecutionStorage(toolCalls, toolResults, options, new Date()).catch((error) => {
391
- logger.warn("[OpenAIProvider] Failed to store tool executions", {
392
- provider: this.providerName,
393
- error: error instanceof Error ? error.message : String(error),
394
- });
395
- });
396
- },
397
- });
398
- }
399
- catch (streamError) {
400
- this.endStreamSpanWithError(streamSpan, streamError);
401
- throw streamError;
134
+ onStreamStart(modelId) {
135
+ const span = streamTracer.startSpan("neurolink.provider.streamText", {
136
+ kind: SpanKind.CLIENT,
137
+ attributes: {
138
+ "gen_ai.system": "openai",
139
+ "gen_ai.request.model": modelId,
140
+ },
141
+ });
142
+ let spanEnded = false;
143
+ const endSpan = () => {
144
+ if (!spanEnded) {
145
+ spanEnded = true;
146
+ span.end();
402
147
  }
403
- // Collect token usage and finish reason asynchronously when the stream completes,
404
- // then end the span. This avoids blocking the stream consumer.
405
- Promise.resolve(result.usage)
406
- .then((usage) => {
407
- streamSpan.setAttribute("gen_ai.usage.input_tokens", usage.inputTokens || 0);
408
- streamSpan.setAttribute("gen_ai.usage.output_tokens", usage.outputTokens || 0);
148
+ };
149
+ return {
150
+ onUsage: (usage) => {
151
+ span.setAttribute("gen_ai.usage.input_tokens", usage.promptTokens);
152
+ span.setAttribute("gen_ai.usage.output_tokens", usage.completionTokens);
409
153
  const cost = calculateCost(this.providerName, this.modelName, {
410
- input: usage.inputTokens || 0,
411
- output: usage.outputTokens || 0,
412
- total: (usage.inputTokens || 0) + (usage.outputTokens || 0),
154
+ input: usage.promptTokens,
155
+ output: usage.completionTokens,
156
+ total: usage.totalTokens,
413
157
  });
414
158
  if (cost && cost > 0) {
415
- streamSpan.setAttribute("neurolink.cost", cost);
159
+ span.setAttribute("neurolink.cost", cost);
416
160
  }
417
- })
418
- .catch(() => {
419
- // Usage may not be available if the stream is aborted
420
- });
421
- Promise.resolve(result.finishReason)
422
- .then((reason) => {
423
- streamSpan.setAttribute("gen_ai.response.finish_reason", reason || "unknown");
424
- })
425
- .catch(() => {
426
- // Finish reason may not be available if the stream is aborted
427
- });
428
- Promise.resolve(result.text)
429
- .then(() => {
430
- streamSpan.end();
431
- })
432
- .catch((err) => {
433
- this.endStreamSpanWithError(streamSpan, err);
434
- });
435
- timeoutController?.cleanup();
436
- // Debug the actual result structure
437
- logger.debug(`OpenAI: streamText result structure:`, {
438
- resultKeys: Object.keys(result),
439
- hasTextStream: !!result.textStream,
440
- hasToolCalls: !!result.toolCalls,
441
- hasToolResults: !!result.toolResults,
442
- resultType: typeof result,
443
- });
444
- const transformedStream = this.createOpenAITransformedStream(result, shouldUseTools, tools, () => capturedProviderError);
445
- // Create analytics promise that resolves after stream completion
446
- const analyticsPromise = streamAnalyticsCollector.createAnalytics(this.providerName, this.modelName, result, Date.now() - startTime, {
447
- requestId: `openai-stream-${Date.now()}`,
448
- streamingMode: true,
449
- });
450
- return {
451
- stream: transformedStream,
452
- provider: this.providerName,
453
- model: this.modelName,
454
- analytics: analyticsPromise,
455
- metadata: {
456
- startTime,
457
- streamId: `openai-${Date.now()}`,
458
- },
459
- };
460
- }
461
- catch (error) {
462
- timeoutController?.cleanup();
463
- throw this.handleProviderError(error);
464
- }
465
- }
466
- async *createOpenAITransformedStream(result, shouldUseTools, tools, getCapturedProviderError) {
467
- try {
468
- logger.debug(`OpenAI: Starting stream transformation`, {
469
- hasTextStream: !!result.textStream,
470
- hasFullStream: !!result.fullStream,
471
- resultKeys: Object.keys(result),
472
- toolsEnabled: shouldUseTools,
473
- toolsCount: Object.keys(tools).length,
474
- });
475
- let chunkCount = 0;
476
- let contentYielded = 0;
477
- const streamToUse = result.fullStream || result.textStream;
478
- if (!streamToUse) {
479
- logger.error("OpenAI: No stream available in result", {
480
- resultKeys: Object.keys(result),
481
- });
482
- return;
483
- }
484
- logger.debug(`OpenAI: Stream source selected:`, {
485
- usingFullStream: !!result.fullStream,
486
- usingTextStream: !!result.textStream && !result.fullStream,
487
- streamSourceType: result.fullStream ? "fullStream" : "textStream",
488
- });
489
- for await (const chunk of streamToUse) {
490
- chunkCount++;
491
- logger.debug(`OpenAI: Processing chunk ${chunkCount}:`, {
492
- chunkType: typeof chunk,
493
- chunkValue: typeof chunk === "string"
494
- ? chunk.substring(0, 50)
495
- : "not-string",
496
- chunkKeys: chunk && typeof chunk === "object"
497
- ? Object.keys(chunk)
498
- : "not-object",
499
- hasText: chunk && typeof chunk === "object" && "text" in chunk,
500
- hasTextDelta: chunk && typeof chunk === "object" && "textDelta" in chunk,
501
- hasType: chunk && typeof chunk === "object" && "type" in chunk,
502
- chunkTypeValue: chunk && typeof chunk === "object" && "type" in chunk
503
- ? chunk.type
504
- : "no-type",
505
- });
506
- const contentToYield = this.extractOpenAIChunkContent(chunk);
507
- if (contentToYield) {
508
- contentYielded++;
509
- logger.debug(`OpenAI: Yielding content ${contentYielded}:`, {
510
- content: contentToYield.substring(0, 50),
511
- length: contentToYield.length,
161
+ },
162
+ onFinish: (reason, capturedError) => {
163
+ span.setAttribute("gen_ai.response.finish_reason", reason || "unknown");
164
+ if (reason === "error") {
165
+ span.setStatus({
166
+ code: SpanStatusCode.ERROR,
167
+ message: capturedError instanceof Error
168
+ ? capturedError.message
169
+ : String(capturedError ?? "stream error"),
512
170
  });
513
- yield { content: contentToYield };
514
171
  }
515
- }
516
- logger.debug(`OpenAI: Stream transformation completed`, {
517
- totalChunks: chunkCount,
518
- contentYielded,
519
- success: contentYielded > 0,
520
- });
521
- if (contentYielded === 0) {
522
- logger.warn(`OpenAI: No content was yielded from stream despite processing ${chunkCount} chunks`);
523
- // Curator P3-6 (round-2 fix): when no content was yielded, the
524
- // production trigger sets NoOutputGeneratedError on
525
- // result.finishReason rejection (NOT on the textStream itself).
526
- // Surface that rejection here so the enriched sentinel actually
527
- // fires for real-world no-output streams.
528
- const detected = await detectPostStreamNoOutput(result, getCapturedProviderError?.());
529
- if (detected) {
530
- logger.warn("OpenAI: Stream produced no output (NoOutputGeneratedError) — caught from finishReason rejection");
531
- stampNoOutputSpan(detected.sentinel);
532
- yield detected.sentinel;
533
- }
534
- }
535
- }
536
- catch (streamError) {
537
- if (NoOutputGeneratedError.isInstance(streamError)) {
538
- logger.warn("OpenAI: Stream produced no output (NoOutputGeneratedError) — caught from textStream");
539
- // Defensive: AI SDK *can* throw this from textStream in some
540
- // failure modes (catastrophic transform errors). Keep this path
541
- // for completeness; the production trigger goes through the
542
- // post-loop detect above.
543
- const sentinel = await buildNoOutputSentinel(streamError, result, getCapturedProviderError?.());
544
- stampNoOutputSpan(sentinel);
545
- yield sentinel;
546
- return;
547
- }
548
- logger.error(`OpenAI: Stream transformation error:`, streamError);
549
- throw streamError;
550
- }
172
+ endSpan();
173
+ },
174
+ };
551
175
  }
552
- extractOpenAIChunkContent(chunk) {
553
- if (chunk && typeof chunk === "object") {
554
- if (process.env.NEUROLINK_DEBUG === "true") {
555
- logger.debug(`OpenAI: Full chunk structure:`, {
556
- chunkKeys: Object.keys(chunk),
557
- fullChunk: JSON.stringify(chunk).substring(0, 500),
558
- });
559
- }
560
- if ("type" in chunk && chunk.type === "error") {
561
- const errorChunk = chunk;
562
- logger.error(`OpenAI: Error chunk received:`, {
563
- errorType: errorChunk.type,
564
- errorDetails: errorChunk.error,
565
- fullChunk: JSON.stringify(chunk),
566
- });
567
- const errorMessage = errorChunk.error &&
568
- typeof errorChunk.error === "object" &&
569
- "message" in errorChunk.error
570
- ? String(errorChunk.error.message)
571
- : "OpenAI API error when tools are enabled";
572
- throw new Error(`OpenAI streaming error with tools: ${errorMessage}. Try disabling tools with --disableTools`);
573
- }
574
- if ("type" in chunk &&
575
- chunk.type === "text-delta" &&
576
- "textDelta" in chunk) {
577
- const textDelta = chunk.textDelta;
578
- logger.debug(`OpenAI: Found text-delta:`, { textDelta });
579
- return textDelta;
580
- }
581
- if ("text" in chunk) {
582
- const text = chunk.text;
583
- logger.debug(`OpenAI: Found direct text:`, { text });
584
- return text;
585
- }
586
- if (process.env.NEUROLINK_DEBUG === "true") {
587
- logger.debug(`OpenAI: Unhandled object chunk:`, {
588
- chunkKeys: Object.keys(chunk),
589
- chunkType: "type" in chunk
590
- ? String(chunk.type)
591
- : "no-type",
592
- fullChunk: JSON.stringify(chunk).substring(0, 500),
593
- });
594
- }
595
- return null;
596
- }
597
- if (typeof chunk === "string") {
598
- logger.debug(`OpenAI: Found string chunk:`, {
599
- content: chunk,
600
- });
601
- return chunk;
602
- }
603
- logger.warn(`OpenAI: Unhandled chunk type:`, {
604
- type: typeof chunk,
605
- value: String(chunk).substring(0, 100),
606
- });
607
- return null;
176
+ // ===========================================================================
177
+ // Embeddings native POST /v1/embeddings
178
+ // ===========================================================================
179
+ /**
180
+ * Default embedding model, overridable via OPENAI_EMBEDDING_MODEL env var.
181
+ */
182
+ getDefaultEmbeddingModel() {
183
+ return process.env.OPENAI_EMBEDDING_MODEL || "text-embedding-3-small";
608
184
  }
609
185
  /**
610
- * Generate embeddings for text using OpenAI text-embedding models
186
+ * Generate an embedding for a single text input via native /v1/embeddings.
187
+ *
611
188
  * @param text - The text to embed
612
189
  * @param modelName - The embedding model to use (default: text-embedding-3-small)
613
190
  * @returns Promise resolving to the embedding vector
614
191
  */
615
192
  async embed(text, modelName) {
616
- const embeddingModelName = modelName || "text-embedding-3-small";
193
+ const embeddingModelName = modelName || this.getDefaultEmbeddingModel();
617
194
  logger.debug("Generating embedding", {
618
195
  provider: this.providerName,
619
196
  model: embeddingModelName,
620
197
  textLength: text.length,
621
198
  });
622
199
  try {
623
- // Create embedding model using the AI SDK
624
- // Create the OpenAI provider, preferring per-instance credentials over env vars
625
- const openai = createOpenAI({
626
- apiKey: this.credentials?.apiKey ?? getOpenAIApiKey(),
627
- ...(this.credentials?.baseURL
628
- ? { baseURL: this.credentials.baseURL }
629
- : {}),
630
- fetch: createProxyFetch(),
631
- });
632
- // Get the text embedding model
633
- const embeddingModel = openai.textEmbeddingModel(embeddingModelName);
634
- // Generate the embedding
635
- const result = await embed({
636
- model: embeddingModel,
637
- value: text,
638
- });
200
+ const [embedding] = await this.callEmbeddings(embeddingModelName, [text], "embed");
639
201
  logger.debug("Embedding generated successfully", {
640
202
  provider: this.providerName,
641
203
  model: embeddingModelName,
642
- embeddingDimension: result.embedding.length,
204
+ embeddingDimension: embedding.length,
643
205
  });
644
- return result.embedding;
206
+ return embedding;
645
207
  }
646
208
  catch (error) {
647
209
  logger.error("Embedding generation failed", {
@@ -653,39 +215,28 @@ export class OpenAIProvider extends BaseProvider {
653
215
  }
654
216
  }
655
217
  /**
656
- * Generate embeddings for multiple texts in a single batch
218
+ * Generate embeddings for multiple texts in a single batch via native /v1/embeddings.
219
+ *
657
220
  * @param texts - The texts to embed
658
221
  * @param modelName - The embedding model to use (default: text-embedding-3-small)
659
222
  * @returns Promise resolving to an array of embedding vectors
660
223
  */
661
224
  async embedMany(texts, modelName) {
662
- const embeddingModelName = modelName || "text-embedding-3-small";
225
+ const embeddingModelName = modelName || this.getDefaultEmbeddingModel();
663
226
  logger.debug("Generating batch embeddings", {
664
227
  provider: this.providerName,
665
228
  model: embeddingModelName,
666
229
  count: texts.length,
667
230
  });
668
231
  try {
669
- // Prefer per-instance credentials over env vars
670
- const openai = createOpenAI({
671
- apiKey: this.credentials?.apiKey ?? getOpenAIApiKey(),
672
- ...(this.credentials?.baseURL
673
- ? { baseURL: this.credentials.baseURL }
674
- : {}),
675
- fetch: createProxyFetch(),
676
- });
677
- const embeddingModel = openai.textEmbeddingModel(embeddingModelName);
678
- const result = await embedMany({
679
- model: embeddingModel,
680
- values: texts,
681
- });
232
+ const embeddings = await this.callEmbeddings(embeddingModelName, texts, "embedMany");
682
233
  logger.debug("Batch embeddings generated successfully", {
683
234
  provider: this.providerName,
684
235
  model: embeddingModelName,
685
- count: result.embeddings.length,
686
- embeddingDimension: result.embeddings[0]?.length,
236
+ count: embeddings.length,
237
+ embeddingDimension: embeddings[0]?.length,
687
238
  });
688
- return result.embeddings;
239
+ return embeddings;
689
240
  }
690
241
  catch (error) {
691
242
  logger.error("Batch embedding generation failed", {
@@ -696,6 +247,65 @@ export class OpenAIProvider extends BaseProvider {
696
247
  throw this.handleProviderError(error);
697
248
  }
698
249
  }
250
+ async callEmbeddings(modelName, input, operation) {
251
+ const url = `${stripTrailingSlash(this.config.baseURL)}/embeddings`;
252
+ const fetchImpl = createProxyFetch();
253
+ const timeoutController = createTimeoutController(30_000, this.providerName, "generate");
254
+ try {
255
+ const res = await fetchImpl(url, {
256
+ method: "POST",
257
+ headers: {
258
+ "Content-Type": "application/json",
259
+ Authorization: `Bearer ${this.config.apiKey}`,
260
+ },
261
+ body: JSON.stringify({
262
+ model: modelName,
263
+ input: input.length === 1 ? input[0] : input,
264
+ }),
265
+ ...(timeoutController?.controller.signal
266
+ ? { signal: timeoutController.controller.signal }
267
+ : {}),
268
+ });
269
+ if (!res.ok) {
270
+ const bodyText = await res.text().catch(() => "");
271
+ let message = `OpenAI ${operation} failed with status ${res.status}`;
272
+ let errorType;
273
+ if (bodyText) {
274
+ try {
275
+ const parsed = JSON.parse(bodyText);
276
+ if (parsed.error?.message) {
277
+ message = parsed.error.message;
278
+ }
279
+ errorType = parsed.error?.type;
280
+ }
281
+ catch {
282
+ // Non-JSON error body — keep the status-based message.
283
+ }
284
+ }
285
+ // Throw a raw, annotated error so the public embed/embedMany catch
286
+ // formats it exactly once via handleProviderError(), preserving
287
+ // status-based classification (401 -> auth, 429 -> rate limit, …).
288
+ throw Object.assign(new Error(message), {
289
+ status: res.status,
290
+ type: errorType,
291
+ });
292
+ }
293
+ const json = (await res.json());
294
+ const embeddings = (json.data ?? [])
295
+ .map((row) => row.embedding)
296
+ .filter((e) => Array.isArray(e));
297
+ if (embeddings.length === 0) {
298
+ throw new ProviderError(`OpenAI ${operation} returned no embeddings`, this.providerName);
299
+ }
300
+ return embeddings;
301
+ }
302
+ finally {
303
+ timeoutController?.cleanup();
304
+ }
305
+ }
306
+ // ===========================================================================
307
+ // Image generation — native POST /v1/images/generations
308
+ // ===========================================================================
699
309
  /**
700
310
  * Image generation via the OpenAI Images API (`/v1/images/generations`).
701
311
  *
@@ -717,10 +327,7 @@ export class OpenAIProvider extends BaseProvider {
717
327
  throw new Error("OpenAI image generation requires a prompt (input.text or prompt)");
718
328
  }
719
329
  const model = options.model ?? this.modelName;
720
- const apiKey = this.credentials?.apiKey ?? getOpenAIApiKey();
721
- const baseURL = (this.credentials?.baseURL ??
722
- process.env.OPENAI_BASE_URL ??
723
- "https://api.openai.com/v1").replace(/\/$/, "");
330
+ const baseURL = stripTrailingSlash(this.config.baseURL ?? OPENAI_DEFAULT_BASE_URL);
724
331
  // Image-gen extras live on `options` but are not part of the strict
725
332
  // TextGenerationOptions shape — cast to a permissive type to read them.
726
333
  const extras = options;
@@ -778,7 +385,7 @@ export class OpenAIProvider extends BaseProvider {
778
385
  response = await proxyFetch(`${baseURL}/images/generations`, {
779
386
  method: "POST",
780
387
  headers: {
781
- Authorization: `Bearer ${apiKey}`,
388
+ Authorization: `Bearer ${this.config.apiKey}`,
782
389
  "Content-Type": "application/json",
783
390
  },
784
391
  body: JSON.stringify(body),
@@ -876,6 +483,4 @@ export class OpenAIProvider extends BaseProvider {
876
483
  return "1024x1024";
877
484
  }
878
485
  }
879
- // Export for factory registration
880
- export default OpenAIProvider;
881
486
  //# sourceMappingURL=openAI.js.map