Files
oh-my-pi/packages/ai/src/auth-gateway/server.ts
T
can1357 1b9d9d0851 refactor(catalog)!: split model catalog from pi-ai
Move bundled models, model cache/manager, thinking metadata, effort helpers,
provider descriptors/discovery, wire constants, and model identity utilities
into the new @oh-my-pi/pi-catalog package.

Update pi-ai to keep provider runtime/auth concerns, move catalog provider
metadata into CATALOG_PROVIDERS, and migrate coding-agent, agent, stats, docs,
and tests to import catalog values from pi-catalog.

Split coding-agent model registry helpers into discovery, roles, and models
config modules while preserving registry orchestration.

BREAKING CHANGE: @oh-my-pi/pi-ai no longer exports catalog subpaths such as
/models, /model-cache, /model-manager, /model-thinking, /effort,
/provider-models*, discovery helpers, and provider wire constants; use the
matching @oh-my-pi/pi-catalog subpaths instead.
2026-06-10 04:06:57 +02:00

869 lines
36 KiB
TypeScript

/**
* omp auth-gateway HTTP server.
*
* Accepts any provider-format request (OpenAI chat-completions, Anthropic
* messages, OpenAI Responses) and dispatches through pi-ai's `streamSimple()`
* — which handles credential injection, anthropic-beta headers, codex
* websocket transport, and all the per-provider intricacies. The gateway is
* pure protocol translation: foreign wire → omp Context → pi-ai stream() →
* omp events → foreign wire.
*
* Endpoints:
* GET /healthz → unauth; ok + version
* GET /v1/usage → aggregated provider usage (5-min per-credential cache via AuthStorage)
* GET /v1/credentials/check → per-credential auth probe (diagnose 401s in a multi-account pool)
* GET /v1/models → list known models from the registry
* POST /v1/chat/completions → OpenAI chat-completions in/out
* POST /v1/messages → Anthropic messages in/out
* POST /v1/responses → OpenAI Responses in/out
*/
import { Effort } from "@oh-my-pi/pi-catalog/effort";
import { extractRetryHint, logger } from "@oh-my-pi/pi-utils";
import type { ApiKeyResolver } from "../auth-retry";
import type { AuthStorage } from "../auth-storage";
import * as anthropicMessages from "../providers/anthropic-messages-server";
import * as openaiChat from "../providers/openai-chat-server";
import * as openaiResponses from "../providers/openai-responses-server";
import * as piNative from "../providers/pi-native-server";
import { isUsageLimitError } from "../rate-limit-utils";
import { streamSimple } from "../stream";
import type { Api, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "../types";
import { parseBind } from "../utils/parse-bind";
import { captureRequestHeaders, corsHeaders, isAuthorized, json, resolvePeer, withCors } from "./http";
import type {
AuthGatewayServerHandle,
AuthGatewayServerOptions,
AuthGatewayFormatModule as FormatModule,
AuthGatewayParsedRequest as ParsedFormatRequest,
} from "./types";
import { DEFAULT_AUTH_GATEWAY_BIND } from "./types";
// ParsedFormatRequest / ParsedFormatOptions / FormatModule come from ./types.
export type ModelResolver = (modelId: string) => Model<Api> | undefined;
export interface AuthGatewayBootOptions extends AuthGatewayServerOptions {
/** Source of credentials. Caller wires this to a broker-backed AuthStorage. */
storage: AuthStorage;
/**
* Resolve a client-requested model id to a pi-ai Model. Caller supplies
* this from a ModelRegistry (lives in `coding-agent` to avoid an inverse
* dependency in `pi-ai`).
*/
resolveModel: ModelResolver;
/** Optional supplier for `/v1/models` listing. Returns the full model array. */
listModels?: () => Iterable<Model<Api>>;
}
// `parseBind` lives in ../utils/parse-bind so the gateway and broker can't
// drift on accepted inputs (e.g. empty hostname, IPv6 brackets).
const FORMAT_ROUTES: Record<string, { module: FormatModule; label: string }> = {
"/v1/chat/completions": { module: openaiChat, label: "openai-chat" },
"/v1/messages": { module: anthropicMessages, label: "anthropic-messages" },
"/v1/responses": { module: openaiResponses, label: "openai-responses" },
};
// (passthrough fast-path removed — it bypassed pi-ai provider logic, in
// particular the Anthropic Claude-Code OAuth system-prompt prefix injection.
// Every request now takes the translate path so credential-specific request
// shaping always applies.)
// Options the caller's wire format may carry but the resolved provider can't
// honour are dropped silently in `buildStreamOptions`. We used to 400 here
// (`Unsupported option: temperature for openai-codex-responses`), but every
// realistic client (llm-git, openai SDK, anthropic SDK) bakes some of these
// defaults in without knowing which model they'll resolve to. Failing loudly
// just turned that into per-call config hell. Silent strip is what the
// upstream provider would do anyway when it ignores extra fields.
/**
* Derive a stable cache identity from the parts of the request that don't
* change turn-to-turn within a logical conversation: model id, system prompt,
* tool definitions, and the first message (the conversation seed). Codex-class
* backends only cache prefixes when an explicit `prompt_cache_key` is set;
* without one, two requests with the same prefix but different trailing
* messages don't coalesce. This bridges Anthropic-style clients (which signal
* caching via `cache_control` markers rather than an opaque key) to Codex's
* keyed model so cross-protocol caching "just works".
*
* Including the first message scopes the key to one logical conversation:
* two different chats with the same system prompt no longer share a cache
* bucket and can't trample each other's prefix-tree entries.
*
* Anthropic-backed requests ignore `sessionId`; the key is harmless there.
*/
function deriveSessionId(modelId: string, context: Context): string {
const parts: string[] = [modelId];
if (context.systemPrompt && context.systemPrompt.length > 0) {
parts.push(context.systemPrompt.join("\n\n"));
}
if (context.tools && context.tools.length > 0) {
parts.push(JSON.stringify(context.tools));
}
const first = context.messages?.[0];
if (first) {
// Strip timestamp / provider metadata so the hash is stable across turns
// of the same conversation (omp re-stamps every parsed Message). role +
// content is what's actually on the wire.
parts.push(JSON.stringify({ role: first.role, content: first.content }));
}
const seed = parts.join("\u0000");
const hex = new Bun.CryptoHasher("sha256").update(seed).digest("hex");
// Format the leading 128 bits as a v4-shape UUID (8-4-4-4-12). Codex's
// `normalizeOpenAIResponsesPromptCacheKey` accepts ≤64 chars verbatim, so
// the 36-char UUID flows through unchanged.
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
}
function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: AbortSignal): SimpleStreamOptions {
const opts: SimpleStreamOptions = { signal };
const { options } = parsed;
// Codex backend rejects `temperature` / `top_p` (per-model defaults only),
// so we drop them silently for that one provider. Every other unsupported
// option is just ignored by `streamSimple` if the underlying provider
// doesn't honour it.
const isCodex = api === "openai-codex-responses";
if (options.maxOutputTokens !== undefined) opts.maxTokens = options.maxOutputTokens;
if (options.temperature !== undefined && !isCodex) opts.temperature = options.temperature;
if (options.topP !== undefined && !isCodex) opts.topP = options.topP;
if (options.topK !== undefined) opts.topK = options.topK;
if (options.minP !== undefined) opts.minP = options.minP;
if (options.stopSequences !== undefined) opts.stopSequences = options.stopSequences;
if (options.presencePenalty !== undefined) opts.presencePenalty = options.presencePenalty;
if (options.frequencyPenalty !== undefined) opts.frequencyPenalty = options.frequencyPenalty;
if (options.repetitionPenalty !== undefined) opts.repetitionPenalty = options.repetitionPenalty;
if (options.metadata !== undefined) opts.metadata = options.metadata;
if (options.headers !== undefined) opts.headers = { ...(opts.headers ?? {}), ...options.headers };
if (options.toolChoice !== undefined) {
opts.toolChoice =
typeof options.toolChoice === "object" ? { type: "tool", name: options.toolChoice.name } : options.toolChoice;
}
if (options.reasoning !== undefined) opts.reasoning = options.reasoning;
if (options.disableReasoning !== undefined) opts.disableReasoning = options.disableReasoning;
if (options.hideThinkingSummary !== undefined) opts.hideThinkingSummary = options.hideThinkingSummary;
if (options.taskBudget !== undefined) opts.taskBudget = options.taskBudget;
if (options.serviceTier !== undefined) opts.serviceTier = options.serviceTier;
if (options.cacheRetention !== undefined) opts.cacheRetention = options.cacheRetention;
// Client-supplied `prompt_cache_key` wins; otherwise derive a stable
// key from the model + system + tools so prefix caching engages on
// Codex-class backends across turns of the same logical conversation.
const promptCacheKey = options.promptCacheKey ?? deriveSessionId(parsed.modelId, parsed.context);
opts.promptCacheKey = promptCacheKey;
opts.sessionId = promptCacheKey;
if (options.thinkingBudgets) {
opts.thinkingBudgets = { ...(opts.thinkingBudgets ?? {}), ...options.thinkingBudgets };
}
if (options.explicitThinkingBudgetTokens !== undefined) {
// Mirror Rust's `resolve_thinking_budget`: explicit budget pins onto
// whichever effort the client requested (or High when unspecified) and
// ALSO sets the effort so providers that gate on `reasoning` actually
// surface the budget.
const effort = options.reasoning ?? Effort.High;
opts.thinkingBudgets = {
...(opts.thinkingBudgets ?? {}),
[effort]: options.explicitThinkingBudgetTokens,
};
opts.reasoning ??= effort;
}
// Fields that don't yet have a matching pi-ai `SimpleStreamOptions` slot.
// Surfaced once in debug logs so they show up when wiring a new provider,
// but NEVER widened into `options.extra` — every consumer would have to
// re-implement the typed parse to read them back out.
// TODO(pi-ai): land first-class fields and replace these blocks.
if (
options.parallelToolCalls !== undefined ||
options.previousResponseId !== undefined ||
options.seed !== undefined ||
options.logitBias !== undefined ||
options.user !== undefined ||
options.responseFormat !== undefined
) {
logger.debug("auth-gateway dropped unsupported typed options", {
api,
parallelToolCalls: options.parallelToolCalls,
previousResponseId: options.previousResponseId,
seed: options.seed,
hasLogitBias: options.logitBias !== undefined,
user: options.user,
hasResponseFormat: options.responseFormat !== undefined,
});
}
return opts;
}
/**
* Classify an upstream / gateway-internal error into a status code and a
* format-neutral type. The order is intentional:
*
* 1. Honour an explicit numeric `status` property on the thrown error.
* 2. Parse a status code embedded in the message string. Provider errors
* virtually always carry one (`Google API error (400): …`, `HTTP 429`,
* `status=503`) and the embedded value is authoritative.
* 3. Fall through to **word-boundaried** substring heuristics. The old
* `lower.includes("rate")` test famously matched
* `GenerateContentRequest`, surfacing every Google 400 as a 429
* `rate_limit_error`. The patterns here all require boundaries so they
* don't collide with provider field names.
*/
export function classifyGatewayError(err: unknown): { status: number; type: string; message: string } {
const message = err instanceof Error ? err.message : String(err);
// 1. Custom pi-ai errors may attach a numeric `status` property.
const statusProp =
typeof err === "object" && err !== null && typeof (err as { status?: unknown }).status === "number"
? (err as { status: number }).status | 0
: undefined;
if (statusProp !== undefined) return bucketStatus(statusProp, message);
if (err instanceof Error && err.name === "AbortError") return { status: 499, type: "request_aborted", message };
// 2. Status code embedded in the message. Requires a contextual keyword
// (`HTTP`, `API error`, `status`, …) or a leading `(NNN)` token so we
// don't trip on incidental three-digit numbers ("took 200ms").
const embedded = extractEmbeddedStatus(message);
if (embedded !== undefined) return bucketStatus(embedded, message);
// 3. Word-boundaried substring heuristics.
if (/\baborted\b|\babort signal\b/i.test(message)) {
return { status: 499, type: "request_aborted", message };
}
if (/\b(?:unauthorized|forbidden)\b/i.test(message)) {
return { status: 401, type: "authentication_error", message };
}
if (
// Match rate-limit phrasings without colliding with
// `GenerateContentRequest`, `accelerate`, `iterate`, `deprecated`, etc.
/\brate[- _]?limit(?:s|ed|ing)?\b|\bquota(?:_exceeded| exceeded)?\b|\btoo[- _]many[- _]requests\b/i.test(
message,
) ||
// Usage-limit phrasings emit no embedded status. Codex friendly text
// reads "You have hit your ChatGPT usage limit … Try again in ~158
// min."; pi-ai's central `isUsageLimitError` already encodes every
// known provider variant, so reuse it instead of forking the regex.
// Without this branch the classifier falls through to the default
// 502/upstream_error, which is what callers were seeing when their
// account hit its cap.
isUsageLimitError(message)
) {
return { status: 429, type: "rate_limit_error", message };
}
if (/\b(?:unsupported|invalid_request|invalid request|bad request|malformed)\b/i.test(message)) {
return { status: 400, type: "invalid_request_error", message };
}
return { status: 502, type: "upstream_error", message };
}
function bucketStatus(status: number, message: string): { status: number; type: string; message: string } {
if (status === 401 || status === 403) return { status, type: "authentication_error", message };
if (status === 429) return { status, type: "rate_limit_error", message };
if (status >= 400 && status < 500) return { status, type: "invalid_request_error", message };
if (status >= 500) return { status, type: "upstream_error", message };
return { status: 502, type: "upstream_error", message };
}
/**
* Pull a status code from common error-message shapes. Returns undefined when
* no contextual keyword is present, so we never guess at incidental numbers.
*/
function extractEmbeddedStatus(message: string): number | undefined {
// `Google API error (400)`, `OpenAI API error (429): …`, `(503)`
// `HTTP 429: too many requests`
// `status: 503`, `status_code=429`, `status=400`
const re = /(?:\bHTTP\b|\bAPI error\b|\bstatus(?:[- _]?code)?\b)\s*[:=]?\s*\(?\s*(\d{3})\b|\((\d{3})\)/i;
const m = message.match(re);
if (!m) return undefined;
const raw = m[1] ?? m[2];
if (!raw) return undefined;
const code = Number.parseInt(raw, 10);
return Number.isFinite(code) && code >= 100 && code < 600 ? code : undefined;
}
/**
* Hook fired by {@link streamSimple} when the upstream request fails in a
* way that's rotatable — today that's HTTP 401 (credential is bad) and
* usage-limit phrasing matched by {@link isUsageLimitError} (Codex's
* `usage_limit_reached`, Anthropic's `usage_limit_reached`, Google's
* `resource_exhausted`, …). The two cases need different storage actions:
*
* - **usage-limit** → {@link AuthStorage.markUsageLimitReached}. Marks just
* the current session's credential as temporarily blocked (honouring
* `retry-after` / `resets_at` hints when present) and returns `true` only
* when a sibling credential is still available. Burning the credential
* with `invalidateCredentialMatching` here would orphan accounts whose
* reset window is several hours away — exactly the bug this helper exists
* to avoid.
* - **auth-failure** → {@link AuthStorage.invalidateCredentialMatching}.
* Suspect/delete the row so it doesn't get re-picked next request.
*
* In both branches we return the next `getApiKey` result (sticky on the
* same `sessionId`) so streamSimple can transparently retry the pre-emit
* failure with a fresh credential. Returning `undefined` aborts the retry
* and surfaces the original error to the caller.
*/
async function refreshGatewayApiKeyAfterAuthError(
storage: AuthStorage,
model: Model<Api>,
sessionId: string,
provider: string,
oldKey: string,
error: unknown,
signal: AbortSignal,
format: string,
peer: string,
): Promise<string | undefined> {
const message = error instanceof Error ? error.message : String(error);
if (isUsageLimitError(message)) {
const retryAfterMs = extractRetryHint(undefined, message);
const { switched, retryAtMs } = await storage.markUsageLimitReached(provider, sessionId, {
retryAfterMs,
baseUrl: model.baseUrl,
signal,
});
logger.debug("auth-gateway retrying provider request after usage-limit block", {
format,
provider,
peer,
switched,
retryAfterMs,
retryAtMs,
error: message,
});
if (!switched) return undefined;
return storage.getApiKey(provider, sessionId, { modelId: model.id, signal });
}
await storage.invalidateCredentialMatching(provider, oldKey, { sessionId, signal });
logger.debug("auth-gateway retrying provider request after credential invalidation", {
format,
provider,
peer,
error: message,
});
return storage.getApiKey(provider, sessionId, { modelId: model.id, signal });
}
/**
* Build the {@link ApiKeyResolver} handed to `streamSimple` for a gateway
* request. Drives the central a/b/c auth-retry policy server-side:
*
* - initial resolve → the credential already resolved for this request.
* - step (b) `!lastChance` → force-refresh the SAME session-sticky credential
* (a peer/broker may have rotated its token out from under our cached copy).
* - step (c) `lastChance` → {@link refreshGatewayApiKeyAfterAuthError} switches
* to a sibling (usage-limit block vs credential invalidation by error class).
*
* `lastKey` tracks the most recent bearer so the switch step invalidates the
* credential that actually failed.
*/
function buildGatewayApiKeyResolver(
storage: AuthStorage,
model: Model<Api>,
sessionId: string,
initialKey: string,
requestSignal: AbortSignal,
format: string,
peer: string,
): ApiKeyResolver {
let lastKey = initialKey;
return async ({ lastChance, error, signal }) => {
const sig = signal ?? requestSignal;
if (error === undefined) {
lastKey = initialKey;
return initialKey;
}
if (!lastChance) {
const refreshed = await storage.getApiKey(model.provider, sessionId, {
modelId: model.id,
signal: sig,
forceRefresh: true,
});
lastKey = refreshed ?? lastKey;
return refreshed;
}
const next = await refreshGatewayApiKeyAfterAuthError(
storage,
model,
sessionId,
model.provider,
lastKey,
error,
sig,
format,
peer,
);
lastKey = next ?? lastKey;
return next;
};
}
function clientClosedResponse(route: { module: FormatModule }): Response {
return route.module.formatError(499, "request_aborted", "client closed request");
}
function mirrorRequestAbort(req: Request): AbortController {
const controller = new AbortController();
if (req.signal.aborted) {
controller.abort(req.signal.reason);
} else {
req.signal.addEventListener("abort", () => controller.abort(req.signal.reason), { once: true });
}
return controller;
}
// (handlePassthrough removed — see note above.)
async function handleFormatEndpoint(
route: { module: FormatModule; label: string },
bootOpts: AuthGatewayBootOptions,
req: Request,
peer: string,
): Promise<Response> {
const controller = mirrorRequestAbort(req);
if (controller.signal.aborted) return clientClosedResponse(route);
let body: unknown;
try {
body = await req.json();
} catch (error) {
if (controller.signal.aborted) return clientClosedResponse(route);
return route.module.formatError(400, "invalid_request_error", `Invalid JSON body: ${String(error)}`);
}
if (controller.signal.aborted) return clientClosedResponse(route);
// All three supported wire formats put the model id on a top-level `model`
// field. Read it without running the full strict schema so the route can
// produce a coherent error envelope when the model id is missing.
const modelId =
typeof body === "object" && body !== null && typeof (body as { model?: unknown }).model === "string"
? (body as { model: string }).model
: undefined;
if (!modelId) {
return route.module.formatError(400, "invalid_request_error", "Missing top-level `model` field");
}
const model = bootOpts.resolveModel(modelId);
if (!model) {
return route.module.formatError(404, "invalid_request_error", `Unknown model: ${modelId}`);
}
// Parse the wire-format request BEFORE resolving the credential so we
// have a stable per-conversation `sessionId` to thread into AuthStorage.
// Sticky-credential tracking and `markUsageLimitReached` both key off
// this id; without it `getApiKey` would re-roundrobin every request
// and `markUsageLimitReached` would no-op (it can only mark the
// credential it last handed out to that session).
let parsed: ParsedFormatRequest;
try {
parsed = route.module.parseRequest(body, req.headers);
} catch (error) {
if (controller.signal.aborted) return clientClosedResponse(route);
const message = error instanceof Error ? error.message : String(error);
return route.module.formatError(400, "invalid_request_error", message);
}
// Merge gateway-captured passthrough headers under the parser's own
// captures. Parsers that set `options.headers` themselves win (they may
// have stripped or normalized values); the gateway's allow-list fills in
// anything they didn't touch.
{
const captured = captureRequestHeaders(req.headers);
parsed.options.headers = { ...captured, ...(parsed.options.headers ?? {}) };
}
if (controller.signal.aborted) return clientClosedResponse(route);
// Sticky credential id: honour the client's `prompt_cache_key` when
// supplied (so external session ids align), otherwise derive from
// modelId + system + tools + first message. Mirrored into
// streamOpts.sessionId / promptCacheKey by `buildStreamOptions`.
const sessionId = parsed.options.promptCacheKey ?? deriveSessionId(parsed.modelId, parsed.context);
parsed.options.promptCacheKey ??= sessionId;
// pi-ai's stream() does NOT consult AuthStorage — the caller (us) is
// expected to resolve the credential and pass it as `options.apiKey`.
// For OAuth providers this returns the access token (refreshed via the
// broker override on AuthStorage when needed).
let apiKey: string | undefined;
try {
apiKey = await bootOpts.storage.getApiKey(model.provider, sessionId, {
modelId: model.id,
signal: controller.signal,
});
} catch (error) {
if (controller.signal.aborted) return clientClosedResponse(route);
const classified = classifyGatewayError(error);
logger.warn("auth-gateway getApiKey threw", { provider: model.provider, peer, error: classified.message });
return route.module.formatError(classified.status, classified.type, classified.message);
}
if (controller.signal.aborted) return clientClosedResponse(route);
if (!apiKey) {
return route.module.formatError(
401,
"authentication_error",
`No credential available for provider ${model.provider}`,
);
}
const streamOpts = buildStreamOptions(parsed, model.api, controller.signal);
streamOpts.apiKey = buildGatewayApiKeyResolver(
bootOpts.storage,
model,
sessionId,
apiKey,
controller.signal,
route.label,
peer,
);
logger.info("auth-gateway request", {
format: route.label,
model: parsed.modelId,
resolvedProvider: model.provider,
resolvedModel: model.id,
stream: parsed.stream,
peer,
});
let events: AssistantMessageEventStream;
try {
if (controller.signal.aborted) return clientClosedResponse(route);
events = streamSimple(model, parsed.context, streamOpts);
} catch (error) {
const classified = classifyGatewayError(error);
logger.warn("auth-gateway streamSimple threw", { format: route.label, error: classified.message, peer });
return route.module.formatError(classified.status, classified.type, classified.message);
}
if (!parsed.stream) {
try {
if (controller.signal.aborted) return clientClosedResponse(route);
const message = await events.result();
if (message.stopReason === "aborted" || message.stopReason === "error") {
const errorMessage =
message.errorMessage ??
(message.stopReason === "aborted" ? "Request was aborted" : "Upstream request failed");
logger.warn("auth-gateway non-streaming failed", {
format: route.label,
reason: message.stopReason,
error: errorMessage,
peer,
});
if (message.stopReason === "aborted") {
return route.module.formatError(499, "request_aborted", errorMessage);
}
const classified = classifyGatewayError(new Error(errorMessage));
return route.module.formatError(classified.status, classified.type, errorMessage);
}
return json(200, route.module.encodeResponse(message, parsed.modelId));
} catch (error) {
if (controller.signal.aborted) return clientClosedResponse(route);
const classified = classifyGatewayError(error);
logger.warn("auth-gateway non-streaming aborted", {
format: route.label,
error: classified.message,
peer,
});
return route.module.formatError(classified.status, classified.type, classified.message);
}
}
if (controller.signal.aborted) return clientClosedResponse(route);
const sseStream = route.module.encodeStream(events, parsed.modelId, parsed.options);
return new Response(sseStream, {
status: 200,
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
Connection: "keep-alive",
// Disable proxy buffering (nginx and ingress controllers honor this).
// Without it the SSE stream gets held until the buffer flushes, which
// stalls the long-thinking-budget calls we exist to support.
"X-Accel-Buffering": "no",
},
});
}
/**
* Pi-native fast path: `POST /v1/pi/stream`. Accepts the canonical pi-ai
* `Context` directly (no wire-format round-trip) and emits a bandwidth-shrunk
* event stream matching `pi-agent`'s `streamProxy`. Skips the OpenAI /
* Anthropic / Responses translation layers — those exist to bridge foreign
* SDKs (llm-git, anthropic-sdk, openai-sdk), and bridging back to pi-native
* just to bridge forward again is wasted work.
*
* Every other gateway concern (bearer auth, model resolve, credential fetch,
* abort mirroring, codex temperature/topP strip, prefix-cache key derivation,
* Claude-Code OAuth shaping inside `streamSimple`) still applies — only
* `parseRequest`/`encodeResponse`/`encodeStream` differ from the format-endpoint
* path.
*/
async function handlePiNative(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response> {
const controller = mirrorRequestAbort(req);
const aborted = (): Response => piNative.formatError(499, "request_aborted", "client closed request");
if (controller.signal.aborted) return aborted();
let body: unknown;
try {
body = await req.json();
} catch (error) {
if (controller.signal.aborted) return aborted();
return piNative.formatError(400, "invalid_request_error", `Invalid JSON body: ${String(error)}`);
}
if (controller.signal.aborted) return aborted();
let parsed: piNative.PiNativeParsedRequest;
try {
parsed = piNative.parseRequest(body, req.headers);
} catch (error) {
if (controller.signal.aborted) return aborted();
const message = error instanceof Error ? error.message : String(error);
return piNative.formatError(400, "invalid_request_error", message);
}
const model = bootOpts.resolveModel(parsed.modelId);
if (!model) {
return piNative.formatError(404, "invalid_request_error", `Unknown model: ${parsed.modelId}`);
}
// Pi-native already parsed `streamOpts.sessionId` (when set by the
// client); fall back to the derived key so credential-stickiness lines
// up with cache-prefix stickiness — same identity used for both means
// the next turn of this conversation reuses the same credential until
// it hits a usage cap, then markUsageLimitReached can hand off.
const sessionId = parsed.options.sessionId ?? deriveSessionId(parsed.modelId, parsed.context);
parsed.options.sessionId ??= sessionId;
let apiKey: string | undefined;
try {
apiKey = await bootOpts.storage.getApiKey(model.provider, sessionId, {
modelId: model.id,
signal: controller.signal,
});
} catch (error) {
if (controller.signal.aborted) return aborted();
const classified = classifyGatewayError(error);
logger.warn("auth-gateway getApiKey threw", { provider: model.provider, peer, error: classified.message });
return piNative.formatError(classified.status, classified.type, classified.message);
}
if (controller.signal.aborted) return aborted();
if (!apiKey) {
return piNative.formatError(
401,
"authentication_error",
`No credential available for provider ${model.provider}`,
);
}
// Build the SimpleStreamOptions actually handed to `streamSimple`. We
// trust the client's options (already allow-listed by `parseRequest`) and
// only inject server-controlled fields. The codex temperature/topP strip
// matches `buildStreamOptions` — Codex rejects them with a 400.
const streamOpts: SimpleStreamOptions = { ...parsed.options, apiKey, signal: controller.signal };
streamOpts.apiKey = buildGatewayApiKeyResolver(
bootOpts.storage,
model,
sessionId,
apiKey,
controller.signal,
"pi-native",
peer,
);
if (model.api === "openai-codex-responses") {
delete streamOpts.temperature;
delete streamOpts.topP;
}
// Merge gateway-captured passthrough headers under the client's own
// headers — the client's values win when they collide.
const captured = captureRequestHeaders(req.headers);
streamOpts.headers = { ...captured, ...(streamOpts.headers ?? {}) };
streamOpts.sessionId ??= sessionId;
logger.info("auth-gateway request", {
format: "pi-native",
model: parsed.modelId,
resolvedProvider: model.provider,
resolvedModel: model.id,
stream: parsed.stream,
peer,
});
let events: AssistantMessageEventStream;
try {
if (controller.signal.aborted) return aborted();
events = streamSimple(model, parsed.context, streamOpts);
} catch (error) {
const classified = classifyGatewayError(error);
logger.warn("auth-gateway streamSimple threw", { format: "pi-native", error: classified.message, peer });
return piNative.formatError(classified.status, classified.type, classified.message);
}
if (!parsed.stream) {
try {
if (controller.signal.aborted) return aborted();
const message = await events.result();
if (message.stopReason === "aborted" || message.stopReason === "error") {
const errorMessage =
message.errorMessage ??
(message.stopReason === "aborted" ? "Request was aborted" : "Upstream request failed");
logger.warn("auth-gateway non-streaming failed", {
format: "pi-native",
reason: message.stopReason,
error: errorMessage,
peer,
});
if (message.stopReason === "aborted") {
return piNative.formatError(499, "request_aborted", errorMessage);
}
const classified = classifyGatewayError(new Error(errorMessage));
return piNative.formatError(classified.status, classified.type, errorMessage);
}
return json(200, { message });
} catch (error) {
if (controller.signal.aborted) return aborted();
const classified = classifyGatewayError(error);
logger.warn("auth-gateway non-streaming aborted", { format: "pi-native", error: classified.message, peer });
return piNative.formatError(classified.status, classified.type, classified.message);
}
}
if (controller.signal.aborted) return aborted();
const sseStream = piNative.encodeStream(events);
return new Response(sseStream, {
status: 200,
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}
/**
* Snapshot of `GET /v1/usage` — `fetchUsageReports` already caches reports at
* a 5-minute per-credential TTL (with jitter, plus last-good fallback on
* failure) inside `AuthStorage`, so this handler is a thin wrapper that
* surfaces the same data to HTTP callers (notably the macOS usage widget).
*/
async function handleUsage(storage: AuthStorage, signal: AbortSignal): Promise<Response> {
const reports = (await storage.fetchUsageReports?.({ signal })) ?? [];
// Drop the heavy provider-specific `raw` payload — UI consumers only need
// `limits` + `metadata`. Match the broker's `/v1/usage` shape so a single
// client struct (Swift widget, llm-git, ...) works against either endpoint.
const trimmed = reports.map(({ raw: _raw, ...rest }) => rest);
return json(200, { generatedAt: Date.now(), reports: trimmed });
}
/**
* Per-credential health probe surfaced on `GET /v1/credentials/check`. Tells
* the caller exactly which row in their broker is producing 401s — the
* aggregate `/v1/usage` endpoint silently drops failed credentials, which is
* the wrong shape when you're diagnosing auth.
*
* The probe is sequential (one credential at a time) to avoid synchronized
* N-account fan-out tripping per-IP rate limits on provider `/usage`
* endpoints. For multi-account pools that's the difference between getting
* a clean diagnosis and getting a 429 storm.
*/
async function handleCredentialsCheck(storage: AuthStorage, signal: AbortSignal): Promise<Response> {
const credentials = await storage.checkCredentials({ signal });
return json(200, { generatedAt: Date.now(), credentials });
}
function handleModelsList(opts: AuthGatewayBootOptions): Response {
const list = opts.listModels ? Array.from(opts.listModels()) : [];
const data = list.map(model => ({
id: model.id,
object: "model" as const,
owned_by: model.provider,
api: model.api,
}));
return json(200, { object: "list", data });
}
export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServerHandle {
const bind = parseBind(opts.bind ?? DEFAULT_AUTH_GATEWAY_BIND);
const tokens = new Set<string>(opts.bearerTokens);
const version = opts.version;
const server = Bun.serve({
hostname: bind.hostname,
port: bind.port,
fetch: async (req): Promise<Response> => {
const url = new URL(req.url);
const pathname = url.pathname;
const peer = resolvePeer(req);
// CORS preflight is always answered without auth — browsers send
// preflights pre-authentication and a 401 here breaks the actual
// request before the bearer is ever attached.
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders(req) });
}
try {
if (req.method === "GET" && pathname === "/healthz") {
return withCors(json(200, { ok: true, version }), req);
}
if (!isAuthorized(req, tokens)) {
logger.info("auth-gateway request unauthorized", { method: req.method, path: pathname, peer });
return withCors(json(401, { error: "unauthorized" }), req);
}
// Aggregated usage — backed by AuthStorage's 5-min per-credential cache.
// Same shape as the broker's `/v1/usage`, so widget/llm-git speak to either with the
// same client struct.
if (req.method === "GET" && pathname === "/v1/usage") {
return withCors(await handleUsage(opts.storage, req.signal), req);
}
// Per-credential auth probe — diagnoses which row in a multi-account
// pool is producing 401s. Aggregated `/v1/usage` silently drops failed
// credentials, so we need a separate endpoint that captures errors.
if (req.method === "GET" && pathname === "/v1/credentials/check") {
return withCors(await handleCredentialsCheck(opts.storage, req.signal), req);
}
// Provider-format dispatch.
const formatRoute = FORMAT_ROUTES[pathname];
if (formatRoute && req.method === "POST") {
return withCors(await handleFormatEndpoint(formatRoute, opts, req, peer), req);
}
// Pi-native fast path. Same auth + provider plumbing as the
// foreign-wire routes, just without the wire-format translation.
if (req.method === "POST" && pathname === "/v1/pi/stream") {
return withCors(await handlePiNative(opts, req, peer), req);
}
// Model catalog.
if (req.method === "GET" && pathname === "/v1/models") {
return withCors(handleModelsList(opts), req);
}
// Route-table miss: no format module to defer to, so we emit a
// plain JSON 404 rather than guessing at a protocol-specific envelope.
return withCors(json(404, { error: `No route: ${req.method} ${pathname}` }), req);
} catch (error) {
logger.error("auth-gateway handler crashed", {
method: req.method,
path: pathname,
peer,
error: String(error),
});
return withCors(json(500, { error: "internal error" }), req);
}
},
// Max-out Bun's idle timeout. Long thinking-budget calls can sit idle
// for minutes before the first token arrives; the default kills them.
idleTimeout: 255,
});
const boundHost = server.hostname ?? bind.hostname;
const boundPort = server.port ?? bind.port;
return {
url: `http://${boundHost}:${boundPort}`,
port: boundPort,
hostname: boundHost,
close: async () => {
server.stop(true);
},
};
}