@hebo-ai/gateway 0.4.1 → 0.5.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +2 -3
  2. package/dist/endpoints/chat-completions/converters.d.ts +3 -1
  3. package/dist/endpoints/chat-completions/converters.js +121 -90
  4. package/dist/endpoints/chat-completions/handler.js +2 -4
  5. package/dist/endpoints/chat-completions/otel.js +7 -0
  6. package/dist/endpoints/chat-completions/schema.d.ts +400 -76
  7. package/dist/endpoints/chat-completions/schema.js +80 -36
  8. package/dist/endpoints/embeddings/handler.js +2 -4
  9. package/dist/endpoints/embeddings/schema.d.ts +1 -1
  10. package/dist/endpoints/embeddings/schema.js +1 -1
  11. package/dist/errors/gateway.js +1 -0
  12. package/dist/lifecycle.js +7 -12
  13. package/dist/logger/default.d.ts +0 -1
  14. package/dist/logger/default.js +30 -6
  15. package/dist/middleware/utils.js +1 -0
  16. package/dist/models/amazon/middleware.js +1 -0
  17. package/dist/models/anthropic/middleware.d.ts +2 -0
  18. package/dist/models/anthropic/middleware.js +77 -16
  19. package/dist/models/google/middleware.js +17 -0
  20. package/dist/models/google/presets.d.ts +387 -0
  21. package/dist/models/google/presets.js +9 -2
  22. package/dist/models/openai/middleware.js +1 -0
  23. package/dist/models/types.d.ts +1 -1
  24. package/dist/models/types.js +1 -0
  25. package/dist/providers/bedrock/index.d.ts +1 -0
  26. package/dist/providers/bedrock/index.js +1 -0
  27. package/dist/providers/bedrock/middleware.d.ts +2 -0
  28. package/dist/providers/bedrock/middleware.js +35 -0
  29. package/dist/telemetry/http.js +0 -3
  30. package/dist/types.d.ts +10 -20
  31. package/dist/utils/request.d.ts +1 -3
  32. package/dist/utils/request.js +3 -26
  33. package/dist/utils/response.d.ts +1 -1
  34. package/dist/utils/response.js +3 -3
  35. package/package.json +19 -21
  36. package/src/endpoints/chat-completions/converters.test.ts +219 -0
  37. package/src/endpoints/chat-completions/converters.ts +144 -104
  38. package/src/endpoints/chat-completions/handler.test.ts +87 -0
  39. package/src/endpoints/chat-completions/handler.ts +2 -5
  40. package/src/endpoints/chat-completions/otel.ts +6 -0
  41. package/src/endpoints/chat-completions/schema.ts +85 -43
  42. package/src/endpoints/embeddings/handler.ts +5 -5
  43. package/src/endpoints/embeddings/schema.ts +1 -1
  44. package/src/errors/gateway.ts +2 -0
  45. package/src/lifecycle.ts +7 -11
  46. package/src/logger/default.ts +34 -8
  47. package/src/middleware/utils.ts +1 -0
  48. package/src/models/amazon/middleware.ts +1 -0
  49. package/src/models/anthropic/middleware.test.ts +332 -1
  50. package/src/models/anthropic/middleware.ts +83 -19
  51. package/src/models/google/middleware.test.ts +31 -0
  52. package/src/models/google/middleware.ts +18 -0
  53. package/src/models/google/presets.ts +13 -2
  54. package/src/models/openai/middleware.ts +1 -0
  55. package/src/models/types.ts +1 -0
  56. package/src/providers/bedrock/index.ts +1 -0
  57. package/src/providers/bedrock/middleware.test.ts +73 -0
  58. package/src/providers/bedrock/middleware.ts +43 -0
  59. package/src/telemetry/http.ts +0 -3
  60. package/src/types.ts +19 -23
  61. package/src/utils/request.ts +5 -33
  62. package/src/utils/response.ts +3 -3
@@ -11,16 +11,16 @@ import type {
11
11
  UserContent,
12
12
  AssistantContent,
13
13
  LanguageModelUsage,
14
- Output,
15
14
  TextStreamPart,
16
15
  ReasoningOutput,
16
+ JSONValue,
17
17
  AssistantModelMessage,
18
18
  ToolModelMessage,
19
19
  UserModelMessage,
20
20
  } from "ai";
