@neutrome/open-ai-router 0.1.3 → 0.1.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neutrome/open-ai-router",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/src/app/cors.ts CHANGED
@@ -4,5 +4,12 @@ export const cors = () =>
4
4
  honoCors({
5
5
  origin: "*",
6
6
  allowMethods: ["GET", "POST", "OPTIONS"],
7
- allowHeaders: ["Content-Type", "Authorization", "X-OpenAIRouter-Token"],
7
+ allowHeaders: [
8
+ "Content-Type",
9
+ "Authorization",
10
+ "X-API-Key",
11
+ "Anthropic-Version",
12
+ "X-Goog-Api-Key",
13
+ "X-OpenAIRouter-Token",
14
+ ],
8
15
  });
@@ -2,7 +2,9 @@ import type { Context } from "hono";
2
2
  import type { Executor, ProgramTransform } from "@neutrome/lil-engine";
3
3
  import type { RequestContext } from "../auth/types.ts";
4
4
  import {
5
+ handleAnthropicMessages,
5
6
  handleChatCompletions,
7
+ handleGoogleGenAI,
6
8
  handleResponses,
7
9
  listConfiguredModels,
8
10
  type RouterExecutionOptions,
@@ -22,6 +24,8 @@ export type ChatCompletionsHandlerOptions = RouterExecutionOptions & {
22
24
  };
23
25
 
24
26
  export type ResponsesHandlerOptions = ChatCompletionsHandlerOptions;
27
+ export type AnthropicMessagesHandlerOptions = ChatCompletionsHandlerOptions;
28
+ export type GoogleGenAIHandlerOptions = ChatCompletionsHandlerOptions;
25
29
 
26
30
  export function listModelsHandler(runtime: RouterRuntime) {
27
31
  return async (c: Context) => {
@@ -97,35 +101,7 @@ export function chatCompletionsHandler(
97
101
  options: ChatCompletionsHandlerOptions = {},
98
102
  ) {
99
103
  return async (c: Context) => {
100
- const ctx = createRequestContext(c);
101
- collectIncomingAuth(ctx, runtime);
102
- const handlerOptions = {
103
- runtime,
104
- ctx,
105
- } as Parameters<typeof handleChatCompletions>[0];
106
-
107
- if (options.executors) {
108
- handlerOptions.executors = options.executors;
109
- }
110
- if (options.resolveExecutor) {
111
- handlerOptions.resolveExecutor = options.resolveExecutor;
112
- }
113
- if (options.transforms) {
114
- handlerOptions.transforms = options.transforms;
115
- }
116
- if (options.observe) {
117
- handlerOptions.observe = options.observe;
118
- }
119
- if (options.requestIdFactory) {
120
- handlerOptions.requestIdFactory = options.requestIdFactory;
121
- }
122
- if (options.executionIdFactory) {
123
- handlerOptions.executionIdFactory = options.executionIdFactory;
124
- }
125
- if (options.upstreamTimeoutMs !== undefined) {
126
- handlerOptions.upstreamTimeoutMs = options.upstreamTimeoutMs;
127
- }
128
-
104
+ const handlerOptions = createExecutionHandlerOptions(c, runtime, options);
129
105
  return handleChatCompletions(handlerOptions);
130
106
  };
131
107
  }
@@ -135,42 +111,86 @@ export function responsesHandler(
135
111
  options: ResponsesHandlerOptions = {},
136
112
  ) {
137
113
  return async (c: Context) => {
138
- const ctx = createRequestContext(c);
139
- collectIncomingAuth(ctx, runtime);
140
- const handlerOptions = {
141
- runtime,
142
- ctx,
143
- } as Parameters<typeof handleResponses>[0];
114
+ const handlerOptions = createExecutionHandlerOptions(c, runtime, options);
115
+ return handleResponses(handlerOptions);
116
+ };
117
+ }
144
118
 
145
- if (options.executors) {
146
- handlerOptions.executors = options.executors;
147
- }
148
- if (options.resolveExecutor) {
149
- handlerOptions.resolveExecutor = options.resolveExecutor;
150
- }
151
- if (options.transforms) {
152
- handlerOptions.transforms = options.transforms;
153
- }
154
- if (options.observe) {
155
- handlerOptions.observe = options.observe;
156
- }
157
- if (options.requestIdFactory) {
158
- handlerOptions.requestIdFactory = options.requestIdFactory;
159
- }
160
- if (options.executionIdFactory) {
161
- handlerOptions.executionIdFactory = options.executionIdFactory;
162
- }
163
- if (options.upstreamTimeoutMs !== undefined) {
164
- handlerOptions.upstreamTimeoutMs = options.upstreamTimeoutMs;
119
+ export function anthropicMessagesHandler(
120
+ runtime: RouterRuntime,
121
+ options: AnthropicMessagesHandlerOptions = {},
122
+ ) {
123
+ return async (c: Context) => {
124
+ const handlerOptions = createExecutionHandlerOptions(c, runtime, options);
125
+ return handleAnthropicMessages(handlerOptions);
126
+ };
127
+ }
128
+
129
+ export function googleGenAIHandler(
130
+ runtime: RouterRuntime,
131
+ options: GoogleGenAIHandlerOptions = {},
132
+ ) {
133
+ return async (c: Context) => {
134
+ const route = parseGoogleGenAIRoute(c.req.raw);
135
+ if (!route) {
136
+ return c.text("Not Found", 404);
165
137
  }
166
138
 
167
- return handleResponses(handlerOptions);
139
+ const request = await rewriteGoogleGenAIRequest(c.req.raw, route);
140
+ const handlerOptions = createExecutionHandlerOptions(
141
+ c,
142
+ runtime,
143
+ options,
144
+ request,
145
+ ) as Parameters<typeof handleGoogleGenAI>[0];
146
+ if (route.stream) {
147
+ handlerOptions.forceStreaming = true;
148
+ }
149
+ return handleGoogleGenAI(handlerOptions);
168
150
  };
169
151
  }
170
152
 
171
- function createRequestContext(c: Context): RequestContext {
153
+ function createExecutionHandlerOptions(
154
+ c: Context,
155
+ runtime: RouterRuntime,
156
+ options: ChatCompletionsHandlerOptions,
157
+ request = c.req.raw,
158
+ ) {
159
+ const ctx = createRequestContext(c, request);
160
+ collectIncomingAuth(ctx, runtime);
161
+ const handlerOptions = {
162
+ runtime,
163
+ ctx,
164
+ } as Parameters<typeof handleChatCompletions>[0];
165
+
166
+ if (options.executors) {
167
+ handlerOptions.executors = options.executors;
168
+ }
169
+ if (options.resolveExecutor) {
170
+ handlerOptions.resolveExecutor = options.resolveExecutor;
171
+ }
172
+ if (options.transforms) {
173
+ handlerOptions.transforms = options.transforms;
174
+ }
175
+ if (options.observe) {
176
+ handlerOptions.observe = options.observe;
177
+ }
178
+ if (options.requestIdFactory) {
179
+ handlerOptions.requestIdFactory = options.requestIdFactory;
180
+ }
181
+ if (options.executionIdFactory) {
182
+ handlerOptions.executionIdFactory = options.executionIdFactory;
183
+ }
184
+ if (options.upstreamTimeoutMs !== undefined) {
185
+ handlerOptions.upstreamTimeoutMs = options.upstreamTimeoutMs;
186
+ }
187
+
188
+ return handlerOptions;
189
+ }
190
+
191
+ function createRequestContext(c: Context, request = c.req.raw): RequestContext {
172
192
  return {
173
- request: c.req.raw,
193
+ request,
174
194
  incomingAuth: new Map(),
175
195
  env: (c.env ?? {}) as Record<string, unknown>,
176
196
  state: new Map(),
@@ -190,3 +210,42 @@ function ownerFromModelId(id: string): string {
190
210
  }
191
211
  return id.slice(0, slash);
192
212
  }
213
+
214
+ function parseGoogleGenAIRoute(request: Request): { model: string; stream: boolean } | null {
215
+ const pathname = new URL(request.url).pathname;
216
+ const match = pathname.match(/^\/v1\/models\/(.+):(generateContent|streamGenerateContent)$/);
217
+ if (!match?.[1] || !match[2]) {
218
+ return null;
219
+ }
220
+
221
+ try {
222
+ return {
223
+ model: decodeURIComponent(match[1]),
224
+ stream: match[2] === "streamGenerateContent",
225
+ };
226
+ } catch {
227
+ return null;
228
+ }
229
+ }
230
+
231
+ async function rewriteGoogleGenAIRequest(
232
+ request: Request,
233
+ route: { model: string; stream: boolean },
234
+ ): Promise<Request> {
235
+ const bodyText = await request.text();
236
+ const headers = new Headers(request.headers);
237
+
238
+ try {
239
+ const raw = JSON.parse(bodyText) as Record<string, unknown>;
240
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
241
+ const body = JSON.stringify({
242
+ ...raw,
243
+ model: route.model,
244
+ ...(route.stream ? { stream: true } : {}),
245
+ });
246
+ return new Request(request.url, { method: request.method, headers, body });
247
+ }
248
+ } catch {}
249
+
250
+ return new Request(request.url, { method: request.method, headers, body: bodyText });
251
+ }
@@ -1,4 +1,9 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ appendAssistantMessage,
4
+ getModel,
5
+ streamTextResponse,
6
+ } from "@neutrome/lil-engine";
2
7
  import { createApp } from "./example.ts";
3
8
 
4
9
  const decoder = new TextDecoder();
@@ -226,4 +231,177 @@ describe("open-ai-router createApp", () => {
226
231
  ],
227
232
  });
228
233
  });
234
+
235
+ it("serves anthropic messages through the app surface", async () => {
236
+ let capturedBody: Record<string, unknown> | null = null;
237
+
238
+ const app = createApp({
239
+ config: {
240
+ providers: {
241
+ openrouter: {
242
+ api_base_url: "https://openrouter.ai/api/v1",
243
+ style: "chat-completions",
244
+ allow_anonymous: true,
245
+ exports: ["gpt-4o"],
246
+ no_prefix: true,
247
+ },
248
+ },
249
+ executors: {},
250
+ },
251
+ fetchImpl: vi.fn(async (_input, init) => {
252
+ capturedBody = JSON.parse(decoder.decode(new Uint8Array(init?.body as ArrayBuffer)));
253
+ return new Response(
254
+ JSON.stringify({
255
+ id: "resp_3",
256
+ model: "gpt-4o",
257
+ choices: [
258
+ {
259
+ index: 0,
260
+ message: { role: "assistant", content: "hello" },
261
+ finish_reason: "stop",
262
+ },
263
+ ],
264
+ }),
265
+ {
266
+ headers: {
267
+ "content-type": "application/json; charset=utf-8",
268
+ },
269
+ },
270
+ );
271
+ }),
272
+ });
273
+
274
+ const response = await app.request("/v1/messages", {
275
+ method: "POST",
276
+ headers: {
277
+ "content-type": "application/json",
278
+ },
279
+ body: JSON.stringify({
280
+ model: "gpt-4o",
281
+ messages: [
282
+ {
283
+ role: "user",
284
+ content: [{ type: "text", text: "hi" }],
285
+ },
286
+ ],
287
+ }),
288
+ });
289
+
290
+ expect(response.status).toBe(200);
291
+ expect(capturedBody).toEqual({
292
+ model: "gpt-4o",
293
+ messages: [{ role: "user", content: "hi" }],
294
+ });
295
+ expect(await response.json()).toEqual({
296
+ id: "resp_3",
297
+ model: "gpt-4o",
298
+ role: "assistant",
299
+ content: [{ type: "text", text: "hello" }],
300
+ stop_reason: "end_turn",
301
+ });
302
+ });
303
+
304
+ it("serves google generateContent routes and decodes namespaced model paths", async () => {
305
+ let capturedModel: string | null = null;
306
+ let executeCalled = false;
307
+
308
+ const app = createApp({
309
+ config: {
310
+ providers: {},
311
+ executors: {
312
+ support: {
313
+ exports: ["*"],
314
+ models: {
315
+ "refund-router": { executor: "support/refund-router" },
316
+ },
317
+ },
318
+ },
319
+ },
320
+ executors: {
321
+ "support/refund-router": {
322
+ async execute(request) {
323
+ executeCalled = true;
324
+ capturedModel = getModel(request) ?? null;
325
+ return appendAssistantMessage(request, "hello");
326
+ },
327
+ async *stream(request) {
328
+ capturedModel = getModel(request) ?? null;
329
+ yield* streamTextResponse("hello");
330
+ },
331
+ },
332
+ },
333
+ });
334
+
335
+ const response = await app.request("/v1/models/support%2Frefund-router:generateContent", {
336
+ method: "POST",
337
+ headers: {
338
+ "content-type": "application/json",
339
+ },
340
+ body: JSON.stringify({
341
+ contents: [{ role: "user", parts: [{ text: "hi" }] }],
342
+ }),
343
+ });
344
+
345
+ expect(response.status).toBe(200);
346
+ expect(executeCalled).toBe(true);
347
+ expect(capturedModel).toBe("support/refund-router");
348
+ expect(await response.json()).toEqual({
349
+ candidates: [
350
+ {
351
+ content: {
352
+ role: "model",
353
+ parts: [{ text: "hello" }],
354
+ },
355
+ },
356
+ ],
357
+ });
358
+ });
359
+
360
+ it("serves google streamGenerateContent routes as event streams", async () => {
361
+ let capturedModel: string | null = null;
362
+
363
+ const app = createApp({
364
+ config: {
365
+ providers: {},
366
+ executors: {
367
+ support: {
368
+ exports: ["*"],
369
+ models: {
370
+ "refund-router": { executor: "support/refund-router" },
371
+ },
372
+ },
373
+ },
374
+ },
375
+ executors: {
376
+ "support/refund-router": {
377
+ async execute(request) {
378
+ capturedModel = getModel(request) ?? null;
379
+ return appendAssistantMessage(request, "hello");
380
+ },
381
+ async *stream(request) {
382
+ capturedModel = getModel(request) ?? null;
383
+ yield* streamTextResponse("hello");
384
+ },
385
+ },
386
+ },
387
+ });
388
+
389
+ const response = await app.request("/v1/models/support%2Frefund-router:streamGenerateContent", {
390
+ method: "POST",
391
+ headers: {
392
+ "content-type": "application/json",
393
+ },
394
+ body: JSON.stringify({
395
+ contents: [{ role: "user", parts: [{ text: "hi" }] }],
396
+ }),
397
+ });
398
+
399
+ expect(response.status).toBe(200);
400
+ expect(capturedModel).toBe("support/refund-router");
401
+ expect(response.headers.get("content-type")).toContain("text/event-stream");
402
+ const body = await response.text();
403
+ expect(body).toContain("\"candidates\"");
404
+ expect(body).toContain("\"text\":\"hello\"");
405
+ expect(body).toContain("\"finishReason\":\"STOP\"");
406
+ });
229
407
  });
