@hebo-ai/gateway 0.3.0 → 0.4.0-alpha.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.
package/README.md CHANGED
@@ -288,11 +288,23 @@ const gw = gateway({
288
288
  * @returns Optional RequestPatch to merge into headers / override body.
289
289
  * Returning a Response stops execution of the endpoint.
290
290
  */
291
- before: async (ctx: { request: Request }): Promise<RequestPatch | Response | void> => {
291
+ onRequest: async (ctx: { request: Request }): Promise<RequestPatch | Response | void> => {
292
292
  // Example Use Cases:
293
- // - Transform request body
294
293
  // - Verify authentication
295
294
  // - Enforce rate limits
295
+ return undefined;
296
+ },
297
+ /**
298
+ * Runs after body is parsed & validated.
299
+ * @param ctx.body Parsed request body.
300
+ * @returns Replacement parsed body, or undefined to keep original body unchanged.
301
+ */
302
+ before: async (ctx: {
303
+ body: ChatCompletionsBody | EmbeddingsBody;
304
+ operation: "text" | "embeddings";
305
+ }): Promise<ChatCompletionsBody | EmbeddingsBody | void> => {
306
+ // Example Use Cases:
307
+ // - Transform request body
296
308
  // - Observability integration
297
309
  return undefined;
298
310
  },
@@ -344,11 +356,22 @@ const gw = gateway({
344
356
  // - Result logging
345
357
  return undefined;
346
358
  },
359
+ /**
360
+ * Runs after the gateway has produced the final Response.
361
+ * @param ctx.response Response object returned by the lifecycle.
362
+ * @returns Replacement response, or undefined to keep original.
363
+ */
364
+ onResponse: async (ctx: { response: Response }): Promise<Response | void> => {
365
+ // Example Use Cases:
366
+ // - Add response headers
367
+ // - Replace or redact response payload
368
+ return undefined;
369
+ },
347
370
  },
348
371
  });
349
372
  ```
350
373
 
351
- The `ctx` object is **readonly for core fields**. Use return values to override request / result and to provide modelId / provider instances.
374
+ The `ctx` object is **readonly for core fields**. Use return values to override request / parsed body / result / response and to provide modelId / provider instances.
352
375
 
353
376
  > [!TIP]
354
377
  > To pass data between hooks, use `ctx.state`. It’s a per-request mutable bag in which you can stash things like auth info, routing decisions, timers, or trace IDs and read them later again in any of the other hooks.
@@ -302,6 +302,7 @@ export class ChatCompletionsStream extends TransformStream {
302
302
  }
303
303
  case "error": {
304
304
  const error = part.error;
305
+ // FUTURE mask in production mode and return responseID
305
306
  controller.enqueue(toOpenAIError(error));
306
307
  break;
307
308
  }
@@ -30,13 +30,14 @@ export const chatCompletions = (config) => {
30
30
  throw new GatewayError(z.prettifyError(parsed.error), 400);
31
31
  }
32
32
  ctx.body = parsed.data;
33
+ ctx.operation = "text";
34
+ ctx.body = (await hooks?.before?.(ctx)) ?? ctx.body;
33
35
  // Resolve model + provider (hooks may override defaults).
34
36
  let inputs, stream;
35
- ({ model: ctx.modelId, stream, ...inputs } = parsed.data);
37
+ ({ model: ctx.modelId, stream, ...inputs } = ctx.body);
36
38
  ctx.resolvedModelId =
37
39
  (await hooks?.resolveModelId?.(ctx)) ?? ctx.modelId;
38
40
  logger.debug(`[chat] resolved ${ctx.modelId} to ${ctx.resolvedModelId}`);
39
- ctx.operation = "text";
40
41
  const override = await hooks?.resolveProvider?.(ctx);
41
42
  ctx.provider =
42
43
  override ??
@@ -79,7 +80,7 @@ export const chatCompletions = (config) => {
79
80
  throw new DOMException("Upstream failed", "AbortError");
80
81
  },
81
82
  timeout: {
82
- chunkMs: 5 * 60 * 1000,
83
+ totalMs: 5 * 60 * 1000,
83
84
  },
84
85
  experimental_include: {
85
86
  requestBody: false,
@@ -88,7 +89,8 @@ export const chatCompletions = (config) => {
88
89
  ...textOptions,
89
90
  });
90
91
  markPerf(ctx.request, "aiSdkEnd");
91
- return toChatCompletionsStream(result, ctx.modelId);
92
+ ctx.result = toChatCompletionsStream(result, ctx.modelId);
93
+ return (await hooks?.after?.(ctx)) ?? ctx.result;
92
94
  }
93
95
  const result = await generateText({
94
96
  model: languageModelWithMiddleware,
@@ -104,7 +106,8 @@ export const chatCompletions = (config) => {
104
106
  });
105
107
  markPerf(ctx.request, "aiSdkEnd");
106
108
  logger.trace({ requestId: resolveRequestId(ctx.request), result }, "[chat] AI SDK result");
107
- return toChatCompletions(result, ctx.modelId);
109
+ ctx.result = toChatCompletions(result, ctx.modelId);
110
+ return (await hooks?.after?.(ctx)) ?? ctx.result;
108
111
  };
109
112
  return { handler: winterCgHandler(handler, config) };
110
113
  };
@@ -30,13 +30,14 @@ export const embeddings = (config) => {
30
30
  throw new GatewayError(z.prettifyError(parsed.error), 400);
31
31
  }
32
32
  ctx.body = parsed.data;
33
+ ctx.operation = "embeddings";
34
+ ctx.body = (await hooks?.before?.(ctx)) ?? ctx.body;
33
35
  // Resolve model + provider (hooks may override defaults).
34
36
  let inputs;
35
- ({ model: ctx.modelId, ...inputs } = parsed.data);
37
+ ({ model: ctx.modelId, ...inputs } = ctx.body);
36
38
  ctx.resolvedModelId =
37
39
  (await hooks?.resolveModelId?.(ctx)) ?? ctx.modelId;
38
40
  logger.debug(`[embeddings] resolved ${ctx.modelId} to ${ctx.resolvedModelId}`);
39
- ctx.operation = "embeddings";
40
41
  const override = await hooks?.resolveProvider?.(ctx);
41
42
  ctx.provider =
42
43
  override ??
@@ -67,7 +68,8 @@ export const embeddings = (config) => {
67
68
  });
68
69
  markPerf(ctx.request, "aiSdkEnd");
69
70
  logger.trace({ requestId: resolveRequestId(ctx.request), result }, "[embeddings] AI SDK result");
70
- return toEmbeddings(result, ctx.modelId);
71
+ ctx.result = toEmbeddings(result, ctx.modelId);
72
+ return (await hooks?.after?.(ctx)) ?? ctx.result;
71
73
  };
72
74
  return { handler: winterCgHandler(handler, config) };
73
75
  };
@@ -27,6 +27,7 @@ export function toOpenAIErrorResponse(error, responseInit) {
27
27
  let message;
28
28
  if (shouldMask) {
29
29
  const requestId = resolveRequestId(responseInit);
30
+ // FUTURE: always attach requestId to errors (masked and unmasked)
30
31
  message = `${STATUS_CODE(meta.status)} (${requestId})`;
31
32
  }
32
33
  else {
package/dist/lifecycle.js CHANGED
@@ -9,23 +9,19 @@ export const winterCgHandler = (run, config) => {
9
9
  const parsedConfig = parseConfig(config);
10
10
  const core = async (ctx) => {
11
11
  try {
12
- const before = await parsedConfig.hooks?.before?.(ctx);
13
- if (before) {
14
- if (before instanceof Response) {
15
- ctx.response = before;
12
+ const onRequest = await parsedConfig.hooks?.onRequest?.(ctx);
13
+ if (onRequest) {
14
+ if (onRequest instanceof Response) {
15
+ ctx.response = onRequest;
16
16
  return;
17
17
  }
18
- ctx.request = maybeApplyRequestPatch(ctx.request, before);
18
+ ctx.request = maybeApplyRequestPatch(ctx.request, onRequest);
19
19
  }
20
20
  ctx.result = await run(ctx);
21
- const after = await parsedConfig.hooks?.after?.(ctx);
22
- if (after)
23
- ctx.result = after;
24
- if (ctx.result instanceof Response) {
25
- ctx.response = ctx.result;
26
- return;
27
- }
28
21
  ctx.response = toResponse(ctx.result, prepareResponseInit(ctx.request));
22
+ const onResponse = await parsedConfig.hooks?.onResponse?.(ctx);
23
+ if (onResponse)
24
+ ctx.response = onResponse;
29
25
  }
30
26
  catch (error) {
31
27
  logger.error({
package/dist/types.d.ts CHANGED
@@ -5,7 +5,7 @@ import type { Logger, LoggerConfig } from "./logger";
5
5
  import type { ModelCatalog, ModelId } from "./models/types";
6
6
  import type { ProviderId, ProviderRegistry } from "./providers/types";
7
7
  /**
8
- * Request overrides returned from the `before` hook.
8
+ * Request overrides returned from the `onRequest` hook.
9
9
  */
10
10
  export type RequestPatch = {
11
11
  /**
@@ -77,10 +77,12 @@ export type HookContext = Omit<Readonly<GatewayContext>, "state"> & {
77
77
  state: GatewayContext["state"];
78
78
  };
79
79
  type RequiredHookContext<K extends keyof GatewayContext> = Omit<HookContext, K> & Required<Pick<HookContext, K>>;
80
- export type BeforeHookContext = RequiredHookContext<"request">;
80
+ export type OnRequestHookContext = RequiredHookContext<"request">;
81
+ export type BeforeHookContext = RequiredHookContext<"request" | "body" | "operation">;
81
82
  export type ResolveModelHookContext = RequiredHookContext<"request" | "body" | "modelId">;
82
83
  export type ResolveProviderHookContext = RequiredHookContext<"request" | "body" | "modelId" | "resolvedModelId" | "operation">;
83
84
  export type AfterHookContext = RequiredHookContext<"request" | "result" | "provider" | "resolvedModelId" | "operation">;
85
+ export type OnResponseHookContext = RequiredHookContext<"request" | "response">;
84
86
  /**
85
87
  * Hooks to plugin to the gateway lifecycle.
86
88
  */
@@ -90,7 +92,12 @@ export type GatewayHooks = {
90
92
  * @returns Optional RequestPatch to merge into headers / override body,
91
93
  * or Response to short-circuit the request.
92
94
  */
93
- before?: (ctx: BeforeHookContext) => void | RequestPatch | Response | Promise<void | RequestPatch | Response>;
95
+ onRequest?: (ctx: OnRequestHookContext) => void | RequestPatch | Response | Promise<void | RequestPatch | Response>;
96
+ /**
97
+ * Runs after request JSON is parsed and validated for chat completions / embeddings.
98
+ * @returns Replacement parsed body, or undefined to keep original.
99
+ */
100
+ before?: (ctx: BeforeHookContext) => void | ChatCompletionsBody | EmbeddingsBody | Promise<void | ChatCompletionsBody | EmbeddingsBody>;
94
101
  /**
95
102
  * Maps a user-provided model ID or alias to a canonical ID.
96
103
  * @returns Canonical model ID or undefined to keep original.
@@ -103,9 +110,14 @@ export type GatewayHooks = {
103
110
  resolveProvider?: (ctx: ResolveProviderHookContext) => ProviderV3 | void | Promise<ProviderV3 | void>;
104
111
  /**
105
112
  * Runs after the endpoint handler.
106
- * @returns Response to replace, or undefined to keep original.
113
+ * @returns Result to replace, or undefined to keep original.
107
114
  */
108
115
  after?: (ctx: AfterHookContext) => void | object | ReadableStream<Uint8Array> | Promise<void | object | ReadableStream<Uint8Array>>;
116
+ /**
117
+ * Runs after the lifecycle has produced the final Response.
118
+ * @returns Replacement Response, or undefined to keep original.
119
+ */
120
+ onResponse?: (ctx: OnResponseHookContext) => void | Response | Promise<void | Response>;
109
121
  };
110
122
  /**
111
123
  * Main configuration object for the gateway.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hebo-ai/gateway",
3
- "version": "0.3.0",
3
+ "version": "0.4.0-alpha.0",
4
4
  "description": "AI gateway as a framework. For full control over models, routing & lifecycle. OpenAI-compatible /chat/completions, /embeddings & /models.",
5
5
  "keywords": [
6
6
  "ai",
@@ -476,6 +476,7 @@ export class ChatCompletionsStream extends TransformStream<
476
476
 
477
477
  case "error": {
478
478
  const error = part.error;
479
+ // FUTURE mask in production mode and return responseID
479
480
  controller.enqueue(toOpenAIError(error));
480
481
  break;
481
482
  }
@@ -2,6 +2,8 @@ import { generateText, streamText, wrapLanguageModel } from "ai";
2
2
  import * as z from "zod/mini";
3
3
 
4
4
  import type {
5
+ AfterHookContext,
6
+ BeforeHookContext,
5
7
  GatewayConfig,
6
8
  Endpoint,
7
9
  GatewayContext,
@@ -43,15 +45,17 @@ export const chatCompletions = (config: GatewayConfig): Endpoint => {
43
45
  }
44
46
  ctx.body = parsed.data;
45
47
 
48
+ ctx.operation = "text";
49
+ ctx.body = (await hooks?.before?.(ctx as BeforeHookContext)) ?? ctx.body;
50
+
46
51
  // Resolve model + provider (hooks may override defaults).
47
52
  let inputs, stream;
48
- ({ model: ctx.modelId, stream, ...inputs } = parsed.data);
53
+ ({ model: ctx.modelId, stream, ...inputs } = ctx.body);
49
54
 
50
55
  ctx.resolvedModelId =
51
56
  (await hooks?.resolveModelId?.(ctx as ResolveModelHookContext)) ?? ctx.modelId;
52
57
  logger.debug(`[chat] resolved ${ctx.modelId} to ${ctx.resolvedModelId}`);
53
58
 
54
- ctx.operation = "text";
55
59
  const override = await hooks?.resolveProvider?.(ctx as ResolveProviderHookContext);
56
60
  ctx.provider =
57
61
  override ??
@@ -101,7 +105,7 @@ export const chatCompletions = (config: GatewayConfig): Endpoint => {
101
105
  throw new DOMException("Upstream failed", "AbortError");
102
106
  },
103
107
  timeout: {
104
- chunkMs: 5 * 60 * 1000,
108
+ totalMs: 5 * 60 * 1000,
105
109
  },
106
110
  experimental_include: {
107
111
  requestBody: false,
@@ -111,7 +115,9 @@ export const chatCompletions = (config: GatewayConfig): Endpoint => {
111
115
  });
112
116
  markPerf(ctx.request, "aiSdkEnd");
113
117
 
114
- return toChatCompletionsStream(result, ctx.modelId);
118
+ ctx.result = toChatCompletionsStream(result, ctx.modelId);
119
+
120
+ return (await hooks?.after?.(ctx as AfterHookContext)) ?? ctx.result;
115
121
  }
116
122
 
117
123
  const result = await generateText({
@@ -130,7 +136,9 @@ export const chatCompletions = (config: GatewayConfig): Endpoint => {
130
136
 
131
137
  logger.trace({ requestId: resolveRequestId(ctx.request), result }, "[chat] AI SDK result");
132
138
 
133
- return toChatCompletions(result, ctx.modelId);
139
+ ctx.result = toChatCompletions(result, ctx.modelId);
140
+
141
+ return (await hooks?.after?.(ctx as AfterHookContext)) ?? ctx.result;
134
142
  };
135
143
 
136
144
  return { handler: winterCgHandler(handler, config) };
@@ -2,6 +2,8 @@ import { embedMany, wrapEmbeddingModel } from "ai";
2
2
  import * as z from "zod/mini";
3
3
 
4
4
  import type {
5
+ AfterHookContext,
6
+ BeforeHookContext,
5
7
  GatewayConfig,
6
8
  Endpoint,
7
9
  GatewayContext,
@@ -43,15 +45,17 @@ export const embeddings = (config: GatewayConfig): Endpoint => {
43
45
  }
44
46
  ctx.body = parsed.data;
45
47
 
48
+ ctx.operation = "embeddings";
49
+ ctx.body = (await hooks?.before?.(ctx as BeforeHookContext)) ?? ctx.body;
50
+
46
51
  // Resolve model + provider (hooks may override defaults).
47
52
  let inputs;
48
- ({ model: ctx.modelId, ...inputs } = parsed.data);
53
+ ({ model: ctx.modelId, ...inputs } = ctx.body);
49
54
 
50
55
  ctx.resolvedModelId =
51
56
  (await hooks?.resolveModelId?.(ctx as ResolveModelHookContext)) ?? ctx.modelId;
52
57
  logger.debug(`[embeddings] resolved ${ctx.modelId} to ${ctx.resolvedModelId}`);
53
58
 
54
- ctx.operation = "embeddings";
55
59
  const override = await hooks?.resolveProvider?.(ctx as ResolveProviderHookContext);
56
60
  ctx.provider =
57
61
  override ??
@@ -94,7 +98,9 @@ export const embeddings = (config: GatewayConfig): Endpoint => {
94
98
  "[embeddings] AI SDK result",
95
99
  );
96
100
 
97
- return toEmbeddings(result, ctx.modelId);
101
+ ctx.result = toEmbeddings(result, ctx.modelId);
102
+
103
+ return (await hooks?.after?.(ctx as AfterHookContext)) ?? ctx.result;
98
104
  };
99
105
 
100
106
  return { handler: winterCgHandler(handler, config) };
@@ -35,6 +35,7 @@ export function toOpenAIErrorResponse(error: unknown, responseInit?: ResponseIni
35
35
  let message;
36
36
  if (shouldMask) {
37
37
  const requestId = resolveRequestId(responseInit);
38
+ // FUTURE: always attach requestId to errors (masked and unmasked)
38
39
  message = `${STATUS_CODE(meta.status)} (${requestId})`;
39
40
  } else {
40
41
  message = meta.message;
package/src/lifecycle.ts CHANGED
@@ -1,4 +1,9 @@
1
- import type { AfterHookContext, BeforeHookContext, GatewayConfig, GatewayContext } from "./types";
1
+ import type {
2
+ GatewayConfig,
3
+ GatewayContext,
4
+ OnRequestHookContext,
5
+ OnResponseHookContext,
6
+ } from "./types";
2
7
 
3
8
  import { parseConfig } from "./config";
4
9
  import { toOpenAIErrorResponse } from "./errors/openai";
@@ -16,25 +21,20 @@ export const winterCgHandler = (
16
21
 
17
22
  const core = async (ctx: GatewayContext): Promise<void> => {
18
23
  try {
19
- const before = await parsedConfig.hooks?.before?.(ctx as BeforeHookContext);
20
- if (before) {
21
- if (before instanceof Response) {
22
- ctx.response = before;
24
+ const onRequest = await parsedConfig.hooks?.onRequest?.(ctx as OnRequestHookContext);
25
+ if (onRequest) {
26
+ if (onRequest instanceof Response) {
27
+ ctx.response = onRequest;
23
28
  return;
24
29
  }
25
- ctx.request = maybeApplyRequestPatch(ctx.request, before);
30
+ ctx.request = maybeApplyRequestPatch(ctx.request, onRequest);
26
31
  }
27
32
 
28
33
  ctx.result = await run(ctx);
29
-
30
- const after = await parsedConfig.hooks?.after?.(ctx as AfterHookContext);
31
- if (after) ctx.result = after;
32
-
33
- if (ctx.result instanceof Response) {
34
- ctx.response = ctx.result;
35
- return;
36
- }
37
34
  ctx.response = toResponse(ctx.result, prepareResponseInit(ctx.request));
35
+
36
+ const onResponse = await parsedConfig.hooks?.onResponse?.(ctx as OnResponseHookContext);
37
+ if (onResponse) ctx.response = onResponse;
38
38
  } catch (error) {
39
39
  logger.error({
40
40
  requestId: resolveRequestId(ctx.request)!,
package/src/types.ts CHANGED
@@ -7,7 +7,7 @@ import type { ModelCatalog, ModelId } from "./models/types";
7
7
  import type { ProviderId, ProviderRegistry } from "./providers/types";
8
8
 
9
9
  /**
10
- * Request overrides returned from the `before` hook.
10
+ * Request overrides returned from the `onRequest` hook.
11
11
  */
12
12
  export type RequestPatch = {
13
13
  /**
@@ -83,7 +83,8 @@ export type HookContext = Omit<Readonly<GatewayContext>, "state"> & {
83
83
 
84
84
  type RequiredHookContext<K extends keyof GatewayContext> = Omit<HookContext, K> &
85
85
  Required<Pick<HookContext, K>>;
86
- export type BeforeHookContext = RequiredHookContext<"request">;
86
+ export type OnRequestHookContext = RequiredHookContext<"request">;
87
+ export type BeforeHookContext = RequiredHookContext<"request" | "body" | "operation">;
87
88
  export type ResolveModelHookContext = RequiredHookContext<"request" | "body" | "modelId">;
88
89
  export type ResolveProviderHookContext = RequiredHookContext<
89
90
  "request" | "body" | "modelId" | "resolvedModelId" | "operation"
@@ -91,6 +92,7 @@ export type ResolveProviderHookContext = RequiredHookContext<
91
92
  export type AfterHookContext = RequiredHookContext<
92
93
  "request" | "result" | "provider" | "resolvedModelId" | "operation"
93
94
  >;
95
+ export type OnResponseHookContext = RequiredHookContext<"request" | "response">;
94
96
 
95
97
  /**
96
98
  * Hooks to plugin to the gateway lifecycle.
@@ -101,9 +103,20 @@ export type GatewayHooks = {
101
103
  * @returns Optional RequestPatch to merge into headers / override body,
102
104
  * or Response to short-circuit the request.
103
105
  */
106
+ onRequest?: (
107
+ ctx: OnRequestHookContext,
108
+ ) => void | RequestPatch | Response | Promise<void | RequestPatch | Response>;
109
+ /**
110
+ * Runs after request JSON is parsed and validated for chat completions / embeddings.
111
+ * @returns Replacement parsed body, or undefined to keep original.
112
+ */
104
113
  before?: (
105
114
  ctx: BeforeHookContext,
106
- ) => void | RequestPatch | Response | Promise<void | RequestPatch | Response>;
115
+ ) =>
116
+ | void
117
+ | ChatCompletionsBody
118
+ | EmbeddingsBody
119
+ | Promise<void | ChatCompletionsBody | EmbeddingsBody>;
107
120
  /**
108
121
  * Maps a user-provided model ID or alias to a canonical ID.
109
122
  * @returns Canonical model ID or undefined to keep original.
@@ -118,7 +131,7 @@ export type GatewayHooks = {
118
131
  ) => ProviderV3 | void | Promise<ProviderV3 | void>;
119
132
  /**
120
133
  * Runs after the endpoint handler.
121
- * @returns Response to replace, or undefined to keep original.
134
+ * @returns Result to replace, or undefined to keep original.
122
135
  */
123
136
  after?: (
124
137
  ctx: AfterHookContext,
@@ -127,6 +140,11 @@ export type GatewayHooks = {
127
140
  | object
128
141
  | ReadableStream<Uint8Array>
129
142
  | Promise<void | object | ReadableStream<Uint8Array>>;
143
+ /**
144
+ * Runs after the lifecycle has produced the final Response.
145
+ * @returns Replacement Response, or undefined to keep original.
146
+ */
147
+ onResponse?: (ctx: OnResponseHookContext) => void | Response | Promise<void | Response>;
130
148
  };
131
149
 
132
150
  /**