@hebo-ai/gateway 0.10.5 → 0.10.7

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.
@@ -133,6 +133,8 @@ export const chatCompletions = (config) => {
133
133
  });
134
134
  logger.trace({ requestId: ctx.requestId, result }, "[chat] AI SDK result");
135
135
  addSpanEvent("hebo.ai-sdk.completed");
136
+ if (result.response.headers)
137
+ ctx.response = { headers: result.response.headers };
136
138
  recordTimeToFirstToken(performance.now() - start, genAiGeneralAttrs, ctx.trace);
137
139
  // Transform result.
138
140
  ctx.result = toChatCompletions(result, ctx.resolvedModelId);
@@ -1055,8 +1055,8 @@ export declare const ChatCompletionsBodySchema: z.ZodObject<{
1055
1055
  export type ChatCompletionsBody = z.infer<typeof ChatCompletionsBodySchema>;
1056
1056
  export declare const ChatCompletionsFinishReasonSchema: z.ZodEnum<{
1057
1057
  length: "length";
1058
- stop: "stop";
1059
1058
  tool_calls: "tool_calls";
1059
+ stop: "stop";
1060
1060
  content_filter: "content_filter";
1061
1061
  }>;
1062
1062
  export type ChatCompletionsFinishReason = z.infer<typeof ChatCompletionsFinishReasonSchema>;
@@ -1109,8 +1109,8 @@ export declare const ChatCompletionsChoiceSchema: z.ZodObject<{
1109
1109
  }, z.core.$strip>;
1110
1110
  finish_reason: z.ZodEnum<{
1111
1111
  length: "length";
1112
- stop: "stop";
1113
1112
  tool_calls: "tool_calls";
1113
+ stop: "stop";
1114
1114
  content_filter: "content_filter";
1115
1115
  }>;
1116
1116
  logprobs: z.ZodOptional<z.ZodUnknown>;
@@ -1183,8 +1183,8 @@ export declare const ChatCompletionsSchema: z.ZodObject<{
1183
1183
  }, z.core.$strip>;
1184
1184
  finish_reason: z.ZodEnum<{
1185
1185
  length: "length";
1186
- stop: "stop";
1187
1186
  tool_calls: "tool_calls";
1187
+ stop: "stop";
1188
1188
  content_filter: "content_filter";
1189
1189
  }>;
1190
1190
  logprobs: z.ZodOptional<z.ZodUnknown>;
@@ -1319,8 +1319,8 @@ export declare const ChatCompletionsChoiceDeltaSchema: z.ZodObject<{
1319
1319
  }, z.core.$strip>;
1320
1320
  finish_reason: z.ZodNullable<z.ZodEnum<{
1321
1321
  length: "length";
1322
- stop: "stop";
1323
1322
  tool_calls: "tool_calls";
1323
+ stop: "stop";
1324
1324
  content_filter: "content_filter";
1325
1325
  }>>;
1326
1326
  logprobs: z.ZodOptional<z.ZodUnknown>;
@@ -1381,8 +1381,8 @@ export declare const ChatCompletionsChunkSchema: z.ZodObject<{
1381
1381
  }, z.core.$strip>;
1382
1382
  finish_reason: z.ZodNullable<z.ZodEnum<{
1383
1383
  length: "length";
1384
- stop: "stop";
1385
1384
  tool_calls: "tool_calls";
1385
+ stop: "stop";
1386
1386
  content_filter: "content_filter";
1387
1387
  }>>;
1388
1388
  logprobs: z.ZodOptional<z.ZodUnknown>;
@@ -81,6 +81,8 @@ export const embeddings = (config) => {
81
81
  });
82
82
  logger.trace({ requestId: ctx.requestId, result }, "[embeddings] AI SDK result");
83
83
  addSpanEvent("hebo.ai-sdk.completed");
84
+ if (result.responses?.[0]?.headers)
85
+ ctx.response = { headers: result.responses[0].headers };
84
86
  // Transform result.
85
87
  ctx.result = toEmbeddings(result, ctx.modelId);
86
88
  logger.trace({ requestId: ctx.requestId, result: ctx.result }, "[chat] Embeddings");
@@ -124,6 +124,8 @@ export const messages = (config) => {
124
124
  });
125
125
  logger.trace({ requestId: ctx.requestId, result }, "[messages] AI SDK result");
126
126
  addSpanEvent("hebo.ai-sdk.completed");