21
21
 
22
- import { convertBase64ToUint8Array } from "@ai-sdk/provider-utils";
23
- import { jsonSchema, tool } from "ai";
22
+ import { Output, jsonSchema, tool } from "ai";
23
+ import { z } from "zod";
24
24
 
25
25
  import type {
26
26
  ChatCompletionsToolCall,
@@ -44,6 +44,8 @@ import type {
44
44
  ChatCompletionsReasoningEffort,
45
45
  ChatCompletionsReasoningConfig,
46
46
  ChatCompletionsReasoningDetail,
47
+ ChatCompletionsResponseFormat,
48
+ ChatCompletionsContentPartText,
47
49
  } from "./schema";
48
50
 
49
51
  import { GatewayError } from "../../errors/gateway";
@@ -54,6 +56,7 @@ export type TextCallOptions = {
54
56
  messages: ModelMessage[];
55
57
  tools?: ToolSet;
56
58
  toolChoice?: ToolChoice<ToolSet>;
59
+ output?: Output.Output;
57
60
  temperature?: number;
58
61
  maxOutputTokens?: number;
59
62
  frequencyPenalty?: number;
@@ -74,6 +77,7 @@ export function convertToTextCallOptions(params: ChatCompletionsInputs): TextCal
74
77
  temperature,
75
78
  max_tokens,
76
79
  max_completion_tokens,
80
+ response_format,
77
81
  reasoning_effort,
78
82
  reasoning,
79
83
  frequency_penalty,
@@ -90,6 +94,7 @@ export function convertToTextCallOptions(params: ChatCompletionsInputs): TextCal
90
94
  messages: convertToModelMessages(messages),
91
95
  tools: convertToToolSet(tools),
92
96
  toolChoice: convertToToolChoice(tool_choice),
97
+ output: convertToOutput(response_format),
93
98
  temperature,
94
99
  maxOutputTokens: max_completion_tokens ?? max_tokens,
95
100
  frequencyPenalty: frequency_penalty,
@@ -103,6 +108,19 @@ export function convertToTextCallOptions(params: ChatCompletionsInputs): TextCal
103
108
  };
104
109
  }
105
110
 