package/src/example.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { Hono } from "hono";
2
2
  import { cors } from "./app/cors.ts";
3
3
  import {
4
+ anthropicMessagesHandler,
4
5
  chatCompletionsHandler,
6
+ googleGenAIHandler,
5
7
  listModelsHandler,
6
8
  responsesHandler,
7
9
  type ChatCompletionsHandlerOptions,
@@ -55,6 +57,12 @@ export function createApp(options: CreateAppOptions) {
55
57
  app.use("/v1/responses", cors());
56
58
  app.post("/v1/responses", responsesHandler(runtime, handlerOptions));
57
59
 
60
+ app.use("/v1/messages", cors());
61
+ app.post("/v1/messages", anthropicMessagesHandler(runtime, handlerOptions));
62
+
63
+ app.use("/v1/models/*", cors());
64
+ app.post("/v1/models/*", googleGenAIHandler(runtime, handlerOptions));
65
+
58
66
  app.get("/health", healthHandler());
59
67
  app.all("*", (c) => c.text("Not Found", 404));
60
68
 
package/src/index.ts CHANGED
@@ -5,7 +5,9 @@ export {
5
5
  createFetchProviderInvoker,
6
6
  createRouterExecutionRuntime,
7
7
  createRouterRuntime,
8
+ handleAnthropicMessages,
8
9
  handleChatCompletions,
10
+ handleGoogleGenAI,
9
11
  handleResponses,
10
12
  listConfiguredModels,
11
13
  ProviderHttpError,
@@ -21,7 +23,9 @@ export type {
21
23
  ExecutorRouteConfig,
22
24
  ExecutorRouteRuntime,
23
25
  ExecutorRuntime,
26
+ HandleAnthropicMessagesOptions,
24
27
  HandleChatCompletionsOptions,
28
+ HandleGoogleGenAIOptions,
25
29
  HandleResponsesOptions,
26
30
  ProviderApiStyle,
27
31
  ProviderApiStyle as ProviderStyle,
@@ -46,5 +50,16 @@ export { proxyAuthDriver } from "./auth/proxy.ts";
46
50
 
47
51
  export { cors } from "./app/cors.ts";
48
52
  export { healthHandler } from "./app/health.ts";
49
- export { chatCompletionsHandler, listModelsHandler, responsesHandler } from "./app/handlers.ts";
50
- export type { ChatCompletionsHandlerOptions, ResponsesHandlerOptions } from "./app/handlers.ts";
53
+ export {
54
+ anthropicMessagesHandler,
55
+ chatCompletionsHandler,
56
+ googleGenAIHandler,
57
+ listModelsHandler,
58
+ responsesHandler,
59
+ } from "./app/handlers.ts";
60
+ export type {
61
+ AnthropicMessagesHandlerOptions,
62
+ ChatCompletionsHandlerOptions,
63
+ GoogleGenAIHandlerOptions,
64
+ ResponsesHandlerOptions,
65
+ } from "./app/handlers.ts";
@@ -1,8 +1,7 @@
1
1
  import {
2
2
  emitChatCompletionsResponse,
3
- emitChatCompletionsStreamChunk,
4
- emitProviderResponse,
5
3
  emitProviderRequest,
4
+ emitProviderResponse,
6
5
  emitProviderStreamChunk,
7
6
  getModel,
8
7
  isStreaming,
@@ -11,8 +10,10 @@ import {
11
10
  parseProviderResponse,
12
11
  parseProviderStreamChunk,
13
12
  setModel,
14
- usageObject,
13
+ setStreaming,
15
14
  type Program,
15
+ type ProviderStyle,
16
+ usageObject,
16
17
  } from "@neutrome/lil-engine";
17
18
  import {
18
19
  createExecutionRuntime,
@@ -60,6 +61,17 @@ export type HandleResponsesOptions = RouterExecutionOptions & {
60
61
  ctx: RequestContext;
61
62
  };
62
63
 
64
+ export type HandleAnthropicMessagesOptions = RouterExecutionOptions & {
65
+ runtime: RouterRuntime;
66
+ ctx: RequestContext;
67
+ };
68
+
69
+ export type HandleGoogleGenAIOptions = RouterExecutionOptions & {
70
+ runtime: RouterRuntime;
71
+ ctx: RequestContext;
72
+ forceStreaming?: boolean;
73
+ };
74
+
63
75
  export class RouterError extends Error {
64
76
  constructor(
65
77
  message: string,
@@ -165,10 +177,39 @@ export async function handleChatCompletions(
165
177
 
166
178
  export async function handleResponses(
167
179
  options: HandleResponsesOptions,
180
+ ): Promise<Response> {
181
+ return handleProviderStyle("responses", options);
182
+ }
183
+
184
+ export async function handleAnthropicMessages(
185
+ options: HandleAnthropicMessagesOptions,
186
+ ): Promise<Response> {
187
+ return handleProviderStyle("anthropic-messages", options);
188
+ }
189
+
190
+ export async function handleGoogleGenAI(
191
+ options: HandleGoogleGenAIOptions,
192
+ ): Promise<Response> {
193
+ return handleProviderStyle(
194
+ "google-genai",
195
+ options,
196
+ options.forceStreaming ? (request) => setStreaming(request, true) : undefined,
197
+ );
198
+ }
199
+
200
+ async function handleProviderStyle(
201
+ style: Exclude<ProviderStyle, "chat-completions">,
202
+ options: RouterExecutionOptions & {
203
+ runtime: RouterRuntime;
204
+ ctx: RequestContext;
205
+ },
206
+ mutateRequest?: (request: Program) => Program,
168
207
  ): Promise<Response> {
169
208
  try {
170
209
  const requestBody = new Uint8Array(await options.ctx.request.arrayBuffer());
171
- const request = parseProviderRequest("responses", requestBody);
210
+ const request = mutateRequest
211
+ ? mutateRequest(parseProviderRequest(style, requestBody))
212
+ : parseProviderRequest(style, requestBody);
172
213
  const resolved = resolveRequestTarget(options.runtime, request);
173
214
  const execution = createRouterExecutionRuntime(
174
215
  options.runtime,
@@ -180,7 +221,7 @@ export async function handleResponses(
180
221
  if (isStreaming(request)) {
181
222
  const chunks = execution.stream(request, { target: resolved.target });
182
223
  return await streamExecutionResponse(
183
- "responses",
224
+ style,
184
225
  chunks,
185
226
  resolved,
186
227
  startTime,
@@ -194,7 +235,7 @@ export async function handleResponses(
194
235
  const userTime = Date.now() - startTime;
195
236
  reportUsage(response, resolved, options.onUsage);
196
237
  return jsonExecutionResponse(
197
- emitProviderResponse("responses", response),
238
+ emitProviderResponse(style, response),
198
239
  resolved,
199
240
  userTime,
200
241
  );
@@ -336,7 +377,7 @@ export function createFetchProviderInvoker(
336
377
  }
337
378
 
338
379
  async function streamExecutionResponse(
339
- responseStyle: "chat-completions" | "responses",
380
+ responseStyle: ProviderStyle,
340
381
  chunks: AsyncIterable<Program>,
341
382
  resolved: ResolvedTarget,
342
383
  startTime: number,
@@ -357,10 +398,7 @@ async function streamExecutionResponse(
357
398
  const hasUsage = usageObject(chunk) !== undefined;
358
399
  if (hasUsage) lastChunkWithUsage = chunk;
359
400
  if (hasUsage && !emitUsageToClient) return;
360
- const bytes =
361
- responseStyle === "chat-completions"
362
- ? emitChatCompletionsStreamChunk(chunk)
363
- : emitProviderStreamChunk("responses", chunk);
401
+ const bytes = emitProviderStreamChunk(responseStyle, chunk);
364
402
  controller.enqueue(
365
403
  encoder.encode(`data: ${decoder.decode(bytes)}\n\n`),
366
404
  );
@@ -2,7 +2,9 @@ export { createRouterRuntime } from "./runtime.ts";
2
2
  export {
3
3
  createFetchProviderInvoker,
4
4
  createRouterExecutionRuntime,
5
+ handleAnthropicMessages,
5
6
  handleChatCompletions,
7
+ handleGoogleGenAI,
6
8
  handleResponses,
7
9
  resolveRequestTarget,
8
10
  RouterError,
@@ -25,7 +27,9 @@ export type {
25
27
  RouterRuntime,
26
28
  } from "./runtime.ts";
27
29
  export type {
30
+ HandleAnthropicMessagesOptions,
28
31
  HandleChatCompletionsOptions,
32
+ HandleGoogleGenAIOptions,
29
33
  HandleResponsesOptions,
30
34
  RouterExecutionOptions,
31
35
  UsageReport,