127
+ if (result.response.headers)
128
+ ctx.response = { headers: result.response.headers };
127
129
  recordTimeToFirstToken(performance.now() - start, genAiGeneralAttrs, ctx.trace);
128
130
  ctx.result = toMessages(result, ctx.resolvedModelId);
129
131
  logger.trace({ requestId: ctx.requestId, result: ctx.result }, "[messages] Messages");
@@ -123,6 +123,8 @@ export const responses = (config) => {
123
123
  });
124
124
  logger.trace({ requestId: ctx.requestId, result }, "[responses] AI SDK result");
125
125
  addSpanEvent("hebo.ai-sdk.completed");
126
+ if (result.response.headers)
127
+ ctx.response = { headers: result.response.headers };
126
128
  recordTimeToFirstToken(performance.now() - start, genAiGeneralAttrs, ctx.trace);
127
129
  ctx.result = toResponses(result, ctx.resolvedModelId, ctx.body.metadata);
128
130
  logger.trace({ requestId: ctx.requestId, result: ctx.result }, "[responses] Responses");
@@ -1,11 +1,20 @@
1
1
  import { AISDKError, APICallError, DownloadError, EmptyResponseBodyError, InvalidArgumentError, InvalidDataContentError, InvalidMessageRoleError, InvalidPromptError, InvalidResponseDataError, InvalidStreamPartError, InvalidToolApprovalError, InvalidToolInputError, JSONParseError, LoadAPIKeyError, LoadSettingError, MessageConversionError, MissingToolResultsError, NoContentGeneratedError, NoImageGeneratedError, NoObjectGeneratedError, NoOutputGeneratedError, NoSpeechGeneratedError, NoSuchModelError, NoSuchProviderError, NoSuchToolError, NoTranscriptGeneratedError, NoVideoGeneratedError, RetryError, ToolCallNotFoundForApprovalError, ToolCallRepairError, TooManyEmbeddingValuesForCallError, TypeValidationError, UIMessageStreamError, UnsupportedModelVersionError, UnsupportedFunctionalityError, } from "ai";
2
2
  import { GatewayError } from "./gateway";