111
+ function convertToOutput(responseFormat: ChatCompletionsResponseFormat | undefined) {
112
+ if (!responseFormat || responseFormat.type === "text") {
113
+ return;
114
+ }
115
+
116
+ const { name, description, schema } = responseFormat.json_schema;
117
+ return Output.object({
118
+ name,
119
+ description,
120
+ schema: jsonSchema(schema),
121
+ });
122
+ }
123
+
106
124
  export function convertToModelMessages(messages: ChatCompletionsMessage[]): ModelMessage[] {
107
125
  const modelMessages: ModelMessage[] = [];
108
126
  const toolById = indexToolMessages(messages);
@@ -154,65 +172,73 @@ export function fromChatCompletionsAssistantMessage(
154
172
 
155
173
  const parts: AssistantContent = [];
156
174
 
157
- if (Array.isArray(parts)) {
158
- if (reasoning_details?.length) {
159
- for (const detail of reasoning_details) {
160
- if (detail.text && detail.type === "reasoning.text") {
161
- parts.push({
162
- type: "reasoning",
163
- text: detail.text,
164
- providerOptions: detail.signature
165
- ? {
166
- unknown: {
167
- signature: detail.signature,
168
- },
169
- }
170
- : undefined,
171
- });
172
- } else if (detail.type === "reasoning.encrypted" && detail.data) {
173
- parts.push({
174
- type: "reasoning",
175
- text: "",
176
- providerOptions: {
177
- unknown: {
178
- redactedData: detail.data,
179
- },
175
+ if (reasoning_details?.length) {
176
+ for (const detail of reasoning_details) {
177
+ if (detail.text && detail.type === "reasoning.text") {
178
+ parts.push({
179
+ type: "reasoning",
180
+ text: detail.text,
181
+ providerOptions: detail.signature
182
+ ? {
183
+ unknown: {
184
+ signature: detail.signature,
185
+ },
186
+ }
187
+ : undefined,
188
+ });
189
+ } else if (detail.type === "reasoning.encrypted" && detail.data) {
190
+ parts.push({
191
+ type: "reasoning",
192
+ text: "",
193
+ providerOptions: {
194
+ unknown: {
195
+ redactedData: detail.data,
180
196
  },
181
- });
182
- }
197
+ },
198
+ });
183
199
  }
184
200
  }
201
+ }
185
202
 
186
- if (tool_calls?.length) {
187
- for (const tc of tool_calls) {
188
- // eslint-disable-next-line no-shadow
189
- const { id, function: fn, extra_content } = tc;
190
- const out: ToolCallPart = {
191
- type: "tool-call",
192
- toolCallId: id,
193
- toolName: fn.name,
194
- input: parseToolOutput(fn.arguments).value,
195
- };
196
- if (extra_content) {
197
- out.providerOptions = extra_content;
198
- }
199
- parts.push(out);
203
+ if (content !== undefined && content !== null) {
204
+ const inputContent =
205
+ typeof content === "string"
206
+ ? ([{ type: "text", text: content }] as ChatCompletionsContentPartText[])
207
+ : content;
208
+ for (const part of inputContent) {
209
+ if (part.type === "text") {
210
+ parts.push({
211
+ type: "text",
212
+ text: part.text,
213
+ });
200
214
  }
201
- } else if (content !== undefined && content !== null) {
202
- parts.push({
203
- type: "text",
204
- text: content,
205
- });
215
+ }
216
+ }
217
+
218
+ if (tool_calls?.length) {
219
+ for (const tc of tool_calls) {
220
+ // eslint-disable-next-line no-shadow
221
+ const { id, function: fn, extra_content } = tc;
222
+ const out: ToolCallPart = {
223
+ type: "tool-call",
224
+ toolCallId: id,
225
+ toolName: fn.name,
226
+ input: parseJsonOrText(fn.arguments).value,
227
+ };
228
+ if (extra_content) {
229
+ out.providerOptions = extra_content as SharedV3ProviderOptions;
230
+ }
231
+ parts.push(out);
206
232
  }
207
233
  }
208
234
 
209
235
  const out: AssistantModelMessage = {
210
- role: role,
211
- content: Array.isArray(parts) && parts.length > 0 ? parts : (content ?? ""),
236
+ role,
237
+ content: parts.length > 0 ? parts : (content ?? ""),
212
238
  };
213
239
 
214
240
  if (extra_content) {
215
- out.providerOptions = extra_content;
241
+ out.providerOptions = extra_content as SharedV3ProviderOptions;
216
242
  }
217
243
 
218
244
  return out;
@@ -234,7 +260,7 @@ export function fromChatCompletionsToolResultMessage(
234
260
  type: "tool-result",
235
261
  toolCallId: tc.id,
236
262
  toolName: tc.function.name,
237
- output: parseToolOutput(toolMsg.content),
263
+ output: parseToolResult(toolMsg.content),
238
264
  });
239
265
  }
240
266
 
@@ -243,48 +269,48 @@ export function fromChatCompletionsToolResultMessage(
243
269
 
244
270
  export function fromChatCompletionsContent(content: ChatCompletionsContentPart[]): UserContent {
245
271
  return content.map((part) => {
246
- if (part.type === "image_url") {
247
- const url = part.image_url.url;
248
- if (url.startsWith("data:")) {
249
- const { mimeType, base64Data } = parseDataUrl(url);
250
-
251
- return mimeType.startsWith("image/")
252
- ? {
253
- type: "image" as const,
254
- image: convertBase64ToUint8Array(base64Data),
255
- mediaType: mimeType,
256
- }
257
- : {
258
- type: "file" as const,
259
- data: convertBase64ToUint8Array(base64Data),
260
- mediaType: mimeType,
261
- };
262
- }
263
-
264
- return {
265
- type: "image" as const,
266
- image: new URL(url),
267
- };
272
+ switch (part.type) {
273
+ case "image_url":
274
+ return fromImageUrlPart(part.image_url.url);
275
+ case "file":
276
+ return fromFilePart(part.file.data, part.file.media_type, part.file.filename);
277
+ case "input_audio":
278
+ return fromFilePart(part.input_audio.data, `audio/${part.input_audio.format}`);
279
+ default:
280
+ return part;
268
281
  }
269
- if (part.type === "file") {
270
- let { data, media_type, filename } = part.file;
271
- return media_type.startsWith("image/")
272
- ? {
273
- type: "image" as const,
274
- image: convertBase64ToUint8Array(data),
275
- mediaType: media_type,
276
- }
277
- : {
278
- type: "file" as const,
279
- data: convertBase64ToUint8Array(data),
280
- filename,
281
- mediaType: media_type,
282
- };
283
- }
284
- return part;
285
282
  });
286
283
  }
287
284
 
285
+ function fromImageUrlPart(url: string) {
286
+ if (url.startsWith("data:")) {
287
+ const { mimeType, base64Data } = parseDataUrl(url);
288
+ return fromFilePart(base64Data, mimeType);
289
+ }
290
+
291
+ return {
292
+ type: "image" as const,
293
+ image: new URL(url),
294
+ };
295
+ }
296
+
297
+ function fromFilePart(base64Data: string, mediaType: string, filename?: string) {
298
+ if (mediaType.startsWith("image/")) {
299
+ return {
300
+ type: "image" as const,
301
+ image: z.util.base64ToUint8Array(base64Data),
302
+ mediaType,
303
+ };
304
+ }
305
+
306
+ return {
307
+ type: "file" as const,
308
+ data: z.util.base64ToUint8Array(base64Data),
309
+ filename,
310
+ mediaType,
311
+ };
312
+ }
313
+
288
314
  export const convertToToolSet = (tools: ChatCompletionsTool[] | undefined): ToolSet | undefined => {
289
315
  if (!tools) {
290
316
  return;
@@ -311,17 +337,39 @@ export const convertToToolChoice = (
311
337
  return toolChoice;
312
338
  }
313
339
 
340
+ // FUTURE: this is right now google specific, which is not supported by AI SDK, until then, we temporarily map it to auto for now https://docs.cloud.google.com/vertex-ai/generative-ai/docs/migrate/openai/overview
341
+ if (toolChoice === "validated") {
342
+ return "auto";
343
+ }
344
+
314
345
  return {
315
346
  type: "tool",
316
347
  toolName: toolChoice.function.name,
317
348
  };
318
349
  };
319
350
 
320
- function parseToolOutput(content: string) {
351
+ function parseToolResult(
352
+ content: string | ChatCompletionsContentPartText[],
353
+ ): ToolResultPart["output"] {
354
+ if (Array.isArray(content)) {
355
+ return {
356
+ type: "content",
357
+ value: content.map((part) => ({
358
+ type: "text",
359
+ text: part.text,
360
+ })),
361
+ };
362
+ }
363
+ return parseJsonOrText(content);
364
+ }
365
+
366
+ function parseJsonOrText(
367
+ content: string,
368
+ ): { type: "json"; value: JSONValue } | { type: "text"; value: string } {
321
369
  try {
322
- return { type: "json" as const, value: JSON.parse(content) };
370
+ return { type: "json", value: JSON.parse(content) };
323
371
  } catch {
324
- return { type: "text" as const, value: content };
372
+ return { type: "text", value: content };
325
373
  }
326
374
  }
327
375
 
@@ -379,8 +427,6 @@ export function toChatCompletions(
379
427
  result: GenerateTextResult<ToolSet, Output.Output>,
380
428
  model: string,
381
429
  ): ChatCompletions {
382
- const finish_reason = toChatCompletionsFinishReason(result.finishReason);
383
-
384
430
  return {
385
431
  id: "chatcmpl-" + crypto.randomUUID(),
386
432
  object: "chat.completion",
@@ -390,7 +436,7 @@ export function toChatCompletions(
390
436
  {
391
437
  index: 0,
392
438
  message: toChatCompletionsAssistantMessage(result),
393
- finish_reason,
439
+ finish_reason: toChatCompletionsFinishReason(result.finishReason),
394
440
  } satisfies ChatCompletionsChoice,
395
441
  ],
396
442
  usage: result.totalUsage ? toChatCompletionsUsage(result.totalUsage) : null,
@@ -430,6 +476,7 @@ export class ChatCompletionsStream<E extends boolean = false> extends TransformS
430
476
  const creationTime = Math.floor(Date.now() / 1000);
431
477
  let toolCallIndexCounter = 0;
432
478
  const reasoningIdToIndex = new Map<string, number>();
479
+ let finishProviderMetadata: SharedV3ProviderMetadata | undefined;
433
480
 
434
481
  const createChunk = (
435
482
  delta: ChatCompletionsAssistantMessageDelta,
@@ -512,14 +559,7 @@ export class ChatCompletionsStream<E extends boolean = false> extends TransformS
512
559
  }
513
560
 
514
561
  case "finish-step": {
515
- controller.enqueue(
516
- createChunk(
517
- {},
518
- part.providerMetadata,
519
- toChatCompletionsFinishReason(part.finishReason),
520
- toChatCompletionsUsage(part.usage),
521
- ),
522
- );
562
+ finishProviderMetadata = part.providerMetadata;
523
563
  break;
524
564
  }
525
565
 
@@ -527,7 +567,7 @@ export class ChatCompletionsStream<E extends boolean = false> extends TransformS
527
567
  controller.enqueue(
528
568
  createChunk(
529
569
  {},
530
- undefined,
570
+ finishProviderMetadata,
531
571
  toChatCompletionsFinishReason(part.finishReason),
532
572
  toChatCompletionsUsage(part.totalUsage),
533
573
  ),
@@ -12,6 +12,7 @@ describe("Chat Completions Handler", () => {
12
12
  const mockLanguageModel = new MockLanguageModelV3({
13
13
  // eslint-disable-next-line require-await
14
14
  doGenerate: async (options) => {
15
+ const isStructuredOutput = options.responseFormat?.type === "json";
15
16
  const isToolCall = options.tools && options.tools.length > 0;
16
17
 
17
18
  if (isToolCall) {
@@ -34,6 +35,24 @@ describe("Chat Completions Handler", () => {
34
35
  };
35
36
  }
36
37
 
38
+ if (isStructuredOutput) {
39
+ return {
40
+ finishReason: { unified: "stop", raw: "stop" },
41
+ usage: {
42
+ inputTokens: { total: 10, noCache: 10, cacheRead: 20, cacheWrite: 0 },
43
+ outputTokens: { total: 20, text: 20, reasoning: 10 },
44
+ },
45
+ content: [
46
+ {
47
+ type: "text",
48
+ text: '{"city":"San Francisco","temp_c":18}',
49
+ },
50
+ ],
51
+ providerMetadata: { provider: { key: "value" } },
52
+ warnings: [],
53
+ };
54
+ }
55
+
37
56
  return {
38
57
  finishReason: { unified: "stop", raw: "stop" },
39
58
  usage: {
@@ -184,6 +203,31 @@ describe("Chat Completions Handler", () => {
184
203
  });
185
204
  });
186
205
 
206
+ test("should accept input_audio content parts", async () => {
207
+ const request = postJson(baseUrl, {
208
+ model: "openai/gpt-oss-20b",
209
+ messages: [
210
+ {
211
+ role: "user",
212
+ content: [
213
+ {
214
+ type: "input_audio",
215
+ input_audio: {
216
+ data: "aGVsbG8=",
217
+ format: "wav",
218
+ },
219
+ },
220
+ ],
221
+ },
222
+ ],
223
+ });
224
+
225
+ const res = await endpoint.handler(request);
226
+ expect(res.status).toBe(200);
227
+ const data = await parseResponse(res);
228
+ expect(data.model).toBe("openai/gpt-oss-20b");
229
+ });
230
+
187
231
  test("should generate completion with tool calls successfully", async () => {
188
232
  const request = postJson(baseUrl, {
189
233
  model: "openai/gpt-oss-20b",
@@ -299,4 +343,47 @@ describe("Chat Completions Handler", () => {
299
343
  const data = await parseResponse(res);
300
344
  expect(data.model).toBe("openai/gpt-oss-20b");
301
345
  });
346
+
347
+ test("should generate non-streaming structured output", async () => {
348
+ const request = postJson(baseUrl, {
349
+ model: "openai/gpt-oss-20b",
350
+ messages: [{ role: "user", content: "Return weather as JSON" }],
351
+ response_format: {
352
+ type: "json_schema",
353
+ json_schema: {
354
+ name: "weather",
355
+ schema: {
356
+ type: "object",
357
+ properties: {
358
+ city: { type: "string" },
359
+ temp_c: { type: "number" },
360
+ },
361
+ required: ["city", "temp_c"],
362
+ additionalProperties: false,
363
+ },
364
+ strict: true,
365
+ },
366
+ },
367
+ });
368
+
369
+ const res = await endpoint.handler(request);
370
+ expect(res.status).toBe(200);
371
+ const data = await parseResponse(res);
372
+ expect(data.choices[0].message.content).toBe('{"city":"San Francisco","temp_c":18}');
373
+ });
374
+
375
+ test('should accept response_format type "text"', async () => {
376
+ const request = postJson(baseUrl, {
377
+ model: "openai/gpt-oss-20b",
378
+ messages: [{ role: "user", content: "Say hi" }],
379
+ response_format: {
380
+ type: "text",
381
+ },
382
+ });
383
+
384
+ const res = await endpoint.handler(request);
385
+ expect(res.status).toBe(200);
386
+ const data = await parseResponse(res);
387
+ expect(data.choices[0].message.content).toBe("Hello from AI");
388
+ });
302
389
  });
@@ -29,7 +29,6 @@ import {
29
29
  recordTokenUsage,
30
30
  } from "../../telemetry/gen-ai";
31
31
  import { addSpanEvent, setSpanAttributes } from "../../telemetry/span";
32
- import { resolveRequestId } from "../../utils/headers";
33
32
  import { prepareForwardHeaders } from "../../utils/request";
34
33
  import { convertToTextCallOptions, toChatCompletions, toChatCompletionsStream } from "./converters";
35
34
  import {
@@ -52,8 +51,6 @@ export const chatCompletions = (config: GatewayConfig): Endpoint => {
52
51
  throw new GatewayError("Method Not Allowed", 405);
53
52
  }
54
53
 
55
- const requestId = resolveRequestId(ctx.request);
56
-
57
54
  // Parse + validate input.
58
55
  try {
59
56
  ctx.body = await ctx.request.json();
@@ -107,7 +104,7 @@ export const chatCompletions = (config: GatewayConfig): Endpoint => {
107
104
  const textOptions = convertToTextCallOptions(inputs);
108
105
  logger.trace(
109
106
  {
110
- requestId,
107
+ requestId: ctx.requestId,
111
108
  options: textOptions,
112
109
  },
113
110
  "[chat] AI SDK options",
@@ -178,7 +175,7 @@ export const chatCompletions = (config: GatewayConfig): Endpoint => {
178
175
  },
179
176
  ...textOptions,
180
177
  });
181
- logger.trace({ requestId, result }, "[chat] AI SDK result");
178
+ logger.trace({ requestId: ctx.requestId, result }, "[chat] AI SDK result");
182
179
  addSpanEvent("hebo.ai-sdk.completed");
183
180
 
184
181
  // Transform result.
@@ -41,6 +41,12 @@ const toMessageParts = (message: ChatCompletionsMessage): Record<string, unknown
41
41
  parts.push(toTextPart(part.text));
42
42
  } else if (part.type === "image_url") {
43
43
  parts.push({ type: "image", content: part.image_url.url });
44
+ } else if (part.type === "input_audio") {
45
+ parts.push({
46
+ type: "audio",
47
+ content: "[REDACTED_BINARY_DATA]",
48
+ format: part.input_audio.format,
49
+ });
44
50
  } else {
45
51
  parts.push({
46
52
  type: "file",