273 lines
7.9 KiB
TypeScript
273 lines
7.9 KiB
TypeScript
import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
|
import { updatePendingScope, type PendingRequestScope } from "@/lib/usage/pendingRequestScope";
|
|
|
|
export type ProviderRequestPrepared = {
|
|
url: string;
|
|
headers: Record<string, string>;
|
|
body: unknown;
|
|
bodyString: string;
|
|
};
|
|
|
|
export type Capture = {
|
|
capture: (request: ProviderRequestPrepared) => Promise<void> | void;
|
|
body: (fallback: unknown) => unknown;
|
|
latest?: () => ProviderRequestPrepared | null;
|
|
};
|
|
|
|
type RequestLoggerLike = {
|
|
logTargetRequest: (url: unknown, headers: Record<string, string>, body: unknown) => void;
|
|
};
|
|
|
|
type WarnLog = {
|
|
warn?: (tag: string, message: string) => void;
|
|
};
|
|
|
|
type FetchInput = Parameters<typeof fetch>[0];
|
|
type FetchInit = Parameters<typeof fetch>[1];
|
|
|
|
type CaptureState = {
|
|
context: AsyncLocalStorage<Capture>;
|
|
wrappedFetch: typeof fetch | null;
|
|
wrappedInnerFetch: typeof fetch | null;
|
|
};
|
|
|
|
const CAPTURE_STATE_KEY = Symbol.for("omniroute.providerRequestCapture.state");
|
|
|
|
function getCaptureState(): CaptureState {
|
|
const scopedGlobal = globalThis as typeof globalThis & {
|
|
[CAPTURE_STATE_KEY]?: CaptureState;
|
|
};
|
|
|
|
if (!scopedGlobal[CAPTURE_STATE_KEY]) {
|
|
scopedGlobal[CAPTURE_STATE_KEY] = {
|
|
context: new AsyncLocalStorage<Capture>(),
|
|
wrappedFetch: null,
|
|
wrappedInnerFetch: null,
|
|
};
|
|
}
|
|
return scopedGlobal[CAPTURE_STATE_KEY];
|
|
}
|
|
|
|
const captureState = getCaptureState();
|
|
const BODY_METHODS = new Set(["POST", "PUT", "PATCH"]);
|
|
const AUTH_BODY_KEYS = new Set([
|
|
"access_token",
|
|
"client_secret",
|
|
"grant_type",
|
|
"id_token",
|
|
"refresh_token",
|
|
]);
|
|
const REQUEST_BODY_KEYS = new Set([
|
|
"conversationId",
|
|
"conversation_id",
|
|
"contents",
|
|
"input",
|
|
"messages",
|
|
"model",
|
|
"prompt",
|
|
"request",
|
|
"tools",
|
|
"userSelectedModel",
|
|
]);
|
|
|
|
export function parseBody(bodyString: string): unknown {
|
|
try {
|
|
return JSON.parse(bodyString);
|
|
} catch {
|
|
return bodyString;
|
|
}
|
|
}
|
|
|
|
async function capturePreparedRequest(
|
|
requestCapture: Capture | null | undefined,
|
|
url: string,
|
|
headers: Record<string, string>,
|
|
body: unknown,
|
|
bodyString: string,
|
|
log?: WarnLog | null
|
|
) {
|
|
if (!requestCapture) return;
|
|
const latest = requestCapture.latest?.();
|
|
if (latest?.url === url && latest.bodyString === bodyString) return;
|
|
|
|
try {
|
|
await requestCapture.capture({ url, headers, body, bodyString });
|
|
} catch (error) {
|
|
log?.warn?.(
|
|
"REQUEST_LOG",
|
|
`Provider request logging hook failed: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
export function captureCurrentProviderRequest(
|
|
url: string,
|
|
headers: Record<string, string>,
|
|
body: unknown,
|
|
bodyString: string,
|
|
log?: WarnLog | null
|
|
) {
|
|
return capturePreparedRequest(
|
|
captureState.context.getStore(),
|
|
url,
|
|
headers,
|
|
body,
|
|
bodyString,
|
|
log
|
|
);
|
|
}
|
|
|
|
export function captureCurrentProviderBody(
|
|
url: string,
|
|
headers: Record<string, string>,
|
|
bodyString: string,
|
|
log?: WarnLog | null
|
|
) {
|
|
return captureCurrentProviderRequest(url, headers, parseBody(bodyString), bodyString, log);
|
|
}
|
|
|
|
export function runWithCapture<T>(requestCapture: Capture, fn: () => Promise<T>): Promise<T> {
|
|
installFetchCapture();
|
|
return captureState.context.run(requestCapture, fn);
|
|
}
|
|
|
|
function installFetchCapture() {
|
|
if (globalThis.fetch === captureState.wrappedFetch) return;
|
|
|
|
captureState.wrappedInnerFetch = globalThis.fetch.bind(globalThis);
|
|
captureState.wrappedFetch = (async (input: FetchInput, init?: FetchInit) => {
|
|
const activeCapture = captureState.context.getStore();
|
|
if (activeCapture) {
|
|
await captureFetchRequest(activeCapture, input, init);
|
|
}
|
|
return captureState.wrappedInnerFetch!(input, init);
|
|
}) as typeof fetch;
|
|
globalThis.fetch = captureState.wrappedFetch;
|
|
}
|
|
|
|
async function captureFetchRequest(requestCapture: Capture, input: FetchInput, init?: FetchInit) {
|
|
const method = getFetchMethod(input, init);
|
|
if (!BODY_METHODS.has(method)) return;
|
|
|
|
const bodyString = bodyToString(init?.body);
|
|
if (!bodyString) return;
|
|
|
|
const body = parseBody(bodyString);
|
|
if (!looksLikeProviderRequestBody(body)) return;
|
|
|
|
await capturePreparedRequest(
|
|
requestCapture,
|
|
getFetchUrl(input),
|
|
getFetchHeaders(input, init),
|
|
body,
|
|
bodyString
|
|
);
|
|
}
|
|
|
|
function getFetchMethod(input: FetchInput, init?: FetchInit) {
|
|
const method = init?.method || (isRequest(input) ? input.method : "GET");
|
|
return String(method || "GET").toUpperCase();
|
|
}
|
|
|
|
function getFetchUrl(input: FetchInput) {
|
|
if (typeof input === "string") return input;
|
|
if (input instanceof URL) return input.toString();
|
|
if (isRequest(input)) return input.url;
|
|
return String(input);
|
|
}
|
|
|
|
function getFetchHeaders(input: FetchInput, init?: FetchInit) {
|
|
const headers = new Headers(isRequest(input) ? input.headers : undefined);
|
|
if (init?.headers) {
|
|
new Headers(init.headers).forEach((value, key) => headers.set(key, value));
|
|
}
|
|
|
|
const result: Record<string, string> = {};
|
|
headers.forEach((value, key) => {
|
|
result[key] = value;
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function bodyToString(body: BodyInit | null | undefined): string | null {
|
|
if (typeof body === "string") return body;
|
|
if (body instanceof URLSearchParams) return body.toString();
|
|
if (body instanceof ArrayBuffer) return new TextDecoder().decode(body);
|
|
if (ArrayBuffer.isView(body)) {
|
|
return new TextDecoder().decode(
|
|
body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength)
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isRequest(input: FetchInput): input is Request {
|
|
return typeof Request !== "undefined" && input instanceof Request;
|
|
}
|
|
|
|
function looksLikeProviderRequestBody(body: unknown) {
|
|
if (!body || typeof body !== "object" || Array.isArray(body)) return false;
|
|
const record = body as Record<string, unknown>;
|
|
|
|
if (Object.keys(record).some((key) => AUTH_BODY_KEYS.has(key))) return false;
|
|
if (Object.keys(record).some((key) => REQUEST_BODY_KEYS.has(key))) return true;
|
|
|
|
return (
|
|
typeof record.query === "string" && !!record.variables && typeof record.variables === "object"
|
|
);
|
|
}
|
|
|
|
export function createPreparedRequestLogger(
|
|
reqLogger: RequestLoggerLike,
|
|
scope: PendingRequestScope
|
|
): Capture {
|
|
let latest: ProviderRequestPrepared | null = null;
|
|
return {
|
|
capture(request) {
|
|
latest = request;
|
|
reqLogger.logTargetRequest(request.url, request.headers, request.body);
|
|
updatePendingScope(scope, {
|
|
providerRequest: request.body,
|
|
providerUrl: request.url,
|
|
stage: "sending_to_provider",
|
|
});
|
|
},
|
|
body(fallback) {
|
|
const resolved = latest?.body ?? fallback;
|
|
// #4091: the captured body is rebuilt from the serialized upstream payload
|
|
// (the fetch-capture does `JSON.parse(JSON.stringify(...))`), which drops
|
|
// non-enumerable properties. The native-Claude tool-name cloak stashes its
|
|
// per-request alias→original map as a NON-ENUMERABLE `_toolNameMap` on the
|
|
// real (fallback) transformed body; without it the response-side un-cloak
|
|
// (`mergeResponseToolNameMap` → `remapToolNamesInResponse`) can't restore
|
|
// MCP / snake_case tool names, so Claude Code receives the cloaked
|
|
// PascalCase name and rejects every call with "No such tool available".
|
|
// Re-attach the map onto the resolved (captured) body — kept non-enumerable
|
|
// so it still never re-serializes into an upstream request.
|
|
if (
|
|
resolved !== fallback &&
|
|
resolved &&
|
|
typeof resolved === "object" &&
|
|
fallback &&
|
|
typeof fallback === "object"
|
|
) {
|
|
const map = (fallback as Record<string, unknown>)._toolNameMap;
|
|
const target = resolved as Record<string, unknown>;
|
|
if (map instanceof Map && !(target._toolNameMap instanceof Map)) {
|
|
Object.defineProperty(target, "_toolNameMap", {
|
|
value: map,
|
|
enumerable: false,
|
|
configurable: true,
|
|
writable: true,
|
|
});
|
|
}
|
|
}
|
|
return resolved;
|
|
},
|
|
latest() {
|
|
return latest;
|
|
},
|
|
};
|
|
}
|