3
- import { STATUS_CODE } from "./utils";
3
+ import { STATUS_TEXT } from "./utils";
4
+ const normalizeApiCallError = (error) => {
5
+ const status = error.statusCode ?? (error.isRetryable ? 502 : 422);
6
+ const statusText = `UPSTREAM_${STATUS_TEXT(status)}`;
7
+ return new GatewayError(error, status, statusText, undefined, error.responseHeaders ?? undefined);
8
+ };
4
9
  export const normalizeAiSdkError = (error) => {
5
10
  if (APICallError.isInstance(error)) {
6
- const status = error.statusCode ?? (error.isRetryable ? 502 : 422);
7
- const code = `UPSTREAM_${STATUS_CODE(status)}`;
8
- return new GatewayError(error, status, code);
11
+ return normalizeApiCallError(error);
12
+ }
13
+ if (RetryError.isInstance(error)) {
14
+ if (APICallError.isInstance(error.lastError)) {
15
+ return normalizeApiCallError(error.lastError);
16
+ }
17
+ return new GatewayError(error, 502, `UPSTREAM_${STATUS_TEXT(502)}`);
9
18
  }
10
19
  if (JSONParseError.isInstance(error) ||
11
20
  InvalidResponseDataError.isInstance(error) ||
@@ -15,7 +24,6 @@ export const normalizeAiSdkError = (error) => {
15
24
  NoOutputGeneratedError.isInstance(error) ||
16
25
  InvalidStreamPartError.isInstance(error) ||
17
26
  UIMessageStreamError.isInstance(error) ||
18
- RetryError.isInstance(error) ||
19
27
  DownloadError.isInstance(error) ||
20
28
  ToolCallRepairError.isInstance(error) ||
21
29
  NoImageGeneratedError.isInstance(error) ||
@@ -23,7 +31,7 @@ export const normalizeAiSdkError = (error) => {
23
31
  NoSpeechGeneratedError.isInstance(error) ||
24
32
  NoTranscriptGeneratedError.isInstance(error) ||
25
33
  NoVideoGeneratedError.isInstance(error)) {
26
- return new GatewayError(error, 502, `UPSTREAM_${STATUS_CODE(502)}`);
34
+ return new GatewayError(error, 502, `UPSTREAM_${STATUS_TEXT(502)}`);
27
35
  }
28
36
  if (InvalidArgumentError.isInstance(error) ||
29
37
  InvalidPromptError.isInstance(error) ||
@@ -40,7 +48,7 @@ export const normalizeAiSdkError = (error) => {
40
48
  TooManyEmbeddingValuesForCallError.isInstance(error) ||
41
49
  NoSuchModelError.isInstance(error) ||
42
50
  NoSuchProviderError.isInstance(error)) {
43
- return new GatewayError(error, 422, `UPSTREAM_${STATUS_CODE(422)}`);
51
+ return new GatewayError(error, 422, `UPSTREAM_${STATUS_TEXT(422)}`);
44
52
  }
45
53
  if (LoadSettingError.isInstance(error) || LoadAPIKeyError.isInstance(error)) {
46
54
  return new GatewayError(error, 500);
@@ -9,7 +9,8 @@ export declare const AnthropicErrorSchema: z.ZodObject<{
9
9
  export declare class AnthropicError {
10
10
  readonly type: "error";
11
11
  readonly error: z.infer<typeof AnthropicErrorSchema>["error"];
12
+ status: number;
12
13
  constructor(message: string, type?: string);
13
14
  }
14
- export declare function toAnthropicError(error: unknown): AnthropicError;
15
- export declare function toAnthropicErrorResponse(error: unknown, responseInit?: ResponseInit): Response;
15
+ export declare function toAnthropicError(error: unknown, requestId?: string): AnthropicError;
16
+ export declare function toAnthropicErrorResponse(error: unknown, init: ResponseInit): Response;
@@ -14,6 +14,8 @@ export class AnthropicError {
14
14
  error;
15
15
  constructor(message, type = "api_error") {
16
16
  this.error = { type, message };
17
+ // internal property to derive status from error handlers without breaking official format
18
+ Object.defineProperty(this, "status", { value: 500, writable: true });
17
19
  }
18
20
  }
19
21
  const mapType = (status) => {
@@ -22,12 +24,12 @@ const mapType = (status) => {
22
24
  return "invalid_request_error";
23
25
  case 401:
24
26
  return "authentication_error";
27
+ case 402:
28
+ return "billing_error";
25
29
  case 403:
26
30
  return "permission_error";
27
31
  case 404:
28
32
  return "not_found_error";
29
- case 402:
30
- return "billing_error";
31
33
  case 413:
32
34
  return "request_too_large";
33
35
  case 429:
@@ -40,15 +42,12 @@ const mapType = (status) => {
40
42
  return status >= 500 ? "api_error" : "invalid_request_error";
41
43
  }
42
44
  };
43
- export function toAnthropicError(error) {
45
+ export function toAnthropicError(error, requestId) {
44
46
  const meta = getErrorMeta(error);
45
- return new AnthropicError(maybeMaskMessage(meta), mapType(meta.status));
47
+ const anthropicError = new AnthropicError(maybeMaskMessage(error instanceof Error ? error.message : String(error), meta.status, requestId), mapType(meta.status));
48
+ anthropicError.status = meta.status;
49
+ return anthropicError;
46
50
  }
47
- export function toAnthropicErrorResponse(error, responseInit) {
48
- const meta = getErrorMeta(error);
49
- return toResponse(new AnthropicError(maybeMaskMessage(meta, resolveRequestId(responseInit)), mapType(meta.status)), {
50
- status: meta.status,
51
- statusText: meta.code,
52
- headers: responseInit?.headers,
53
- });
51
+ export function toAnthropicErrorResponse(error, init) {
52
+ return toResponse(new AnthropicError(maybeMaskMessage(error instanceof Error ? error.message : String(error), init.status ?? 500, resolveRequestId(init)), mapType(init.status ?? 500)), init);
54
53
  }
@@ -1,5 +1,6 @@
1
1
  export declare class GatewayError extends Error {
2
2
  readonly status: number;
3
- readonly code: string;
4
- constructor(error: unknown, status: number, code?: string, cause?: unknown);
3
+ readonly statusText: string;
4
+ readonly headers: Record<string, string> | undefined;
5
+ constructor(error: unknown, status: number, statusText?: string, cause?: unknown, headers?: Record<string, string>);
5
6
  }
@@ -1,13 +1,19 @@
1
- import { STATUS_CODE } from "./utils";
1
+ import { X_SHOULD_RETRY_HEADER } from "../utils/headers";
2
+ import { STATUS_TEXT } from "./utils";
2
3
  export class GatewayError extends Error {
3
4
  status;
4
- code;
5
- constructor(error, status, code, cause) {
5
+ statusText;
6
+ headers;
7
+ constructor(error, status, statusText, cause, headers) {
6
8
  const isError = error instanceof Error;
7
9
  super(isError ? error.message : String(error));
8
10
  this.name = "GatewayError";
9
11
  this.cause = cause ?? (isError ? error : undefined);
10
12
  this.status = status;
11
- this.code = code ?? STATUS_CODE(status);
13
+ this.statusText = statusText ?? STATUS_TEXT(status);
14
+ this.headers = headers;
15
+ if (!this.statusText.startsWith("UPSTREAM_")) {
16
+ (this.headers ??= {})[X_SHOULD_RETRY_HEADER] = "false";
17
+ }
12
18
  }
13
19
  }
@@ -9,7 +9,8 @@ export declare const OpenAIErrorSchema: z.ZodObject<{
9
9
  }, z.core.$strip>;
10
10
  export declare class OpenAIError {
11
11
  readonly error: z.infer<typeof OpenAIErrorSchema>["error"];
12
+ status: number;
12
13
  constructor(message: string, type?: string, code?: string, param?: string);
13
14
  }
14
- export declare function toOpenAIError(error: unknown): OpenAIError;
15
- export declare function toOpenAIErrorResponse(error: unknown, responseInit?: ResponseInit): Response;
15
+ export declare function toOpenAIError(error: unknown, requestId?: string): OpenAIError;
16
+ export declare function toOpenAIErrorResponse(error: unknown, init: ResponseInit): Response;
@@ -14,18 +14,17 @@ export class OpenAIError {
14
14
  error;
15
15
  constructor(message, type = "server_error", code, param = "") {
16
16
  this.error = { message, type, code: code?.toLowerCase(), param };
17
+ // internal property to derive status from error handlers without breaking official format
18
+ Object.defineProperty(this, "status", { value: 500, writable: true });
17
19
  }
18
20
  }
19
21
  const mapType = (status) => (status < 500 ? "invalid_request_error" : "server_error");
20
- export function toOpenAIError(error) {
22
+ export function toOpenAIError(error, requestId) {
21
23
  const meta = getErrorMeta(error);
22
- return new OpenAIError(maybeMaskMessage(meta), mapType(meta.status), meta.code);
24
+ const openAIError = new OpenAIError(maybeMaskMessage(error instanceof Error ? error.message : String(error), meta.status, requestId), mapType(meta.status), meta.statusText);
25
+ openAIError.status = meta.status;
26
+ return openAIError;
23
27
  }
24
- export function toOpenAIErrorResponse(error, responseInit) {
25
- const meta = getErrorMeta(error);
26
- return toResponse(new OpenAIError(maybeMaskMessage(meta, resolveRequestId(responseInit)), mapType(meta.status), meta.code), {
27
- ...responseInit,
28
- status: meta.status,
29
- statusText: meta.code,
30
- });
28
+ export function toOpenAIErrorResponse(error, init) {
29
+ return toResponse(new OpenAIError(maybeMaskMessage(error instanceof Error ? error.message : String(error), init.status ?? 500, resolveRequestId(init)), mapType(init.status ?? 500), init.statusText ?? "INTERNAL_SERVER_ERROR"), init);
31
30
  }
@@ -16,11 +16,11 @@ export declare const STATUS_CODES: {
16
16
  readonly 503: "SERVICE_UNAVAILABLE";
17
17
  readonly 504: "GATEWAY_TIMEOUT";
18
18
  };
19
- export declare const STATUS_CODE: (status: number) => "BAD_REQUEST" | "UNAUTHORIZED" | "PAYMENT_REQUIRED" | "FORBIDDEN" | "NOT_FOUND" | "METHOD_NOT_ALLOWED" | "CONFLICT" | "PAYLOAD_TOO_LARGE" | "UNSUPPORTED_MEDIA_TYPE" | "UNPROCESSABLE_ENTITY" | "TOO_MANY_REQUESTS" | "CLIENT_CLOSED_REQUEST" | "INTERNAL_SERVER_ERROR" | "BAD_GATEWAY" | "SERVICE_UNAVAILABLE" | "GATEWAY_TIMEOUT";
19
+ export declare const STATUS_TEXT: (status: number) => "BAD_REQUEST" | "UNAUTHORIZED" | "PAYMENT_REQUIRED" | "FORBIDDEN" | "NOT_FOUND" | "METHOD_NOT_ALLOWED" | "CONFLICT" | "PAYLOAD_TOO_LARGE" | "UNSUPPORTED_MEDIA_TYPE" | "UNPROCESSABLE_ENTITY" | "TOO_MANY_REQUESTS" | "CLIENT_CLOSED_REQUEST" | "INTERNAL_SERVER_ERROR" | "BAD_GATEWAY" | "SERVICE_UNAVAILABLE" | "GATEWAY_TIMEOUT";
20
20
  export type ErrorMeta = {
21
21
  status: number;
22
- code: string;
23
- message: string;
22
+ statusText: string;
23
+ headers: Record<string, string>;
24
24
  };
25
25
  export declare function getErrorMeta(error: unknown): ErrorMeta;
26
- export declare function maybeMaskMessage(meta: ErrorMeta, requestId?: string): string;
26
+ export declare function maybeMaskMessage(message: string, status: number, requestId?: string): string;
@@ -19,37 +19,37 @@ export const STATUS_CODES = {
19
19
  503: "SERVICE_UNAVAILABLE",
20
20
  504: "GATEWAY_TIMEOUT",
21
21
  };
22
- export const STATUS_CODE = (status) => {
22
+ export const STATUS_TEXT = (status) => {
23
23
  const label = STATUS_CODES[status];
24
24
  if (label)
25
25
  return label;
26
26
  return status >= 400 && status < 500 ? STATUS_CODES[400] : STATUS_CODES[500];
27
27
  };
28
- // FUTURE: always return a wrapped GatewayError?
29
28
  export function getErrorMeta(error) {
30
- const message = error instanceof Error ? error.message : String(error);
31
29
  let status;
32
- let code;
30
+ let statusText;
31
+ let headers;
33
32
  if (error instanceof GatewayError) {
34
- ({ status, code } = error);
33
+ ({ status, statusText, headers } = error);
35
34
  }
36
35
  else {
37
36
  const normalized = normalizeAiSdkError(error);
38
37
  if (normalized) {
39
- ({ status, code } = normalized);
38
+ ({ status, statusText, headers } = normalized);
40
39
  }
41
40
  else {
42
41
  status = 500;
43
- code = STATUS_CODE(status);
42
+ statusText = STATUS_TEXT(status);
43
+ headers = {};
44
44
  }
45
45
  }
46
- return { status, code, message };
46
+ return { status, statusText, headers: headers ?? {} };
47
47
  }
48
- export function maybeMaskMessage(meta, requestId) {
48
+ export function maybeMaskMessage(message, status, requestId) {
49
49
  // FUTURE: consider masking all upstream errors, also 4xx
50
- if (!(isProduction() && meta.status >= 500)) {
51
- return meta.message;
50
+ if (!(isProduction() && status >= 500)) {
51
+ return message;
52
52
  }
53
53
  // FUTURE: always attach requestId to errors (masked and unmasked)
54
- return `${STATUS_CODE(meta.status)} (${requestId ?? "see requestId in response headers"})`;
54
+ return `${STATUS_TEXT(status)} (${requestId ?? "see requestId in response headers"})`;
55
55
  }
package/dist/lifecycle.js CHANGED
@@ -2,6 +2,7 @@ import { parseConfig } from "./config";
2
2
  import { toAnthropicError, toAnthropicErrorResponse } from "./errors/anthropic";
3
3
  import { GatewayError } from "./errors/gateway";
4
4
  import { toOpenAIError, toOpenAIErrorResponse } from "./errors/openai";
5
+ import { getErrorMeta } from "./errors/utils";
5
6
  import { logger } from "./logger";
6
7
  import { getBaggageAttributes } from "./telemetry/baggage";
7
8
  import { instrumentFetch } from "./telemetry/fetch";
@@ -53,8 +54,7 @@ export const winterCgHandler = (run, config) => {
53
54
  requestId: ctx.requestId,
54
55
  err: reason ?? ctx.request.signal.reason,
55
56
  });
56
- const isUpstreamError = reason instanceof GatewayError && reason.code.startsWith("UPSTREAM_");
57
- span.recordError(reason, realStatus >= 500 || isUpstreamError);
57
+ span.recordError(reason, true);
58
58
  }
59
59
  span.setAttributes({ "http.response.status_code_effective": realStatus });
60
60
  if (ctx.operation === "chat" ||
@@ -76,10 +76,10 @@ export const winterCgHandler = (run, config) => {
76
76
  }
77
77
  if (!ctx.response) {
78
78
  ctx.result = (await run(ctx, parsedConfig));
79
- const formatError = ctx.operation === "messages" ? toAnthropicError : toOpenAIError;
80
- ctx.response = toResponse(ctx.result, prepareResponseInit(ctx.requestId), {
79
+ const toError = ctx.operation === "messages" ? toAnthropicError : toOpenAIError;
80
+ ctx.response = toResponse(ctx.result, prepareResponseInit(ctx.requestId, ctx.response), {
81
81
  onDone: finalize,
82
- formatError,
82
+ toError: (error) => toError(error, ctx.requestId),
83
83
  });
84
84
  }
85
85
  if (parsedConfig.hooks?.onResponse) {
@@ -111,11 +111,10 @@ export const winterCgHandler = (run, config) => {
111
111
  const errorPayload = ctx.request.signal.aborted
112
112
  ? new GatewayError(error ?? ctx.request.signal.reason, 499)
113
113
  : error;
114
- const errorResponseInit = prepareResponseInit(ctx.requestId);
115
- ctx.response ??=
116
- ctx.operation === "messages"
117
- ? toAnthropicErrorResponse(errorPayload, errorResponseInit)
118
- : toOpenAIErrorResponse(errorPayload, errorResponseInit);
114
+ if (!(ctx.response instanceof Response)) {
115
+ const toErrorResponse = ctx.operation === "messages" ? toAnthropicErrorResponse : toOpenAIErrorResponse;
116
+ ctx.response = toErrorResponse(errorPayload, prepareResponseInit(ctx.requestId, getErrorMeta(errorPayload)));
117
+ }
119
118
  finalize(ctx.response.status, error);
120
119
  }
121
120
  });
@@ -1,5 +1,5 @@
1
1
  import { metrics } from "@opentelemetry/api";
2
- import { STATUS_CODE } from "../errors/utils";
2
+ import { STATUS_TEXT } from "../errors/utils";
3
3
  const getMeter = () => metrics.getMeter("@hebo/gateway");
4
4
  let requestDurationHistogram;
5
5
  let timePerOutputTokenHistogram;
@@ -75,7 +75,7 @@ export const recordRequestDuration = (duration, status, ctx, signalLevel) => {
75
75
  return;
76
76
  const attrs = getGenAiGeneralAttributes(ctx, signalLevel);
77
77
  if (status !== 200) {
78
- attrs["error.type"] = `${status} ${STATUS_CODE(status).toLowerCase()}`;
78
+ attrs["error.type"] = `${status} ${STATUS_TEXT(status).toLowerCase()}`;
79
79
  }
80
80
  getRequestDurationHistogram().record(duration / 1000, attrs);
81
81
  };
package/dist/types.d.ts CHANGED
@@ -69,8 +69,10 @@ export type GatewayContext = {
69
69
  result?: ChatCompletions | ChatCompletionsStream | Embeddings | Messages | MessagesStream | Model | ModelList | Responses | ResponsesStream;
70
70
  /**
71
71
  * Response object returned by the handler.
72
+ * Handlers may set this to a `ResponseInit` containing upstream response
73
+ * headers; the lifecycle merges allowlisted headers into the final `Response`.
72
74
  */
73
- response?: Response;
75
+ response?: Response | ResponseInit;
74
76
  /**
75
77
  * Per-request telemetry signal level override.
76
78
  * When set (via body parameter or hook), overrides `cfg.telemetry.signals.gen_ai`
@@ -1,4 +1,9 @@
1
1
  export declare const REQUEST_ID_HEADER = "x-request-id";
2
+ export declare const RETRY_AFTER_HEADER = "retry-after";
3
+ export declare const RETRY_AFTER_MS_HEADER = "retry-after-ms";
4
+ export declare const X_SHOULD_RETRY_HEADER = "x-should-retry";
2
5
  type HeaderSource = Request | ResponseInit | undefined;
3
6
  export declare const resolveRequestId: (source: HeaderSource) => string | undefined;
7
+ export declare const filterResponseHeaders: (upstream?: HeadersInit) => Record<string, string>;
8
+ export declare const buildRetryHeaders: (status: number, upstream?: Record<string, string>) => Record<string, string>;
4
9
  export {};
@@ -1,22 +1,69 @@
1
1
  export const REQUEST_ID_HEADER = "x-request-id";
2
+ export const RETRY_AFTER_HEADER = "retry-after";
3
+ export const RETRY_AFTER_MS_HEADER = "retry-after-ms";
4
+ export const X_SHOULD_RETRY_HEADER = "x-should-retry";
5
+ const RESPONSE_HEADER_ALLOWLIST = [
6
+ RETRY_AFTER_HEADER,
7
+ RETRY_AFTER_MS_HEADER,
8
+ X_SHOULD_RETRY_HEADER,
9
+ ];
10
+ const RETRYABLE_STATUS_CODES = new Set([408, 409, 429, 500, 502, 503, 504]);
11
+ const DEFAULT_RETRY_AFTER_MS = 1000;
2
12
  export const resolveRequestId = (source) => {
3
13
  if (!source)
4
14
  return undefined;
5
15
  if (source instanceof Request) {
6
16
  return source.headers.get(REQUEST_ID_HEADER) ?? undefined;
7
17
  }
8
- const headers = source.headers;
9
- if (!headers)
18
+ if (!source.headers)
10
19
  return undefined;
20
+ return getHeader(source.headers, REQUEST_ID_HEADER);
21
+ };
22
+ function getHeader(headers, key) {
11
23
  if (headers instanceof Headers) {
12
- return headers.get(REQUEST_ID_HEADER) ?? undefined;
24
+ return headers.get(key) ?? undefined;
13
25
  }
14
26
  if (Array.isArray(headers)) {
15
- for (const [key, value] of headers) {
16
- if (key.toLowerCase() === REQUEST_ID_HEADER)
17
- return value;
27
+ for (const [k, v] of headers) {
28
+ if (k.toLowerCase() === key.toLowerCase()) {
29
+ return v;
30
+ }
31
+ }
32
+ return undefined;
33
+ }
34
+ return headers[key] ?? headers[key.toLowerCase()];
35
+ }
36
+ export const filterResponseHeaders = (upstream) => {
37
+ if (!upstream)
38
+ return {};
39
+ const filtered = {};
40
+ for (const key of RESPONSE_HEADER_ALLOWLIST) {
41
+ const value = getHeader(upstream, key);
42
+ if (value !== undefined) {
43
+ filtered[key] = value;
18
44
  }
45
+ }
46
+ return filtered;
47
+ };
48
+ function deriveRetryAfterMs(retryAfter) {
49
+ if (retryAfter === undefined)
50
+ return undefined;
51
+ const num = Number(retryAfter);
52
+ if (Number.isFinite(num) && num > 0)
53
+ return num * 1000;
54
+ const dateMs = Date.parse(retryAfter);
55
+ if (!Number.isFinite(dateMs))
19
56
  return undefined;
57
+ const deltaMs = dateMs - Date.now();
58
+ return deltaMs > 0 ? deltaMs : undefined;
59
+ }
60
+ export const buildRetryHeaders = (status, upstream = {}) => {
61
+ if (!RETRYABLE_STATUS_CODES.has(status)) {
62
+ upstream[X_SHOULD_RETRY_HEADER] = "false";
63
+ return upstream;
20
64
  }
21
- return headers[REQUEST_ID_HEADER];
65
+ upstream[RETRY_AFTER_MS_HEADER] ??= String(deriveRetryAfterMs(upstream[RETRY_AFTER_HEADER]) ?? DEFAULT_RETRY_AFTER_MS);
66
+ upstream[RETRY_AFTER_HEADER] = String(Math.ceil((Number(upstream[RETRY_AFTER_MS_HEADER]) || DEFAULT_RETRY_AFTER_MS) / 1000));
67
+ upstream[X_SHOULD_RETRY_HEADER] ??= "true";
68
+ return upstream;
22
69
  };
@@ -1,7 +1,7 @@
1
1
  import type { SseFrame } from "./stream";
2
- export declare const prepareResponseInit: (requestId: string) => ResponseInit;
3
- export declare const mergeResponseInit: (defaultHeaders: HeadersInit, responseInit?: ResponseInit) => ResponseInit;
2
+ export declare const prepareResponseInit: (requestId: string, upstream?: ResponseInit) => ResponseInit;
3
+ export declare const mergeResponseInit: (headers: Record<string, string>, responseInit?: ResponseInit) => ResponseInit;
4
4
  export declare const toResponse: (result: ReadableStream<SseFrame> | Uint8Array<ArrayBuffer> | object | string, responseInit?: ResponseInit, streamOptions?: {
5
5
  onDone?: (status: number, reason?: unknown) => void;
6
- formatError?: (error: unknown) => unknown;
6
+ toError?: (error: unknown) => unknown;
7
7
  }) => Response;
@@ -1,19 +1,23 @@
1
- import { REQUEST_ID_HEADER } from "./headers";
1
+ import { buildRetryHeaders, filterResponseHeaders, REQUEST_ID_HEADER } from "./headers";
2
2
  import { toSseStream } from "./stream";
3
3
  const TEXT_ENCODER = new TextEncoder();
4
- export const prepareResponseInit = (requestId) => ({
5
- headers: { [REQUEST_ID_HEADER]: requestId },
6
- });
7
- export const mergeResponseInit = (defaultHeaders, responseInit) => {
8
- const headers = new Headers(defaultHeaders);
4
+ export const prepareResponseInit = (requestId, upstream) => {
5
+ const init = upstream ?? {};
6
+ init.headers = filterResponseHeaders(upstream?.headers);
7
+ if (init.status && init.status >= 400)
8
+ init.headers = buildRetryHeaders(init.status, init.headers);
9
+ init.headers[REQUEST_ID_HEADER] = requestId;
10
+ return init;
11
+ };
12
+ export const mergeResponseInit = (headers, responseInit) => {
13
+ if (!responseInit)
14
+ return { headers };
9
15
  const override = responseInit?.headers;
10
16
  if (override) {
11
17
  new Headers(override).forEach((value, key) => {
12
- headers.set(key, value);
18
+ headers[key] = value;
13
19
  });
14
20
  }
15
- if (!responseInit)
16
- return { headers };
17
21
  return {
18
22
  status: responseInit.status,
19
23
  statusText: responseInit.statusText,
@@ -5,6 +5,6 @@ export type SseFrame<T = unknown, E extends string | undefined = string | undefi
5
5
  export type SseErrorFrame = SseFrame<Error, "error" | undefined>;
6
6
  export declare function toSseStream(src: ReadableStream<SseFrame>, options?: {
7
7
  onDone?: (status: number, reason?: unknown) => void;
8
+ toError?: (error: unknown) => unknown;
8
9
  keepAliveMs?: number;
9
- formatError?: (error: unknown) => unknown;
10
10
  }): ReadableStream<Uint8Array>;
@@ -1,4 +1,3 @@
1
- import { toOpenAIError } from "../errors/openai";
2
1
  const TEXT_ENCODER = new TextEncoder();
3
2
  const SSE_DONE_CHUNK = TEXT_ENCODER.encode("data: [DONE]\n\n");
4
3
  const SSE_KEEP_ALIVE_CHUNK = TEXT_ENCODER.encode(": keep-alive\n\n");
@@ -59,13 +58,9 @@ export function toSseStream(src, options = {}) {
59
58
  }
60
59
  const value = result.value;
61
60
  if (value.event === "error" || value.data instanceof Error) {
62
- const error = options.formatError
63
- ? options.formatError(value.data)
64
- : toOpenAIError(value.data);
61
+ const error = options.toError?.(value.data) ?? value.data;
65
62
  controller.enqueue(TEXT_ENCODER.encode(serializeSseFrame({ event: value.event, data: error })));
66
- const openAiError = toOpenAIError(value.data);
67
- const errorStatus = openAiError?.error.type === "invalid_request_error" ? 422 : 502;
68
- done(controller, errorStatus, value.data);
63
+ done(controller, error["status"] ?? 502, value.data);
69
64
  reader.cancel(value.data).catch(() => { });
70
65
  return;
71
66
  }
@@ -74,12 +69,9 @@ export function toSseStream(src, options = {}) {
74
69
  }
75
70
  catch (error) {
76
71
  try {
77
- const errorPayload = options.formatError
78
- ? options.formatError(error)
79
- : toOpenAIError(error);
80
72
  controller.enqueue(TEXT_ENCODER.encode(serializeSseFrame({
81
73
  event: "error",
82
- data: errorPayload,
74
+ data: options.toError?.(error) ?? error,
83
75
  })));
84
76
  }
85
77
  catch { }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hebo-ai/gateway",
3
- "version": "0.10.5",
3
+ "version": "0.10.7",
4
4
  "description": "AI gateway as a framework. For full control over models, routing & lifecycle. OpenAI /chat/completions, OpenResponses /responses & Anthropic /messages.",
5
5
  "keywords": [
6
6
  "ai",