@nebulaos/llm-gateway 0.1.0 → 0.1.2

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/dist/index.js CHANGED
@@ -30,18 +30,29 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
- LLMGateway: () => LLMGateway
33
+ LLMGateway: () => LLMGateway,
34
+ LLMGatewayError: () => LLMGatewayError
34
35
  });
35
36
  module.exports = __toCommonJS(index_exports);
36
37
  var import_openai = __toESM(require("openai"));
37
38
  var import_node_crypto = require("crypto");
38
39
  var import_core = require("@nebulaos/core");
40
+ var LLMGatewayError = class extends Error {
41
+ constructor(message, code, status, cause) {
42
+ super(message);
43
+ this.code = code;
44
+ this.status = status;
45
+ this.cause = cause;
46
+ this.name = "LLMGatewayError";
47
+ }
48
+ };
39
49
  var LLMGateway = class {
40
50
  providerName = "llm-gateway";
41
51
  modelName;
42
52
  client;
43
53
  baseUrl;
44
54
  logger;
55
+ options;
45
56
  capabilities = {
46
57
  inputFiles: {
47
58
  mimeTypes: ["image/*"],
@@ -58,58 +69,134 @@ var LLMGateway = class {
58
69
  baseURL,
59
70
  ...config.clientOptions
60
71
  });
72
+ this.options = config.options;
61
73
  }
62
74
  async generate(messages, tools, options) {
75
+ const mergedOptions = { ...this.options, ...options };
63
76
  const model = `route:${this.modelName}`;
64
- const headers = this.buildGatewayHeaders();
65
- this.logger.debug("LLM Gateway request", {
66
- model,
67
- baseUrl: this.baseUrl,
68
- stream: false,
69
- messageCount: messages.length,
70
- toolCount: tools?.length ?? 0
71
- });
72
- try {
73
- const response = await this.client.chat.completions.create(
74
- {
77
+ const messagesPreview = messages.map((m) => ({
78
+ role: m.role,
79
+ content: typeof m.content === "string" ? m.content.length > 500 ? m.content.slice(0, 500) + "..." : m.content : Array.isArray(m.content) ? `[${m.content.length} parts]` : String(m.content)
80
+ }));
81
+ const toolsPreview = tools?.map((t) => ({
82
+ name: t.function.name,
83
+ description: t.function.description?.slice(0, 200)
84
+ }));
85
+ return import_core.Tracing.withSpan(
86
+ {
87
+ kind: "llm",
88
+ name: `llm:${this.modelName}`,
89
+ data: {
90
+ provider: this.providerName,
91
+ model: this.modelName,
92
+ messagesCount: messages.length,
93
+ toolsCount: tools?.length ?? 0,
94
+ responseFormat: mergedOptions?.responseFormat,
95
+ messages: messagesPreview,
96
+ tools: toolsPreview
97
+ }
98
+ },
99
+ async (llmSpan) => {
100
+ const headers = this.buildGatewayHeaders();
101
+ this.logger.debug("LLM Gateway request", {
75
102
  model,
76
- messages: this.convertMessages(messages),
77
- tools: this.convertTools(tools),
78
- response_format: options?.responseFormat?.type === "json" ? options.responseFormat.schema ? {
79
- type: "json_schema",
80
- json_schema: { name: "response", schema: options.responseFormat.schema }
81
- } : { type: "json_object" } : void 0,
82
- ...this.extractExtraOptions(options)
83
- },
84
- { headers }
85
- );
86
- this.logger.debug("LLM Gateway response", {
87
- model,
88
- finishReason: response.choices?.[0]?.finish_reason,
89
- hasUsage: Boolean(response.usage)
90
- });
91
- const choice = response.choices[0];
92
- const message = choice.message;
93
- return {
94
- content: message.content || "",
95
- toolCalls: message.tool_calls?.map((tc) => ({
96
- id: tc.id,
97
- type: "function",
98
- function: {
99
- name: tc.function.name,
100
- arguments: tc.function.arguments
101
- }
102
- })),
103
- finishReason: this.mapFinishReason(choice.finish_reason),
104
- usage: this.mapUsage(response.usage)
105
- };
106
- } catch (error) {
107
- this.logger.error("LLM Gateway request failed", error, void 0, void 0);
108
- throw error;
109
- }
103
+ baseUrl: this.baseUrl,
104
+ stream: false,
105
+ messageCount: messages.length,
106
+ toolCount: tools?.length ?? 0
107
+ });
108
+ try {
109
+ const { data: response, response: httpResponse } = await this.client.chat.completions.create(
110
+ {
111
+ model,
112
+ messages: this.convertMessages(messages),
113
+ tools: this.convertTools(tools),
114
+ response_format: mergedOptions?.responseFormat?.type === "json" ? mergedOptions.responseFormat.schema ? {
115
+ type: "json_schema",
116
+ json_schema: { name: "response", schema: mergedOptions.responseFormat.schema }
117
+ } : { type: "json_object" } : void 0,
118
+ ...this.extractExtraOptions(mergedOptions)
119
+ },
120
+ { headers }
121
+ ).withResponse();
122
+ this.logger.debug("LLM Gateway response", {
123
+ model,
124
+ finishReason: response.choices?.[0]?.finish_reason,
125
+ hasUsage: Boolean(response.usage)
126
+ });
127
+ const choice = response.choices[0];
128
+ const message = choice.message;
129
+ const usage = this.mapUsage(response.usage);
130
+ const finishReason = this.mapFinishReason(choice.finish_reason);
131
+ const enrichment = this.extractEnrichmentFromHeaders(httpResponse.headers);
132
+ await llmSpan.end({
133
+ status: "success",
134
+ data: {
135
+ usage: enrichment.usage ?? usage,
136
+ finishReason,
137
+ toolCallsCount: message.tool_calls?.length ?? 0,
138
+ outputPreview: message.content?.slice(0, 200),
139
+ // Enrichment from backend gateway
140
+ modelActual: enrichment.modelActual,
141
+ fallbackUsed: enrichment.fallbackUsed,
142
+ cost: enrichment.cost
143
+ }
144
+ });
145
+ return {
146
+ content: message.content || "",
147
+ toolCalls: message.tool_calls?.map((tc) => ({
148
+ id: tc.id,
149
+ type: "function",
150
+ function: {
151
+ name: tc.function.name,
152
+ arguments: tc.function.arguments
153
+ }
154
+ })),
155
+ finishReason,
156
+ usage: enrichment.usage ?? usage
157
+ };
158
+ } catch (error) {
159
+ this.logger.error("LLM Gateway request failed", error, void 0, void 0);
160
+ const gatewayError = this.handleError(error);
161
+ await llmSpan.end({
162
+ status: "error",
163
+ data: {
164
+ error: {
165
+ message: gatewayError.message,
166
+ code: gatewayError.code,
167
+ status: gatewayError.status
168
+ }
169
+ }
170
+ });
171
+ throw gatewayError;
172
+ }
173
+ }
174
+ );
110
175
  }
111
176
  async *generateStream(messages, tools, options) {
177
+ const mergedOptions = { ...this.options, ...options };
112
178
  const model = `route:${this.modelName}`;
179
+ const messagesPreview = messages.map((m) => ({
180
+ role: m.role,
181
+ content: typeof m.content === "string" ? m.content.length > 500 ? m.content.slice(0, 500) + "..." : m.content : Array.isArray(m.content) ? `[${m.content.length} parts]` : String(m.content)
182
+ }));
183
+ const toolsPreview = tools?.map((t) => ({
184
+ name: t.function.name,
185
+ description: t.function.description?.slice(0, 200)
186
+ }));
187
+ const llmSpan = await import_core.Tracing.startSpan({
188
+ kind: "llm",
189
+ name: `llm:${this.modelName}`,
190
+ data: {
191
+ provider: this.providerName,
192
+ model: this.modelName,
193
+ messagesCount: messages.length,
194
+ toolsCount: tools?.length ?? 0,
195
+ responseFormat: mergedOptions?.responseFormat,
196
+ messages: messagesPreview,
197
+ tools: toolsPreview
198
+ }
199
+ });
113
200
  const headers = this.buildGatewayHeaders();
114
201
  this.logger.debug("LLM Gateway stream request", {
115
202
  model,
@@ -127,43 +214,66 @@ var LLMGateway = class {
127
214
  tools: this.convertTools(tools),
128
215
  stream: true,
129
216
  stream_options: { include_usage: true },
130
- response_format: options?.responseFormat?.type === "json" ? options.responseFormat.schema ? {
217
+ response_format: mergedOptions?.responseFormat?.type === "json" ? mergedOptions.responseFormat.schema ? {
131
218
  type: "json_schema",
132
- json_schema: { name: "response", schema: options.responseFormat.schema }
219
+ json_schema: { name: "response", schema: mergedOptions.responseFormat.schema }
133
220
  } : { type: "json_object" } : void 0,
134
- ...this.extractExtraOptions(options)
221
+ ...this.extractExtraOptions(mergedOptions)
135
222
  },
136
223
  { headers }
137
224
  );
138
225
  } catch (error) {
139
226
  this.logger.error("LLM Gateway stream request failed", error, void 0, void 0);
140
- throw error;
227
+ const gatewayError = this.handleError(error);
228
+ if (llmSpan) {
229
+ await llmSpan.end({
230
+ status: "error",
231
+ data: {
232
+ error: {
233
+ message: gatewayError.message,
234
+ code: gatewayError.code,
235
+ status: gatewayError.status
236
+ }
237
+ }
238
+ });
239
+ }
240
+ throw gatewayError;
141
241
  }
242
+ let finalUsage;
243
+ let finalFinishReason;
244
+ let toolCallsCount = 0;
245
+ let outputPreview = "";
142
246
  try {
143
247
  for await (const chunk of stream) {
144
248
  if (chunk.usage) {
249
+ finalUsage = this.mapUsage(chunk.usage);
145
250
  yield {
146
251
  type: "finish",
147
252
  reason: "stop",
148
- usage: this.mapUsage(chunk.usage)
253
+ usage: finalUsage
149
254
  };
150
255
  }
151
256
  const choice = chunk.choices?.[0];
152
257
  if (!choice) continue;
153
258
  if (choice.finish_reason) {
259
+ finalFinishReason = this.mapFinishReason(choice.finish_reason);
154
260
  yield {
155
261
  type: "finish",
156
- reason: this.mapFinishReason(choice.finish_reason)
262
+ reason: finalFinishReason
157
263
  };
158
264
  }
159
265
  const delta = choice.delta;
160
266
  if (!delta) continue;
161
267
  if (delta.content) {
268
+ if (outputPreview.length < 200) {
269
+ outputPreview += delta.content.slice(0, 200 - outputPreview.length);
270
+ }
162
271
  yield { type: "content_delta", delta: delta.content };
163
272
  }
164
273
  if (delta.tool_calls) {
165
274
  for (const tc of delta.tool_calls) {
166
275
  if (tc.id && tc.function?.name) {
276
+ toolCallsCount++;
167
277
  yield {
168
278
  type: "tool_call_start",
169
279
  index: tc.index,
@@ -181,10 +291,199 @@ var LLMGateway = class {
181
291
  }
182
292
  }
183
293
  }
294
+ if (llmSpan) {
295
+ await llmSpan.end({
296
+ status: "success",
297
+ data: {
298
+ usage: finalUsage,
299
+ finishReason: finalFinishReason,
300
+ toolCallsCount,
301
+ outputPreview
302
+ }
303
+ });
304
+ }
184
305
  } catch (error) {
185
306
  this.logger.error("LLM Gateway stream failed", error, void 0, void 0);
186
- throw error;
307
+ const gatewayError = this.handleError(error);
308
+ if (llmSpan) {
309
+ await llmSpan.end({
310
+ status: "error",
311
+ data: {
312
+ error: {
313
+ message: gatewayError.message,
314
+ code: gatewayError.code,
315
+ status: gatewayError.status
316
+ }
317
+ }
318
+ });
319
+ }
320
+ throw gatewayError;
321
+ }
322
+ }
323
+ // ==========================================================================
324
+ // Error Handling
325
+ // ==========================================================================
326
+ /**
327
+ * Transforms raw errors into actionable LLMGatewayError with clear messages.
328
+ * This ensures developers get specific guidance on how to resolve issues.
329
+ *
330
+ * Differentiates between:
331
+ * - Gateway errors: LLM Gateway API key issues (check NEBULAOS_API_KEY env var)
332
+ * - Provider errors: LLM provider API key issues (check route config in dashboard)
333
+ */
334
+ handleError(error) {
335
+ if (error instanceof import_openai.APIError) {
336
+ const status = error.status;
337
+ const originalMessage = error.message;
338
+ const errorSource = this.extractErrorSource(error);
339
+ if (status === 401) {
340
+ if (errorSource === "gateway") {
341
+ return new LLMGatewayError(
342
+ `LLM Gateway authentication failed: Your LLM Gateway API key is invalid or expired. Please verify your NEBULAOS_API_KEY environment variable or check your LLM Gateway API key in the NebulaOS dashboard. Original error: ${originalMessage}`,
343
+ "GATEWAY_AUTH_ERROR",
344
+ status,
345
+ error
346
+ );
347
+ } else {
348
+ return new LLMGatewayError(
349
+ `LLM Provider authentication failed: The API key configured for your LLM provider (OpenAI, Azure, etc.) is invalid or expired. Please verify the provider API key in your route configuration in the NebulaOS dashboard. Original error: ${originalMessage}`,
350
+ "PROVIDER_AUTH_ERROR",
351
+ status,
352
+ error
353
+ );
354
+ }
355
+ }
356
+ if (status === 403) {
357
+ if (errorSource === "gateway") {
358
+ return new LLMGatewayError(
359
+ `LLM Gateway access denied: Your LLM Gateway API key does not have permission to access this route. Please verify the route is allowed for your LLM Gateway API key in the NebulaOS dashboard. Original error: ${originalMessage}`,
360
+ "GATEWAY_FORBIDDEN",
361
+ status,
362
+ error
363
+ );
364
+ } else {
365
+ return new LLMGatewayError(
366
+ `LLM Provider access denied: The provider API key does not have permission for this operation. Please verify the provider API key permissions in the NebulaOS dashboard. Original error: ${originalMessage}`,
367
+ "PROVIDER_FORBIDDEN",
368
+ status,
369
+ error
370
+ );
371
+ }
372
+ }
373
+ if (status === 429) {
374
+ return new LLMGatewayError(
375
+ `LLM Gateway rate limit exceeded: Too many requests to the LLM provider. Please wait before retrying or check your rate limit configuration. Original error: ${originalMessage}`,
376
+ "LLM_GATEWAY_RATE_LIMIT",
377
+ status,
378
+ error
379
+ );
380
+ }
381
+ if (status === 400) {
382
+ return new LLMGatewayError(
383
+ `LLM Gateway request error: Invalid request parameters. Please check your request configuration (model, messages, tools). Original error: ${originalMessage}`,
384
+ "LLM_GATEWAY_BAD_REQUEST",
385
+ status,
386
+ error
387
+ );
388
+ }
389
+ if (status === 404) {
390
+ return new LLMGatewayError(
391
+ `LLM Gateway route not found: The specified model or route does not exist. Please verify the route alias '${this.modelName}' is correct and provisioned. Original error: ${originalMessage}`,
392
+ "LLM_GATEWAY_NOT_FOUND",
393
+ status,
394
+ error
395
+ );
396
+ }
397
+ if (status === 408 || status === 504) {
398
+ return new LLMGatewayError(
399
+ `LLM Gateway timeout: The request took too long to complete. This may be due to high load or a complex request. Please try again. Original error: ${originalMessage}`,
400
+ "LLM_GATEWAY_TIMEOUT",
401
+ status,
402
+ error
403
+ );
404
+ }
405
+ if (status && status >= 500) {
406
+ return new LLMGatewayError(
407
+ `LLM Gateway server error: The LLM provider returned an error (${status}). This is typically a temporary issue. Please try again later. Original error: ${originalMessage}`,
408
+ "LLM_GATEWAY_SERVER_ERROR",
409
+ status,
410
+ error
411
+ );
412
+ }
413
+ return new LLMGatewayError(
414
+ `LLM Gateway error (${status}): ${originalMessage}`,
415
+ "LLM_GATEWAY_ERROR",
416
+ status,
417
+ error
418
+ );
419
+ }
420
+ if (error instanceof Error) {
421
+ const msg = error.message.toLowerCase();
422
+ if (msg.includes("econnrefused") || msg.includes("enotfound") || msg.includes("network")) {
423
+ return new LLMGatewayError(
424
+ `LLM Gateway connection failed: Unable to connect to the LLM Gateway at ${this.baseUrl}. Please verify the gateway is running and accessible. Original error: ${error.message}`,
425
+ "LLM_GATEWAY_CONNECTION_ERROR",
426
+ void 0,
427
+ error
428
+ );
429
+ }
430
+ if (msg.includes("timeout") || msg.includes("timed out") || msg.includes("etimedout")) {
431
+ return new LLMGatewayError(
432
+ `LLM Gateway timeout: The connection timed out. Please check network connectivity and try again. Original error: ${error.message}`,
433
+ "LLM_GATEWAY_TIMEOUT",
434
+ void 0,
435
+ error
436
+ );
437
+ }
438
+ return new LLMGatewayError(
439
+ `LLM Gateway error: ${error.message}`,
440
+ "LLM_GATEWAY_ERROR",
441
+ void 0,
442
+ error
443
+ );
187
444
  }
445
+ return new LLMGatewayError(
446
+ `LLM Gateway error: An unexpected error occurred. Details: ${String(error)}`,
447
+ "LLM_GATEWAY_UNKNOWN_ERROR",
448
+ void 0,
449
+ error
450
+ );
451
+ }
452
+ /**
453
+ * Extracts the error source from an APIError.
454
+ * The backend sets X-Error-Source header or includes source in the error body
455
+ * to differentiate between gateway errors (LLM Gateway API key) and provider errors.
456
+ *
457
+ * @returns "gateway" if the error is from LLM Gateway authentication,
458
+ * "provider" if the error is from the upstream LLM provider,
459
+ * undefined if the source cannot be determined.
460
+ */
461
+ extractErrorSource(error) {
462
+ const headers = error.headers;
463
+ if (headers) {
464
+ const errorSource = headers["x-error-source"] || headers["X-Error-Source"];
465
+ if (errorSource === "gateway" || errorSource === "provider") {
466
+ return errorSource;
467
+ }
468
+ }
469
+ const errorBody = error.error;
470
+ if (errorBody && typeof errorBody === "object") {
471
+ const nestedError = errorBody.error;
472
+ if (nestedError && typeof nestedError === "object" && nestedError.source) {
473
+ const source = nestedError.source;
474
+ if (source === "gateway" || source === "provider") {
475
+ return source;
476
+ }
477
+ }
478
+ if (errorBody.source === "gateway" || errorBody.source === "provider") {
479
+ return errorBody.source;
480
+ }
481
+ }
482
+ const msg = error.message.toLowerCase();
483
+ if (msg.includes("llm gateway api key") || msg.includes("llm gateway")) {
484
+ return "gateway";
485
+ }
486
+ return void 0;
188
487
  }
189
488
  // ==========================================================================
190
489
  // Helpers (copied from OpenAI provider)
@@ -212,6 +511,46 @@ var LLMGateway = class {
212
511
  }
213
512
  return headers;
214
513
  }
514
+ /**
515
+ * Extracts enrichment data from backend HTTP headers.
516
+ * Backend returns this data so SDK can enrich its own span (avoiding duplicate spans).
517
+ */
518
+ extractEnrichmentFromHeaders(headers) {
519
+ const result = {};
520
+ const modelActual = headers.get("x-llm-model-actual");
521
+ if (modelActual) {
522
+ result.modelActual = modelActual;
523
+ }
524
+ const fallbackUsed = headers.get("x-llm-fallback-used");
525
+ if (fallbackUsed) {
526
+ result.fallbackUsed = fallbackUsed === "true";
527
+ }
528
+ const usageRaw = headers.get("x-llm-usage");
529
+ if (usageRaw) {
530
+ try {
531
+ const usage = JSON.parse(usageRaw);
532
+ result.usage = {
533
+ promptTokens: usage.prompt_tokens,
534
+ completionTokens: usage.completion_tokens,
535
+ totalTokens: usage.total_tokens,
536
+ reasoningTokens: usage.completion_tokens_details?.reasoning_tokens,
537
+ // Preserve any additional token fields from provider
538
+ ...usage
539
+ };
540
+ } catch {
541
+ this.logger.warn("Failed to parse x-llm-usage header", { usageRaw });
542
+ }
543
+ }
544
+ const cost = headers.get("x-llm-cost");
545
+ const costAvailable = headers.get("x-llm-cost-available");
546
+ if (cost) {
547
+ result.cost = {
548
+ amountUsd: cost,
549
+ available: costAvailable === "true"
550
+ };
551
+ }
552
+ return result;
553
+ }
215
554
  convertMessages(messages) {
216
555
  const allowedToolCallIds = /* @__PURE__ */ new Set();
217
556
  return messages.flatMap((m) => {
@@ -313,6 +652,7 @@ var LLMGateway = class {
313
652
  };
314
653
  // Annotate the CommonJS export names for ESM import in node:
315
654
  0 && (module.exports = {
316
- LLMGateway
655
+ LLMGateway,
656
+ LLMGatewayError
317
657
  });
318
658
  //# sourceMappingURL=index.js.map