/** * pxpipe proxy as a single Web-standard fetch handler. * Adapted by src/node.ts and src/worker.ts; uses only Request/Response/URL/fetch. */ import { type TransformOptions, type TransformInfo } from './transform.js'; import type { Usage } from './types.js'; export interface ProxyConfig { /** 'cloudflare-ai-gateway': routes both families through gatewayBaseUrl; * OpenAI paths drop the `/v1` prefix to match gateway shape. */ provider?: 'cloudflare-ai-gateway'; /** Gateway base URL (account/gateway-scoped). Required when provider is set. */ gatewayBaseUrl?: string; /** Extra headers injected on every upstream request (e.g. gateway auth). */ gatewayHeaders?: Record; /** Anthropic API base, no trailing slash. Defaults to api.anthropic.com. */ upstream?: string; /** Override or supply an API key. If unset, we forward whatever the client sent. */ apiKey?: string; /** Override the Anthropic `authorization` bearer. Pass a function to re-resolve * per request: subscription tokens expire, and a client that froze its bearer * at startup (a container env var) cannot renew one mid-run. Resolving here * keeps rotation on the host, with a single writer. */ authToken?: string | (() => string | undefined); /** OpenAI API base for GPT chat completions, no trailing slash. */ openAIUpstream?: string; /** Override or supply an OpenAI API key. If unset, we forward Authorization. */ openAIApiKey?: string; /** Cloudflare's OpenAI-compatible Chat Completions endpoint and bearer key. */ cloudflareUpstream?: string; cloudflareApiKey?: string; /** Exact model ids routed to each non-default provider. Unlisted Claude * models retain normal Anthropic routing. */ openAIModels?: string[]; cloudflareModels?: string[]; /** Pass a function to inject dynamic values per-request (e.g. live charsPerToken); * static object for Workers/tests. */ transform?: TransformOptions | (() => TransformOptions); /** Called after every request — useful for logging / metrics in the host. */ onRequest?: (event: ProxyEvent) => void | Promise; /** Persist 4xx diagnostics: the gzipped request body plus the upstream error * body. Off by default because either side may contain prompts or secrets. */ captureErrorReqBody?: boolean; /** Abort the upstream request if response headers have not arrived within this * many ms. Cleared once headers land, so long generations are unaffected. * 0 disables. */ upstreamHeadersTimeoutMs?: number; /** Abort the upstream response if no bytes arrive for this many ms. This is the * stall guard: a wedged connection can otherwise be held open forever. 0 disables. */ upstreamIdleTimeoutMs?: number; /** How long an in-flight request may reject an identical retry before the dedupe * fails open. Prevents one stalled request from permanently 409-ing its retries. * 0 disables dedupe entirely. */ duplicateHoldMs?: number; /** Hard ceiling, in bytes, on an inbound request body pxpipe will hold in * memory. Transformable routes have to read the whole body, so without a * ceiling one client decides how much the proxy allocates: a Worker or a Node * instance bound to anything other than loopback is then one long request away * from memory exhaustion. Over-limit bodies get a provider-shaped 413 before * any upstream call. Routes pxpipe only labels are never rejected by this - * they carry uploads and audio - but their model sniff is bounded too. * * Defaults to {@link DEFAULT_MAX_REQUEST_BYTES}. A non-integer, zero or * negative value is ignored in favour of that default: an unusable limit must * not silently become no limit. */ maxRequestBytes?: number; } export interface ProxyEvent { method: string; path: string; /** Top-level request model when present. Used for telemetry/dashboard labels only. */ model?: string; /** Provider cost/usage semantics after any internal wire bridge. Unlike * `path`, this describes the upstream that actually billed the request. */ accountingProvider?: 'anthropic' | 'openai' | 'google'; status: number; /** Wall-clock ms from request start to event fire (≈ end of upstream body). */ durationMs: number; /** Wall-clock ms from request start to upstream response headers. */ firstByteMs?: number; /** Wall-clock ms spent in the local transform (render + encode), excluding any * upstream probe. `durationMs - transformMs` is the upstream half, so a slow * request can be attributed to our own CPU vs the provider without guessing. */ transformMs?: number; info?: TransformInfo; /** Usage block from Anthropic's response — input/output/cache tokens. */ usage?: Usage; /** Model stop reason from the response ("end_turn", "tool_use", "max_tokens", * "refusal", …). "refusal" = safety classifier fired on the transformed request — * scorers must fail cost comparisons on refusal rows (refusals emit almost no * output and would otherwise look "cheaper"). OpenAI finish_reason ("stop", * "length", "content_filter", …) is normalized into the same field. */ stopReason?: string; error?: string; /** First ~2 KiB of the upstream 4xx body (not captured on 2xx or 5xx). */ errorBody?: string; /** sha256[0..8] of the transformed outgoing body — set on every /v1/messages POST for correlation. */ reqBodySha8?: string; /** Gzipped transformed body, populated only on 4xx. Node may write to sidecar (see reqBodySamplePath). */ reqBodyGz?: Uint8Array; /** Set by the Node host instead of reqBodyGz when the body was written to a sidecar file. */ reqBodySamplePath?: string; /** Ground-truth char counts from the response stream, independent of usage.output_tokens. * Absent when the body couldn't be scanned (5xx, unknown content-type). See OutputMeasurement. */ measurement?: OutputMeasurement; /** Upstream response media/encoding metadata for scanner diagnostics. */ responseContentType?: string; responseContentEncoding?: string; } /** Default ceiling on a buffered inbound body: 16 MiB. * * Chosen against what the transform actually has to hold, not against what a * provider accepts. Real Claude Code requests measured on production traffic sit * far below this even with a 400k system slab and a long tool-heavy history, so * the limit is not reachable by ordinary use. Hosts that genuinely need more can * raise it explicitly; nothing raises it implicitly. */ export declare const DEFAULT_MAX_REQUEST_BYTES: number; /** * Ground-truth char counts from the response stream, independent of usage.output_tokens. * redactedBlockCount blocks are opaque server bytes — no char count available for those. */ export interface OutputMeasurement { textChars: number; thinkingChars: number; toolUseChars: number; redactedBlockCount: number; } /** What the client presented, by shape only. * * Classification never inspects a credential's contents beyond its prefix and * segment structure, and never reads a local token store: pxpipe does not know * or want to know which account a token belongs to. Shape is enough to decide * routing, and it is the only thing safe to decide it on. */ export type InboundCredential = 'none' /** `x-api-key`, which only Anthropic uses. */ | 'anthropic-key' /** `Bearer sk-ant-…`: an Anthropic key or subscription token. */ | 'anthropic-bearer' /** `Bearer `: how Codex and ChatGPT subscription auth arrive. */ | 'oauth-jwt' /** `Bearer sk-…` that is not Anthropic: an OpenAI-style API key. */ | 'api-key-bearer' /** A bearer of unrecognised shape. Gateways and self-hosted upstreams use these. */ | 'opaque-bearer'; export declare function classifyInboundCredential(headers: Headers): InboundCredential; /** The decision for the outgoing `authorization` header. */ export type OutboundAuth = /** Forward what the client sent, unchanged. */ { action: 'keep-inbound'; reason: string; } /** Install the host's configured key in its place. */ | { action: 'replace'; reason: string; } /** Send no authorization at all. */ | { action: 'drop'; reason: string; }; /** * Credential policy for a direct OpenAI-family route: `/v1/responses`, * `/v1/chat/completions`, `/v1/models` and the provider-prefixed equivalents, * where the client speaks to the OpenAI upstream itself rather than through a * Messages bridge. * * Three rules, in priority order: * * 1. An Anthropic-shaped credential never reaches an OpenAI upstream. That is a * cross-provider credential disclosure, and a guaranteed 401 on top. The * route classifier already refuses this for the ambiguous `/v1/models` path; * this applies the same rule to every OpenAI route. * 2. Subscription OAuth is preserved even when the host has an API key * configured. A Codex user proxying through pxpipe means to spend their own * subscription; silently substituting the host key bills the wrong account * and usually fails, and the user has no way to see why. * 3. Otherwise a configured key replaces whatever arrived, and is used as the * fallback when nothing arrived. This is the documented "host supplies the * credential" mode. */ export declare function resolveOpenAIRouteAuth(inbound: InboundCredential, hasConfiguredKey: boolean): OutboundAuth; export declare function resetGoogleCountShapePreferenceForTests(): void; /** * Resolve upstream URLs from config. Pure — unit-testable. * * Every env-derived input is trimmed of leading/trailing whitespace before * URL construction, and trailing slashes are stripped so `base + path` joins * cleanly. The trim is defensive: a stray space in OPENAI_UPSTREAM / * ANTHROPIC_UPSTREAM / PXPIPE_GATEWAY_BASE_URL (commonly introduced by * cmd.exe `set VAR=...`, a copy-paste with a trailing space, or a shell * quoting bug in a launcher script) would otherwise build URLs like * "https://api.openai.com /v1/..." — fetch() then throws * "Failed to parse URL" with no actionable log line, and the operator is * left guessing. Trimming at the URL boundary keeps the failure mode loud * (the proxy still returns a real 401/502 from upstream) instead of silent. */ export declare function resolveUpstreams(config: ProxyConfig): { anthropic: string; openai: string; stripOpenAIV1: boolean; }; /** Parse PXPIPE_GATEWAY_HEADERS — JSON object or `k=v;k2=v2`. */ export declare function parseGatewayHeaders(spec: string | undefined): Record; /** Build the proxy fetch handler. */ export declare function createProxy(config?: ProxyConfig): (req: Request) => Promise; //# sourceMappingURL=proxy.d.ts.map