forked from bai/curriculum-project-hub
5b55cf18a8
This reverts commit e7ad5580ec.
226 lines
7.7 KiB
TypeScript
226 lines
7.7 KiB
TypeScript
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
import { createServer, type IncomingHttpHeaders, type IncomingMessage, type ServerResponse } from "node:http";
|
|
import { once } from "node:events";
|
|
import { classifyNetworkFailure, type NetworkFailureCategory } from "./networkFailure.js";
|
|
|
|
const MAX_PROVIDER_REQUEST_BYTES = 32 * 1024 * 1024;
|
|
const HOP_BY_HOP_HEADERS = new Set([
|
|
"connection",
|
|
"keep-alive",
|
|
"proxy-authenticate",
|
|
"proxy-authorization",
|
|
"te",
|
|
"trailer",
|
|
"transfer-encoding",
|
|
"upgrade",
|
|
]);
|
|
const REPLACED_REQUEST_HEADERS = new Set([
|
|
...HOP_BY_HOP_HEADERS,
|
|
"authorization",
|
|
"content-length",
|
|
"host",
|
|
"x-api-key",
|
|
]);
|
|
|
|
export interface ProviderUpstreamCredential {
|
|
readonly baseUrl: string;
|
|
readonly authToken: string;
|
|
readonly anthropicApiKey: string;
|
|
}
|
|
|
|
export interface AgentProviderLease {
|
|
readonly sdkEnv: Readonly<Record<string, string | undefined>>;
|
|
readonly sensitiveValues: readonly string[];
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
export interface ProviderProxyDiagnostic {
|
|
readonly code:
|
|
| "provider_proxy_unauthorized"
|
|
| "provider_proxy_request_too_large"
|
|
| "provider_proxy_redirect_refused"
|
|
| "provider_proxy_upstream_failed";
|
|
readonly category: NetworkFailureCategory | "authorization" | "request_limit" | "redirect";
|
|
}
|
|
|
|
export interface ProviderProxyOptions {
|
|
readonly onDiagnostic?: (diagnostic: ProviderProxyDiagnostic) => void;
|
|
}
|
|
|
|
/**
|
|
* Starts one loopback-only proxy for one Agent run. The Agent receives only a
|
|
* random run capability; customer provider credentials stay in this Hub
|
|
* closure and are attached immediately before the upstream request.
|
|
*/
|
|
export async function openProviderProxyLease(
|
|
upstream: ProviderUpstreamCredential,
|
|
options: ProviderProxyOptions = {},
|
|
): Promise<AgentProviderLease> {
|
|
const capability = randomBytes(32).toString("base64url");
|
|
const handler = (request: IncomingMessage, response: ServerResponse): void => {
|
|
void forwardProviderRequest(request, response, capability, upstream, options);
|
|
};
|
|
const server = createServer(handler);
|
|
server.requestTimeout = 5 * 60_000;
|
|
server.headersTimeout = 30_000;
|
|
server.maxRequestsPerSocket = 1_000;
|
|
server.listen(0, "127.0.0.1");
|
|
await Promise.race([
|
|
once(server, "listening"),
|
|
once(server, "error").then(([error]) => Promise.reject(error)),
|
|
]);
|
|
server.unref();
|
|
const address = server.address();
|
|
if (address === null || typeof address === "string") {
|
|
server.close();
|
|
throw new Error("provider proxy did not bind a TCP address");
|
|
}
|
|
|
|
let closed = false;
|
|
return {
|
|
sdkEnv: {
|
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`,
|
|
ANTHROPIC_AUTH_TOKEN: capability,
|
|
ANTHROPIC_API_KEY: "",
|
|
},
|
|
sensitiveValues: [capability],
|
|
async close(): Promise<void> {
|
|
if (closed) return;
|
|
closed = true;
|
|
server.closeAllConnections();
|
|
if (!server.listening) return;
|
|
server.close();
|
|
await once(server, "close");
|
|
},
|
|
};
|
|
}
|
|
|
|
async function forwardProviderRequest(
|
|
request: IncomingMessage,
|
|
response: ServerResponse,
|
|
capability: string,
|
|
upstream: ProviderUpstreamCredential,
|
|
options: ProviderProxyOptions,
|
|
): Promise<void> {
|
|
if (!authorized(request.headers.authorization, capability)) {
|
|
options.onDiagnostic?.({ code: "provider_proxy_unauthorized", category: "authorization" });
|
|
response.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
|
|
response.end("unauthorized");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const requestUrl = new URL(request.url ?? "/", "http://provider-proxy.invalid");
|
|
const upstreamBase = upstream.baseUrl.endsWith("/") ? upstream.baseUrl : `${upstream.baseUrl}/`;
|
|
const upstreamUrl = new URL(`${requestUrl.pathname.replace(/^\//, "")}${requestUrl.search}`, upstreamBase);
|
|
const body = await readBoundedBody(request);
|
|
const headers = forwardedRequestHeaders(request.headers);
|
|
headers.set("authorization", `Bearer ${upstream.authToken}`);
|
|
if (upstream.anthropicApiKey !== "") {
|
|
headers.set("x-api-key", upstream.anthropicApiKey);
|
|
}
|
|
headers.set("accept-encoding", "identity");
|
|
|
|
const abort = new AbortController();
|
|
request.once("aborted", () => abort.abort());
|
|
const upstreamResponse = await fetch(upstreamUrl, {
|
|
method: request.method ?? "GET",
|
|
headers,
|
|
...(body.byteLength === 0 ? {} : { body }),
|
|
redirect: "manual",
|
|
signal: abort.signal,
|
|
});
|
|
if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) {
|
|
options.onDiagnostic?.({ code: "provider_proxy_redirect_refused", category: "redirect" });
|
|
await upstreamResponse.body?.cancel();
|
|
response.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
|
response.end("provider redirect refused");
|
|
return;
|
|
}
|
|
response.writeHead(upstreamResponse.status, forwardedResponseHeaders(upstreamResponse.headers));
|
|
if (upstreamResponse.body === null) {
|
|
response.end();
|
|
return;
|
|
}
|
|
for await (const chunk of upstreamResponse.body) {
|
|
if (!response.write(Buffer.from(chunk))) {
|
|
await once(response, "drain");
|
|
}
|
|
}
|
|
response.end();
|
|
} catch (error) {
|
|
const diagnostic = error instanceof ProviderProxyRequestError
|
|
? { code: error.code, category: error.category } as const
|
|
: {
|
|
code: "provider_proxy_upstream_failed",
|
|
category: classifyNetworkFailure(error),
|
|
} as const;
|
|
options.onDiagnostic?.(diagnostic);
|
|
if (!response.headersSent) {
|
|
response.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
|
response.end("provider request failed");
|
|
} else {
|
|
response.destroy();
|
|
}
|
|
}
|
|
}
|
|
|
|
function authorized(value: string | undefined, capability: string): boolean {
|
|
if (value === undefined || !value.startsWith("Bearer ")) return false;
|
|
const supplied = Buffer.from(value.slice("Bearer ".length));
|
|
const expected = Buffer.from(capability);
|
|
return supplied.byteLength === expected.byteLength && timingSafeEqual(supplied, expected);
|
|
}
|
|
|
|
function forwardedRequestHeaders(source: IncomingHttpHeaders): Headers {
|
|
const result = new Headers();
|
|
for (const [name, value] of Object.entries(source)) {
|
|
if (value === undefined || REPLACED_REQUEST_HEADERS.has(name.toLowerCase())) continue;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) result.append(name, item);
|
|
} else {
|
|
result.set(name, value);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function forwardedResponseHeaders(source: Headers): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
for (const [name, value] of source.entries()) {
|
|
const normalized = name.toLowerCase();
|
|
if (HOP_BY_HOP_HEADERS.has(normalized) || normalized === "content-length" ||
|
|
normalized === "content-encoding") continue;
|
|
result[name] = value;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function readBoundedBody(request: IncomingMessage): Promise<Buffer> {
|
|
const declared = Number(request.headers["content-length"] ?? 0);
|
|
if (Number.isFinite(declared) && declared > MAX_PROVIDER_REQUEST_BYTES) {
|
|
throw new ProviderProxyRequestError("provider_proxy_request_too_large", "request_limit");
|
|
}
|
|
const chunks: Buffer[] = [];
|
|
let bytes = 0;
|
|
for await (const chunk of request) {
|
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
|
bytes += buffer.byteLength;
|
|
if (bytes > MAX_PROVIDER_REQUEST_BYTES) {
|
|
throw new ProviderProxyRequestError("provider_proxy_request_too_large", "request_limit");
|
|
}
|
|
chunks.push(buffer);
|
|
}
|
|
return Buffer.concat(chunks, bytes);
|
|
}
|
|
|
|
class ProviderProxyRequestError extends Error {
|
|
constructor(
|
|
readonly code: "provider_proxy_request_too_large",
|
|
readonly category: "request_limit",
|
|
) {
|
|
super(code);
|
|
this.name = "ProviderProxyRequestError";
|
|
}
|
|
}
|