@mupt-ai/dari-gateway 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/README.md +135 -2
- package/dari-gateway.example.yaml +28 -0
- package/package.json +42 -4
- package/src/bun.ts +4 -0
- package/src/context/eval_catalog.ts +11 -0
- package/src/context/interaction_fingerprint.ts +691 -0
- package/src/context/interaction_fingerprint_format.ts +189 -0
- package/src/context/interaction_lease_depth.ts +150 -0
- package/src/context/lease_history.ts +200 -0
- package/src/context/pricing.ts +30 -0
- package/src/context/provider_prompt_accounting.ts +241 -0
- package/src/context/routing_context.ts +337 -0
- package/src/http/auth.ts +43 -0
- package/src/http/errors.ts +114 -0
- package/src/http/handler.ts +110 -0
- package/src/http/http2_server.ts +122 -0
- package/src/http/serve.ts +119 -0
- package/src/index.ts +22 -0
- package/src/ingress/anthropic_messages.ts +162 -0
- package/src/ingress/anthropic_models.ts +28 -0
- package/src/ingress/chat_completions.ts +49 -0
- package/src/ingress/claude_code/request_context.ts +36 -0
- package/src/ingress/codex.ts +81 -0
- package/src/ingress/codex_models.ts +113 -0
- package/src/ingress/json.ts +113 -0
- package/src/ingress/models.ts +55 -0
- package/src/interaction/capabilities.ts +30 -0
- package/src/interaction/image.ts +12 -0
- package/src/interaction/portable_reasoning_state.ts +25 -0
- package/src/interaction/provider_continuation_state.ts +36 -0
- package/src/interaction/tool_identity.ts +65 -0
- package/src/interaction/types.ts +282 -0
- package/src/models/context_limits.ts +8 -0
- package/src/models/execution_provider.ts +22 -0
- package/src/models/gateways.ts +73 -0
- package/src/models/ids.ts +26 -0
- package/src/models/provider_defaults.ts +47 -0
- package/src/models/runtime_overrides.ts +221 -0
- package/src/observability/errors.ts +25 -0
- package/src/platform/platform.ts +204 -0
- package/src/platform/routing_contract.ts +6 -0
- package/src/protocol/input/anthropic_messages.ts +531 -0
- package/src/protocol/input/chat_completions.ts +255 -0
- package/src/protocol/input/chat_completions_validation.ts +293 -0
- package/src/protocol/input/openai_responses.ts +749 -0
- package/src/protocol/input/router_model.ts +53 -0
- package/src/protocol/output/anthropic_messages.ts +32 -0
- package/src/protocol/output/anthropic_messages_output.ts +129 -0
- package/src/protocol/output/anthropic_messages_stream.ts +281 -0
- package/src/protocol/output/chat_completions.ts +451 -0
- package/src/protocol/output/chat_reasoning_detail.ts +19 -0
- package/src/protocol/output/delivered_stream_snapshot.ts +31 -0
- package/src/protocol/output/openai_responses.ts +30 -0
- package/src/protocol/output/openai_responses_output.ts +159 -0
- package/src/protocol/output/openai_responses_stream.ts +631 -0
- package/src/protocol/output/sse_stream.ts +102 -0
- package/src/providers/anthropic_safeguards.ts +115 -0
- package/src/providers/aws_assume_role.ts +147 -0
- package/src/providers/cancel_estimate.ts +140 -0
- package/src/providers/execution.ts +800 -0
- package/src/providers/model_capabilities.ts +50 -0
- package/src/providers/pi.ts +499 -0
- package/src/providers/pi_context.ts +466 -0
- package/src/providers/pi_options.ts +416 -0
- package/src/providers/pi_output.ts +500 -0
- package/src/providers/provider_attempt_runner.ts +153 -0
- package/src/providers/provider_call.ts +433 -0
- package/src/providers/provider_reported_cost.ts +138 -0
- package/src/providers/reasoning.ts +59 -0
- package/src/providers/response_capture.ts +65 -0
- package/src/providers/stream_finalization.ts +264 -0
- package/src/providers/subscription_limits.ts +208 -0
- package/src/providers/subscription_usage.ts +99 -0
- package/src/runtime/claude_code/auxiliary_routing.ts +80 -0
- package/src/runtime/customer_disconnect.ts +66 -0
- package/src/runtime/explicit_model.ts +86 -0
- package/src/runtime/harness_identity/conversation.ts +87 -0
- package/src/runtime/harness_identity/extract.ts +323 -0
- package/src/runtime/harness_identity/index.ts +14 -0
- package/src/runtime/prepare_routed_completion.ts +224 -0
- package/src/runtime/provider_stream_lifecycle.ts +105 -0
- package/src/runtime/provider_stream_outcome.ts +83 -0
- package/src/runtime/request_tags.ts +34 -0
- package/src/runtime/routed_completion.ts +102 -0
- package/src/runtime/routed_protocol.ts +33 -0
- package/src/runtime/routed_runtime.ts +722 -0
- package/src/runtime/routing_lease.ts +273 -0
- package/src/runtime/runtime_reporter.ts +115 -0
- package/src/self_host/config.ts +173 -0
- package/src/self_host/main.ts +104 -0
- package/src/self_host/platform.ts +63 -0
- package/src/self_host/prefix_store.ts +315 -0
- package/src/self_host/resolve.ts +240 -0
- package/src/self_host/subscriptions/access.ts +43 -0
- package/src/self_host/subscriptions/http.ts +204 -0
- package/src/self_host/subscriptions/index.ts +43 -0
- package/src/self_host/subscriptions/oauth.ts +395 -0
- package/src/self_host/subscriptions/store.ts +339 -0
- package/src/strategies/auto_routing_selector.ts +72 -0
- package/src/strategies/eval_relevance.ts +57 -0
- package/src/strategies/fallback_decision.ts +30 -0
- package/src/strategies/priority_selector.ts +40 -0
- package/src/strategies/route_decision.ts +106 -0
- package/src/strategies/routing.ts +455 -0
- package/src/strategies/selector_projection.ts +207 -0
- package/src/telemetry/background.ts +40 -0
- package/src/telemetry/benchmark_replay.ts +160 -0
- package/src/telemetry/billing_finalization.ts +167 -0
- package/src/telemetry/ingest_values.ts +15 -0
- package/src/telemetry/model_cost.ts +14 -0
- package/src/telemetry/prefix_state.ts +183 -0
- package/src/telemetry/report.ts +832 -0
- package/src/telemetry/requests/interaction_observability.ts +258 -0
- package/src/telemetry/requests/retained_interaction_request.ts +20 -0
- package/src/telemetry/scrub.ts +38 -0
- package/src/telemetry/skills/claude/catalog.ts +119 -0
- package/src/telemetry/skills/claude/file_reads.ts +153 -0
- package/src/telemetry/skills/claude/observations.ts +450 -0
- package/src/telemetry/skills/codex/catalog.ts +171 -0
- package/src/telemetry/skills/codex/exec_file_reads.ts +55 -0
- package/src/telemetry/skills/codex/explicit.ts +116 -0
- package/src/telemetry/skills/codex/file_reads.ts +51 -0
- package/src/telemetry/skills/codex/identity.ts +21 -0
- package/src/telemetry/skills/observations.ts +94 -0
- package/src/telemetry/skills/paths.ts +82 -0
- package/src/telemetry/skills/pi.ts +276 -0
- package/src/telemetry/skills/shell_file_reads.ts +267 -0
- package/src/telemetry/skills/shell_words.ts +202 -0
- package/src/telemetry/tools/input.ts +16 -0
- package/src/telemetry/tools/observations.ts +349 -0
- package/src/telemetry/usage.ts +173 -0
- package/src/types.ts +150 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { RouterFrameworkError } from "@mupt-ai/dari-router";
|
|
2
|
+
import { RouterCoreError } from "@mupt-ai/dari-router/policy-engine";
|
|
3
|
+
|
|
4
|
+
export class HttpError extends Error {
|
|
5
|
+
readonly status: number;
|
|
6
|
+
readonly code: string;
|
|
7
|
+
readonly param?: string;
|
|
8
|
+
// Response headers the error carries to the client, such as retry-after.
|
|
9
|
+
readonly headers?: Record<string, string>;
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
status: number,
|
|
13
|
+
message: string,
|
|
14
|
+
code = "invalid_request_error",
|
|
15
|
+
param?: string,
|
|
16
|
+
headers?: Record<string, string>,
|
|
17
|
+
) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.param = param;
|
|
22
|
+
this.headers = headers;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function errorResponseHeaders(error: unknown): Record<string, string> {
|
|
27
|
+
return error instanceof HttpError && error.headers ? error.headers : {};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// @mupt-ai/dari-router is transport-agnostic; its error kinds map onto the HTTP
|
|
31
|
+
// statuses this service always used for the same conditions.
|
|
32
|
+
const CORE_ERROR_STATUS: Record<RouterCoreError["kind"], number> = {
|
|
33
|
+
invalid_request: 400,
|
|
34
|
+
selector_output: 502,
|
|
35
|
+
configuration: 500,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function httpErrorFrom(error: RouterCoreError): HttpError {
|
|
39
|
+
return new HttpError(CORE_ERROR_STATUS[error.kind], error.message, error.code, error.param);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function rethrowFrameworkErrorAsHttp(error: unknown, status: number): never {
|
|
43
|
+
throw error instanceof RouterFrameworkError
|
|
44
|
+
? new HttpError(status, error.message, error.code, error.param)
|
|
45
|
+
: error;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Error code for logging/telemetry, covering both transport and core errors.
|
|
49
|
+
export function errorCode(error: unknown, fallback: string): string {
|
|
50
|
+
if (
|
|
51
|
+
error instanceof HttpError
|
|
52
|
+
|| error instanceof RouterCoreError
|
|
53
|
+
|| error instanceof RouterFrameworkError
|
|
54
|
+
) {
|
|
55
|
+
return error.code;
|
|
56
|
+
}
|
|
57
|
+
return fallback;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function errorResponse(error: unknown): Response {
|
|
61
|
+
const normalized = normalizeError(error);
|
|
62
|
+
return Response.json(
|
|
63
|
+
{
|
|
64
|
+
error: {
|
|
65
|
+
message: normalized.message,
|
|
66
|
+
type: openAiErrorType(normalized.status),
|
|
67
|
+
code: normalized.code,
|
|
68
|
+
...(normalized.param ? { param: normalized.param } : {}),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
{ status: normalized.status, headers: errorResponseHeaders(error) }
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function openAiErrorType(status: number): string {
|
|
76
|
+
if (status === 429) return "rate_limit_error";
|
|
77
|
+
return status >= 500 ? "server_error" : "invalid_request_error";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function normalizeError(error: unknown): {
|
|
81
|
+
status: number;
|
|
82
|
+
message: string;
|
|
83
|
+
code: string;
|
|
84
|
+
param?: string;
|
|
85
|
+
} {
|
|
86
|
+
if (error instanceof HttpError) {
|
|
87
|
+
return {
|
|
88
|
+
status: error.status,
|
|
89
|
+
message: error.message,
|
|
90
|
+
code: error.code,
|
|
91
|
+
param: error.param,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (error instanceof RouterCoreError) {
|
|
95
|
+
return {
|
|
96
|
+
status: CORE_ERROR_STATUS[error.kind],
|
|
97
|
+
message: error.message,
|
|
98
|
+
code: error.code,
|
|
99
|
+
param: error.param,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (error instanceof RouterFrameworkError) {
|
|
103
|
+
return {
|
|
104
|
+
status: error.status,
|
|
105
|
+
message: error.message,
|
|
106
|
+
code: error.code,
|
|
107
|
+
param: error.param,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (error instanceof Error) {
|
|
111
|
+
return { status: 500, message: error.message, code: "internal_error" };
|
|
112
|
+
}
|
|
113
|
+
return { status: 500, message: "Internal server error", code: "internal_error" };
|
|
114
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import {
|
|
2
|
+
handleResponses,
|
|
3
|
+
handleResponsesWebSocketFallback,
|
|
4
|
+
} from "../ingress/codex";
|
|
5
|
+
import { handleModels } from "../ingress/models";
|
|
6
|
+
import { handleChatCompletions } from "../ingress/chat_completions";
|
|
7
|
+
import { handleAnthropicMessages } from "../ingress/anthropic_messages";
|
|
8
|
+
import { bindGatewayPlatform, type GatewayPlatform } from "../platform/platform";
|
|
9
|
+
import { DEFAULT_ROUTER_ALIAS } from "../platform/routing_contract";
|
|
10
|
+
import type { SelectorBackend } from "../strategies/routing";
|
|
11
|
+
import { errorResponse, HttpError } from "./errors";
|
|
12
|
+
|
|
13
|
+
export type GatewayHandlerOptions = {
|
|
14
|
+
platform: GatewayPlatform;
|
|
15
|
+
selectorBackend: SelectorBackend;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The gateway's routed HTTP surface: `/v1/{chat/completions,responses,
|
|
20
|
+
* messages,models}` on the default router and `/{router_id}/v1/...` on a
|
|
21
|
+
* named one. Binds `options.platform` for every request it serves.
|
|
22
|
+
*/
|
|
23
|
+
export function createRequestHandler(options: GatewayHandlerOptions) {
|
|
24
|
+
const { selectorBackend } = options;
|
|
25
|
+
bindGatewayPlatform(options.platform);
|
|
26
|
+
return async function handleRequest(request: Request): Promise<Response> {
|
|
27
|
+
const url = new URL(request.url);
|
|
28
|
+
if (
|
|
29
|
+
request.method === "GET" &&
|
|
30
|
+
(url.pathname === "/healthz" || url.pathname === "/health")
|
|
31
|
+
) {
|
|
32
|
+
return Response.json({ status: "ok" });
|
|
33
|
+
}
|
|
34
|
+
// Claude Code probes this first-party endpoint before its Messages call
|
|
35
|
+
// when ANTHROPIC_BASE_URL points at a compatible gateway.
|
|
36
|
+
if (request.method === "HEAD" && url.pathname === "/api/hello") {
|
|
37
|
+
return new Response(null, { status: 200 });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const endpoint = routedEndpoint(url.pathname);
|
|
41
|
+
if (endpoint?.protocol === "chat" && request.method === "POST") {
|
|
42
|
+
return await handleChatCompletions(
|
|
43
|
+
request,
|
|
44
|
+
endpoint.routerId,
|
|
45
|
+
selectorBackend,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (endpoint?.protocol === "responses") {
|
|
49
|
+
if (request.method === "GET") return handleResponsesWebSocketFallback();
|
|
50
|
+
if (request.method === "POST") {
|
|
51
|
+
return await handleResponses(
|
|
52
|
+
request,
|
|
53
|
+
endpoint.routerId,
|
|
54
|
+
selectorBackend,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (endpoint?.protocol === "messages" && request.method === "POST") {
|
|
59
|
+
return await handleAnthropicMessages(
|
|
60
|
+
request,
|
|
61
|
+
endpoint.routerId,
|
|
62
|
+
selectorBackend,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (endpoint?.protocol === "models" && request.method === "GET") {
|
|
66
|
+
return await handleModels(request, endpoint.routerId);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return errorResponse(new HttpError(404, "Not found", "not_found"));
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type RoutedEndpoint = {
|
|
74
|
+
routerId: string;
|
|
75
|
+
protocol: "chat" | "responses" | "messages" | "models";
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function routedEndpoint(pathname: string): RoutedEndpoint | null {
|
|
79
|
+
const defaultProtocol = protocolForSuffix(pathname, "/v1");
|
|
80
|
+
if (defaultProtocol !== null) {
|
|
81
|
+
return { routerId: DEFAULT_ROUTER_ALIAS, protocol: defaultProtocol };
|
|
82
|
+
}
|
|
83
|
+
const match = pathname.match(
|
|
84
|
+
/^\/([^/]+)(?:\/v1)?\/(chat\/completions|responses|messages|models)$/,
|
|
85
|
+
);
|
|
86
|
+
if (match === null) return null;
|
|
87
|
+
return {
|
|
88
|
+
routerId: match[1],
|
|
89
|
+
protocol: protocolForRoute(match[2]),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function protocolForSuffix(
|
|
94
|
+
pathname: string,
|
|
95
|
+
prefix: string,
|
|
96
|
+
): RoutedEndpoint["protocol"] | null {
|
|
97
|
+
if (pathname === `${prefix}/chat/completions`) return "chat";
|
|
98
|
+
if (pathname === `${prefix}/responses`) return "responses";
|
|
99
|
+
if (pathname === `${prefix}/messages`) return "messages";
|
|
100
|
+
if (pathname === `${prefix}/models`) return "models";
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function protocolForRoute(route: string): RoutedEndpoint["protocol"] {
|
|
105
|
+
if (route === "chat/completions") return "chat";
|
|
106
|
+
if (route === "responses") return "responses";
|
|
107
|
+
if (route === "messages") return "messages";
|
|
108
|
+
if (route === "models") return "models";
|
|
109
|
+
throw new Error(`Unsupported routed endpoint: ${route}`);
|
|
110
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createServer,
|
|
3
|
+
type Http2ServerRequest,
|
|
4
|
+
type Http2ServerResponse,
|
|
5
|
+
type ServerHttp2Session,
|
|
6
|
+
} from "node:http2";
|
|
7
|
+
import { Readable } from "node:stream";
|
|
8
|
+
import { pipeline } from "node:stream/promises";
|
|
9
|
+
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
|
|
10
|
+
|
|
11
|
+
export type Http2RequestHandler = (request: Request) => Response | Promise<Response>;
|
|
12
|
+
|
|
13
|
+
export type Http2RoutingServer = {
|
|
14
|
+
readonly port: number;
|
|
15
|
+
stop(force?: boolean): Promise<void>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const HTTP2_FORBIDDEN_RESPONSE_HEADERS = new Set([
|
|
19
|
+
"connection",
|
|
20
|
+
"keep-alive",
|
|
21
|
+
"proxy-connection",
|
|
22
|
+
"transfer-encoding",
|
|
23
|
+
"upgrade",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export function startHttp2Server(port: number, handler: Http2RequestHandler): Http2RoutingServer {
|
|
27
|
+
const sessions = new Set<ServerHttp2Session>();
|
|
28
|
+
let closePromise: Promise<void> | undefined;
|
|
29
|
+
const server = createServer((request, response) => {
|
|
30
|
+
void handleRequest(request, response, handler);
|
|
31
|
+
});
|
|
32
|
+
server.on("session", (session) => {
|
|
33
|
+
sessions.add(session);
|
|
34
|
+
session.once("close", () => sessions.delete(session));
|
|
35
|
+
});
|
|
36
|
+
server.listen(port, "0.0.0.0");
|
|
37
|
+
const address = server.address();
|
|
38
|
+
if (address === null || typeof address === "string") {
|
|
39
|
+
throw new Error("HTTP/2 routing server did not bind to a TCP port.");
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
port: address.port,
|
|
43
|
+
stop(force = false): Promise<void> {
|
|
44
|
+
for (const session of sessions) {
|
|
45
|
+
if (force) session.destroy();
|
|
46
|
+
else session.close();
|
|
47
|
+
}
|
|
48
|
+
closePromise ??= new Promise((resolve, reject) => {
|
|
49
|
+
server.close((error) => {
|
|
50
|
+
if (error) reject(error);
|
|
51
|
+
else resolve();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
return closePromise;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function handleRequest(
|
|
60
|
+
incoming: Http2ServerRequest,
|
|
61
|
+
outgoing: Http2ServerResponse,
|
|
62
|
+
handler: Http2RequestHandler,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
const abort = new AbortController();
|
|
65
|
+
incoming.once("aborted", () => abort.abort());
|
|
66
|
+
outgoing.once("close", () => {
|
|
67
|
+
if (!outgoing.writableEnded) abort.abort();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const response = await handler(toRequest(incoming, abort.signal));
|
|
72
|
+
outgoing.statusCode = response.status;
|
|
73
|
+
for (const [name, value] of response.headers) {
|
|
74
|
+
if (!HTTP2_FORBIDDEN_RESPONSE_HEADERS.has(name.toLowerCase())) {
|
|
75
|
+
outgoing.setHeader(name, value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (response.body === null || incoming.method === "HEAD") {
|
|
79
|
+
outgoing.end();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
await pipeline(
|
|
83
|
+
Readable.fromWeb(response.body as unknown as NodeReadableStream),
|
|
84
|
+
outgoing,
|
|
85
|
+
);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (abort.signal.aborted || outgoing.destroyed) return;
|
|
88
|
+
console.error("routing HTTP/2 request failed", error);
|
|
89
|
+
if (outgoing.headersSent) {
|
|
90
|
+
outgoing.destroy();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
outgoing.statusCode = 500;
|
|
94
|
+
outgoing.setHeader("content-type", "application/json");
|
|
95
|
+
outgoing.end(JSON.stringify({ error: { message: "Internal server error." } }));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function toRequest(incoming: Http2ServerRequest, signal: AbortSignal): Request {
|
|
100
|
+
const headers = new Headers();
|
|
101
|
+
for (const [name, value] of Object.entries(incoming.headers)) {
|
|
102
|
+
if (name.startsWith(":")) continue;
|
|
103
|
+
if (Array.isArray(value)) {
|
|
104
|
+
for (const item of value) headers.append(name, item);
|
|
105
|
+
} else if (value !== undefined) {
|
|
106
|
+
headers.set(name, value);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const method = incoming.method ?? "GET";
|
|
111
|
+
const hasBody = method !== "GET" && method !== "HEAD";
|
|
112
|
+
return new Request(
|
|
113
|
+
`${incoming.scheme ?? "http"}://${incoming.authority ?? "localhost"}${incoming.url}`,
|
|
114
|
+
{
|
|
115
|
+
method,
|
|
116
|
+
headers,
|
|
117
|
+
body: hasBody ? Readable.toWeb(incoming) : undefined,
|
|
118
|
+
signal,
|
|
119
|
+
duplex: hasBody ? "half" : undefined,
|
|
120
|
+
} as RequestInit & { duplex?: "half" },
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { captureOperationalError } from "../observability/errors";
|
|
2
|
+
import { drainBackgroundTelemetryFor } from "../telemetry/background";
|
|
3
|
+
import { startHttp2Server } from "./http2_server";
|
|
4
|
+
|
|
5
|
+
const GRACEFUL_SERVER_STOP_TIMEOUT_MS = 5_000;
|
|
6
|
+
const FORCE_STOP_SETTLE_TIMEOUT_MS = 250;
|
|
7
|
+
// Container platforms commonly send SIGKILL 10 seconds after SIGTERM. Reserve
|
|
8
|
+
// the final 2 seconds for closing telemetry clients and 500ms for process
|
|
9
|
+
// scheduling.
|
|
10
|
+
const SHUTDOWN_WORK_TIMEOUT_MS = 7_500;
|
|
11
|
+
|
|
12
|
+
type StoppableServer = {
|
|
13
|
+
stop(force?: boolean): Promise<void>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function startServer(
|
|
17
|
+
listenPort: number,
|
|
18
|
+
handler: (request: Request) => Promise<Response>,
|
|
19
|
+
options: { http2?: boolean } = {},
|
|
20
|
+
) {
|
|
21
|
+
if (options.http2) {
|
|
22
|
+
return startHttp2Server(listenPort, handler);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The selector runs before the first response byte, and routed models can
|
|
26
|
+
// remain silent while reasoning. Disable Bun's default 10s idle timeout;
|
|
27
|
+
// clients and infrastructure still bound total request lifetime.
|
|
28
|
+
return Bun.serve({
|
|
29
|
+
port: listenPort,
|
|
30
|
+
fetch: handler,
|
|
31
|
+
idleTimeout: 0,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* On SIGTERM/SIGINT: stop accepting work, let active requests finish, drain
|
|
37
|
+
* background reporting within the shutdown budget, run `closeClients`, exit.
|
|
38
|
+
*/
|
|
39
|
+
export function installShutdownHandlers(
|
|
40
|
+
server: StoppableServer,
|
|
41
|
+
closeClients: () => Promise<unknown>,
|
|
42
|
+
): void {
|
|
43
|
+
let shuttingDown = false;
|
|
44
|
+
const shutdown = async (signal: NodeJS.Signals): Promise<void> => {
|
|
45
|
+
if (shuttingDown) return;
|
|
46
|
+
shuttingDown = true;
|
|
47
|
+
const workDeadlineAtMs = performance.now() + SHUTDOWN_WORK_TIMEOUT_MS;
|
|
48
|
+
console.info("routing server shutting down", JSON.stringify({ signal }));
|
|
49
|
+
try {
|
|
50
|
+
await stopServerForShutdown(server);
|
|
51
|
+
} finally {
|
|
52
|
+
const remaining = await drainBackgroundTelemetryFor(
|
|
53
|
+
Math.max(0, workDeadlineAtMs - performance.now()),
|
|
54
|
+
);
|
|
55
|
+
if (remaining > 0) {
|
|
56
|
+
console.warn(
|
|
57
|
+
"routing server background drain timed out",
|
|
58
|
+
JSON.stringify({ remaining_task_count: remaining }),
|
|
59
|
+
);
|
|
60
|
+
captureOperationalError(
|
|
61
|
+
new Error(`Routing shutdown abandoned ${remaining} background task(s).`),
|
|
62
|
+
{ code: "background_drain_timeout" },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
await closeClients();
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
|
71
|
+
process.once("SIGINT", () => void shutdown("SIGINT"));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* stop(false) refuses new work and lets active requests finish; if that does
|
|
76
|
+
* not settle in time, one stop(true) destroys what is left so stream
|
|
77
|
+
* cancellation registers its finalizers before the background drain starts.
|
|
78
|
+
* Both calls are bounded: a hung stop promise must not consume the shutdown
|
|
79
|
+
* budget.
|
|
80
|
+
*/
|
|
81
|
+
export async function stopServerForShutdown(
|
|
82
|
+
server: StoppableServer,
|
|
83
|
+
gracefulTimeoutMs: number = GRACEFUL_SERVER_STOP_TIMEOUT_MS,
|
|
84
|
+
forceStopSettleTimeoutMs: number = FORCE_STOP_SETTLE_TIMEOUT_MS,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
if ((await stopWithin(server, false, gracefulTimeoutMs)) !== "timed_out") {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if ((await stopWithin(server, true, forceStopSettleTimeoutMs)) === "timed_out") {
|
|
90
|
+
reportServerStopFailure(
|
|
91
|
+
new Error("Routing server force-stop settlement timed out."),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function stopWithin(
|
|
97
|
+
server: StoppableServer,
|
|
98
|
+
force: boolean,
|
|
99
|
+
timeoutMs: number,
|
|
100
|
+
): Promise<"stopped" | "failed" | "timed_out"> {
|
|
101
|
+
return Promise.race([
|
|
102
|
+
new Promise<void>((resolve) => resolve(server.stop(force))).then(
|
|
103
|
+
() => "stopped" as const,
|
|
104
|
+
(error: unknown) => {
|
|
105
|
+
reportServerStopFailure(error);
|
|
106
|
+
return "failed" as const;
|
|
107
|
+
},
|
|
108
|
+
),
|
|
109
|
+
Bun.sleep(timeoutMs).then(() => "timed_out" as const),
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function reportServerStopFailure(error: unknown): void {
|
|
114
|
+
console.warn(
|
|
115
|
+
"routing server shutdown failed",
|
|
116
|
+
error instanceof Error ? error.name : "UnknownError",
|
|
117
|
+
);
|
|
118
|
+
captureOperationalError(error, { code: "server_force_stop_failed" });
|
|
119
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Public entry points. Every module is also importable by path
|
|
2
|
+
// (`@mupt-ai/dari-gateway/<dir>/<module>`) for deployments that compose the
|
|
3
|
+
// gateway with their own platform. This entry needs no Bun APIs; the Bun
|
|
4
|
+
// server and the SQLite-backed self-hosted gateway live in `./bun`.
|
|
5
|
+
export { createRequestHandler, type GatewayHandlerOptions } from "./http/handler";
|
|
6
|
+
export type {
|
|
7
|
+
GatewayPlatform,
|
|
8
|
+
OperationalErrorContext,
|
|
9
|
+
PendingLeaseWrite,
|
|
10
|
+
PrefixStateWrite,
|
|
11
|
+
ProviderCredentialRequest,
|
|
12
|
+
RequestErrorContext,
|
|
13
|
+
RequestRecord,
|
|
14
|
+
RequestSummary,
|
|
15
|
+
RequestTags,
|
|
16
|
+
ResolveRouterRequest,
|
|
17
|
+
SubscriptionHold,
|
|
18
|
+
} from "./platform/platform";
|
|
19
|
+
export { createAutoRoutingSelectorBackend } from "./strategies/auto_routing_selector";
|
|
20
|
+
export { createPrioritySelectorBackend } from "./strategies/priority_selector";
|
|
21
|
+
export { createLlmSelectorBackend, type SelectorBackend } from "./strategies/routing";
|
|
22
|
+
export type { ProviderCredential, ResolvedRouter } from "./types";
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { isRecord } from "@mupt-ai/dari-router/policy-engine";
|
|
2
|
+
import { anthropicMessagesResponse } from "../protocol/output/anthropic_messages";
|
|
3
|
+
import { anthropicMessagesSseStream } from "../protocol/output/anthropic_messages_stream";
|
|
4
|
+
import { anthropicToken } from "../http/auth";
|
|
5
|
+
import { errorResponseHeaders, normalizeError } from "../http/errors";
|
|
6
|
+
import { captureRequestError } from "../observability/errors";
|
|
7
|
+
import { anthropicMessagesToInteraction } from "../protocol/input/anthropic_messages";
|
|
8
|
+
import { handleRoutedCompletion } from "../runtime/routed_completion";
|
|
9
|
+
import type { SelectorBackend } from "../strategies/routing";
|
|
10
|
+
import { claudeCodeRequestContext } from "./claude_code/request_context";
|
|
11
|
+
import {
|
|
12
|
+
claudeCodeConversationIdentity,
|
|
13
|
+
} from "../runtime/harness_identity";
|
|
14
|
+
import { parseJsonRequest } from "./json";
|
|
15
|
+
import type { AnthropicSafeguards } from "../interaction/types";
|
|
16
|
+
|
|
17
|
+
type AnthropicErrorType =
|
|
18
|
+
| "authentication_error"
|
|
19
|
+
| "permission_error"
|
|
20
|
+
| "not_found_error"
|
|
21
|
+
| "rate_limit_error"
|
|
22
|
+
| "api_error"
|
|
23
|
+
| "invalid_request_error";
|
|
24
|
+
|
|
25
|
+
const LEGACY_CLAUDE_CODE_BETA = "claude-code-20250219";
|
|
26
|
+
const SAFEGUARDS_BETA_PREFIX = "dangerous-tool-use-";
|
|
27
|
+
|
|
28
|
+
export async function handleAnthropicMessages(
|
|
29
|
+
request: Request,
|
|
30
|
+
routerId: string,
|
|
31
|
+
selectorBackend: SelectorBackend,
|
|
32
|
+
): Promise<Response> {
|
|
33
|
+
try {
|
|
34
|
+
const apiKey = anthropicToken(request.headers);
|
|
35
|
+
const rawPayload = await parseJsonRequest<Record<string, unknown>>(request);
|
|
36
|
+
const interaction = anthropicMessagesToInteraction(rawPayload);
|
|
37
|
+
const isClaudeCode = isClaudeCodeRequest(request.headers);
|
|
38
|
+
const anthropicSafeguards = safeguardsFrom(rawPayload, request.headers);
|
|
39
|
+
const forwarded = anthropicSafeguards === undefined
|
|
40
|
+
? interaction
|
|
41
|
+
: { ...interaction, anthropicSafeguards };
|
|
42
|
+
const payload = isClaudeCode
|
|
43
|
+
? {
|
|
44
|
+
...forwarded,
|
|
45
|
+
agentContext: claudeCodeRequestContext(request.headers),
|
|
46
|
+
// Native tool results may be followed by harness-injected text.
|
|
47
|
+
// Preserve their structural continuation semantics, not prompt inference.
|
|
48
|
+
...(latestMessageHasToolResult(rawPayload.messages)
|
|
49
|
+
? { requestPurpose: "tool_continuation" as const } : {}),
|
|
50
|
+
claudeCodeUserAgent: request.headers.get("user-agent")!.trim(),
|
|
51
|
+
}
|
|
52
|
+
: forwarded;
|
|
53
|
+
return await handleRoutedCompletion({
|
|
54
|
+
request,
|
|
55
|
+
routerId,
|
|
56
|
+
apiKey,
|
|
57
|
+
payload,
|
|
58
|
+
responseBody: anthropicMessagesResponse,
|
|
59
|
+
streamBody: anthropicMessagesSseStream,
|
|
60
|
+
errorResponse: anthropicErrorResponse,
|
|
61
|
+
selectorBackend,
|
|
62
|
+
clientHarness: isClaudeCode ? "claude_code" : undefined,
|
|
63
|
+
harnessConversationIdentity: isClaudeCode
|
|
64
|
+
? claudeCodeConversationIdentity(
|
|
65
|
+
request.headers,
|
|
66
|
+
payload.promptCacheKey,
|
|
67
|
+
)
|
|
68
|
+
: undefined,
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
const response = anthropicErrorResponse(error);
|
|
72
|
+
captureRequestError(error, {
|
|
73
|
+
request,
|
|
74
|
+
routerId,
|
|
75
|
+
sourceProtocol: "anthropic_messages",
|
|
76
|
+
status: response.status,
|
|
77
|
+
});
|
|
78
|
+
return response;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function latestMessageHasToolResult(messages: unknown): boolean {
|
|
83
|
+
if (!Array.isArray(messages)) return false;
|
|
84
|
+
const message = messages.at(-1);
|
|
85
|
+
return isRecord(message) && message.role === "user" && Array.isArray(message.content)
|
|
86
|
+
&& message.content.some((block) => isRecord(block) && block.type === "tool_result");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// A safeguards body field only works with its paired beta, so forward the two
|
|
90
|
+
// together or not at all.
|
|
91
|
+
function safeguardsFrom(
|
|
92
|
+
payload: Record<string, unknown>,
|
|
93
|
+
headers: Headers,
|
|
94
|
+
): AnthropicSafeguards | undefined {
|
|
95
|
+
if (!Array.isArray(payload.safeguards) || payload.safeguards.length === 0) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const betas = [...anthropicBetas(headers)].filter((beta) =>
|
|
99
|
+
beta.startsWith(SAFEGUARDS_BETA_PREFIX)
|
|
100
|
+
);
|
|
101
|
+
return betas.length === 0 ? undefined : { safeguards: payload.safeguards, betas };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function anthropicBetas(headers: Headers): Set<string> {
|
|
105
|
+
return new Set(
|
|
106
|
+
(headers.get("anthropic-beta") ?? "")
|
|
107
|
+
.split(",")
|
|
108
|
+
.map((value) => value.trim().toLowerCase())
|
|
109
|
+
.filter(Boolean),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isClaudeCodeRequest(headers: Headers): boolean {
|
|
114
|
+
const userAgent = headers.get("user-agent")?.trim().toLowerCase() ?? "";
|
|
115
|
+
const app = headers.get("x-app")?.trim().toLowerCase();
|
|
116
|
+
const betas = anthropicBetas(headers);
|
|
117
|
+
return (
|
|
118
|
+
userAgent.startsWith("claude-cli/") &&
|
|
119
|
+
(app === "cli" || app === "cli-bg") &&
|
|
120
|
+
(
|
|
121
|
+
Boolean(headers.get("x-claude-code-session-id")?.trim()) ||
|
|
122
|
+
betas.has(LEGACY_CLAUDE_CODE_BETA)
|
|
123
|
+
)
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Dari-specific codes clients act on; every other code stays internal.
|
|
128
|
+
const CLIENT_VISIBLE_ERROR_CODES = new Set([
|
|
129
|
+
"personal_subscription_unavailable",
|
|
130
|
+
"subscription_switched",
|
|
131
|
+
"subscription_usage_limit",
|
|
132
|
+
]);
|
|
133
|
+
|
|
134
|
+
export function anthropicErrorResponse(error: unknown): Response {
|
|
135
|
+
const { status, message, code } = normalizeError(error);
|
|
136
|
+
return Response.json(
|
|
137
|
+
{
|
|
138
|
+
type: "error",
|
|
139
|
+
error: {
|
|
140
|
+
type: anthropicErrorType(status),
|
|
141
|
+
message,
|
|
142
|
+
...(CLIENT_VISIBLE_ERROR_CODES.has(code) ? { code } : {}),
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{ status, headers: errorResponseHeaders(error) },
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function anthropicErrorType(status: number): AnthropicErrorType {
|
|
150
|
+
switch (status) {
|
|
151
|
+
case 401:
|
|
152
|
+
return "authentication_error";
|
|
153
|
+
case 403:
|
|
154
|
+
return "permission_error";
|
|
155
|
+
case 404:
|
|
156
|
+
return "not_found_error";
|
|
157
|
+
case 429:
|
|
158
|
+
return "rate_limit_error";
|
|
159
|
+
default:
|
|
160
|
+
return status >= 500 ? "api_error" : "invalid_request_error";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/* Anthropic-compatible model discovery. Claude Desktop, Cowork, and other
|
|
2
|
+
Anthropic-SDK gateway clients list models before first use and parse the
|
|
3
|
+
Anthropic list shape ({data: [{type: "model", ...}]}), not the Codex
|
|
4
|
+
catalog shape. Discovery advertises the tier synonyms from router_model.ts —
|
|
5
|
+
the only ids such clients accept as usable (verified in-app: `dari/routing`
|
|
6
|
+
is rejected, bare tier names classify). Fixed created_at keeps the response
|
|
7
|
+
byte-stable. */
|
|
8
|
+
|
|
9
|
+
import { ANTHROPIC_ROUTER_MODEL_TIERS } from "../protocol/input/router_model";
|
|
10
|
+
|
|
11
|
+
const ROUTER_MODEL_CREATED_AT = "2026-01-01T00:00:00Z";
|
|
12
|
+
|
|
13
|
+
const tierLabel = (tier: string) =>
|
|
14
|
+
`Dari Routing (${tier[0].toUpperCase()}${tier.slice(1)} tier)`;
|
|
15
|
+
|
|
16
|
+
export function anthropicModelsResponse(): Record<string, unknown> {
|
|
17
|
+
return {
|
|
18
|
+
data: ANTHROPIC_ROUTER_MODEL_TIERS.map((tier) => ({
|
|
19
|
+
type: "model",
|
|
20
|
+
id: tier,
|
|
21
|
+
display_name: tierLabel(tier),
|
|
22
|
+
created_at: ROUTER_MODEL_CREATED_AT,
|
|
23
|
+
})),
|
|
24
|
+
first_id: ANTHROPIC_ROUTER_MODEL_TIERS[0],
|
|
25
|
+
last_id: ANTHROPIC_ROUTER_MODEL_TIERS[ANTHROPIC_ROUTER_MODEL_TIERS.length - 1],
|
|
26
|
+
has_more: false,
|
|
27
|
+
};
|
|
28
|
+
}
|