@neutrome/open-ai-router 0.4.2 → 0.5.1
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/.dev.vars.example +2 -0
- package/README.md +5 -1
- package/package.json +5 -4
- package/src/admission/access.ts +0 -1
- package/src/admission/types.ts +0 -1
- package/src/app/factory.test.ts +40 -34
- package/src/app/factory.ts +120 -173
- package/src/app/google-genai-route.ts +48 -0
- package/src/app/http-utils.ts +14 -0
- package/src/app/model-listing.ts +29 -0
- package/src/app/response-lifecycle.ts +66 -0
- package/src/app/types.ts +38 -0
- package/src/index.ts +11 -43
- package/src/otlp.ts +155 -0
- package/src/router/audit.ts +5 -12
- package/src/router/config.ts +4 -4
- package/src/router/error-codec.ts +170 -0
- package/src/router/errors.ts +38 -1
- package/src/router/execute.test.ts +177 -107
- package/src/router/execute.ts +68 -924
- package/src/router/execution-events.ts +39 -0
- package/src/router/execution-invocation.ts +144 -0
- package/src/router/execution-observation.ts +161 -0
- package/src/router/execution-resolution.ts +120 -0
- package/src/router/execution-runtime.test.ts +156 -83
- package/src/router/execution-runtime.ts +144 -542
- package/src/router/execution-transforms.ts +82 -0
- package/src/router/execution-types.ts +8 -19
- package/src/router/index.ts +6 -5
- package/src/router/mcp.ts +12 -6
- package/src/router/provider-invoker.ts +134 -0
- package/src/router/provider-stream.ts +192 -0
- package/src/router/provider-telemetry.ts +59 -0
- package/src/router/remote-mcp-execution.ts +61 -0
- package/src/router/request-program.ts +16 -0
- package/src/router/request-target.ts +56 -0
- package/src/router/resolve.test.ts +43 -10
- package/src/router/resolve.ts +50 -28
- package/src/router/response-delivery.ts +141 -0
- package/src/router/runtime.test.ts +56 -0
- package/src/router/runtime.ts +79 -9
- package/src/router/targets.ts +3 -1
- package/src/router/upstream-client.ts +137 -0
- package/src/telemetry.test.ts +78 -0
- package/src/telemetry.ts +316 -0
- package/src/trace-labels.ts +29 -0
- package/src/upstream-auth/env.ts +15 -3
- package/src/upstream-auth/proxy.ts +6 -1
- package/src/upstream-auth/registry.ts +3 -1
- package/src/util/glob.ts +1 -1
- package/src/util/model-syntax.ts +3 -3
- package/src/worker.ts +12 -6
- package/worker-configuration.d.ts +195 -164
- package/pnpm-workspace.yaml +0 -4
- package/src/admission/jwt.test.ts +0 -96
- package/src/admission/jwt.ts +0 -221
- package/src/app/handlers.ts +0 -198
- package/src/example.test.ts +0 -377
- package/src/example.ts +0 -73
- package/src/observer.test.ts +0 -54
- package/src/observer.ts +0 -315
- package/src/timing.ts +0 -36
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { createOpenAiErrorResponse } from "../router/errors.ts";
|
|
3
|
+
|
|
4
|
+
export function honoEnv(context: Context): Record<string, unknown> {
|
|
5
|
+
return (context.env ?? {}) as Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function unauthorizedResponse(): Response {
|
|
9
|
+
return createOpenAiErrorResponse(
|
|
10
|
+
"Authentication required",
|
|
11
|
+
401,
|
|
12
|
+
"auth_required",
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { canAccessPrincipalModel } from "../admission/access.ts";
|
|
2
|
+
import type { RuntimeAuthenticator } from "../admission/types.ts";
|
|
3
|
+
import { createOpenAiErrorResponse } from "../router/errors.ts";
|
|
4
|
+
import { listConfiguredModels } from "../router/index.ts";
|
|
5
|
+
|
|
6
|
+
export function createModelListingHandler(options: {
|
|
7
|
+
runtime: Parameters<typeof listConfiguredModels>[0];
|
|
8
|
+
auth: RuntimeAuthenticator;
|
|
9
|
+
}) {
|
|
10
|
+
return async (request: Request, env: Record<string, unknown>) => {
|
|
11
|
+
const principal = await options.auth.authenticate(request, env);
|
|
12
|
+
if (!principal) {
|
|
13
|
+
return createOpenAiErrorResponse(
|
|
14
|
+
"Authentication required",
|
|
15
|
+
401,
|
|
16
|
+
"auth_required",
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
const data = listConfiguredModels(options.runtime)
|
|
20
|
+
.filter((id) => canAccessPrincipalModel(principal, id))
|
|
21
|
+
.map((id) => ({
|
|
22
|
+
id,
|
|
23
|
+
object: "model" as const,
|
|
24
|
+
created: 0,
|
|
25
|
+
owned_by: id.split("/")[0] || "unknown",
|
|
26
|
+
}));
|
|
27
|
+
return { object: "list" as const, data };
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
|
|
3
|
+
export function isEventStreamResponse(response: Response): boolean {
|
|
4
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
5
|
+
return contentType.startsWith("text/event-stream");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function runBackgroundTask(
|
|
9
|
+
context: Context,
|
|
10
|
+
task: () => unknown,
|
|
11
|
+
label: string,
|
|
12
|
+
): void {
|
|
13
|
+
const tracked = Promise.resolve()
|
|
14
|
+
.then(task)
|
|
15
|
+
.catch((error) => {
|
|
16
|
+
console.error(`[open-ai-router] ${label} failed:`, error);
|
|
17
|
+
});
|
|
18
|
+
let executionContext:
|
|
19
|
+
| { waitUntil?(promise: Promise<unknown>): void }
|
|
20
|
+
| undefined;
|
|
21
|
+
try {
|
|
22
|
+
executionContext = context.executionCtx as
|
|
23
|
+
| { waitUntil?(promise: Promise<unknown>): void }
|
|
24
|
+
| undefined;
|
|
25
|
+
} catch {
|
|
26
|
+
executionContext = undefined;
|
|
27
|
+
}
|
|
28
|
+
if (executionContext?.waitUntil) {
|
|
29
|
+
executionContext.waitUntil(tracked);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
void tracked;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function finalizeWhenStreamCloses(
|
|
36
|
+
response: Response,
|
|
37
|
+
finalize: () => void,
|
|
38
|
+
): Response {
|
|
39
|
+
if (!response.body) return response;
|
|
40
|
+
const reader = response.body.getReader();
|
|
41
|
+
const body = new ReadableStream<Uint8Array>({
|
|
42
|
+
async pull(controller) {
|
|
43
|
+
try {
|
|
44
|
+
const { done, value } = await reader.read();
|
|
45
|
+
if (done) {
|
|
46
|
+
finalize();
|
|
47
|
+
controller.close();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
controller.enqueue(value);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
finalize();
|
|
53
|
+
controller.error(error);
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
async cancel(reason) {
|
|
57
|
+
finalize();
|
|
58
|
+
await reader.cancel(reason).catch(() => {});
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
return new Response(body, {
|
|
62
|
+
status: response.status,
|
|
63
|
+
statusText: response.statusText,
|
|
64
|
+
headers: new Headers(response.headers),
|
|
65
|
+
});
|
|
66
|
+
}
|
package/src/app/types.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Executor, ProgramTransform } from "@neutrome/lilsdk-ts";
|
|
2
|
+
import type {
|
|
3
|
+
AuthPrincipal,
|
|
4
|
+
RuntimeAuthenticator,
|
|
5
|
+
} from "../admission/types.ts";
|
|
6
|
+
import type { ExecutionSummary } from "../telemetry.ts";
|
|
7
|
+
import type { RouterConfig } from "../router/config.ts";
|
|
8
|
+
import type { ExecutionRuntimeOptions } from "../router/execution-types.ts";
|
|
9
|
+
import type { RemoteMcpClientFactory } from "../router/mcp.ts";
|
|
10
|
+
|
|
11
|
+
export type RouterAppAuth = RuntimeAuthenticator;
|
|
12
|
+
|
|
13
|
+
export type RouterAppHooks = {
|
|
14
|
+
beforeRequest?(ctx: {
|
|
15
|
+
request: Request;
|
|
16
|
+
model: string | null;
|
|
17
|
+
principal: AuthPrincipal;
|
|
18
|
+
env: Record<string, unknown>;
|
|
19
|
+
}): Promise<Response | void>;
|
|
20
|
+
onExecutionSummary?(
|
|
21
|
+
summary: ExecutionSummary,
|
|
22
|
+
env: Record<string, unknown>,
|
|
23
|
+
): void | Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type RouterAppOptions = {
|
|
27
|
+
config: RouterConfig;
|
|
28
|
+
executorImplementations: Readonly<Record<string, Executor>>;
|
|
29
|
+
transforms?: Readonly<Record<string, ProgramTransform>>;
|
|
30
|
+
auth: RouterAppAuth;
|
|
31
|
+
hooks?: RouterAppHooks;
|
|
32
|
+
fetchImpl?: typeof fetch;
|
|
33
|
+
observe?: ExecutionRuntimeOptions["observe"];
|
|
34
|
+
requestIdFactory?: ExecutionRuntimeOptions["requestIdFactory"];
|
|
35
|
+
executionIdFactory?: ExecutionRuntimeOptions["executionIdFactory"];
|
|
36
|
+
upstreamTimeoutMs?: number;
|
|
37
|
+
remoteMcpClientFactory?: RemoteMcpClientFactory;
|
|
38
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,24 +1,21 @@
|
|
|
1
1
|
export { createRouterApp } from "./app/factory.ts";
|
|
2
|
-
export type { RouterAppAuth, RouterAppHooks, RouterAppOptions } from "./app/factory.ts";
|
|
3
|
-
export {
|
|
4
|
-
createTraceCollector,
|
|
5
|
-
renderProgramAsLil,
|
|
6
|
-
} from "./observer.ts";
|
|
7
2
|
export type {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
} from "./
|
|
12
|
-
|
|
13
|
-
export {
|
|
14
|
-
|
|
3
|
+
RouterAppAuth,
|
|
4
|
+
RouterAppHooks,
|
|
5
|
+
RouterAppOptions,
|
|
6
|
+
} from "./app/types.ts";
|
|
7
|
+
export { createRouterTelemetry } from "./telemetry.ts";
|
|
8
|
+
export type {
|
|
9
|
+
ExecutionSummary,
|
|
10
|
+
ExecutionSummarySpan,
|
|
11
|
+
RouterTelemetry,
|
|
12
|
+
} from "./telemetry.ts";
|
|
15
13
|
|
|
16
14
|
export {
|
|
17
15
|
createFetchProviderInvoker,
|
|
18
16
|
createExecutionRuntime,
|
|
19
17
|
createRouterExecutionRuntime,
|
|
20
18
|
createRouterRuntime,
|
|
21
|
-
createAuditEvent,
|
|
22
19
|
createInMemoryAuditSink,
|
|
23
20
|
ExecutionError,
|
|
24
21
|
handleAnthropicMessages,
|
|
@@ -36,8 +33,6 @@ export {
|
|
|
36
33
|
executionTargetId,
|
|
37
34
|
} from "./router/index.ts";
|
|
38
35
|
export type {
|
|
39
|
-
AuditEvent,
|
|
40
|
-
AuditEventInput,
|
|
41
36
|
CreateRouterOptions,
|
|
42
37
|
ExecutionErrorDetails,
|
|
43
38
|
ExecutionErrorKind,
|
|
@@ -93,32 +88,5 @@ export type {
|
|
|
93
88
|
export { buildUpstreamAuthChain } from "./upstream-auth/registry.ts";
|
|
94
89
|
export { envAuthDriver } from "./upstream-auth/env.ts";
|
|
95
90
|
export { proxyAuthDriver } from "./upstream-auth/proxy.ts";
|
|
96
|
-
export {
|
|
97
|
-
createRuntimeJwtAuthenticator,
|
|
98
|
-
DEFAULT_RUNTIME_AUTH_AUDIENCE,
|
|
99
|
-
DEFAULT_RUNTIME_AUTH_ISSUER,
|
|
100
|
-
readRuntimeToken,
|
|
101
|
-
verifyRuntimeJwt,
|
|
102
|
-
} from "./admission/jwt.ts";
|
|
103
91
|
export { canAccessPrincipalModel } from "./admission/access.ts";
|
|
104
|
-
export type {
|
|
105
|
-
AuthPrincipal,
|
|
106
|
-
RuntimeAuthenticator,
|
|
107
|
-
} from "./admission/types.ts";
|
|
108
|
-
export type { RuntimeJwtAuthConfig } from "./admission/jwt.ts";
|
|
109
|
-
|
|
110
|
-
export { cors } from "./app/cors.ts";
|
|
111
|
-
export { healthHandler } from "./app/health.ts";
|
|
112
|
-
export {
|
|
113
|
-
anthropicMessagesHandler,
|
|
114
|
-
chatCompletionsHandler,
|
|
115
|
-
googleGenAIHandler,
|
|
116
|
-
listModelsHandler,
|
|
117
|
-
responsesHandler,
|
|
118
|
-
} from "./app/handlers.ts";
|
|
119
|
-
export type {
|
|
120
|
-
AnthropicMessagesHandlerOptions,
|
|
121
|
-
ChatCompletionsHandlerOptions,
|
|
122
|
-
GoogleGenAIHandlerOptions,
|
|
123
|
-
ResponsesHandlerOptions,
|
|
124
|
-
} from "./app/handlers.ts";
|
|
92
|
+
export type { AuthPrincipal, RuntimeAuthenticator } from "./admission/types.ts";
|
package/src/otlp.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
export type OtlpSpan = {
|
|
2
|
+
traceId: string;
|
|
3
|
+
spanId: string;
|
|
4
|
+
parentSpanId?: string;
|
|
5
|
+
name: string;
|
|
6
|
+
startTimeUnixNano: string;
|
|
7
|
+
endTimeUnixNano: string;
|
|
8
|
+
attributes: OtlpAttribute[];
|
|
9
|
+
status?: { code: number; message?: string };
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type Environment = Record<string, unknown>;
|
|
13
|
+
type OtlpAttribute = {
|
|
14
|
+
key: string;
|
|
15
|
+
value: { stringValue?: string; intValue?: string; boolValue?: boolean };
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export async function exportOtlp(
|
|
19
|
+
env: Environment,
|
|
20
|
+
signal: "traces" | "errors",
|
|
21
|
+
spans: OtlpSpan[],
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
if (spans.length === 0) return;
|
|
24
|
+
const endpoint = endpointFor(env, signal);
|
|
25
|
+
if (!endpoint) return;
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch(endpoint, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: {
|
|
30
|
+
"content-type": "application/json",
|
|
31
|
+
...headersFor(env, signal),
|
|
32
|
+
},
|
|
33
|
+
body: JSON.stringify({
|
|
34
|
+
resourceSpans: [
|
|
35
|
+
{
|
|
36
|
+
resource: {
|
|
37
|
+
attributes: attributes({
|
|
38
|
+
"service.name":
|
|
39
|
+
stringEnv(env, "OTEL_SERVICE_NAME") ?? "neutrome-router",
|
|
40
|
+
}),
|
|
41
|
+
},
|
|
42
|
+
scopeSpans: [
|
|
43
|
+
{ scope: { name: "@neutrome/open-ai-router" }, spans },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
console.error("[telemetry] OTLP export rejected", {
|
|
51
|
+
signal,
|
|
52
|
+
status: response.status,
|
|
53
|
+
statusText: response.statusText,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
} catch (error) {
|
|
57
|
+
console.error("[telemetry] OTLP export failed", error);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function rootOtlpSpan(input: {
|
|
62
|
+
traceId: string;
|
|
63
|
+
spanId: string;
|
|
64
|
+
startedAt: string;
|
|
65
|
+
finishedAt: string;
|
|
66
|
+
error?: string;
|
|
67
|
+
}): OtlpSpan {
|
|
68
|
+
return {
|
|
69
|
+
traceId: input.traceId,
|
|
70
|
+
spanId: input.spanId,
|
|
71
|
+
name: "router.request",
|
|
72
|
+
startTimeUnixNano: unixNanos(input.startedAt),
|
|
73
|
+
endTimeUnixNano: unixNanos(input.finishedAt),
|
|
74
|
+
attributes: [],
|
|
75
|
+
...(input.error ? { status: { code: 2, message: input.error } } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function attributes(values: Record<string, unknown>): OtlpAttribute[] {
|
|
80
|
+
const result: OtlpAttribute[] = [];
|
|
81
|
+
for (const [key, value] of Object.entries(values)) {
|
|
82
|
+
if (typeof value === "string")
|
|
83
|
+
result.push({ key, value: { stringValue: value } });
|
|
84
|
+
else if (typeof value === "boolean")
|
|
85
|
+
result.push({ key, value: { boolValue: value } });
|
|
86
|
+
else if (typeof value === "number" && Number.isFinite(value))
|
|
87
|
+
result.push({ key, value: { intValue: String(Math.trunc(value)) } });
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function unixNanos(timestamp: string): string {
|
|
93
|
+
return `${Date.parse(timestamp)}000000`;
|
|
94
|
+
}
|
|
95
|
+
export function randomHex(bytes: number): string {
|
|
96
|
+
return [...crypto.getRandomValues(new Uint8Array(bytes))]
|
|
97
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
98
|
+
.join("");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function endpointFor(
|
|
102
|
+
env: Environment,
|
|
103
|
+
signal: "traces" | "errors",
|
|
104
|
+
): string | undefined {
|
|
105
|
+
const endpoint = stringEnv(
|
|
106
|
+
env,
|
|
107
|
+
signal === "traces"
|
|
108
|
+
? "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"
|
|
109
|
+
: "OTEL_EXPORTER_OTLP_ERRORS_ENDPOINT",
|
|
110
|
+
);
|
|
111
|
+
if (endpoint) return endpoint;
|
|
112
|
+
const base = stringEnv(env, "OTEL_EXPORTER_OTLP_ENDPOINT");
|
|
113
|
+
return base
|
|
114
|
+
? new URL(
|
|
115
|
+
"v1/traces",
|
|
116
|
+
`${base.endsWith("/") ? base : `${base}/`}`,
|
|
117
|
+
).toString()
|
|
118
|
+
: undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function headersFor(
|
|
122
|
+
env: Environment,
|
|
123
|
+
signal: "traces" | "errors",
|
|
124
|
+
): Record<string, string> {
|
|
125
|
+
return parseHeaders(
|
|
126
|
+
stringEnv(
|
|
127
|
+
env,
|
|
128
|
+
signal === "traces"
|
|
129
|
+
? "OTEL_EXPORTER_OTLP_TRACES_HEADERS"
|
|
130
|
+
: "OTEL_EXPORTER_OTLP_ERRORS_HEADERS",
|
|
131
|
+
) ?? stringEnv(env, "OTEL_EXPORTER_OTLP_HEADERS"),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseHeaders(value: string | undefined): Record<string, string> {
|
|
136
|
+
if (!value) return {};
|
|
137
|
+
return Object.fromEntries(
|
|
138
|
+
value.split(",").flatMap((entry) => {
|
|
139
|
+
const index = entry.indexOf("=");
|
|
140
|
+
return index < 1
|
|
141
|
+
? []
|
|
142
|
+
: [
|
|
143
|
+
[
|
|
144
|
+
entry.slice(0, index).trim(),
|
|
145
|
+
decodeURIComponent(entry.slice(index + 1).trim()),
|
|
146
|
+
],
|
|
147
|
+
];
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function stringEnv(env: Environment, key: string): string | undefined {
|
|
153
|
+
const value = env[key];
|
|
154
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
155
|
+
}
|
package/src/router/audit.ts
CHANGED
|
@@ -1,20 +1,13 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
export function createAuditEvent(input: AuditEventInput): AuditEvent {
|
|
4
|
-
return {
|
|
5
|
-
...input,
|
|
6
|
-
timestamp: input.timestamp ?? new Date().toISOString(),
|
|
7
|
-
};
|
|
8
|
-
}
|
|
1
|
+
import type { ExecutionEvent } from "@neutrome/lilsdk-ts";
|
|
9
2
|
|
|
10
3
|
export function createInMemoryAuditSink(): {
|
|
11
|
-
events:
|
|
12
|
-
observe(event:
|
|
4
|
+
events: ExecutionEvent[];
|
|
5
|
+
observe(event: ExecutionEvent): void;
|
|
13
6
|
} {
|
|
14
|
-
const events:
|
|
7
|
+
const events: ExecutionEvent[] = [];
|
|
15
8
|
return {
|
|
16
9
|
events,
|
|
17
|
-
observe(event:
|
|
10
|
+
observe(event: ExecutionEvent) {
|
|
18
11
|
events.push(event);
|
|
19
12
|
},
|
|
20
13
|
};
|
package/src/router/config.ts
CHANGED
|
@@ -6,9 +6,9 @@ export type ProviderApiStyle = ProviderStyle;
|
|
|
6
6
|
export type UpstreamAuthConfig = UpstreamAuthConfigShape;
|
|
7
7
|
|
|
8
8
|
export type ProviderTargetConfig = {
|
|
9
|
-
|
|
9
|
+
apiBaseUrl: string;
|
|
10
10
|
style: ProviderApiStyle;
|
|
11
|
-
exports?: string[]
|
|
11
|
+
exports?: string[];
|
|
12
12
|
headers?: Record<string, string>;
|
|
13
13
|
};
|
|
14
14
|
|
|
@@ -27,7 +27,7 @@ export type ModelRouteConfig =
|
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
export type ModelNamespaceConfig = {
|
|
30
|
-
exports?: string[]
|
|
30
|
+
exports?: string[];
|
|
31
31
|
models: Record<string, ModelRouteConfig>;
|
|
32
32
|
};
|
|
33
33
|
|
|
@@ -45,7 +45,7 @@ export type RemoteMcpServerConfig = {
|
|
|
45
45
|
};
|
|
46
46
|
|
|
47
47
|
export type RouterConfig = {
|
|
48
|
-
|
|
48
|
+
modelSuffixes?: string[];
|
|
49
49
|
upstreamAuth?: UpstreamAuthConfig;
|
|
50
50
|
providers: Record<string, ProviderTargetConfig>;
|
|
51
51
|
modelNamespaces: Record<string, ModelNamespaceConfig>;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendErrorBlock,
|
|
3
|
+
createProgram,
|
|
4
|
+
emitProviderError,
|
|
5
|
+
firstError,
|
|
6
|
+
parseProviderError,
|
|
7
|
+
type Program,
|
|
8
|
+
type ProviderStyle,
|
|
9
|
+
} from "@neutrome/lil-engine";
|
|
10
|
+
import { ExecutionError, ProviderHttpError, RouterError } from "./errors.ts";
|
|
11
|
+
import type { ProviderRuntime } from "./runtime.ts";
|
|
12
|
+
|
|
13
|
+
export async function toProviderError(
|
|
14
|
+
provider: ProviderRuntime,
|
|
15
|
+
response: Response,
|
|
16
|
+
): Promise<ProviderHttpError> {
|
|
17
|
+
const body = new Uint8Array(await response.arrayBuffer());
|
|
18
|
+
const program = parseProviderError(provider.style, body, {
|
|
19
|
+
provider: provider.name,
|
|
20
|
+
status: response.status,
|
|
21
|
+
contentType: response.headers.get("content-type"),
|
|
22
|
+
});
|
|
23
|
+
const summary =
|
|
24
|
+
firstError(program)?.message ||
|
|
25
|
+
`Provider ${provider.name} returned ${response.status}`;
|
|
26
|
+
return new ProviderHttpError(summary, response.status, program);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function errorResponse(
|
|
30
|
+
error: unknown,
|
|
31
|
+
responseStyle: ProviderStyle,
|
|
32
|
+
): Response {
|
|
33
|
+
if (error instanceof ProviderHttpError) {
|
|
34
|
+
return new Response(
|
|
35
|
+
toBody(emitProviderError(responseStyle, error.program)),
|
|
36
|
+
{
|
|
37
|
+
status: error.status,
|
|
38
|
+
headers: {
|
|
39
|
+
"content-type": "application/json; charset=utf-8",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (error instanceof RouterError) {
|
|
46
|
+
return emitErrorProgramResponse(
|
|
47
|
+
routerErrorProgram(error),
|
|
48
|
+
error.status,
|
|
49
|
+
responseStyle,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (error instanceof ExecutionError) {
|
|
54
|
+
const providerError = unwrapCause(error.cause, ProviderHttpError);
|
|
55
|
+
if (providerError) {
|
|
56
|
+
return errorResponse(providerError, responseStyle);
|
|
57
|
+
}
|
|
58
|
+
const nestedRouterError = unwrapCause(error.cause, RouterError);
|
|
59
|
+
if (nestedRouterError) {
|
|
60
|
+
return errorResponse(nestedRouterError, responseStyle);
|
|
61
|
+
}
|
|
62
|
+
const routerError = routerErrorFromExecutionError(error);
|
|
63
|
+
return emitErrorProgramResponse(
|
|
64
|
+
routerErrorProgram(routerError),
|
|
65
|
+
routerError.status,
|
|
66
|
+
responseStyle,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.error("[open-ai-router] unhandled error:", error);
|
|
71
|
+
return emitErrorProgramResponse(
|
|
72
|
+
appendErrorBlock(createProgram(), {
|
|
73
|
+
source: "router.internal",
|
|
74
|
+
kind: "invalid_request_error",
|
|
75
|
+
status: 500,
|
|
76
|
+
message: "Internal router error",
|
|
77
|
+
data: { code: "internal_error", param: null },
|
|
78
|
+
}),
|
|
79
|
+
500,
|
|
80
|
+
responseStyle,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function emitErrorProgramResponse(
|
|
85
|
+
program: Program,
|
|
86
|
+
status: number,
|
|
87
|
+
responseStyle: ProviderStyle,
|
|
88
|
+
): Response {
|
|
89
|
+
return new Response(toBody(emitProviderError(responseStyle, program)), {
|
|
90
|
+
status,
|
|
91
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function routerErrorProgram(error: RouterError): Program {
|
|
96
|
+
return appendErrorBlock(createProgram(), {
|
|
97
|
+
source: "router",
|
|
98
|
+
kind: error.type,
|
|
99
|
+
status: error.status,
|
|
100
|
+
message: error.message,
|
|
101
|
+
data: { code: error.code, param: null },
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function errorProgramFromUnknown(error: unknown): Program {
|
|
106
|
+
if (error instanceof ProviderHttpError) {
|
|
107
|
+
return error.program;
|
|
108
|
+
}
|
|
109
|
+
if (error instanceof RouterError) {
|
|
110
|
+
return routerErrorProgram(error);
|
|
111
|
+
}
|
|
112
|
+
if (error instanceof ExecutionError) {
|
|
113
|
+
const providerError = unwrapCause(error.cause, ProviderHttpError);
|
|
114
|
+
if (providerError) return providerError.program;
|
|
115
|
+
const nestedRouterError = unwrapCause(error.cause, RouterError);
|
|
116
|
+
if (nestedRouterError) return routerErrorProgram(nestedRouterError);
|
|
117
|
+
return routerErrorProgram(routerErrorFromExecutionError(error));
|
|
118
|
+
}
|
|
119
|
+
return appendErrorBlock(createProgram(), {
|
|
120
|
+
source: "router.stream",
|
|
121
|
+
kind: "stream_error",
|
|
122
|
+
status: 500,
|
|
123
|
+
message: error instanceof Error ? error.message : String(error),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function routerErrorFromExecutionError(error: ExecutionError): RouterError {
|
|
128
|
+
switch (error.kind) {
|
|
129
|
+
case "validation":
|
|
130
|
+
return new RouterError(error.message, 400, "invalid_program");
|
|
131
|
+
case "resolution":
|
|
132
|
+
return new RouterError(error.message, 400, "target_resolution_failed");
|
|
133
|
+
case "cancellation":
|
|
134
|
+
return new RouterError(error.message, 499, "request_cancelled");
|
|
135
|
+
case "provider":
|
|
136
|
+
return new RouterError(error.message, 502, "provider_execution_failed");
|
|
137
|
+
case "transform":
|
|
138
|
+
return new RouterError(error.message, 500, "transform_failed");
|
|
139
|
+
case "executor":
|
|
140
|
+
return new RouterError(error.message, 500, "executor_failed");
|
|
141
|
+
case "stream":
|
|
142
|
+
return new RouterError(error.message, 500, "stream_failed");
|
|
143
|
+
case "recursion_limit":
|
|
144
|
+
return new RouterError(error.message, 500, "recursion_limit");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function unwrapCause<T extends Error>(
|
|
149
|
+
error: unknown,
|
|
150
|
+
ctor: abstract new (...args: never[]) => T,
|
|
151
|
+
): T | null {
|
|
152
|
+
let current = error;
|
|
153
|
+
while (current && typeof current === "object") {
|
|
154
|
+
if (current instanceof ctor) {
|
|
155
|
+
return current;
|
|
156
|
+
}
|
|
157
|
+
if (!("cause" in current)) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
current = (current as { cause?: unknown }).cause;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function toBody(bytes: Uint8Array): ArrayBuffer {
|
|
166
|
+
return bytes.buffer.slice(
|
|
167
|
+
bytes.byteOffset,
|
|
168
|
+
bytes.byteOffset + bytes.byteLength,
|
|
169
|
+
) as ArrayBuffer;
|
|
170
|
+
}
|
package/src/router/errors.ts
CHANGED
|
@@ -44,7 +44,10 @@ export class ExecutionError extends Error {
|
|
|
44
44
|
cause?: unknown;
|
|
45
45
|
} = {},
|
|
46
46
|
) {
|
|
47
|
-
super(
|
|
47
|
+
super(
|
|
48
|
+
message,
|
|
49
|
+
options.cause === undefined ? undefined : { cause: options.cause },
|
|
50
|
+
);
|
|
48
51
|
this.kind = kind;
|
|
49
52
|
if (options.stage !== undefined) {
|
|
50
53
|
this.stage = options.stage;
|
|
@@ -55,6 +58,40 @@ export class ExecutionError extends Error {
|
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
export class RouterError extends Error {
|
|
62
|
+
constructor(
|
|
63
|
+
message: string,
|
|
64
|
+
readonly status: number,
|
|
65
|
+
readonly code: string,
|
|
66
|
+
readonly type = "invalid_request_error",
|
|
67
|
+
) {
|
|
68
|
+
super(message);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class ProviderHttpError extends Error {
|
|
73
|
+
constructor(
|
|
74
|
+
message: string,
|
|
75
|
+
readonly status: number,
|
|
76
|
+
readonly program: import("@neutrome/lil-engine").Program,
|
|
77
|
+
) {
|
|
78
|
+
super(message);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
58
82
|
export function isExecutionError(error: unknown): error is ExecutionError {
|
|
59
83
|
return error instanceof ExecutionError;
|
|
60
84
|
}
|
|
85
|
+
|
|
86
|
+
export function createOpenAiErrorResponse(
|
|
87
|
+
message: string,
|
|
88
|
+
status: number,
|
|
89
|
+
code: string,
|
|
90
|
+
): Response {
|
|
91
|
+
return new Response(
|
|
92
|
+
JSON.stringify({
|
|
93
|
+
error: { message, type: "invalid_request_error", param: null, code },
|
|
94
|
+
}),
|
|
95
|
+
{ status, headers: { "content-type": "application/json; charset=utf-8" } },
|
|
96
|
+
);
|
|
97
|
+
}
|