feat: added auth-gateway usage cache with single-flight 15s ttl fallback

- Added AbortSignal propagation and timeout-race handling for broker health, usage, refresh, and snapshot calls.
- Added single-flight usage-report caching with 15s TTL, per-caller abort races, and null-on-fail fallback.
- Expanded provider schemas and parse/build logic for cache metadata, headers, stop controls, and image/file content.
- Hardened auth flows by rejecting refresh sentinels and using timing-safe bearer-token comparisons.
This commit is contained in:
can1357
2026-05-17 01:10:25 +02:00
parent df1c1a6ba8
commit 484fca9c01
34 changed files with 2967 additions and 674 deletions
+17 -12
View File
@@ -217,7 +217,8 @@ fn run_pty_sync(
ct: task::CancelToken,
) -> Result<PtyRunResult> {
let pty_system = native_pty_system();
ct.heartbeat().map_err(|err| Error::from_reason(format!("PTY setup cancelled before openpty: {err}")))?;
ct.heartbeat()
.map_err(|err| Error::from_reason(format!("PTY setup cancelled before openpty: {err}")))?;
const PTY_STARTUP_TIMEOUT: Duration = Duration::from_secs(5);
let pair = if cfg!(windows) {
@@ -227,9 +228,9 @@ fn run_pty_sync(
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let result = pty_system.openpty(PtySize {
rows: config.rows,
cols: config.cols,
pixel_width: 0,
rows: config.rows,
cols: config.cols,
pixel_width: 0,
pixel_height: 0,
});
let _ = tx.send(result);
@@ -237,16 +238,18 @@ fn run_pty_sync(
match rx.recv_timeout(PTY_STARTUP_TIMEOUT) {
Ok(Ok(pair)) => pair,
Ok(Err(e)) => return Err(Error::from_reason(format!("Failed to open PTY: {e}"))),
Err(_) => return Err(Error::from_reason(
"PTY creation timed out (5s). ConPTY may be unavailable on this system.",
)),
Err(_) => {
return Err(Error::from_reason(
"PTY creation timed out (5s). ConPTY may be unavailable on this system.",
));
},
}
} else {
pty_system
.openpty(PtySize {
rows: config.rows,
cols: config.cols,
pixel_width: 0,
rows: config.rows,
cols: config.cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|err| Error::from_reason(format!("Failed to open PTY: {err}")))?
@@ -273,14 +276,16 @@ fn run_pty_sync(
cmd.env(key, value);
}
}
ct.heartbeat().map_err(|err| Error::from_reason(format!("PTY setup cancelled before spawn: {err}")))?;
ct.heartbeat()
.map_err(|err| Error::from_reason(format!("PTY setup cancelled before spawn: {err}")))?;
let mut child = pair
.slave
.spawn_command(cmd)
.map_err(|err| Error::from_reason(format!("Failed to spawn PTY command: {err}")))?;
drop(pair.slave);
ct.heartbeat().map_err(|err| Error::from_reason(format!("PTY setup cancelled before reader: {err}")))?;
ct.heartbeat()
.map_err(|err| Error::from_reason(format!("PTY setup cancelled before reader: {err}")))?;
let master = pair.master;
let mut writer = master
+19 -4
View File
@@ -1,7 +1,6 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Renamed public schema utilities in `@oh-my-pi/pi-ai/utils/schema` by replacing `sanitizeSchemaForGoogle`, `sanitizeSchemaForCCA`, `prepareSchemaForCCA`, and `sanitizeSchemaForMCP` with `normalizeSchemaForGoogle`, `normalizeSchemaForCCA`, and `normalizeSchemaForMCP`
@@ -11,10 +10,13 @@
### Added
- Added support for Anthropic image message parts with `type: "url"` and `type: "file"` sources
- Added `stopSequences` and `frequencyPenalty` to shared stream options and wired them through to OpenAI request translation
- Added optional request cancellation support to auth-broker interactions by propagating `AbortSignal` into health, snapshot, usage, and refresh calls
- Added `AuthStorage.setConfigApiKey` / `removeConfigApiKey` / `clearConfigApiKeys` for config-sourced per-provider bearers (e.g. `models.yml` `providers.<name>.apiKey`). The new tier sits between runtime `--api-key` and stored credentials in `getApiKey`/`peekApiKey` resolution, so a bearer pinned in config now beats the broker's OAuth access token. Also suppresses OAuth `account_uuid` attribution when active, since outbound auth is the explicit config bearer, not OAuth. `describeCredentialSource` reports `"config override (models.yml)"` for visibility.
- Added per-model `additional_rate_limits` parsing to `openaiCodexUsageProvider`. The Codex `wham/usage` endpoint surfaces a separate `GPT-5.3-Codex-Spark` rate limit (`metered_feature: codex_bengalfox`) on Pro accounts; these now emit dedicated `openai-codex:spark:{primary,secondary}` `UsageLimit` entries with `scope.tier = "spark"`, mirroring how Anthropic exposes `anthropic:7d:sonnet` separately from the umbrella `anthropic:7d` bucket. The osx-widgets client already keyed spark detection off `limit.id.includes("spark")`; this populates that contract end-to-end.
- Added `GET /v1/usage` to the auth-broker API to expose aggregated usage reports from `AuthStorage.fetchUsageReports`
- Added auth-broker usage polling response handling that returns normalized usage reports plus generation timestamp for clients
- Added auth-broker usage polling response handling that returns normalized usage reports plus generation timestamp for clients (5-min per-credential cache via `AuthStorage`)
- Added the auth-broker subsystem (`@oh-my-pi/pi-ai/auth-broker`) for sharing OAuth credentials across machines without leaking refresh tokens.
- `startAuthBroker(...)` boots a `Bun.serve` HTTP server exposing `GET /v1/healthz`, `GET /v1/snapshot`, `POST /v1/credential` (upsert), `POST /v1/credential/:id/refresh`, and `POST /v1/credential/:id/disable`.
- `AuthBrokerClient` is the matching HTTP client used by remote clients.
@@ -26,16 +28,20 @@
- Exposed the OAuth provider catalog (`getOAuthProviders`, `OAuthProvider`, `OAuthProviderInfo`) and `refreshOAuthToken` through the package barrel so the coding-agent CLI can target them without reaching into `utils/oauth`.
- Added the auth-gateway subsystem (`@oh-my-pi/pi-ai/auth-gateway`) — a forward-proxy that sits between unauthenticated clients (the macOS usage widget, llm-git, robomp containers, …) and the broker. Clients send standard provider-format requests; the gateway parses them into omp's canonical `Context`, dispatches through pi-ai's `streamSimple()`, and translates the canonical event stream back to the matching wire format. `Authorization` is injected server-side so access tokens never leave the gateway host. Wire surface:
- `GET /healthz` — unauth liveness.
- `GET /v1/usage` — aggregated provider usage; 30s cache via `AuthStorage.fetchUsageReports`.
- `GET /v1/usage` — aggregated provider usage; 5-min per-credential cache via `AuthStorage.fetchUsageReports`.
- `GET /v1/models` — model catalog (scoped to providers with credentials).
- `POST /v1/chat/completions` — OpenAI chat-completions in/out.
- `POST /v1/messages` — Anthropic messages in/out (text + thinking + tool_use blocks, SSE event taxonomy preserved).
- `POST /v1/responses` — OpenAI Responses in/out (reasoning items + function_call output items, SSE pass-through).
- Added exports from `@oh-my-pi/pi-ai/auth-gateway`: `startAuthGateway`, `AuthGatewayServerOptions`, `AuthGatewayBootOptions`, `AuthGatewayServerHandle`, `ModelResolver`, `DEFAULT_AUTH_GATEWAY_BIND`, plus per-format `parseRequest` / `encodeResponse` / `encodeStream` triples under `auth-gateway/formats/{openai-chat,anthropic-messages,openai-responses}`.
- Added exports from `@oh-my-pi/pi-ai/auth-gateway`: `startAuthGateway`, `AuthGatewayServerOptions`, `AuthGatewayBootOptions`, `AuthGatewayServerHandle`, `ModelResolver`, `DEFAULT_AUTH_GATEWAY_BIND`. Per-format `parseRequest` / `encodeResponse` / `encodeStream` triples are reachable via the `./providers/*` subpath as `openai-chat-server`, `anthropic-messages-server`, and `openai-responses-server`.
- Added `listProvidersWithEnvKey()` to enumerate every provider with an env-var fallback (used by the new migrate command in coding-agent).
### Changed
- Changed auth-gateway parsing for OpenAI chat-completions and Responses to ignore unsupported SDK-only fields instead of rejecting requests
- Changed auth-gateway protocol handling to include CORS headers on responses and support browser-origin requests
- Changed prompt-cache handling to resolve cache keys from request metadata and headers and preserve them through protocol translation
- Changed Anthropic messages parsing to forward request `metadata` through to downstream execution
- Changed usage report caching to use a 5-minute per-credential TTL with jittered refresh timing to reduce usage endpoint rate-limit collisions
- Changed usage polling failure handling so transient errors continue serving the last known report instead of returning null and dropping the credential from usage aggregates after cache expiry
- Changed `sanitizeSchemaForGoogle` to normalize snake_case schema keys (such as `any_of` and `additional_properties`) to camelCase and auto-generate `propertyOrdering` for multi-property objects
@@ -46,6 +52,11 @@
### Fixed
- Fixed `RemoteAuthCredentialStore.getUsageReport` to return the matching credential-specific usage report and coalesce parallel callers into one broker `/v1/usage` fetch
- Fixed auth-broker credential upload validation to reject the remote refresh-token sentinel and prevent storing a non-refresh value
- Fixed OpenAI Responses streaming output to emit `reasoning_summary_text` events and parse/send `summary_text` reasoning payloads
- Fixed Anthropic stop-sequence handling by trimming requests to the API limit of four entries before forwarding
- Fixed prompt caching behavior across protocol translations so cached-token usage is preserved when Anthropic and OpenAI requests are routed through each other
- Fixed Claude usage fetching to retry transient `429` and `5xx` responses with exponential backoff, respecting `Retry-After` before returning failure
- Fixed auth-gateway request translation to preserve OpenAI Responses string/system message content, reasoning replay payloads, completed item text in stream item-done events, Anthropic tool-result ordering, and OpenAI Chat/Responses cached-token usage totals
- Fixed auth-gateway failure handling so unsupported request controls, upstream terminal errors, non-streaming aborts, and already-aborted client requests fail explicitly instead of being accepted, ignored, or encoded as successful HTTP 200 responses
@@ -57,6 +68,10 @@
- Fixed object schema normalization so explicit open-map declarations (`additionalProperties: true` and schema-valued `additionalProperties`) are preserved instead of being converted to closed objects
- Fixed unsupported schema constraints on arrays and strings (`maxItems`, `uniqueItems`, `pattern`, `minLength`, `maxLength`, and `minItems` when greater than 1) by demoting them into `description` rather than dropping them
### Security
- Hardened auth-gateway bearer-token checks with constant-time comparison to avoid timing-side-channel leaks
## [15.1.2] - 2026-05-15
### Breaking Changes
+40 -13
View File
@@ -68,48 +68,60 @@ export class AuthBrokerClient {
this.#fetch = opts.fetchImpl ?? fetch;
}
healthz(): Promise<HealthzResponse> {
return this.#request("GET", "/v1/healthz", { schema: healthzResponseSchema, auth: false });
healthz(signal?: AbortSignal): Promise<HealthzResponse> {
return this.#request("GET", "/v1/healthz", { schema: healthzResponseSchema, auth: false, signal });
}
fetchSnapshot(): Promise<SnapshotResponse> {
fetchSnapshot(signal?: AbortSignal): Promise<SnapshotResponse> {
// `snapshotResponseSchema` narrows `refresh` to the sentinel literal where
// the public type uses plain `string`; the wire shape is identical.
return this.#request("GET", "/v1/snapshot", { schema: snapshotResponseSchema }) as Promise<SnapshotResponse>;
return this.#request("GET", "/v1/snapshot", {
schema: snapshotResponseSchema,
signal,
}) as Promise<SnapshotResponse>;
}
fetchUsage(): Promise<UsageResponse> {
// `usageResponseSchema` keeps the report array as `unknown[]` — per-provider
// usage modules own the inner shape; the broker doesn't re-validate it.
return this.#request("GET", "/v1/usage", { schema: usageResponseSchema }) as Promise<UsageResponse>;
fetchUsage(signal?: AbortSignal): Promise<UsageResponse> {
// Validates the envelope (`generatedAt`, `reports[].provider`, `limits`,
// `metadata`) but leaves provider-specific extension fields permissive so
// the broker can ship new shapes ahead of the client. `raw` is accepted
// but normally stripped by the broker before send.
return this.#request("GET", "/v1/usage", { schema: usageResponseSchema, signal }) as Promise<UsageResponse>;
}
async refreshCredential(id: number): Promise<CredentialRefreshResponse> {
async refreshCredential(id: number, signal?: AbortSignal): Promise<CredentialRefreshResponse> {
return this.#request("POST", `/v1/credential/${id}/refresh`, {
schema: credentialRefreshResponseSchema,
signal,
}) as Promise<CredentialRefreshResponse>;
}
async disableCredential(id: number, cause: string): Promise<CredentialDisableResponse> {
async disableCredential(id: number, cause: string, signal?: AbortSignal): Promise<CredentialDisableResponse> {
const body: CredentialDisableRequest = { cause };
return this.#request("POST", `/v1/credential/${id}/disable`, {
body,
schema: credentialDisableResponseSchema,
signal,
});
}
async uploadCredential(provider: string, credential: AuthCredential): Promise<CredentialUploadResponse> {
async uploadCredential(
provider: string,
credential: AuthCredential,
signal?: AbortSignal,
): Promise<CredentialUploadResponse> {
const body: CredentialUploadRequest = { provider, credential };
return this.#request("POST", "/v1/credential", {
body,
schema: credentialUploadResponseSchema,
signal,
}) as Promise<CredentialUploadResponse>;
}
async #request<TSchema extends ZodType>(
method: "GET" | "POST",
path: string,
opts: { schema: TSchema; auth?: boolean; body?: unknown },
opts: { schema: TSchema; auth?: boolean; body?: unknown; signal?: AbortSignal },
): Promise<zInfer<TSchema>> {
const auth = opts.auth ?? true;
const url = `${this.#baseUrl}${path}`;
@@ -121,14 +133,25 @@ export class AuthBrokerClient {
headers["Content-Type"] = "application/json";
}
// Fast-fail when the caller's signal is already aborted — avoids spinning
// up a fetch + timer that the first `await` would just abort anyway.
if (opts.signal?.aborted) {
throw new AuthBrokerError("Auth broker request aborted", { cause: opts.signal.reason });
}
let lastError: unknown;
for (let attempt = 0; attempt <= this.#maxRetries; attempt += 1) {
// Compose caller's signal with the per-attempt timeout so either
// source can cancel the in-flight fetch. `AbortSignal.any` is the
// supported merge primitive in Bun ≥ 1.0 / Node ≥ 20.
const timeoutSignal = AbortSignal.timeout(this.#timeoutMs);
const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutSignal]) : timeoutSignal;
try {
const response = await this.#fetch(url, {
method,
headers,
body: payload,
signal: AbortSignal.timeout(this.#timeoutMs),
signal,
});
const text = await response.text();
if (!response.ok) {
@@ -157,6 +180,10 @@ export class AuthBrokerClient {
return validated.data;
} catch (error) {
lastError = error;
// Caller-driven abort wins over retry — the caller said stop.
if (opts.signal?.aborted) {
throw new AuthBrokerError("Auth broker request aborted", { cause: opts.signal.reason });
}
if (error instanceof AuthBrokerError && error.status !== undefined) {
// HTTP errors (4xx/5xx) don't retry — caller knows what to do.
throw error;
+147 -5
View File
@@ -4,7 +4,8 @@
* `upsert*`, `delete*ForProvider`) throw because login flows are server-side.
*
* Cache (`getCache`/`setCache`/`cleanExpiredCache`) is in-memory and ephemeral —
* usage reports cache TTL is ~30s, so durability across runs isn't required.
* usage reports cache TTL is 5 minutes per credential, so durability across
* runs isn't required.
*/
import { logger } from "@oh-my-pi/pi-utils";
import {
@@ -20,11 +21,24 @@ import type { UsageReport } from "../usage";
import type { OAuthCredentials } from "../utils/oauth/types";
import type { AuthBrokerClient } from "./client";
/**
* Client-side TTL for the aggregate `/v1/usage` response. Set below the
* broker server's own 30s usage cache so we typically pick up the broker's
* cached value instead of re-walking the network — but high enough to absorb
* the parallel fan-out from `#rankOAuthSelections` into a single round-trip.
*/
const USAGE_CACHE_TTL_MS = 15_000;
interface CacheEntry {
value: string;
expiresAtSec: number;
}
interface UsageCacheEntry {
reports: UsageReport[];
fetchedAt: number;
}
export interface RemoteAuthCredentialStoreOptions {
client: AuthBrokerClient;
/**
@@ -38,6 +52,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
readonly #client: AuthBrokerClient;
#snapshot: AuthCredentialSnapshot;
#cache: Map<string, CacheEntry> = new Map();
#usageCache?: UsageCacheEntry;
#usageInflight?: Promise<UsageReport[] | null>;
#closed = false;
constructor(opts: RemoteAuthCredentialStoreOptions) {
@@ -151,8 +167,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
_provider: Provider,
credentialId: number,
_credential: OAuthCredential,
signal?: AbortSignal,
): Promise<OAuthCredentials> {
const { entry } = await this.#client.refreshCredential(credentialId);
const { entry } = await this.#client.refreshCredential(credentialId, signal);
if (entry.credential.type !== "oauth") {
throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`);
}
@@ -174,9 +191,78 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
* rate-limited by Anthropic's per-IP `/usage` cap the way a heavy
* residential laptop is, so all credentials surface every cycle.
*/
async fetchUsageReports(): Promise<UsageReport[] | null> {
const body = await this.#client.fetchUsage();
return body.reports;
async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
return this.#raceWithSignal(this.#loadUsageReports(), signal);
}
/**
* Per-credential usage hook consumed by `AuthStorage.#getUsageReport`. Pulls
* the aggregate broker `/v1/usage` once and serves all callers from the
* same response (coalesced + cached), then matches the credential to a
* report by provider + identity (accountId / email / projectId).
*
* The broker already aggregates with its own 30s TTL on the server side; our
* 15s client TTL is below that so we usually re-use the broker's cache too.
*/
async getUsageReport(
provider: Provider,
credential: OAuthCredential,
signal?: AbortSignal,
): Promise<UsageReport | null> {
const reports = await this.#raceWithSignal(this.#loadUsageReports(), signal);
if (!reports) return null;
return matchUsageReport(reports, provider, credential);
}
/**
* Reject the awaited promise when the caller's signal aborts, without
* affecting the shared upstream fetch. Used to give each caller their
* own cancel without one caller's abort cascading into a peer's in-flight
* request through the single-flight `#usageInflight`.
*/
#raceWithSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return promise;
if (signal.aborted) return Promise.reject(new Error("auth-broker request aborted"));
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
signal.removeEventListener("abort", onAbort);
reject(new Error("auth-broker request aborted"));
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
value => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
err => {
signal.removeEventListener("abort", onAbort);
reject(err);
},
);
});
}
#loadUsageReports(): Promise<UsageReport[] | null> {
const cached = this.#usageCache;
if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) {
return Promise.resolve(cached.reports);
}
if (this.#usageInflight) return this.#usageInflight;
const inflight = this.#client
.fetchUsage()
.then(body => {
this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
return body.reports;
})
.catch(error => {
logger.warn("auth-broker usage fetch failed", { error: String(error) });
return null;
})
.finally(() => {
this.#usageInflight = undefined;
});
this.#usageInflight = inflight;
return inflight;
}
close(): void {
@@ -185,3 +271,59 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
this.#cache.clear();
}
}
/**
* Match a broker-supplied usage report to a specific OAuth credential. The
* broker returns aggregate reports across all credentials it manages, so we
* pick the one whose identity (accountId / email / projectId) lines up with
* the credential the caller is asking about.
*
* Falls back to the lone candidate when only one matches the provider; falls
* through to `null` when nothing matches, which `AuthStorage` treats as "no
* usage data" (ranking proceeds without a usage signal for this credential).
*/
function matchUsageReport(reports: UsageReport[], provider: Provider, credential: OAuthCredential): UsageReport | null {
const candidates = reports.filter(report => report.provider === provider);
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
const accountId = credential.accountId?.trim().toLowerCase();
const email = credential.email?.trim().toLowerCase();
const projectId = credential.projectId?.trim().toLowerCase();
for (const report of candidates) {
if (reportMatchesIdentity(report, accountId, email, projectId)) return report;
}
return null;
}
function reportMatchesIdentity(
report: UsageReport,
accountId: string | undefined,
email: string | undefined,
projectId: string | undefined,
): boolean {
const metadata = (report.metadata ?? {}) as Record<string, unknown>;
if (accountId) {
const metaAccount = readMetadataString(metadata, "accountId") ?? readMetadataString(metadata, "account_id");
if (metaAccount && metaAccount.toLowerCase() === accountId) return true;
for (const limit of report.limits) {
if (limit.scope.accountId?.toLowerCase() === accountId) return true;
}
}
if (email) {
const metaEmail = readMetadataString(metadata, "email");
if (metaEmail && metaEmail.toLowerCase() === email) return true;
}
if (projectId) {
const metaProject = readMetadataString(metadata, "projectId") ?? readMetadataString(metadata, "project_id");
if (metaProject && metaProject.toLowerCase() === projectId) return true;
for (const limit of report.limits) {
if (limit.scope.projectId?.toLowerCase() === projectId) return true;
}
}
return false;
}
function readMetadataString(metadata: Record<string, unknown>, key: string): string | undefined {
const value = metadata[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
+7 -40
View File
@@ -11,6 +11,7 @@
*/
import { logger } from "@oh-my-pi/pi-utils";
import type { AuthStorage } from "../auth-storage";
import { parseBind } from "../utils/parse-bind";
import { AuthBrokerRefresher } from "./refresher";
import type {
CredentialDisableResponse,
@@ -47,42 +48,6 @@ export interface AuthBrokerServerHandle {
close(): Promise<void>;
}
interface ParsedBind {
hostname: string;
port: number;
}
function parsePort(raw: string, bind: string): number {
if (!/^\d+$/.test(raw)) {
throw new Error(`Invalid bind '${bind}'; port must be an integer.`);
}
const port = Number.parseInt(raw, 10);
if (!Number.isFinite(port) || port < 0 || port > 65535) {
throw new Error(`Invalid bind '${bind}'; port out of range.`);
}
return port;
}
function parseBind(raw: string): ParsedBind {
const trimmed = raw.trim();
if (trimmed.length === 0) {
throw new Error("Invalid bind; expected 'host:port' or 'port'.");
}
if (/^\d+$/.test(trimmed)) {
return { hostname: "127.0.0.1", port: parsePort(trimmed, raw) };
}
const lastColon = trimmed.lastIndexOf(":");
if (lastColon < 0) {
throw new Error(`Invalid bind '${raw}'; expected 'host:port' or 'port'.`);
}
const hostPart = trimmed.slice(0, lastColon);
const portPart = trimmed.slice(lastColon + 1);
if (hostPart.length === 0) {
throw new Error(`Invalid bind '${raw}'; host must not be empty.`);
}
return { hostname: hostPart, port: parsePort(portPart, raw) };
}
function json(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
@@ -174,10 +139,12 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
}
if (req.method === "GET" && pathname === "/v1/usage") {
try {
// AuthStorage caches usage reports internally with a 30s TTL
// (USAGE_REPORT_TTL_MS) so back-to-back widget polls re-use the
// AuthStorage caches usage reports internally with a 5-minute per-credential
// TTL (USAGE_REPORT_TTL_MS) so back-to-back widget polls re-use the
// last fetch instead of hitting provider endpoints repeatedly.
const reports = (await opts.storage.fetchUsageReports?.()) ?? [];
// `req.signal` propagates HTTP-client disconnects all the way to the
// per-caller cancel without touching the shared upstream fetch.
const reports = (await opts.storage.fetchUsageReports?.({ signal: req.signal })) ?? [];
// Drop the `raw` field — it's the provider-specific upstream body,
// large and unstable. Everything UI-relevant lives in `limits` and
// `metadata`.
@@ -194,7 +161,7 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
if (refreshMatch) {
const id = Number.parseInt(refreshMatch[1], 10);
try {
const entry = await opts.storage.forceRefreshCredentialById(id);
const entry = await opts.storage.forceRefreshCredentialById(id, req.signal);
const body: CredentialRefreshResponse = { entry };
logger.info("auth-broker credential refreshed", {
id,
+11 -1
View File
@@ -20,7 +20,17 @@ import { usageReportSchema } from "../usage";
export const oauthCredentialSchema = z
.object({
type: z.literal("oauth"),
refresh: z.string().min(1),
refresh: z
.string()
.min(1)
// Reject the sentinel literal on writes: if a client somehow round-trips
// a snapshot back into POST /v1/credential, accepting the sentinel as a
// real refresh token would silently break that credential's refresh
// forever (the broker would store `"__remote__"` and try to use it as
// the upstream refresh token).
.refine(value => value !== REMOTE_REFRESH_SENTINEL, {
message: `refresh token must not equal the remote sentinel (${REMOTE_REFRESH_SENTINEL})`,
}),
access: z.string().min(1),
expires: z.number(),
enterpriseUrl: z.string().optional(),
+163 -1
View File
@@ -4,11 +4,13 @@
* Centralized so we share the same JSON shape, auth check,
* and peer-resolution logic.
*/
import { timingSafeEqual as nodeTimingSafeEqual } from "node:crypto";
const JSON_HEADERS = {
"Content-Type": "application/json",
"X-Content-Type-Options": "nosniff",
} as const;
export function json(status: number, body: unknown): Response {
return new Response(JSON.stringify(body) ?? "null", {
status,
@@ -22,11 +24,171 @@ export function resolvePeer(req: Request): string {
return req.headers.get("x-real-ip") ?? "unknown";
}
/**
* Constant-time byte comparison. Falls back to a manual XOR accumulator if
* `node:crypto.timingSafeEqual` isn't available. Always processes every byte
* of the longer input so length itself doesn't leak via timing.
*/
export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length === b.length && typeof nodeTimingSafeEqual === "function") {
return nodeTimingSafeEqual(a, b);
}
const len = Math.max(a.length, b.length);
let diff = a.length ^ b.length;
for (let i = 0; i < len; i++) {
// Out-of-range reads return undefined → coerce to 0 via `| 0`.
const av = (i < a.length ? a[i] : 0) | 0;
const bv = (i < b.length ? b[i] : 0) | 0;
diff |= av ^ bv;
}
return diff === 0;
}
const TOKEN_ENCODER = new TextEncoder();
export function isAuthorized(req: Request, tokens: ReadonlySet<string>): boolean {
if (tokens.size === 0) return true;
const header = req.headers.get("authorization");
if (!header) return false;
const match = header.match(/^Bearer\s+(.+)$/i);
if (!match) return false;
return tokens.has(match[1].trim());
const presented = TOKEN_ENCODER.encode(match[1].trim());
// Iterate every allowed token regardless of early hits so the result
// timing reflects the full set, not the position of the match.
let ok = false;
for (const tok of tokens) {
const expected = TOKEN_ENCODER.encode(tok);
if (timingSafeEqual(presented, expected)) ok = true;
}
return ok;
}
/**
* Allow-list of inbound request headers that the gateway captures and forwards
* to the underlying parsers (which decide whether to surface them to the
* provider). Case-insensitive; `x-stainless-` is a prefix match.
*/
const PASSTHROUGH_HEADER_NAMES: Record<string, true> = {
"anthropic-beta": true,
"anthropic-version": true,
"openai-organization": true,
"openai-project": true,
"openai-beta": true,
// Codex / ChatGPT-OAuth backend headers (see openai-codex/constants.ts).
// `session_id` and `conversation_id` thread the upstream session so prompt
// caching and per-conversation rate limiting work; `chatgpt-account-id` and
// `originator` identify the calling account and client surface.
"chatgpt-account-id": true,
originator: true,
session_id: true,
conversation_id: true,
// Vendor-neutral cache-identity headers. The gateway also reads these to
// populate `options.promptCacheKey` (see `resolvePromptCacheKey` below)
// so explicit client hints win over the derived fallback.
"x-prompt-cache-key": true,
"x-session-id": true,
"x-conversation-id": true,
};
/**
* Extract allow-listed passthrough headers from an inbound request. Keys are
* lowercased; empty values are dropped. Called once per request in
* `handleFormatEndpoint`; parsers then read `options.headers`.
*/
export function captureRequestHeaders(headers: Headers): Record<string, string> {
const out: Record<string, string> = {};
headers.forEach((value, key) => {
if (!value) return;
const lower = key.toLowerCase();
if (PASSTHROUGH_HEADER_NAMES[lower] || lower.startsWith("x-stainless-")) {
out[lower] = value;
}
});
return out;
}
/**
* Priority order for resolving a client-supplied prompt-cache identity. The
* first non-empty value wins. When none are present, the gateway derives a
* stable UUID from the request's stable parts.
*/
const CACHE_KEY_HEADERS: readonly string[] = [
"x-prompt-cache-key",
"session_id",
"conversation_id",
"x-session-id",
"x-conversation-id",
];
function readBodyCacheKey(body: unknown): string | undefined {
if (body === null || typeof body !== "object") return undefined;
const root = body as Record<string, unknown>;
// Explicit body fields (OpenAI Responses / Chat).
const direct = root.prompt_cache_key;
if (typeof direct === "string" && direct.length > 0) return direct;
// Nested `metadata` (Codex CLI / Anthropic clients that route a session
// identifier through the metadata bag).
const metadata = root.metadata;
if (metadata === null || typeof metadata !== "object") return undefined;
const meta = metadata as Record<string, unknown>;
for (const field of ["prompt_cache_key", "session_id", "conversation_id"] as const) {
const v = meta[field];
if (typeof v === "string" && v.length > 0) return v;
}
return undefined;
}
/**
* Resolve a prompt-cache identity from inbound request body + headers.
* Order of precedence (first wins):
* 1. Body `prompt_cache_key`
* 2. Body `metadata.{prompt_cache_key,session_id,conversation_id}`
* 3. Header `x-prompt-cache-key`
* 4. Header `session_id` / `conversation_id` (Codex / ChatGPT-OAuth surface)
* 5. Header `x-session-id` / `x-conversation-id` (common informal)
* Returns undefined when none present; the gateway then derives a stable
* UUID from the request's stable parts.
*/
export function resolvePromptCacheKey(body: unknown, headers?: Headers): string | undefined {
const fromBody = readBodyCacheKey(body);
if (fromBody) return fromBody;
if (!headers) return undefined;
for (const name of CACHE_KEY_HEADERS) {
const v = headers.get(name);
if (v && v.length > 0) return v;
}
return undefined;
}
const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers":
"authorization, content-type, anthropic-version, anthropic-beta, openai-organization, openai-project, x-stainless-*, x-api-key",
"Access-Control-Max-Age": "86400",
};
/**
* CORS headers for the auth-gateway. Currently echoes a wildcard origin; the
* request is accepted so future tightening can mirror `Origin` without
* threading the request through every caller.
*/
export function corsHeaders(_req: Request): Record<string, string> {
return { ...CORS_HEADERS };
}
/**
* Re-emit `response` with CORS headers merged. The original response body is
* passed through unchanged. Used by the gateway wrapper so every outbound
* format-endpoint response carries the same CORS surface as the preflight.
*/
export function withCors(response: Response, req: Request): Response {
const headers = new Headers(response.headers);
const cors = corsHeaders(req);
for (const k in cors) headers.set(k, cors[k]);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
+237 -237
View File
@@ -10,7 +10,7 @@
*
* Endpoints:
* GET /healthz → unauth; ok + version
* GET /v1/usage → aggregated provider usage (30s cache via AuthStorage)
* GET /v1/usage → aggregated provider usage (5-min per-credential cache via AuthStorage)
* 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
@@ -24,7 +24,8 @@ import * as openaiChat from "../providers/openai-chat-server";
import * as openaiResponses from "../providers/openai-responses-server";
import { streamSimple } from "../stream";
import type { Api, AssistantMessageEventStream, Model, SimpleStreamOptions } from "../types";
import { isAuthorized, json, resolvePeer } from "./http";
import { parseBind } from "../utils/parse-bind";
import { captureRequestHeaders, corsHeaders, isAuthorized, json, resolvePeer, withCors } from "./http";
import type {
AuthGatewayServerHandle,
AuthGatewayServerOptions,
@@ -50,24 +51,8 @@ export interface AuthGatewayBootOptions extends AuthGatewayServerOptions {
listModels?: () => Iterable<Model<Api>>;
}
interface ParsedBind {
hostname: string;
port: number;
}
function parseBind(raw: string): ParsedBind {
const trimmed = raw.trim();
if (/^\d+$/.test(trimmed)) {
return { hostname: "127.0.0.1", port: Number.parseInt(trimmed, 10) };
}
const lastColon = trimmed.lastIndexOf(":");
if (lastColon < 0) throw new Error(`Invalid bind '${raw}'; expected 'host:port' or 'port'.`);
const port = Number.parseInt(trimmed.slice(lastColon + 1), 10);
if (!Number.isFinite(port) || port < 0 || port > 65535) {
throw new Error(`Invalid bind '${raw}'; port out of range.`);
}
return { hostname: trimmed.slice(0, lastColon), port };
}
// `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" },
@@ -75,55 +60,10 @@ const FORMAT_ROUTES: Record<string, { module: FormatModule; label: string }> = {
"/v1/responses": { module: openaiResponses, label: "openai-responses" },
};
/**
* Wire path on the upstream provider that each inbound format maps to when
* passthrough is taken. Same path as the gateway's inbound route in every
* case — that's what makes the fast-path "passthrough" rather than
* "rewrite": we forward the bytes to the same logical endpoint on the real
* provider, with `Authorization` swapped.
*/
const FORMAT_TO_UPSTREAM_PATH: Record<string, string> = {
"openai-chat": "/v1/chat/completions",
"anthropic-messages": "/v1/messages",
"openai-responses": "/v1/responses",
};
/**
* Inbound format → set of model.api values where a 1:1 byte passthrough is
* legal. When the inbound format matches the model's native API, we skip the
* translate/rebuild round-trip and forward the request body unchanged with
* `Authorization` rewritten. Two big wins:
* - prompt caching hints (`cache_control`, etc.) flow through to upstream
* intact; the gateway no longer breaks anthropic prompt-caching;
* - provider-specific options (`metadata`, `service_tier`, `tool_choice`
* extensions, …) work without per-field allowlist maintenance here.
*
* `openai-codex-responses` is deliberately absent — codex runs over a
* websocket transport that has no equivalent inbound shape, so it always
* takes the translate path.
*/
const FORMAT_TO_PASSTHROUGH_API: Record<string, ReadonlySet<Api>> = {
"openai-chat": new Set(["openai-completions"]),
"anthropic-messages": new Set(["anthropic-messages"]),
"openai-responses": new Set(["openai-responses"]),
};
/**
* Hop-by-hop headers per RFC 7230. Stripped from both the inbound (so we don't
* forward the client's `Authorization` containing only the gateway bearer) and
* the upstream response (so we don't pass `Transfer-Encoding: chunked` back
* after we've already buffered).
*/
const HOP_BY_HOP_HEADERS = new Set<string>([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
]);
// (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
@@ -133,6 +73,46 @@ const HOP_BY_HOP_HEADERS = new Set<string>([
// 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(parsed: ParsedFormatRequest): string {
const { modelId, context } = parsed;
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;
@@ -145,26 +125,116 @@ function buildStreamOptions(parsed: ParsedFormatRequest, api: Api, signal: Abort
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.serviceTier !== undefined) opts.serviceTier = options.serviceTier;
if (options.presencePenalty !== undefined) opts.presencePenalty = options.presencePenalty;
if (options.disableReasoning !== undefined) opts.disableReasoning = options.disableReasoning;
if (options.cacheRetention !== undefined) opts.cacheRetention = options.cacheRetention;
if (options.thinkingBudget !== undefined) {
// Anthropic gives a single budget number with no effort label; bridge it
// to pi-ai's per-level map and default the level to "high" so providers
// that key off `reasoning` actually surface the budget.
opts.thinkingBudgets = { ...(opts.thinkingBudgets ?? {}), [Effort.High]: options.thinkingBudget };
opts.reasoning ??= Effort.High;
// 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.
opts.sessionId = options.promptCacheKey ?? deriveSessionId(parsed);
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
* provider-style error type tag. Used by `handleFormatEndpoint` /
* `handlePassthrough` to drive `route.module.formatError` so every wire
* format emits its native envelope shape.
*/
function classifyGatewayError(err: unknown): { status: number; type: string; message: string } {
const message = err instanceof Error ? err.message : String(err);
const lower = message.toLowerCase();
// Custom pi-ai errors may attach a numeric `status` property; honor it
// when present and pick the matching tag.
const statusProp =
typeof err === "object" && err !== null && typeof (err as { status?: unknown }).status === "number"
? (err as { status: number }).status | 0
: undefined;
if (statusProp !== undefined) {
if (statusProp === 401 || statusProp === 403)
return { status: statusProp, type: "authentication_error", message };
if (statusProp === 429) return { status: 429, type: "rate_limit_error", message };
if (statusProp >= 400 && statusProp < 500) return { status: statusProp, type: "invalid_request_error", message };
if (statusProp >= 500) return { status: statusProp, type: "upstream_error", message };
}
if (err instanceof Error && err.name === "AbortError") return { status: 499, type: "request_aborted", message };
if (lower.includes("aborted") || lower.includes("abortsignal")) {
return { status: 499, type: "request_aborted", message };
}
if (
lower.includes("401") ||
lower.includes("403") ||
lower.includes("unauthorized") ||
lower.includes("forbidden")
) {
return { status: 401, type: "authentication_error", message };
}
if (lower.includes("429") || lower.includes("rate") || lower.includes("quota")) {
return { status: 429, type: "rate_limit_error", message };
}
if (lower.includes("unsupported") || lower.includes("invalid")) {
return { status: 400, type: "invalid_request_error", message };
}
return { status: 502, type: "upstream_error", message };
}
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) {
@@ -175,108 +245,7 @@ function mirrorRequestAbort(req: Request): AbortController {
return controller;
}
function clientClosedResponse(): Response {
return json(499, { error: "client closed request" });
}
/**
* 1:1 byte passthrough fast-path. When the inbound format matches the model's
* native API (per {@link FORMAT_TO_PASSTHROUGH_API}), we skip parse + translate
* + re-emit and forward the request body as-is to the upstream provider with
* `Authorization` rewritten to the real access token. Provider-specific
* features (anthropic prompt caching, openai `service_tier`, tool-choice
* extensions, …) pass through unchanged.
*
* `body` is the already-parsed JSON; we re-serialize it to bytes for the
* upstream request. Re-serialization is intentional — Bun's `Request#json()`
* consumes the underlying stream, so the original bytes aren't available
* anyway, and any client-side whitespace/key-order difference is irrelevant
* to every provider this gateway targets.
*/
async function handlePassthrough(
route: { module: FormatModule; label: string },
model: Model<Api>,
body: unknown,
apiKey: string,
req: Request,
peer: string,
signal: AbortSignal,
): Promise<Response> {
const wirePath = FORMAT_TO_UPSTREAM_PATH[route.label];
if (!wirePath) {
// Shouldn't happen — caller already confirmed FORMAT_TO_PASSTHROUGH_API
// has an entry for this label, which implies a wire path exists.
return json(500, { error: `No upstream wire path for format ${route.label}` });
}
const baseUrl = model.baseUrl.replace(/\/+$/, "");
const upstreamUrl = `${baseUrl}${wirePath}`;
const upstreamHeaders = new Headers();
req.headers.forEach((value, key) => {
const lower = key.toLowerCase();
// Strip every header the client uses to identify itself to the gateway.
// The gateway is the only thing that should be telling the upstream
// provider who's calling; the client's bearer (or anthropic's `x-api-key`,
// which omp's own anthropic provider always sends alongside `Authorization`)
// is just access control INTO the gateway and would otherwise leak to
// upstream as a 401-inducing bogus credential.
if (lower === "authorization" || lower === "x-api-key") return;
if (lower === "host" || lower === "content-length") return;
if (HOP_BY_HOP_HEADERS.has(lower)) return;
upstreamHeaders.set(key, value);
});
upstreamHeaders.set("Authorization", `Bearer ${apiKey}`);
let upstream: Response;
try {
upstream = await fetch(upstreamUrl, {
method: req.method,
headers: upstreamHeaders,
body: JSON.stringify(body),
signal,
});
} catch (error) {
if (signal.aborted) return clientClosedResponse();
const message = error instanceof Error ? error.message : String(error);
logger.warn("auth-gateway passthrough upstream failed", {
format: route.label,
provider: model.provider,
model: model.id,
upstream: upstreamUrl,
peer,
error: message,
});
return json(502, { error: message });
}
logger.info("auth-gateway passthrough", {
format: route.label,
provider: model.provider,
model: model.id,
upstream: upstreamUrl,
status: upstream.status,
peer,
});
// Pass body straight through without buffering. Strip hop-by-hop headers
// from upstream, plus `content-encoding` and `content-length`: Bun's
// `fetch` transparently decodes gzip/br/deflate bodies but leaves the
// `Content-Encoding` header intact — forwarding it makes the client try to
// re-decode plain bytes and crash with `ZlibError`. `content-length` is
// stale too once Bun re-frames the response.
const outboundHeaders = new Headers();
upstream.headers.forEach((value, key) => {
const lower = key.toLowerCase();
if (HOP_BY_HOP_HEADERS.has(lower)) return;
if (lower === "content-encoding" || lower === "content-length") return;
outboundHeaders.set(key, value);
});
return new Response(upstream.body, {
status: upstream.status,
statusText: upstream.statusText,
headers: outboundHeaders,
});
}
// (handlePassthrough removed — see note above.)
async function handleFormatEndpoint(
route: { module: FormatModule; label: string },
@@ -285,32 +254,31 @@ async function handleFormatEndpoint(
peer: string,
): Promise<Response> {
const controller = mirrorRequestAbort(req);
if (controller.signal.aborted) return clientClosedResponse();
if (controller.signal.aborted) return clientClosedResponse(route);
let body: unknown;
try {
body = await req.json();
} catch (error) {
if (controller.signal.aborted) return clientClosedResponse();
return json(400, { error: `Invalid JSON body: ${String(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();
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 passthrough
// fast-path doesn't block on provider-specific fields the schema would
// otherwise reject (anthropic `metadata`, openai `service_tier`, …).
// 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 json(400, { error: "Missing top-level `model` field" });
return route.module.formatError(400, "invalid_request_error", "Missing top-level `model` field");
}
const model = bootOpts.resolveModel(modelId);
if (!model) {
return json(404, { error: `Unknown model: ${modelId}` });
return route.module.formatError(404, "invalid_request_error", `Unknown model: ${modelId}`);
}
// pi-ai's stream() does NOT consult AuthStorage — the caller (us) is
@@ -319,40 +287,48 @@ async function handleFormatEndpoint(
// broker override on AuthStorage when needed).
let apiKey: string | undefined;
try {
apiKey = await bootOpts.storage.getApiKey(model.provider, undefined, { modelId: model.id });
apiKey = await bootOpts.storage.getApiKey(model.provider, undefined, {
modelId: model.id,
signal: controller.signal,
});
} catch (error) {
if (controller.signal.aborted) return clientClosedResponse();
const message = error instanceof Error ? error.message : String(error);
logger.warn("auth-gateway getApiKey threw", { provider: model.provider, peer, error: message });
return json(502, { error: message });
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();
if (controller.signal.aborted) return clientClosedResponse(route);
if (!apiKey) {
return json(401, { error: `No credential available for provider ${model.provider}` });
return route.module.formatError(
401,
"authentication_error",
`No credential available for provider ${model.provider}`,
);
}
// Fast path: 1:1 byte passthrough when the inbound format matches the
// model's native API. Skips schema validation entirely — provider-specific
// fields (prompt caching, service tier, …) flow through unchanged.
const passthroughApis = FORMAT_TO_PASSTHROUGH_API[route.label];
if (passthroughApis?.has(model.api)) {
return handlePassthrough(route, model, body, apiKey, req, peer, controller.signal);
}
// Translate path: parse + validate against the strict format schema,
// rebuild as omp's canonical Context, dispatch through pi-ai's
// streamSimple, encode the canonical event stream back to the inbound
// format. Used when the inbound wire format and the selected model's
// native API differ (e.g. /v1/chat/completions targeting an Anthropic
// model, or /v1/responses targeting openai-codex over websocket).
// Parse + validate against the strict format schema, rebuild as omp's
// canonical Context, dispatch through pi-ai's streamSimple, encode the
// canonical event stream back to the inbound format. There is no
// passthrough fast-path — every request flows through pi-ai so that
// credential-specific request shaping (OAuth Claude-Code prefix, beta
// headers, codex websocket transport, …) always applies.
let parsed: ParsedFormatRequest;
try {
parsed = route.module.parseRequest(body);
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 json(400, { error: message });
return route.module.formatError(400, "invalid_request_error", message);
}
if (controller.signal.aborted) return clientClosedResponse();
// 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);
const streamOpts = buildStreamOptions(parsed, model.api, controller.signal);
streamOpts.apiKey = apiKey;
@@ -368,17 +344,17 @@ async function handleFormatEndpoint(
let events: AssistantMessageEventStream;
try {
if (controller.signal.aborted) return clientClosedResponse();
if (controller.signal.aborted) return clientClosedResponse(route);
events = streamSimple(model, parsed.context, streamOpts);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.warn("auth-gateway streamSimple threw", { format: route.label, error: message, peer });
return json(502, { error: message });
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();
if (controller.signal.aborted) return clientClosedResponse(route);
const message = await events.result();
if (message.stopReason === "aborted" || message.stopReason === "error") {
const errorMessage =
@@ -390,17 +366,25 @@ async function handleFormatEndpoint(
error: errorMessage,
peer,
});
return json(message.stopReason === "aborted" ? 499 : 502, { error: errorMessage });
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();
const errMsg = error instanceof Error ? error.message : String(error);
logger.warn("auth-gateway non-streaming aborted", { format: route.label, error: errMsg, peer });
return json(502, { error: errMsg });
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();
if (controller.signal.aborted) return clientClosedResponse(route);
const sseStream = route.module.encodeStream(events, parsed.modelId, parsed.options);
return new Response(sseStream, {
@@ -409,17 +393,22 @@ async function handleFormatEndpoint(
"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",
},
});
}
/**
* Snapshot of `GET /v1/usage` — fetchUsageReports already caches reports at 30s TTL
* inside AuthStorage, so this handler is a thin wrapper that surfaces the same
* data to HTTP callers (notably the macOS usage widget).
* 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): Promise<Response> {
const reports = (await storage.fetchUsageReports?.()) ?? [];
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.
@@ -450,34 +439,42 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
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 json(200, { ok: true, version });
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 json(401, { error: "unauthorized" });
return withCors(json(401, { error: "unauthorized" }), req);
}
// Aggregated usage — backed by AuthStorage's 30s cache. Same shape as
// the broker's `/v1/usage`, so widget/llm-git speak to either with the
// 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 await handleUsage(opts.storage);
return withCors(await handleUsage(opts.storage, req.signal), req);
}
// Provider-format dispatch.
const formatRoute = FORMAT_ROUTES[pathname];
if (formatRoute && req.method === "POST") {
return await handleFormatEndpoint(formatRoute, opts, req, peer);
return withCors(await handleFormatEndpoint(formatRoute, opts, req, peer), req);
}
// Model catalog.
if (req.method === "GET" && pathname === "/v1/models") {
return handleModelsList(opts);
return withCors(handleModelsList(opts), req);
}
return json(404, { error: `No route: ${req.method} ${pathname}` });
// 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,
@@ -485,9 +482,12 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
peer,
error: String(error),
});
return json(500, { error: "internal 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;
+60 -9
View File
@@ -18,33 +18,78 @@ export const DEFAULT_AUTH_GATEWAY_BIND = "127.0.0.1:4000";
export type AuthGatewayToolChoice = "auto" | "none" | "required" | { name: string };
export interface AuthGatewayParsedRequestOptions {
// ── Sampling ──────────────────────────────────────────────────────────
maxOutputTokens?: number;
temperature?: number;
topP?: number;
topK?: number;
/** OpenAI nucleus-min sampling (`min_p`). */
minP?: number;
/** Anthropic `stop_sequences` / OpenAI `stop`. */
stopSequences?: string[];
/** OpenAI `presence_penalty`. */
presencePenalty?: number;
/** OpenAI `frequency_penalty`. */
frequencyPenalty?: number;
/** OpenRouter / vLLM `repetition_penalty`. */
repetitionPenalty?: number;
/** OpenAI deterministic-sampling `seed`. */
seed?: number;
/** OpenAI `logit_bias` map (token id → bias). */
logitBias?: Record<string, number>;
/** OpenAI `response_format` (text | json_object | json_schema). Opaque passthrough. */
responseFormat?: unknown;
// ── Tools ─────────────────────────────────────────────────────────────
toolChoice?: AuthGatewayToolChoice;
/** OpenAI `parallel_tool_calls`. */
parallelToolCalls?: boolean;
// ── Reasoning ─────────────────────────────────────────────────────────
/** Effort-level reasoning request (OpenAI Responses / Chat `reasoning_effort`). */
reasoning?: Effort;
/** Force-disable reasoning (Anthropic `thinking: { type: "disabled" }`). */
disableReasoning?: boolean;
/**
* Token budget for thinking (Anthropic `thinking.budget_tokens`). Bridged to
* pi-ai via `thinkingBudgets[high]` when the wire format only carries a
* single budget number with no effort label.
* Explicit Anthropic `thinking.budget_tokens`. Mirrors Rust's
* `resolve_thinking_budget`: pins onto whichever effort the client
* requested (defaulting to High when unspecified). Preferred over the
* removed legacy single-number `thinkingBudget` for new code.
*/
thinkingBudget?: number;
explicitThinkingBudgetTokens?: number;
/** Per-effort thinking budget map. */
thinkingBudgets?: Partial<Record<Effort, number>>;
/** Suppress the provider's reasoning summary stream. */
hideThinkingSummary?: boolean;
// ── Service / routing ─────────────────────────────────────────────────
/** OpenAI service tier (auto|default|flex|scale|priority). */
serviceTier?: ServiceTier;
/** Presence penalty (OpenAI). */
presencePenalty?: number;
/** Cache retention hint derived from inbound `cache_control` markers. */
cacheRetention?: CacheRetention;
/** OpenAI Responses `prompt_cache_key`; bridges to pi-ai `sessionId`. */
promptCacheKey?: string;
/** OpenAI Responses `previous_response_id` for response chaining. */
previousResponseId?: string;
/** OpenAI / abuse-tracking `user` field. */
user?: string;
// ── Passthrough ───────────────────────────────────────────────────────
/**
* Provider-specific request controls that need server-side routing support
* but aren't yet first-class on this interface.
* Provider-specific metadata. Anthropic uses `metadata.user_id`; OpenRouter
* carries routing hints; xAI uses `search_parameters`; OpenAI accepts a
* free-form bag. The gateway forwards as-is.
*/
metadata?: Record<string, unknown>;
/**
* Captured allow-listed passthrough headers (anthropic-beta,
* anthropic-version, openai-organization, openai-project, openai-beta,
* x-stainless-*). Keys are lowercased.
*/
headers?: Record<string, string>;
/**
* Escape hatch for provider-specific request controls that don't yet have a
* first-class field. Prefer adding a typed field over widening this.
*/
extra?: Record<string, unknown>;
}
@@ -57,13 +102,19 @@ export interface AuthGatewayParsedRequest {
}
export interface AuthGatewayFormatModule {
parseRequest(body: unknown): AuthGatewayParsedRequest;
parseRequest(body: unknown, headers?: Headers): AuthGatewayParsedRequest;
encodeResponse(message: AssistantMessage, requestedModelId: string): Record<string, unknown>;
encodeStream(
events: AssistantMessageEventStream,
requestedModelId: string,
options?: AuthGatewayParsedRequestOptions,
): ReadableStream<Uint8Array>;
/**
* Emit a protocol-specific error envelope. OpenAI returns
* `{ error: { message, type } }`; Anthropic returns
* `{ type: "error", error: { type, message } }`.
*/
formatError(status: number, type: string, message: string): Response;
}
export interface AuthGatewayServerOptions {
+121 -15
View File
@@ -145,11 +145,16 @@ export interface AuthCredentialStore {
* implements this against the broker; SQLite stores leave it undefined.
*
* Precedence: `AuthStorageOptions.refreshOAuthCredential` > this hook > local.
*
* `signal` propagates the agent's cancel (ESC, request abort, …) all the
* way to the broker fetch so a hung connection can't strand the caller
* for `timeoutMs * (maxRetries + 1)`.
*/
refreshOAuthCredential?(
provider: Provider,
credentialId: number,
credential: OAuthCredential,
signal?: AbortSignal,
): Promise<OAuthCredentials>;
/**
* Optional store-supplied aggregate usage fetch. When present, `AuthStorage`
@@ -158,8 +163,26 @@ export interface AuthCredentialStore {
* isn't rate-limited like a heavy residential client).
*
* Precedence: `AuthStorageOptions.fetchUsageReports` > this hook > local fan-out.
*
* `signal` propagates the agent's cancel down to the broker fetch.
*/
fetchUsageReports?(): Promise<UsageReport[] | null>;
fetchUsageReports?(signal?: AbortSignal): Promise<UsageReport[] | null>;
/**
* Optional store-supplied per-credential usage report lookup. When present,
* `AuthStorage` consults this before its own per-credential upstream fetch
* (`#getUsageReport`). `RemoteAuthCredentialStore` implements this against
* the broker's aggregate `/v1/usage` (one coalesced round-trip shared across
* all callers) so multi-credential ranking on the client never hits the
* upstream provider's rate-limited usage endpoint from the laptop IP.
*
* Returning `null` is authoritative — `AuthStorage` does NOT fall back to
* the local fetch path. The store hook owns the decision, since falling
* back would re-introduce the per-IP rate-limit problem the broker exists
* to avoid.
*
* `signal` propagates the agent's cancel down to the broker fetch.
*/
getUsageReport?(provider: Provider, credential: OAuthCredential, signal?: AbortSignal): Promise<UsageReport | null>;
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -214,6 +237,7 @@ export type AuthStorageOptions = {
provider: Provider,
credentialId: number,
credential: OAuthCredential,
signal?: AbortSignal,
) => Promise<OAuthCredentials>;
/**
* Human-readable description of the credential store backing this
@@ -235,7 +259,7 @@ export type AuthStorageOptions = {
* Implementations may return null when no usage data is available; the
* AuthStorage caller surfaces that to its own consumer unchanged.
*/
fetchUsageReports?: () => Promise<UsageReport[] | null>;
fetchUsageReports?: (signal?: AbortSignal) => Promise<UsageReport[] | null>;
};
// ─────────────────────────────────────────────────────────────────────────────
@@ -315,6 +339,12 @@ type UsageRequestDescriptor = {
type AuthApiKeyOptions = {
baseUrl?: string;
modelId?: string;
/**
* Caller's cancel signal. Threaded into any broker-bound OAuth refresh so
* `ESC` / request abort actually kills a hung broker fetch instead of
* stranding the caller for `timeoutMs * (maxRetries + 1)`.
*/
signal?: AbortSignal;
};
function requiresOpenAICodexProModel(provider: string, modelId: string | undefined): boolean {
@@ -362,6 +392,33 @@ function parseUsageCacheEntry<T>(raw: string): UsageCacheEntry<T> | undefined {
}
}
/**
* Race `promise` against `signal`, rejecting only this caller when the signal
* fires. The underlying promise keeps running so other awaiters on the same
* single-flight fetch aren't punished by a peer's cancel.
*/
function raceUsageWithSignal<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
if (!signal) return promise;
if (signal.aborted) return Promise.reject(new Error("usage fetch aborted"));
return new Promise<T>((resolve, reject) => {
const onAbort = (): void => {
signal.removeEventListener("abort", onAbort);
reject(new Error("usage fetch aborted"));
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
value => {
signal.removeEventListener("abort", onAbort);
resolve(value);
},
err => {
signal.removeEventListener("abort", onAbort);
reject(err);
},
);
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Usage Cache (backed by AuthCredentialStore)
// ─────────────────────────────────────────────────────────────────────────────
@@ -425,7 +482,7 @@ export class AuthStorage {
#rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
#usageCache: UsageCache;
#usageRequestInFlight: Map<string, Promise<UsageReport | null>> = new Map();
#usageReportsInFlight: Map<string, Promise<UsageReport[]>> = new Map();
#usageReportsInFlight: Map<string, Promise<UsageReport[] | null>> = new Map();
#usageFetch: typeof fetch;
#usageRequestTimeoutMs: number;
#usageLogger?: UsageLogger;
@@ -1520,6 +1577,7 @@ export class AuthStorage {
request.provider,
refreshableCredential,
refreshableCredentialId,
timeoutSignal,
);
const refreshedCredential = this.#mergeRefreshedUsageCredential(request.credential, refreshed);
this.#persistRefreshedUsageCredential(request.provider, request.credential, refreshedCredential);
@@ -1788,8 +1846,16 @@ export class AuthStorage {
async #getUsageReport(
provider: Provider,
credential: OAuthCredential,
options?: { baseUrl?: string; timeoutMs?: number },
options?: { baseUrl?: string; timeoutMs?: number; signal?: AbortSignal },
): Promise<UsageReport | null> {
// Store-level hook (e.g. `RemoteAuthCredentialStore`) is authoritative
// when present: the broker already aggregates usage from a less-throttled
// IP, and falling back to the local per-credential fetch would defeat the
// whole point of routing through it.
const storeHook = this.#store.getUsageReport?.bind(this.#store);
if (storeHook) {
return storeHook(provider, credential, options?.signal);
}
return this.#fetchUsageCached(
this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
options?.timeoutMs ?? this.#usageRequestTimeoutMs,
@@ -1798,6 +1864,8 @@ export class AuthStorage {
async fetchUsageReports(options?: {
baseUrlResolver?: (provider: Provider) => string | undefined;
/** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */
signal?: AbortSignal;
}): Promise<UsageReport[] | null> {
// Caller override > store-level hook > local per-credential fan-out.
// `RemoteAuthCredentialStore` implements the store hook so a gateway
@@ -1805,7 +1873,21 @@ export class AuthStorage {
// needing the caller to wire it explicitly.
const override = this.#fetchUsageReportsOverride ?? this.#store.fetchUsageReports?.bind(this.#store);
if (override) {
return override();
// Reuse the in-flight map so concurrent callers (widget poll + format
// dispatch + credential selection) coalesce into one upstream call.
// Each caller's `signal` only cancels THAT caller's await; the
// shared upstream fetch runs to completion so peers aren't punished.
const OVERRIDE_KEY = "__override__";
let shared = this.#usageReportsInFlight.get(OVERRIDE_KEY);
if (!shared) {
// Don't forward the caller signal into the shared fetch — first caller's
// abort would otherwise cancel the upstream for every peer.
shared = override().finally(() => {
this.#usageReportsInFlight.delete(OVERRIDE_KEY);
});
this.#usageReportsInFlight.set(OVERRIDE_KEY, shared);
}
return raceUsageWithSignal(shared, options?.signal);
}
if (!this.#usageProviderResolver) return null;
@@ -1877,7 +1959,7 @@ export class AuthStorage {
async markUsageLimitReached(
provider: string,
sessionId: string | undefined,
options?: { retryAfterMs?: number; baseUrl?: string },
options?: { retryAfterMs?: number; baseUrl?: string; signal?: AbortSignal },
): Promise<boolean> {
const sessionCredential = this.#getSessionCredential(provider, sessionId);
if (!sessionCredential) return false;
@@ -1998,14 +2080,23 @@ export class AuthStorage {
return { selection, usage, usageChecked: true, blockedUntil: undefined as number | undefined };
}),
);
const usageResults = await Promise.race([usagePromise, Bun.sleep(usageTimeout).then(() => null)]).then(
result =>
const timeoutSignal = Promise.withResolvers<null>();
// `Bun.sleep` keeps the event loop alive even after Promise.race resolves,
// which leaks a 7.5–15s timer per credential-selection call. Use an unref'd
// timer so the timeout doesn't pin the process and clear it on the happy
// path so memory drops immediately.
const timer = setTimeout(() => timeoutSignal.resolve(null), usageTimeout);
(timer as { unref?: () => void }).unref?.();
const usageResults = await Promise.race([usagePromise, timeoutSignal.promise]).then(result => {
clearTimeout(timer);
return (
result ??
args.order.map(idx => {
const selection = args.credentials[idx];
return selection ? { selection, usage: null, usageChecked: false, blockedUntil: undefined } : null;
}),
);
})
);
});
for (let orderPos = 0; orderPos < usageResults.length; orderPos += 1) {
const result = usageResults[orderPos];
@@ -2131,6 +2222,7 @@ export class AuthStorage {
provider,
candidate.selection.credential,
credentialId,
options?.signal,
);
candidate.selection.credential = {
...candidate.selection.credential,
@@ -2176,6 +2268,7 @@ export class AuthStorage {
provider: Provider,
credential: OAuthCredential,
credentialId: number | undefined,
signal?: AbortSignal,
): Promise<OAuthCredentials> {
if (Date.now() < credential.expires) return credential;
let refreshPromise: Promise<OAuthCredentials>;
@@ -2185,7 +2278,7 @@ export class AuthStorage {
const storeRefresh = this.#store.refreshOAuthCredential?.bind(this.#store);
const overrideRefresh = this.#refreshOAuthCredentialOverride ?? storeRefresh;
if (overrideRefresh && credentialId !== undefined) {
refreshPromise = overrideRefresh(provider, credentialId, credential);
refreshPromise = overrideRefresh(provider, credentialId, credential, signal);
} else {
const customProvider = getOAuthProvider(provider);
if (customProvider) {
@@ -2198,17 +2291,29 @@ export class AuthStorage {
}
}
// Bound the refresh so a slow/hanging token endpoint cannot stall credential selection.
// Caller-driven abort jumps the gun on the timeout — the agent's ESC must
// take priority over the floor timeout.
let timeout: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
let onAbort: (() => void) | undefined;
const cancellationPromise = new Promise<never>((_, reject) => {
timeout = setTimeout(
() => reject(new Error(`OAuth token refresh timed out for provider: ${provider}`)),
DEFAULT_OAUTH_REFRESH_TIMEOUT_MS,
);
if (signal) {
if (signal.aborted) {
reject(new Error("OAuth token refresh aborted by caller"));
return;
}
onAbort = () => reject(new Error("OAuth token refresh aborted by caller"));
signal.addEventListener("abort", onAbort, { once: true });
}
});
try {
return await Promise.race([refreshPromise, timeoutPromise]);
return await Promise.race([refreshPromise, cancellationPromise]);
} finally {
if (timeout) clearTimeout(timeout);
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
}
}
@@ -2276,6 +2381,7 @@ export class AuthStorage {
provider,
selection.credential,
this.#getStoredCredentials(provider)[selection.index]?.id,
options?.signal,
);
const apiKey = customProvider.getApiKey
? customProvider.getApiKey(refreshedCredentials)
@@ -2519,7 +2625,7 @@ export class AuthStorage {
* Returns the redacted snapshot entry for the refreshed row.
* Throws when no OAuth credential with that id is loaded.
*/
async forceRefreshCredentialById(id: number): Promise<AuthCredentialSnapshotEntry> {
async forceRefreshCredentialById(id: number, signal?: AbortSignal): Promise<AuthCredentialSnapshotEntry> {
for (const [provider, entries] of this.#data) {
const index = entries.findIndex(entry => entry.id === id);
if (index === -1) continue;
@@ -2530,7 +2636,7 @@ export class AuthStorage {
// Pass a clone with expires=0 so the cached not-yet-expired short-circuit
// in #refreshOAuthCredential doesn't suppress the requested refresh.
const stale: OAuthCredential = { ...target.credential, expires: 0 };
const refreshed = await this.#refreshOAuthCredential(provider as Provider, stale, id);
const refreshed = await this.#refreshOAuthCredential(provider as Provider, stale, id, signal);
const updated: OAuthCredential = {
type: "oauth",
access: refreshed.access,
@@ -38,6 +38,22 @@ export const base64ImageSourceSchema = z.object({
media_type: z.string().min(1),
});
export const urlImageSourceSchema = z.object({
type: z.literal("url"),
url: z.url(),
});
export const fileImageSourceSchema = z.object({
type: z.literal("file"),
file_id: z.string().min(1),
});
export const imageSourceSchema = z.discriminatedUnion("type", [
base64ImageSourceSchema,
urlImageSourceSchema,
fileImageSourceSchema,
]);
const textBlockSchema = z.object({
type: z.literal("text"),
text: z.string(),
@@ -46,7 +62,7 @@ const textBlockSchema = z.object({
const imageBlockSchema = z.object({
type: z.literal("image"),
source: base64ImageSourceSchema,
source: imageSourceSchema,
cache_control: cacheControlSchema.optional(),
});
@@ -54,11 +70,13 @@ const thinkingBlockSchema = z.object({
type: z.literal("thinking"),
thinking: z.string(),
signature: z.string().optional(),
cache_control: cacheControlSchema.optional(),
});
const redactedThinkingBlockSchema = z.object({
type: z.literal("redacted_thinking"),
data: z.string(),
cache_control: cacheControlSchema.optional(),
});
const toolUseBlockSchema = z.object({
@@ -66,6 +84,7 @@ const toolUseBlockSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
input: z.record(z.string(), z.unknown()).optional(),
cache_control: cacheControlSchema.optional(),
});
const toolResultContentBlockSchema = z.discriminatedUnion("type", [textBlockSchema, imageBlockSchema]);
@@ -78,6 +97,12 @@ const toolResultBlockSchema = z.object({
cache_control: cacheControlSchema.optional(),
});
// Catch-all for content block variants Anthropic ships that the gateway doesn't
// natively understand (server_tool_use, web_search_tool_result, mcp_*,
// container_upload, code_execution_*, document, …). The walker flattens these
// to a text placeholder so legitimate Anthropic clients don't get rejected.
const unknownContentBlockSchema = z.object({ type: z.string() }).loose();
// ─── System ────────────────────────────────────────────────────────────────
const systemBlockSchema = z.object({
@@ -90,13 +115,19 @@ export const systemSchema = z.union([z.string(), z.array(systemBlockSchema)]).op
// ─── Messages ──────────────────────────────────────────────────────────────
const userContentBlockSchema = z.discriminatedUnion("type", [textBlockSchema, imageBlockSchema, toolResultBlockSchema]);
const userContentBlockSchema = z.union([
z.discriminatedUnion("type", [textBlockSchema, imageBlockSchema, toolResultBlockSchema]),
unknownContentBlockSchema,
]);
const assistantContentBlockSchema = z.discriminatedUnion("type", [
textBlockSchema,
thinkingBlockSchema,
redactedThinkingBlockSchema,
toolUseBlockSchema,
const assistantContentBlockSchema = z.union([
z.discriminatedUnion("type", [
textBlockSchema,
thinkingBlockSchema,
redactedThinkingBlockSchema,
toolUseBlockSchema,
]),
unknownContentBlockSchema,
]);
export const userMessageSchema = z.object({
@@ -122,20 +153,18 @@ export const toolSchema = z.object({
// ─── Tool choice ───────────────────────────────────────────────────────────
export const toolChoiceSchema = z
.discriminatedUnion("type", [
z.object({ type: z.literal("auto"), disable_parallel_tool_use: z.unknown().optional() }),
z.object({ type: z.literal("any"), disable_parallel_tool_use: z.unknown().optional() }),
z.object({ type: z.literal("none"), disable_parallel_tool_use: z.unknown().optional() }),
z.object({
type: z.literal("tool"),
name: z.string().min(1),
disable_parallel_tool_use: z.unknown().optional(),
}),
])
.refine(value => value.disable_parallel_tool_use === undefined, {
message: "tool_choice.disable_parallel_tool_use is not supported by this gateway",
});
// `disable_parallel_tool_use` is accepted on every variant; the walker maps it
// onto `options.parallelToolCalls = !disable_parallel_tool_use`.
export const toolChoiceSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("auto"), disable_parallel_tool_use: z.boolean().optional() }),
z.object({ type: z.literal("any"), disable_parallel_tool_use: z.boolean().optional() }),
z.object({ type: z.literal("none"), disable_parallel_tool_use: z.boolean().optional() }),
z.object({
type: z.literal("tool"),
name: z.string().min(1),
disable_parallel_tool_use: z.boolean().optional(),
}),
]);
// ─── Thinking ──────────────────────────────────────────────────────────────
@@ -175,11 +204,10 @@ export const anthropicMessagesRequestSchema = z.object({
stop_sequences: z.array(z.string()).optional(),
stream: z.boolean().optional(),
thinking: thinkingConfigSchema.optional(),
// Spec fields that the gateway tolerates but doesn't translate. Anthropic
// clients commonly send `metadata: { user_id }` — failing the request just
// because we can't route it is hostile. They're accepted permissively and
// silently dropped on the translate path.
metadata: z.unknown().optional(),
// Anthropic clients commonly send `metadata: { user_id }`; the walker
// surfaces it on `options.metadata` for downstream provider forwarding.
metadata: z.record(z.string(), z.unknown()).optional(),
// Spec fields that the gateway tolerates but doesn't translate yet.
container: z.unknown().optional(),
context_management: z.unknown().optional(),
mcp_servers: z.unknown().optional(),
@@ -1,3 +1,5 @@
import { logger } from "@oh-my-pi/pi-utils";
import { captureRequestHeaders, resolvePromptCacheKey } from "../auth-gateway/http";
import type {
AssistantMessage,
AssistantMessageEventStream,
@@ -38,6 +40,43 @@ export type { ParsedRequest };
type ImageContentPart = { type: "image"; data: string; mimeType: string };
// Dedup noise from unknown-block-type warnings. Module-scoped so the warn
// fires once per (category, type) pair across the lifetime of the process.
const WARNED_UNKNOWN_BLOCK_TYPES = new Set<string>();
function warnUnknownBlockType(category: "user" | "assistant", blockType: string): void {
const key = `${category}:${blockType}`;
if (WARNED_UNKNOWN_BLOCK_TYPES.has(key)) return;
WARNED_UNKNOWN_BLOCK_TYPES.add(key);
logger.warn("anthropic-messages: unknown content block flattened to text placeholder", {
category,
blockType,
});
}
// pi-ai's `ImageContent` only carries base64 + mimeType. When the inbound
// uses `url` or `file_id` sources we surface a text placeholder so the
// downstream provider still sees a sane history; warn once per source kind.
const WARNED_NON_BASE64_IMAGE_SOURCES = new Set<string>();
function warnNonBase64ImageSource(sourceType: string): void {
if (WARNED_NON_BASE64_IMAGE_SOURCES.has(sourceType)) return;
WARNED_NON_BASE64_IMAGE_SOURCES.add(sourceType);
logger.warn("anthropic-messages: image source surfaced as text placeholder (pi-ai ImageContent lacks URL channel)", {
sourceType,
});
}
// Compact, log-safe stringification for unknown content blocks. Keeps the
// placeholder informative without dumping multi-KB structures into history.
function describeUnknownBlock(block: { type: string }): string {
try {
const json = JSON.stringify(block);
if (json !== undefined && json.length <= 200) return `[${block.type}: ${json}]`;
} catch {
// fall through
}
return `[${block.type}]`;
}
function buildSystemPrompt(raw: AnthropicSystem): string[] | undefined {
if (raw === undefined) return undefined;
if (typeof raw === "string") return raw.length > 0 ? [raw] : undefined;
@@ -91,13 +130,30 @@ function walkUserContent(
if (block.type === "text") {
userParts.push({ type: "text", text: block.text });
} else if (block.type === "image") {
if (block.source.type !== "base64") continue;
userParts.push({ type: "image", data: block.source.data, mimeType: block.source.media_type });
} else if (block.type === "tool_result") {
// tool_result blocks must follow any plain text/image siblings.
if (userParts.length > 0) {
throw new Error("anthropic-messages: user text/image blocks before tool_result are not supported");
// SDK's typed source covers base64+url; our schema also accepts the
// forward-compat `file` variant. Narrow against a widened shape so
// every variant is handled at runtime regardless of SDK lag.
const source = block.source as {
type: string;
data?: string;
media_type?: string;
url?: string;
file_id?: string;
};
if (source.type === "base64" && source.data && source.media_type) {
userParts.push({ type: "image", data: source.data, mimeType: source.media_type });
} else {
warnNonBase64ImageSource(source.type);
const ref =
source.type === "url" ? (source.url ?? "") : source.type === "file" ? (source.file_id ?? "") : "";
userParts.push({ type: "text", text: `[image: ${ref}]` });
}
} else if (block.type === "tool_result") {
// Anthropic permits tool_result blocks to follow plain text/image
// siblings in the same user message. pi-ai's history is a flat
// sequence of typed messages, so flush the accumulated parts as a
// separate UserMessage before emitting the ToolResultMessage.
flush();
messages.push({
role: "toolResult",
toolCallId: block.tool_use_id,
@@ -107,6 +163,13 @@ function walkUserContent(
isError: block.is_error === true,
timestamp,
});
} else {
// Unknown variant (server_tool_use, mcp_*, document, web_search_tool_result,
// container_upload, code_execution_*, …). Flatten to a text placeholder
// so the downstream provider still gets a coherent transcript.
const unknown = block as { type: string };
warnUnknownBlockType("user", unknown.type);
userParts.push({ type: "text", text: describeUnknownBlock(unknown) });
}
}
flush();
@@ -143,6 +206,14 @@ function walkAssistantContent(
arguments: block.input ?? {},
});
break;
default: {
// Unknown assistant variant (server_tool_use, mcp_tool_use, …).
// Flatten to a text placeholder; warn once per unknown type.
const unknown = block as { type: string };
warnUnknownBlockType("assistant", unknown.type);
out.push({ type: "text", text: describeUnknownBlock(unknown) });
break;
}
}
}
return out;
@@ -215,7 +286,7 @@ function deriveCacheRetention(data: {
return strongest;
}
export function parseRequest(body: unknown): ParsedRequest {
export function parseRequest(body: unknown, headers?: Headers): ParsedRequest {
const parsed = anthropicMessagesRequestSchema.safeParse(body);
if (!parsed.success) {
throw new Error(`anthropic-messages: ${parsed.error.message}`);
@@ -251,23 +322,48 @@ export function parseRequest(body: unknown): ParsedRequest {
if (data.stop_sequences) options.stopSequences = data.stop_sequences;
const toolChoice = mapToolChoice(data.tool_choice as AnthropicToolChoice | undefined);
if (toolChoice !== undefined) options.toolChoice = toolChoice;
// `disable_parallel_tool_use === true` means the client wants the model to
// emit at most one tool call per turn; map to pi-ai's negated boolean.
// Leave undefined when the field is absent or explicitly `false` so we
// don't override provider defaults.
if (data.tool_choice?.disable_parallel_tool_use === true) {
options.parallelToolCalls = false;
}
if (data.thinking) {
switch (data.thinking.type) {
case "enabled":
options.thinkingBudget = data.thinking.budget_tokens;
options.explicitThinkingBudgetTokens = data.thinking.budget_tokens;
break;
case "disabled":
options.disableReasoning = true;
break;
case "adaptive":
if (data.thinking.budget_tokens !== undefined) {
options.thinkingBudget = data.thinking.budget_tokens;
options.explicitThinkingBudgetTokens = data.thinking.budget_tokens;
}
break;
}
}
const cacheRetention = deriveCacheRetention(data);
if (cacheRetention !== undefined) options.cacheRetention = cacheRetention;
// Anthropic clients commonly send `metadata: { user_id }`; forward verbatim
// so downstream providers (and our anthropic-passthrough fast-path) can
// preserve abuse-tracking signal.
if (data.metadata !== undefined) {
options.metadata = data.metadata as Record<string, unknown>;
}
const cacheKey = resolvePromptCacheKey(body, headers);
if (cacheKey !== undefined) options.promptCacheKey = cacheKey;
// Allow-listed header capture. The gateway's `handleFormatEndpoint`
// already merges its own pre-capture under whatever the parser sets, but
// we populate here too so direct callers of `parseRequest` (tests, custom
// wrappers) see the same surface. `anthropic-version` is the most
// load-bearing — some downstream Anthropic-API targets reject requests
// missing it.
if (headers) {
const captured = captureRequestHeaders(headers);
if (Object.keys(captured).length > 0) options.headers = captured;
}
return {
modelId: data.model,
@@ -364,6 +460,9 @@ export function encodeResponse(message: AssistantMessage, requestedModelId: stri
model: requestedModelId,
content: encodeContentBlocks(message),
stop_reason: mapStopReasonOut(message.stopReason),
// TODO: surface the matched stop sequence once pi-ai's
// `AssistantMessage.stopReason` carries the matched string. Intentionally
// `null` for now (Anthropic schema allows it).
stop_sequence: null,
usage: encodeUsage(message),
};
@@ -409,6 +508,8 @@ export function encodeStream(
model: requestedModelId,
content: [],
stop_reason: null,
// TODO: same as encodeResponse — surface matched stop sequence
// once pi-ai propagates it.
stop_sequence: null,
usage: encodeUsage(partial),
},
@@ -522,6 +623,8 @@ export function encodeStream(
controller.enqueue(
sseFrame("message_delta", {
type: "message_delta",
// TODO: surface matched stop sequence once pi-ai
// propagates it on the `done` event.
delta: { stop_reason: mapStopReasonOut(ev.reason), stop_sequence: null },
usage: encodeUsage(ev.message),
}),
@@ -556,3 +659,19 @@ export function encodeStream(
},
});
}
// ---------------------------------------------------------------------------
// Error envelope
// ---------------------------------------------------------------------------
/**
* Anthropic error envelope: `{ type: "error", error: { type, message } }`.
* See https://docs.anthropic.com/en/api/errors. Returned as a `Response` so
* the gateway can hand it straight back to the client without extra wrapping.
*/
export function formatError(status: number, type: string, message: string): Response {
return new Response(JSON.stringify({ type: "error", error: { type, message } }), {
status,
headers: { "Content-Type": "application/json" },
});
}
+16
View File
@@ -15,6 +15,7 @@ import {
isEnoent,
isRetryableError,
isUnexpectedSocketCloseMessage,
logger,
readSseEvents,
} from "@oh-my-pi/pi-utils";
import { hasOpus47ApiRestrictions, mapEffortToAnthropicAdaptiveEffort } from "../model-thinking";
@@ -204,6 +205,9 @@ type AnthropicSamplingParams = MessageCreateParamsStreaming & {
top_k?: number;
};
const ANTHROPIC_STOP_SEQUENCES_MAX = 4;
let warnedStopSequencesTrim = false;
/**
* Adaptive thinking `display` is supported starting with Claude Opus 4.7.
* Older adaptive-thinking models (Opus 4.6, Sonnet 4.6+) reject the field.
@@ -1781,6 +1785,18 @@ function buildParams(
if (options?.topK !== undefined) {
params.top_k = options.topK;
}
if (options?.stopSequences?.length) {
const seqs = options.stopSequences;
if (seqs.length > ANTHROPIC_STOP_SEQUENCES_MAX && !warnedStopSequencesTrim) {
warnedStopSequencesTrim = true;
logger.warn("anthropic: stop_sequences exceeds 4; extra entries dropped", {
received: seqs.length,
kept: ANTHROPIC_STOP_SEQUENCES_MAX,
});
}
params.stop_sequences =
seqs.length > ANTHROPIC_STOP_SEQUENCES_MAX ? seqs.slice(0, ANTHROPIC_STOP_SEQUENCES_MAX) : seqs;
}
// Opus 4.7+ rejects non-default sampling parameters with 400 error.
if (hasOpus47ApiRestrictions(model.id)) {
@@ -1,8 +1,11 @@
/**
* Zod schemas for the OpenAI chat-completions request shape we accept on the
* gateway. Mirrors https://platform.openai.com/docs/api-reference/chat — only
* the shapes the gateway translation layer understands. Unsupported fields
* inside `stream_options` are rejected explicitly.
* the shapes the gateway translation layer understands. Unknown fields on
* permissive objects are accepted-and-stripped (via `z.unknown()` passthroughs
* or `.loose()`) so the official OpenAI SDK — which sends a growing pile of
* non-strict defaults (e.g. `stream_options.include_obfuscation`) — does not
* trip 400s on shapes we simply ignore.
*/
import type {
ChatCompletionContentPart,
@@ -22,15 +25,62 @@ export const textPartSchema = z.object({
});
/**
* OpenAI documents `image_url` as either `{ url: string }` or — older clients —
* a bare string. Accept both shapes; downstream we extract a URL.
* OpenAI documents `image_url` as either `{ url: string, detail?: ... }` or —
* older clients — a bare string. Accept both shapes; downstream we extract a
* URL. `detail` is accepted for forward-compat but currently dropped (pi-ai's
* `ImageContent` has no detail field — TODO: plumb through if/when added).
*/
export const imagePartSchema = z.object({
type: z.literal("image_url"),
image_url: z.union([z.string(), z.object({ url: z.string() })]),
image_url: z.union([
z.string(),
z.object({
url: z.string(),
detail: z.enum(["auto", "low", "high"]).optional(),
}),
]),
});
export const userContentPartSchema = z.union([textPartSchema, imagePartSchema]);
/** OpenAI audio input block (gpt-4o-audio). Accepted; currently dropped downstream. */
export const inputAudioPartSchema = z.object({
type: z.literal("input_audio"),
input_audio: z.object({
data: z.string(),
format: z.enum(["wav", "mp3"]),
}),
});
/** OpenAI file input block (file_search / vision-document). Accepted; currently dropped downstream. */
export const filePartSchema = z.object({
type: z.literal("file"),
file: z.object({
file_id: z.string().optional(),
filename: z.string().optional(),
file_data: z.string().optional(),
}),
});
/** Replayed assistant refusal block. Accepted; currently dropped downstream. */
export const refusalPartSchema = z.object({
type: z.literal("refusal"),
refusal: z.string(),
});
/**
* Forward-compat catch-all for unknown content-part types. Matches every other
* `{ type: string, ... }` object so a new OpenAI block kind does not 400 the
* whole request; the walker ignores parts whose `type` it does not know.
*/
export const unknownPartSchema = z.object({ type: z.string() }).loose();
export const userContentPartSchema = z.union([
textPartSchema,
imagePartSchema,
inputAudioPartSchema,
filePartSchema,
refusalPartSchema,
unknownPartSchema,
]);
// ─── Tool calls / tools ─────────────────────────────────────────────────────
@@ -49,6 +99,8 @@ export const toolSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
parameters: z.record(z.string(), z.unknown()).optional(),
/** OpenAI structured-output strict mode. Accepted, not enforced upstream. */
strict: z.boolean().optional(),
}),
});
@@ -62,6 +114,12 @@ export const toolChoiceSchema = z.union([
type: z.literal("function"),
function: z.object({ name: z.string().min(1) }),
}),
// Anthropic-style `{ type: 'tool', name }` — translated to the OpenAI
// function shape in the walker.
z.object({
type: z.literal("tool"),
name: z.string().min(1),
}),
]);
// ─── Messages ───────────────────────────────────────────────────────────────
@@ -95,25 +153,40 @@ export const toolMessageSchema = z.object({
tool_call_id: z.string().optional(),
});
/**
* Legacy `function` role (pre-tools API). Translated to a `tool` role
* canonical message in the walker so downstream providers see one shape.
*/
export const functionMessageSchema = z.object({
role: z.literal("function"),
name: z.string(),
content: z.string().nullable(),
});
export const messageSchema = z.discriminatedUnion("role", [
systemMessageSchema,
developerMessageSchema,
userMessageSchema,
assistantMessageSchema,
toolMessageSchema,
functionMessageSchema,
]);
// ─── Stream options ─────────────────────────────────────────────────────────
export const streamOptionsSchema = z
.object({
include_usage: z.boolean().optional(),
})
.strict();
/**
* Permissive: the official OpenAI SDK sets `include_obfuscation: false` by
* default. We only consume `include_usage`, so unknown keys are silently
* stripped rather than 400'd.
*/
export const streamOptionsSchema = z.object({
include_usage: z.boolean().optional(),
});
// ─── Stop sequences ─────────────────────────────────────────────────────────
export const stopSchema = z.union([z.string(), z.array(z.string())]);
// OpenAI rejects > 4 stop strings; mirror that at the gateway.
export const stopSchema = z.union([z.string(), z.array(z.string()).max(4)]);
// ─── Top-level request ──────────────────────────────────────────────────────
@@ -129,14 +202,32 @@ export const openaiChatRequestSchema = z.object({
stop: stopSchema.optional(),
stream: z.boolean().optional(),
stream_options: streamOptionsSchema.optional(),
// Passthroughs surfaced to providers via options.extra. We accept any JSON
// for them — the provider validates further if it cares.
// ── Typed first-class passthroughs (now consumed by the walker) ────────
response_format: z.unknown().optional(),
seed: z.number().optional(),
presence_penalty: z.number().optional(),
frequency_penalty: z.number().optional(),
logit_bias: z.record(z.string(), z.number()).optional(),
user: z.string().optional(),
reasoning_effort: z.enum(["minimal", "low", "medium", "high", "xhigh"]).optional(),
parallel_tool_calls: z.boolean().optional(),
service_tier: z.enum(["auto", "default", "flex", "scale", "priority"]).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
// ── Accept-and-ignore passthroughs ─────────────────────────────────────
// Forward acceptance only: validating these would 400 on shapes the
// gateway has no opinion on. The downstream provider does the real check.
logprobs: z.unknown().optional(),
top_logprobs: z.unknown().optional(),
prediction: z.unknown().optional(),
modalities: z.unknown().optional(),
audio: z.unknown().optional(),
store: z.unknown().optional(),
prompt_cache_key: z.unknown().optional(),
safety_identifier: z.unknown().optional(),
n: z.unknown().optional(),
web_search_options: z.unknown().optional(),
});
/**
+182 -38
View File
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { resolvePromptCacheKey } from "../auth-gateway/http";
/**
* Parsed inbound OpenAI chat-completions request, ready to feed into pi-ai
* `stream(model, context, options)`.
@@ -10,6 +11,7 @@ import type {
Context,
ImageContent,
Message,
ServiceTier,
StopReason,
TextContent,
Tool,
@@ -28,11 +30,26 @@ import {
export type { ParsedRequest };
type ReasoningEffort = NonNullable<ParsedRequest["options"]["reasoning"]>;
function isReasoningEffort(value: unknown): value is ReasoningEffort {
return value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh";
}
function isServiceTier(value: unknown): value is ServiceTier {
return value === "auto" || value === "default" || value === "flex" || value === "scale" || value === "priority";
}
// ---------------------------------------------------------------------------
// parseRequest
// ---------------------------------------------------------------------------
export function parseRequest(body: unknown): ParsedRequest {
export function parseRequest(body: unknown, headers?: Headers): ParsedRequest {
// Header capture is centralized in `auth-gateway/server.ts` (allow-listed
// headers like openai-organization/openai-project/openai-beta/x-stainless-*
// land on `options.headers` automatically). We consult `headers` here too
// for `resolvePromptCacheKey` to pull a cache identity out of inbound
// vendor-neutral headers when the body doesn't carry one.
const parsed = openaiChatRequestSchema.safeParse(body);
if (!parsed.success) {
throw new Error(`openai-chat: ${parsed.error.message}`);
@@ -67,8 +84,16 @@ export function parseRequest(body: unknown): ParsedRequest {
);
break;
case "tool":
messages.push(buildToolMessage(m.content, m.tool_call_id, now));
pushToolResultMessages(messages, m.content, m.tool_call_id, undefined, now);
break;
case "function": {
// Legacy `function` role (pre-tools API): the message carries the tool's
// name on `name` and its output on `content`. Translate to a canonical
// `toolResult` with a synthetic id (no original id on the wire).
const fn = m as { role: "function"; name: string; content: string | null };
pushToolResultMessages(messages, fn.content ?? "", undefined, fn.name, now);
break;
}
}
}
@@ -83,39 +108,50 @@ export function parseRequest(body: unknown): ParsedRequest {
// Prefer max_completion_tokens (newer) over max_tokens.
const maxOutputTokens = data.max_completion_tokens ?? data.max_tokens;
const stopSequences = normalizeStop(data.stop);
const toolChoice = normalizeToolChoice(data.tool_choice);
// Schema accepts the Anthropic-style {type:'tool', name} variant that the SDK
// union doesn't model; the normalizer collapses it to a plain name lookup.
const toolChoice = normalizeToolChoice(data.tool_choice as Parameters<typeof normalizeToolChoice>[0]);
const includeStreamingUsage = data.stream_options?.include_usage === true;
// `includeStreamingUsage` is the one genuinely-opaque flag — the streaming
// encoder reads it later off `options.extra`. Everything else now lives on
// a typed field; `extra` stays undefined when only typed values are set.
const extra: Record<string, unknown> = {};
let hasExtra = false;
const carry = <K extends string>(key: K, value: unknown) => {
if (value === undefined) return;
extra[key] = value;
hasExtra = true;
};
carry("response_format", data.response_format);
carry("seed", data.seed);
carry("presence_penalty", data.presence_penalty);
carry("frequency_penalty", data.frequency_penalty);
carry("logit_bias", data.logit_bias);
carry("user", data.user);
if (includeStreamingUsage) {
extra.includeStreamingUsage = true;
hasExtra = true;
}
const options: ParsedRequest["options"] = {};
if (maxOutputTokens !== undefined) options.maxOutputTokens = maxOutputTokens;
if (data.temperature !== undefined) options.temperature = data.temperature;
if (data.top_p !== undefined) options.topP = data.top_p;
if (stopSequences) options.stopSequences = stopSequences;
if (toolChoice !== undefined) options.toolChoice = toolChoice;
if (data.presence_penalty !== undefined) options.presencePenalty = data.presence_penalty;
if (data.frequency_penalty !== undefined) options.frequencyPenalty = data.frequency_penalty;
if (data.seed !== undefined) options.seed = data.seed;
if (data.logit_bias !== undefined) options.logitBias = data.logit_bias;
if (data.user !== undefined) options.user = data.user;
if (data.response_format !== undefined) options.responseFormat = data.response_format;
if (data.parallel_tool_calls !== undefined) options.parallelToolCalls = data.parallel_tool_calls;
if (data.reasoning_effort !== undefined && isReasoningEffort(data.reasoning_effort)) {
options.reasoning = data.reasoning_effort;
}
if (data.service_tier !== undefined && isServiceTier(data.service_tier)) {
options.serviceTier = data.service_tier;
}
if (data.metadata !== undefined) options.metadata = data.metadata;
const cacheKey = resolvePromptCacheKey(body, headers);
if (cacheKey !== undefined) options.promptCacheKey = cacheKey;
if (hasExtra) options.extra = extra;
return {
modelId: data.model,
context,
stream: data.stream === true,
options: {
...(maxOutputTokens !== undefined ? { maxOutputTokens } : {}),
...(data.temperature !== undefined ? { temperature: data.temperature } : {}),
...(data.top_p !== undefined ? { topP: data.top_p } : {}),
...(stopSequences ? { stopSequences } : {}),
...(toolChoice !== undefined ? { toolChoice } : {}),
...(hasExtra ? { extra } : {}),
},
options,
};
}
@@ -141,6 +177,9 @@ function parseUserLikeContent(
continue;
}
if (part.type !== "image_url") continue;
// input_audio / file / refusal / unknown-type parts are accepted by the
// schema for forward-compat but dropped here — pi-ai's canonical user
// content only models text and image today.
const url = typeof part.image_url === "string" ? part.image_url : part.image_url.url;
const decoded = decodeDataUri(url);
if (decoded) {
@@ -215,21 +254,63 @@ function buildAssistantMessage(
};
}
function buildToolMessage(
content: string | OpenAIChatContentPart[] | undefined,
/**
* Walk a wire `tool` (or legacy `function`) message into canonical messages.
* Tool-result content may carry images alongside text; pi-ai's
* `ToolResultMessage` accepts both, but most downstream providers ignore
* images on tool results. To mirror Rust's `encode_messages` behavior we
* keep text inside the tool-result message and hoist any image parts into a
* follow-up `user` message so they still reach the model.
*/
function pushToolResultMessages(
messages: Message[],
content: string | OpenAIChatContentPart[] | undefined | null,
toolCallId: string | undefined,
toolName: string | undefined,
now: number,
): ToolResultMessage {
return {
): void {
const textParts: TextContent[] = [];
const imageParts: ImageContent[] = [];
if (typeof content === "string") {
if (content.length > 0) textParts.push({ type: "text", text: content });
} else if (Array.isArray(content)) {
for (const part of content) {
if (part.type === "text") {
textParts.push({ type: "text", text: part.text });
continue;
}
if (part.type !== "image_url") continue;
const url = typeof part.image_url === "string" ? part.image_url : part.image_url.url;
const decoded = decodeDataUri(url);
if (decoded) {
imageParts.push({ type: "image", data: decoded.data, mimeType: decoded.mimeType });
} else {
// No fetcher available; degrade gracefully to a text placeholder.
textParts.push({ type: "text", text: `[image: ${url}]` });
}
}
}
const toolMsg: ToolResultMessage = {
role: "toolResult",
toolCallId: toolCallId ?? "",
// OpenAI chat-completions doesn't carry the tool name on tool-role messages;
// downstream providers that need it tolerate an empty string.
toolName: "",
content: [{ type: "text", text: stringifyContent(content) }],
// OpenAI's `tool` role omits the tool name on the wire; the legacy
// `function` role supplies it. Downstream providers tolerate empty.
toolName: toolName ?? "",
content: textParts.length > 0 ? textParts : [{ type: "text", text: "" }],
isError: false,
timestamp: now,
};
messages.push(toolMsg);
if (imageParts.length > 0) {
messages.push({
role: "user",
content: imageParts,
timestamp: now,
});
}
}
function buildTools(tools: OpenAIChatTool[]): Tool[] | undefined {
@@ -255,7 +336,15 @@ function normalizeStop(value: string | string[] | undefined): string[] | undefin
function normalizeToolChoice(value: OpenAIChatToolChoice | undefined): ParsedRequest["options"]["toolChoice"] {
if (value === undefined) return undefined;
if (value === "auto" || value === "none" || value === "required") return value;
if ("function" in value) return { name: value.function.name };
if (typeof value === "object" && value !== null) {
// OpenAI canonical: { type: 'function', function: { name } }
if ("function" in value && value.function) return { name: value.function.name };
// Anthropic-style passthrough (schema-allowed): { type: 'tool', name }
const anthropicLike = value as unknown as { type?: string; name?: string };
if (anthropicLike.type === "tool" && typeof anthropicLike.name === "string") {
return { name: anthropicLike.name };
}
}
return undefined;
}
@@ -264,12 +353,19 @@ function normalizeToolChoice(value: OpenAIChatToolChoice | undefined): ParsedReq
// ---------------------------------------------------------------------------
export function encodeResponse(message: AssistantMessage, requestedModelId: string): Record<string, unknown> {
const { text, toolCalls } = flattenAssistant(message);
const { text, reasoning, toolCalls } = flattenAssistant(message);
const responseMessage: Record<string, unknown> = {
role: "assistant",
content: text.length > 0 ? text : null,
// pi-ai does not surface real refusals yet; emit `null` so SDKs that
// probe `.refusal` see the documented field shape rather than missing.
refusal: null,
};
if (reasoning.length > 0) {
// DeepSeek-style / o-series reasoning channel.
responseMessage.reasoning_content = reasoning;
}
if (toolCalls.length > 0) {
responseMessage.tool_calls = toolCalls.map(tc => ({
id: tc.id,
@@ -283,11 +379,15 @@ export function encodeResponse(message: AssistantMessage, requestedModelId: stri
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: requestedModelId,
// Real OpenAI always emits this key, even when the value is null. Mirror
// the contract so probing SDKs do not throw on a missing field.
system_fingerprint: null,
choices: [
{
index: 0,
message: responseMessage,
finish_reason: mapFinishReason(message.stopReason, toolCalls.length > 0),
logprobs: null,
},
],
usage: buildUsage(message),
@@ -296,29 +396,45 @@ export function encodeResponse(message: AssistantMessage, requestedModelId: stri
function buildUsage(message: AssistantMessage): Record<string, unknown> {
const promptTokens = message.usage.input + message.usage.cacheRead + message.usage.cacheWrite;
return {
const usage: Record<string, unknown> = {
prompt_tokens: promptTokens,
completion_tokens: message.usage.output,
total_tokens: promptTokens + message.usage.output,
prompt_tokens_details: { cached_tokens: message.usage.cacheRead },
};
if (message.usage.reasoningTokens !== undefined) {
usage.completion_tokens_details = { reasoning_tokens: message.usage.reasoningTokens };
}
return usage;
}
function flattenAssistant(message: AssistantMessage): { text: string; toolCalls: ToolCall[] } {
function flattenAssistant(message: AssistantMessage): {
text: string;
reasoning: string;
toolCalls: ToolCall[];
} {
let text = "";
let reasoning = "";
const toolCalls: ToolCall[] = [];
for (const part of message.content) {
switch (part.type) {
case "text":
text += part.text;
break;
case "thinking":
reasoning += part.thinking;
break;
case "redactedThinking":
// Opaque blob — surface verbatim on the reasoning channel so the
// concatenation round-trips through clients that just echo it.
reasoning += part.data;
break;
case "toolCall":
toolCalls.push(part);
break;
// thinking / redactedThinking: dropped — openai chat-completions has no reasoning channel.
}
}
return { text, toolCalls };
return { text, reasoning, toolCalls };
}
function isOnlyRaw(args: Record<string, unknown>): boolean {
@@ -341,6 +457,8 @@ function stringifyArgs(args: Record<string, unknown>): string {
function mapFinishReason(reason: StopReason, hasToolCalls: boolean): string {
if (reason === "toolUse" || (hasToolCalls && reason === "stop")) return "tool_calls";
if (reason === "length") return "length";
// pi-ai's StopReason does not currently carry a content-filter signal;
// when it does, map it to "content_filter" here.
return "stop";
}
@@ -367,7 +485,8 @@ export function encodeStream(
object: "chat.completion.chunk",
created,
model: requestedModelId,
choices: [{ index: 0, delta, finish_reason: finishReason }],
system_fingerprint: null,
choices: [{ index: 0, delta, finish_reason: finishReason, logprobs: null }],
...(includeUsage ? { usage: null } : {}),
});
@@ -381,6 +500,7 @@ export function encodeStream(
object: "chat.completion.chunk",
created,
model: requestedModelId,
system_fingerprint: null,
choices: [],
usage: buildUsage(message),
});
@@ -406,6 +526,14 @@ export function encodeStream(
}
break;
case "thinking_delta":
// DeepSeek-style / o-series reasoning channel. Clients that don't
// understand it ignore the unknown delta key.
if (event.delta.length > 0) {
writeSse(controller, baseChunk({ reasoning_content: event.delta }, null));
}
break;
case "toolcall_start": {
hasToolCalls = true;
const idx = nextToolIndex++;
@@ -463,7 +591,7 @@ export function encodeStream(
return;
}
// Drop start / *_start / *_end / thinking_* — chat-completions wire only
// Drop start / *_start / *_end — chat-completions wire only
// surfaces deltas and the terminal finish_reason.
default:
break;
@@ -482,3 +610,19 @@ export function encodeStream(
},
});
}
// ---------------------------------------------------------------------------
// formatError
// ---------------------------------------------------------------------------
/**
* OpenAI chat-completions error envelope:
* `{ error: { message, type } }`
* Matches the shape the official SDK auto-parses into `APIError`.
*/
export function formatError(status: number, type: string, message: string): Response {
return new Response(JSON.stringify({ error: { message, type } }), {
status,
headers: { "Content-Type": "application/json" },
});
}
@@ -1084,6 +1084,13 @@ function buildParams(
if (options?.repetitionPenalty !== undefined) {
params.repetition_penalty = options.repetitionPenalty;
}
if (options?.stopSequences?.length) {
const seqs = options.stopSequences;
params.stop = seqs.length === 1 ? seqs[0] : seqs.slice(0, 4);
}
if (options?.frequencyPenalty !== undefined) {
params.frequency_penalty = options.frequencyPenalty;
}
if (shouldSendServiceTier(options?.serviceTier, model.provider)) {
params.service_tier = options.serviceTier;
}
@@ -1,9 +1,11 @@
/**
* Zod schemas for the OpenAI Responses API request shape we accept on the
* gateway. Mirrors https://platform.openai.com/docs/api-reference/responses —
* only the item types the gateway translation layer understands. Unsupported
* controls (background/include/metadata/prompt/…) are caught explicitly with
* `.refine(...)` so the error message names them.
* gateway. Mirrors https://platform.openai.com/docs/api-reference/responses.
*
* Unsupported / opaque controls (background/include/metadata/prompt/…) are
* accepted as `z.unknown().optional()` so we silently ignore rather than 400.
* Real clients (codex, openai-python, llm-git) routinely send these and a 400
* is a worse outcome than dropping them on the floor.
*/
import type {
EasyInputMessage,
@@ -17,18 +19,46 @@ import type {
} from "openai/resources/responses/responses";
import * as z from "zod/v4";
// ─── Input items ────────────────────────────────────────────────────────────
// ─── Input content blocks ───────────────────────────────────────────────────
const inputTextSchema = z.object({
type: z.literal("input_text"),
text: z.string(),
});
const plainTextSchema = z.object({
type: z.literal("text"),
text: z.string(),
});
const inputImageBlockSchema = z
.object({
type: z.literal("input_image"),
detail: z.enum(["auto", "low", "high"]).optional(),
image_url: z.string().optional(),
file_id: z.string().optional(),
})
.refine(v => typeof v.image_url === "string" || typeof v.file_id === "string", {
message: "input_image requires at least one of `image_url` or `file_id`",
});
const inputFileBlockSchema = z.object({
type: z.literal("input_file"),
file_id: z.string().optional(),
filename: z.string().optional(),
file_data: z.string().optional(),
});
const outputTextSchema = z.object({
type: z.literal("output_text"),
text: z.string(),
});
const outputRefusalSchema = z.object({
type: z.literal("refusal"),
refusal: z.string(),
});
const summaryTextSchema = z.object({
type: z.literal("summary_text"),
text: z.string(),
@@ -39,13 +69,15 @@ const reasoningTextSchema = z.object({
text: z.string(),
});
const plainTextSchema = z.object({
type: z.literal("text"),
text: z.string(),
});
const inputContentBlockSchema = z.union([
inputTextSchema,
plainTextSchema,
inputImageBlockSchema,
inputFileBlockSchema,
]);
const outputContentBlockSchema = z.union([outputTextSchema, plainTextSchema, outputRefusalSchema]);
const inputContentBlockSchema = z.union([inputTextSchema, plainTextSchema]);
const outputContentBlockSchema = z.union([outputTextSchema, plainTextSchema]);
// ─── Input items ────────────────────────────────────────────────────────────
const userMessageItemSchema = z.object({
type: z.literal("message").optional(),
@@ -83,7 +115,24 @@ const functionCallItemSchema = z.object({
const functionCallOutputItemSchema = z.object({
type: z.literal("function_call_output"),
call_id: z.string().min(1),
output: z.string().optional(),
// Codex CLI replays multimodal tool results in array form (text + refusal).
output: z.union([z.string(), z.array(outputContentBlockSchema)]).optional(),
});
const customToolCallItemSchema = z.object({
type: z.literal("custom_tool_call"),
id: z.string().optional(),
call_id: z.string().min(1),
name: z.string().min(1),
// Raw input string — NOT JSON.stringified. apply_patch flow streams a
// freeform body and reading it as JSON would corrupt it.
input: z.string(),
});
const customToolCallOutputItemSchema = z.object({
type: z.literal("custom_tool_call_output"),
call_id: z.string().min(1),
output: z.string(),
});
/**
@@ -98,8 +147,10 @@ export const inputItemSchema = z.union([
reasoningItemSchema,
functionCallItemSchema,
functionCallOutputItemSchema,
customToolCallItemSchema,
customToolCallOutputItemSchema,
// Tolerated but not bridged (file_search_call, web_search_call, …).
z.object({ type: z.string() }),
z.object({ type: z.string() }).loose(),
]);
// Variant types alias the canonical SDK union members so the walker can
@@ -112,6 +163,13 @@ export type OpenAIResponsesReasoningItem = ResponseReasoningItem;
export type OpenAIResponsesFunctionCallItem = ResponseFunctionToolCall;
export type OpenAIResponsesFunctionCallOutputItem = ResponseInputItem.FunctionCallOutput;
/** Inferred shape of the custom tool call input item (no canonical SDK alias). */
export type OpenAIResponsesCustomToolCallItem = z.infer<typeof customToolCallItemSchema>;
export type OpenAIResponsesCustomToolCallOutputItem = z.infer<typeof customToolCallOutputItemSchema>;
export type OpenAIResponsesInputImageBlock = z.infer<typeof inputImageBlockSchema>;
export type OpenAIResponsesInputFileBlock = z.infer<typeof inputFileBlockSchema>;
export type OpenAIResponsesOutputRefusalBlock = z.infer<typeof outputRefusalSchema>;
// ─── Tools ──────────────────────────────────────────────────────────────────
export const toolSchema = z.object({
@@ -122,14 +180,30 @@ export const toolSchema = z.object({
strict: z.boolean().optional(),
});
// Built-in tool entries (web_search, file_search, …) — accepted but skipped
// by the walker.
const builtinToolSchema = z.object({
type: z.string(),
});
// Built-in / hosted tool entries (web_search_preview, file_search, …) — accepted
// but skipped by the walker.
const builtinToolSchema = z
.object({
type: z.string(),
})
.loose();
// ─── Tool choice ────────────────────────────────────────────────────────────
const hostedToolType = z.enum([
"web_search_preview",
"file_search",
"computer_use_preview",
"code_interpreter",
"image_generation",
"mcp",
]);
const allowedToolEntrySchema = z.object({
type: z.string(),
name: z.string().optional(),
});
export const toolChoiceSchema = z.union([
z.literal("auto"),
z.literal("none"),
@@ -138,13 +212,31 @@ export const toolChoiceSchema = z.union([
type: z.literal("function"),
name: z.string().min(1),
}),
// Codex apply_patch flow.
z.object({
type: z.literal("custom"),
name: z.string().min(1),
}),
// Hosted-tool selection (no extra fields).
z.object({
type: hostedToolType,
}),
// `allowed_tools` — walker treats as auto.
z.object({
type: z.literal("allowed_tools"),
mode: z.enum(["auto", "required"]),
tools: z.array(allowedToolEntrySchema),
}),
]);
// ─── Reasoning config ───────────────────────────────────────────────────────
export const reasoningConfigSchema = z.object({
effort: z.string().optional(),
summary: z.string().optional(),
// `none` maps to hideThinkingSummary; auto/concise/detailed mean "show
// summary". pi-ai has no per-level plumbing for the latter — walker logs
// once and treats them as default.
summary: z.enum(["auto", "concise", "detailed", "none"]).optional(),
});
// ─── Stop ───────────────────────────────────────────────────────────────────
@@ -153,12 +245,6 @@ export const stopSchema = z.union([z.string(), z.array(z.string()), z.null()]);
// ─── Top-level request ──────────────────────────────────────────────────────
const refuse = (field: string) =>
z
.unknown()
.refine(v => v === undefined, { message: `openai-responses: unsupported option \`${field}\`` })
.optional();
export const openaiResponsesRequestSchema = z.object({
model: z.string().min(1),
input: z.union([z.string(), z.array(inputItemSchema)]).optional(),
@@ -174,18 +260,21 @@ export const openaiResponsesRequestSchema = z.object({
store: z.boolean().optional(),
previous_response_id: z.string().optional(),
parallel_tool_calls: z.boolean().optional(),
prompt_cache_key: z.string().optional(),
metadata: z.unknown().optional(),
user: z.string().optional(),
service_tier: z.string().optional(),
presence_penalty: z.number().optional(),
// Explicitly rejected.
background: refuse("background"),
include: refuse("include"),
metadata: refuse("metadata"),
prompt: refuse("prompt"),
safety_identifier: refuse("safety_identifier"),
text: refuse("text"),
top_logprobs: refuse("top_logprobs"),
truncation: refuse("truncation"),
user: refuse("user"),
frequency_penalty: z.number().optional(),
// Accepted-but-ignored: include `reasoning.encrypted_content` is the canonical
// way to request reasoning replay — silently accept and drop.
background: z.unknown().optional(),
include: z.unknown().optional(),
prompt: z.unknown().optional(),
safety_identifier: z.unknown().optional(),
text: z.unknown().optional(),
top_logprobs: z.unknown().optional(),
truncation: z.unknown().optional(),
});
/**
@@ -7,12 +7,10 @@
*
* Spec: https://platform.openai.com/docs/api-reference/responses
* Inverse direction (source-of-truth for item shapes): ../../providers/openai-responses.ts
*
* Note: images and other non-text input/output parts are not emitted by this
* encoder (omp's TextContent/ImageContent split is preserved on input but the
* Responses format documents far more part types than we exercise here).
*/
import { logger } from "@oh-my-pi/pi-utils";
import { resolvePromptCacheKey } from "../auth-gateway/http";
import type { AuthGatewayParsedRequest as ParsedRequest } from "../auth-gateway/types";
import type {
AssistantMessage,
@@ -24,9 +22,20 @@ import type {
Tool,
ToolCall,
} from "../types";
import {
type OpenAIResponsesFunctionCallItem,
type OpenAIResponsesFunctionCallOutputItem,
type OpenAIResponsesInputContent,
type OpenAIResponsesOutputContent,
type OpenAIResponsesReasoningItem,
type OpenAIResponsesTool,
openaiResponsesRequestSchema,
} from "./openai-responses-server-schema";
export type { ParsedRequest };
// ─── narrow guards ──────────────────────────────────────────────────────────
function isReasoningEffort(value: unknown): value is NonNullable<ParsedRequest["options"]["reasoning"]> {
return value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh";
}
@@ -35,7 +44,15 @@ function isServiceTier(value: unknown): value is NonNullable<ParsedRequest["opti
return value === "auto" || value === "default" || value === "flex" || value === "scale" || value === "priority";
}
// ─── helpers ────────────────────────────────────────────────────────────────
function isObj(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function asString(v: unknown): string | undefined {
return typeof v === "string" ? v : undefined;
}
// ─── id helpers ─────────────────────────────────────────────────────────────
function uuidNoDashes(): string {
return crypto.randomUUID().replace(/-/g, "");
@@ -57,59 +74,124 @@ function makeFuncCallId(): string {
return `fc_${uuidNoDashes()}`;
}
import {
type OpenAIResponsesFunctionCallItem,
type OpenAIResponsesFunctionCallOutputItem,
type OpenAIResponsesInputContent,
type OpenAIResponsesOutputContent,
type OpenAIResponsesReasoningItem,
type OpenAIResponsesTool,
type OpenAIResponsesToolChoice,
openaiResponsesRequestSchema,
} from "./openai-responses-server-schema";
function isObj(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
function makeCustomCallId(): string {
return `ctc_${uuidNoDashes()}`;
}
function asString(v: unknown): string | undefined {
return typeof v === "string" ? v : undefined;
}
// ─── once-only warnings ─────────────────────────────────────────────────────
// Module-scoped so we don't spam logs once per turn.
// ─── inbound parser ─────────────────────────────────────────────────────────
let warnedImageNotSupported = false;
let warnedFileNotSupported = false;
let warnedReasoningSummaryLevel = false;
// ─── inbound parser helpers ─────────────────────────────────────────────────
function extractReasoningTextFromItem(item: OpenAIResponsesReasoningItem): string {
const fromContent = (item.content ?? []).map(c => c.text).join("");
if (fromContent) return fromContent;
return (item.summary ?? []).map(c => c.text).join("");
// Prefer `summary[]` — mirrors real OpenAI and the openai-responses provider
// which writes the surfaced reasoning summary into `summary[].text`.
const fromSummary = (item.summary ?? []).map(c => c.text).join("");
if (fromSummary) return fromSummary;
return (item.content ?? []).map(c => c.text).join("");
}
function inputTextOf(blocks: OpenAIResponsesInputContent[] | string | undefined): string | TextContent[] {
type InputBlockUnion =
| { type: "input_text"; text: string }
| { type: "text"; text: string }
| { type: "input_image"; detail?: "auto" | "low" | "high"; image_url?: string; file_id?: string }
| { type: "input_file"; file_id?: string; filename?: string; file_data?: string };
/**
* Walk an input message's content array and produce pi-ai's `TextContent[]`.
* `input_image`/`input_file` blocks become bracketed text placeholders since
* pi-ai's `ImageContent` only carries inline base64 data and we have no
* resolver for OpenAI `image_url` / `file_id` references. Logs once per kind.
*/
function inputContentParts(blocks: OpenAIResponsesInputContent[] | string | undefined): string | TextContent[] {
if (typeof blocks === "string") return blocks;
if (!blocks) return [];
const parts: TextContent[] = [];
for (const block of blocks) {
if (block.type === "input_text") parts.push({ type: "text", text: block.text });
for (const raw of blocks) {
const block = raw as InputBlockUnion;
if (block.type === "input_text" || block.type === "text") {
parts.push({ type: "text", text: block.text });
} else if (block.type === "input_image") {
if (!warnedImageNotSupported) {
warnedImageNotSupported = true;
logger.warn("openai-responses-server: input_image dropped (no pi-ai bridge for image_url/file_id)", {
hasUrl: typeof block.image_url === "string",
hasFileId: typeof block.file_id === "string",
});
}
const ref = block.image_url ?? block.file_id ?? "?";
parts.push({ type: "text", text: `[image: ${ref}]` });
} else if (block.type === "input_file") {
if (!warnedFileNotSupported) {
warnedFileNotSupported = true;
logger.warn("openai-responses-server: input_file dropped (no pi-ai bridge for file_id/file_data)", {
hasFileId: typeof block.file_id === "string",
hasFileData: typeof block.file_data === "string",
});
}
const ref = block.file_id ?? block.filename ?? "?";
parts.push({ type: "text", text: `[file: ${ref}]` });
}
}
return parts.length === 1 ? parts[0].text : parts;
}
type OutputBlockUnion =
| { type: "output_text"; text: string }
| { type: "text"; text: string }
| { type: "refusal"; refusal: string };
function outputTextOf(blocks: OpenAIResponsesOutputContent[] | string | undefined): TextContent[] {
if (typeof blocks === "string") return blocks.length > 0 ? [{ type: "text", text: blocks }] : [];
if (!blocks) return [];
const out: TextContent[] = [];
for (const block of blocks) {
if (block.type === "output_text") out.push({ type: "text", text: block.text });
for (const raw of blocks) {
const block = raw as OutputBlockUnion;
if (block.type === "output_text" || block.type === "text") {
out.push({ type: "text", text: block.text });
} else if (block.type === "refusal") {
// Preserve the refusal reason so history replay still carries it.
out.push({ type: "text", text: `[refusal: ${block.refusal}]` });
}
}
return out;
}
function mapToolChoice(value: OpenAIResponsesToolChoice | undefined): ParsedRequest["options"]["toolChoice"] {
// The schema accepts a much wider tool_choice union than the SDK type so the
// walker narrows against the local schema shape.
type ParsedToolChoice =
| "auto"
| "none"
| "required"
| { type: "function"; name: string }
| { type: "custom"; name: string }
| {
type:
| "web_search_preview"
| "file_search"
| "computer_use_preview"
| "code_interpreter"
| "image_generation"
| "mcp";
}
| { type: "allowed_tools"; mode: "auto" | "required"; tools: Array<{ type: string; name?: string }> };
function mapToolChoice(value: ParsedToolChoice | undefined): ParsedRequest["options"]["toolChoice"] {
if (value === undefined) return undefined;
if (value === "auto" || value === "none" || value === "required") return value;
// Schema only validates ToolChoiceFunction; narrow defensively against the
// wider SDK union (allowed/types/mcp/custom/apply_patch/shell variants).
if ("type" in value && value.type === "function" && "name" in value) return { name: value.name };
if ("type" in value) {
// `custom` (codex apply_patch) and `function` both resolve to the same
// pi-ai shape: pi-ai's dispatcher matches `Tool.name` AND `customWireName`,
// so passing the wire name works for either.
if (value.type === "function" || value.type === "custom") return { name: value.name };
// Hosted tools + allowed_tools — we don't surface these to pi-ai; fall
// back to letting the model pick a tool freely.
return "auto";
}
return undefined;
}
@@ -117,7 +199,7 @@ function buildTools(tools: Array<OpenAIResponsesTool | { type: string }> | undef
if (!tools) return undefined;
const out: Tool[] = [];
for (const t of tools) {
// Skip non-function tools (web_search_call, file_search_call, …).
// Skip non-function tools (web_search, file_search, …).
if (t.type !== "function") continue;
const fn = t as Extract<OpenAIResponsesTool, { type: "function" }>;
const tool: Tool = {
@@ -155,7 +237,32 @@ function ensureAssistantPlaceholder(messages: Message[], modelId: string, now: n
return placeholder;
}
export function parseRequest(body: unknown): ParsedRequest {
/** Flatten a function_call_output array form (text + refusal) into a single string. */
function flattenFunctionOutputArray(blocks: readonly unknown[]): string {
const parts: string[] = [];
for (const raw of blocks) {
if (!isObj(raw)) continue;
const t = raw.type;
if (t === "output_text" || t === "text") {
const text = asString(raw.text);
if (text) parts.push(text);
} else if (t === "refusal") {
const refusal = asString(raw.refusal);
if (refusal) parts.push(`[refusal: ${refusal}]`);
}
}
return parts.join("");
}
// ─── parseRequest ───────────────────────────────────────────────────────────
export function parseRequest(body: unknown, headers?: Headers): ParsedRequest {
// Header capture is centralized in `auth-gateway/server.ts` (the
// allow-listed set lands on `options.headers` automatically). We also
// consult `headers` here to populate `options.promptCacheKey` when the
// client signals a cache identity outside the body — see the
// `resolvePromptCacheKey` call further down.
const parsed = openaiResponsesRequestSchema.safeParse(body);
if (!parsed.success) {
throw new Error(`openai-responses: ${parsed.error.message}`);
@@ -183,14 +290,14 @@ export function parseRequest(body: unknown): ParsedRequest {
};
switch (msg.role) {
case "system": {
const text = inputTextOf(msg.content as OpenAIResponsesInputContent[] | string | undefined);
const text = inputContentParts(msg.content as OpenAIResponsesInputContent[] | string | undefined);
const flat = typeof text === "string" ? text : text.map(p => p.text).join("");
if (flat.length > 0) systemPrompt.push(flat);
break;
}
case "user":
case "developer": {
const content = inputTextOf(msg.content as OpenAIResponsesInputContent[] | string | undefined);
const content = inputContentParts(msg.content as OpenAIResponsesInputContent[] | string | undefined);
messages.push({ role: msg.role, content, timestamp: now });
break;
}
@@ -235,8 +342,8 @@ export function parseRequest(body: unknown): ParsedRequest {
const argsRaw = call.arguments ?? "{}";
let args: Record<string, unknown>;
try {
const parsed: unknown = JSON.parse(argsRaw);
args = isObj(parsed) ? parsed : {};
const parsedArgs: unknown = JSON.parse(argsRaw);
args = isObj(parsedArgs) ? parsedArgs : {};
} catch {
throw new Error(`openai-responses: function_call ${call.call_id} has invalid JSON arguments`);
}
@@ -250,26 +357,49 @@ export function parseRequest(body: unknown): ParsedRequest {
ensureAssistantPlaceholder(messages, data.model, now).content.push(toolCall);
continue;
}
if (effectiveType === "custom_tool_call") {
const call = item as { id?: string; call_id: string; name: string; input: string };
// Custom tools carry a raw input string. We stash it in `arguments.input`
// matching pi-ai's openai-responses-shared convention, and tag the call
// with `customWireName` so encoders re-emit it as `custom_tool_call`.
const toolCall: ToolCall = {
type: "toolCall",
id: call.call_id,
name: call.name,
arguments: { input: call.input ?? "" },
customWireName: call.name,
...(call.id ? { thoughtSignature: call.id } : {}),
};
ensureAssistantPlaceholder(messages, data.model, now).content.push(toolCall);
continue;
}
if (effectiveType === "function_call_output") {
const output = item as OpenAIResponsesFunctionCallOutputItem;
// Find the matching tool call name from earlier assistant content.
let toolName = "";
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m.role !== "assistant") continue;
for (const c of m.content) {
if (c.type === "toolCall" && c.id === output.call_id) {
toolName = c.name;
break;
}
}
if (toolName) break;
}
const toolName = findToolNameById(messages, output.call_id);
const text =
typeof output.output === "string"
? output.output
: Array.isArray(output.output)
? flattenFunctionOutputArray(output.output)
: "";
messages.push({
role: "toolResult",
toolCallId: output.call_id,
toolName,
content: [{ type: "text", text: typeof output.output === "string" ? output.output : "" }],
content: [{ type: "text", text }],
isError: false,
timestamp: now,
});
continue;
}
if (effectiveType === "custom_tool_call_output") {
const output = item as { call_id: string; output: string };
const toolName = findToolNameById(messages, output.call_id);
messages.push({
role: "toolResult",
toolCallId: output.call_id,
toolName,
content: [{ type: "text", text: output.output ?? "" }],
isError: false,
timestamp: now,
});
@@ -292,25 +422,41 @@ export function parseRequest(body: unknown): ParsedRequest {
if (data.stop !== undefined && data.stop !== null) {
options.stopSequences = typeof data.stop === "string" ? [data.stop] : data.stop;
}
const toolChoice = mapToolChoice(data.tool_choice);
const toolChoice = mapToolChoice(data.tool_choice as ParsedToolChoice | undefined);
if (toolChoice !== undefined) options.toolChoice = toolChoice;
if (data.reasoning?.effort && isReasoningEffort(data.reasoning.effort)) {
options.reasoning = data.reasoning.effort;
}
// OpenAI summary "auto"|"concise"|"detailed" → request a visible summary;
// absent → leave pi-ai's default. The "none" / absence inverse maps to
// `hideThinkingSummary: true`.
if (data.reasoning?.summary === undefined) {
// no-op; provider decides
} else if (data.reasoning.summary === "none") {
// OpenAI summary: `none` → suppress; `auto`/`concise`/`detailed` → request
// visible summary. pi-ai has no per-level plumbing — log once and let the
// provider default kick in.
if (data.reasoning?.summary === "none") {
options.hideThinkingSummary = true;
} else if (
data.reasoning?.summary === "auto" ||
data.reasoning?.summary === "concise" ||
data.reasoning?.summary === "detailed"
) {
if (!warnedReasoningSummaryLevel) {
warnedReasoningSummaryLevel = true;
logger.debug("openai-responses-server: reasoning.summary level not differentiated", {
level: data.reasoning.summary,
});
}
}
if (data.service_tier !== undefined && isServiceTier(data.service_tier)) {
options.serviceTier = data.service_tier;
}
if (data.presence_penalty !== undefined) options.presencePenalty = data.presence_penalty;
// `store`, `previous_response_id`, `parallel_tool_calls` are accepted by the
// schema for forward-compatibility but not yet plumbed through pi-ai.
if (data.frequency_penalty !== undefined) options.frequencyPenalty = data.frequency_penalty;
if (data.parallel_tool_calls !== undefined) options.parallelToolCalls = data.parallel_tool_calls;
const cacheKey = resolvePromptCacheKey(body, headers);
if (cacheKey !== undefined) options.promptCacheKey = cacheKey;
if (data.previous_response_id !== undefined) options.previousResponseId = data.previous_response_id;
if (data.user !== undefined) options.user = data.user;
if (isObj(data.metadata)) options.metadata = data.metadata;
// `store` is a stateful-storage hint that omp's gateway doesn't honour;
// silently accepted by the schema. No typed slot — drop.
return {
modelId: data.model,
@@ -320,25 +466,61 @@ export function parseRequest(body: unknown): ParsedRequest {
};
}
function findToolNameById(messages: Message[], callId: string): string {
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m.role !== "assistant") continue;
for (const c of m.content) {
if (c.type === "toolCall" && c.id === callId) return c.name;
}
}
return "";
}
// ─── formatError ────────────────────────────────────────────────────────────
export function formatError(status: number, type: string, message: string): Response {
return new Response(JSON.stringify({ error: { message, type } }), {
status,
headers: { "Content-Type": "application/json" },
});
}
// ─── output item builders (shared by streaming + non-streaming encoders) ────
type ReasoningOutputItem = {
type: "reasoning";
id: string;
summary: Array<{ type?: string; text?: string }>;
content?: Array<{ type: "reasoning_text"; text: string }>;
summary: Array<{ type: "summary_text"; text: string }>;
} & Record<string, unknown>;
type OutputItem =
| ReasoningOutputItem
| {
type: "message";
id: string;
role: "assistant";
status: "completed";
content: Array<{ type: "output_text"; text: string; annotations: never[] }>;
}
| { type: "function_call"; id: string; call_id: string; name: string; arguments: string; status: "completed" };
type MessageOutputItem = {
type: "message";
id: string;
role: "assistant";
status: "completed";
content: Array<{ type: "output_text"; text: string; annotations: never[] }>;
};
type FunctionCallOutputItem = {
type: "function_call";
id: string;
call_id: string;
name: string;
arguments: string;
status: "completed";
};
type CustomToolCallOutputItem = {
type: "custom_tool_call";
id: string;
call_id: string;
name: string;
input: string;
status: "completed";
};
type OutputItem = ReasoningOutputItem | MessageOutputItem | FunctionCallOutputItem | CustomToolCallOutputItem;
type ResponseStatus = "completed" | "in_progress" | "failed" | "incomplete";
@@ -349,22 +531,29 @@ function responseStatusForStopReason(message: AssistantMessage): ResponseStatus
}
function buildReasoningItem(part: ThinkingContent): ReasoningOutputItem {
const baseId = part.itemId ?? makeReasoningId();
if (part.thinkingSignature) {
try {
const parsed: unknown = JSON.parse(part.thinkingSignature);
if (isObj(parsed) && parsed.type === "reasoning") {
const id = part.itemId ?? asString(parsed.id) ?? makeReasoningId();
return { ...parsed, type: "reasoning", id } as ReasoningOutputItem;
const sigParsed: unknown = JSON.parse(part.thinkingSignature);
if (isObj(sigParsed) && sigParsed.type === "reasoning") {
const id = part.itemId ?? asString(sigParsed.id) ?? makeReasoningId();
// Preserve any extra fields (encrypted_content, …) the original carried,
// but normalize the summary into the canonical `{type, text}[]` shape.
const merged: Record<string, unknown> = { ...sigParsed, type: "reasoning", id };
merged.summary = [{ type: "summary_text", text: part.thinking }];
// `content[]` is the encrypted/raw side-channel; leave whatever was
// already there. If absent, omit — real OpenAI only emits `content[]`
// when `include=['reasoning.encrypted_content']` is set.
return merged as ReasoningOutputItem;
}
} catch {
// Not a serialized Responses reasoning item; fall back to raw thinking text.
// Not a serialized Responses reasoning item; fall through to fresh build.
}
}
return {
type: "reasoning",
id: part.itemId ?? makeReasoningId(),
summary: [],
content: [{ type: "reasoning_text", text: part.thinking }],
id: baseId,
summary: [{ type: "summary_text", text: part.thinking }],
};
}
@@ -372,9 +561,9 @@ function reasoningItemId(part: ThinkingContent): string {
if (part.itemId) return part.itemId;
if (part.thinkingSignature) {
try {
const parsed: unknown = JSON.parse(part.thinkingSignature);
if (isObj(parsed)) {
const id = asString(parsed.id);
const sigParsed: unknown = JSON.parse(part.thinkingSignature);
if (isObj(sigParsed)) {
const id = asString(sigParsed.id);
if (id) return id;
}
} catch {
@@ -390,7 +579,7 @@ function reasoningItemId(part: ThinkingContent): string {
*/
function buildOutputItems(message: AssistantMessage): OutputItem[] {
const out: OutputItem[] = [];
let pendingMessage: Extract<OutputItem, { type: "message" }> | null = null;
let pendingMessage: MessageOutputItem | null = null;
const flushMessage = () => {
if (pendingMessage) {
out.push(pendingMessage);
@@ -415,17 +604,28 @@ function buildOutputItems(message: AssistantMessage): OutputItem[] {
out.push(buildReasoningItem(part));
} else if (part.type === "toolCall") {
flushMessage();
const id = part.thoughtSignature ?? makeFuncCallId();
out.push({
type: "function_call",
id,
call_id: part.id,
name: part.name,
arguments: JSON.stringify(part.arguments ?? {}),
status: "completed",
});
if (part.customWireName) {
const rawInput = typeof part.arguments?.input === "string" ? (part.arguments.input as string) : "";
out.push({
type: "custom_tool_call",
id: part.thoughtSignature ?? makeCustomCallId(),
call_id: part.id,
name: part.customWireName,
input: rawInput,
status: "completed",
});
} else {
out.push({
type: "function_call",
id: part.thoughtSignature ?? makeFuncCallId(),
call_id: part.id,
name: part.name,
arguments: JSON.stringify(part.arguments ?? {}),
status: "completed",
});
}
}
// RedactedThinking is silently dropped — no direct Responses wire representation.
// RedactedThinking / Image are silently dropped — no direct Responses wire representation.
}
flushMessage();
return out;
@@ -501,6 +701,8 @@ interface OpenFunctionCall {
callId: string;
name: string;
argsText: string;
/** Set when the underlying ToolCall is a custom-tool emission. */
customWireName?: string;
}
type OpenItem = OpenMessage | OpenReasoning | OpenFunctionCall;
@@ -529,6 +731,16 @@ export function encodeStream(
const state: { open: OpenItem | null } = { open: null };
const finishedItems: OutputItem[] = [];
const responseSnapshot = (status: ResponseStatus, output: OutputItem[] | []) => ({
id: responseId,
object: "response",
created_at: createdAt,
status,
model: requestedModelId,
output,
usage: null,
});
const openMessage = (): OpenMessage => {
const itemId = makeMsgId();
const item = {
@@ -557,10 +769,18 @@ export function encodeStream(
const item = {
type: "reasoning" as const,
id: itemId,
summary: [] as never[],
content: [] as Array<{ type: "reasoning_text"; text: string }>,
summary: [] as Array<{ type: "summary_text"; text: string }>,
};
emit("response.output_item.added", { output_index: outputIndex, item });
// Open the summary part. Real OpenAI streams summary text in the
// canonical `reasoning_summary_*` lifecycle; pi-ai's own decoder
// reads `summary[].text` from the eventual `output_item.done`.
emit("response.reasoning_summary_part.added", {
item_id: itemId,
output_index: outputIndex,
summary_index: 0,
part: { type: "summary_text", text: "" },
});
const next: OpenReasoning = { kind: "reasoning", itemId, outputIndex, reasoningText: "" };
state.open = next;
return next;
@@ -569,19 +789,41 @@ export function encodeStream(
const openToolCall = (partial: AssistantMessage, contentIndex: number): OpenFunctionCall => {
const part = partial.content[contentIndex];
const tc = part && part.type === "toolCall" ? part : undefined;
const itemId = tc?.thoughtSignature ?? makeFuncCallId();
const customWireName: string | undefined =
tc && typeof tc.customWireName === "string" && tc.customWireName.length > 0
? tc.customWireName
: undefined;
const isCustom = customWireName !== undefined;
const itemId = tc?.thoughtSignature ?? (isCustom ? makeCustomCallId() : makeFuncCallId());
const callId = tc?.id ?? "";
const name = tc?.name ?? "";
const item = {
type: "function_call" as const,
id: itemId,
call_id: callId,
name,
arguments: "",
status: "in_progress",
};
const name = customWireName ?? tc?.name ?? "";
const item = isCustom
? {
type: "custom_tool_call" as const,
id: itemId,
call_id: callId,
name,
input: "",
status: "in_progress",
}
: {
type: "function_call" as const,
id: itemId,
call_id: callId,
name,
arguments: "",
status: "in_progress",
};
emit("response.output_item.added", { output_index: outputIndex, item });
const next: OpenFunctionCall = { kind: "function_call", itemId, outputIndex, callId, name, argsText: "" };
const next: OpenFunctionCall = {
kind: "function_call",
itemId,
outputIndex,
callId,
name,
argsText: "",
...(isCustom ? { customWireName } : {}),
};
state.open = next;
return next;
};
@@ -589,8 +831,6 @@ export function encodeStream(
const closeOpen = () => {
if (!state.open) return;
if (state.open.kind === "message") {
// (No defensive part-close needed; text_end always flushes the part before
// the next non-text event triggers closeOpen.)
const item = {
type: "message",
id: state.open.itemId,
@@ -607,38 +847,57 @@ export function encodeStream(
content: state.open.content,
});
} else if (state.open.kind === "reasoning") {
const summary = [{ type: "summary_text" as const, text: state.open.reasoningText ?? "" }];
const item = {
type: "reasoning",
id: state.open.itemId,
summary: [],
content: [{ type: "reasoning_text", text: state.open.reasoningText ?? "" }],
summary,
};
emit("response.output_item.done", { output_index: state.open.outputIndex, item });
finishedItems.push({
type: "reasoning",
id: state.open.itemId,
summary: [],
content: [{ type: "reasoning_text", text: state.open.reasoningText ?? "" }],
summary,
});
} else {
const args = state.open.argsText ?? "";
const item = {
type: "function_call",
id: state.open.itemId,
call_id: state.open.callId ?? "",
name: state.open.name ?? "",
arguments: args,
status: "completed",
};
emit("response.output_item.done", { output_index: state.open.outputIndex, item });
finishedItems.push({
type: "function_call",
id: state.open.itemId,
call_id: state.open.callId ?? "",
name: state.open.name ?? "",
arguments: args,
status: "completed",
});
const text = state.open.argsText ?? "";
if (state.open.customWireName) {
const item = {
type: "custom_tool_call",
id: state.open.itemId,
call_id: state.open.callId ?? "",
name: state.open.customWireName,
input: text,
status: "completed",
};
emit("response.output_item.done", { output_index: state.open.outputIndex, item });
finishedItems.push({
type: "custom_tool_call",
id: state.open.itemId,
call_id: state.open.callId ?? "",
name: state.open.customWireName,
input: text,
status: "completed",
});
} else {
const item = {
type: "function_call",
id: state.open.itemId,
call_id: state.open.callId ?? "",
name: state.open.name ?? "",
arguments: text,
status: "completed",
};
emit("response.output_item.done", { output_index: state.open.outputIndex, item });
finishedItems.push({
type: "function_call",
id: state.open.itemId,
call_id: state.open.callId ?? "",
name: state.open.name ?? "",
arguments: text,
status: "completed",
});
}
}
outputIndex++;
state.open = null;
@@ -652,20 +911,24 @@ export function encodeStream(
switch (ev.type) {
case "start": {
createdAt = Math.floor((ev.partial.timestamp || Date.now()) / 1000);
// response.created — initial envelope.
controller.enqueue(
encoder.encode(
sseEvent("response.created", {
type: "response.created",
sequence_number: seq(),
response: {
id: responseId,
object: "response",
created_at: createdAt,
status: "in_progress",
model: requestedModelId,
output: [],
usage: null,
},
response: responseSnapshot("in_progress", []),
}),
),
);
// response.in_progress — mirrors real OpenAI; some clients gate
// on it before reading items.
controller.enqueue(
encoder.encode(
sseEvent("response.in_progress", {
type: "response.in_progress",
sequence_number: seq(),
response: responseSnapshot("in_progress", []),
}),
),
);
@@ -701,6 +964,9 @@ export function encodeStream(
delta: ev.delta,
logprobs: [],
});
// TODO: when pi-ai surfaces output_text annotations
// (web_search citations, …), emit
// `response.output_text.annotation.added` here.
break;
}
case "text_end": {
@@ -734,10 +1000,10 @@ export function encodeStream(
if (!state.open || state.open.kind !== "reasoning") break;
const cur: OpenReasoning = state.open;
cur.reasoningText += ev.delta;
emit("response.reasoning_text.delta", {
emit("response.reasoning_summary_text.delta", {
item_id: cur.itemId,
output_index: cur.outputIndex,
content_index: 0,
summary_index: 0,
delta: ev.delta,
});
break;
@@ -747,12 +1013,18 @@ export function encodeStream(
const cur: OpenReasoning = state.open;
const text = ev.content ?? cur.reasoningText;
cur.reasoningText = text;
emit("response.reasoning_text.done", {
emit("response.reasoning_summary_text.done", {
item_id: cur.itemId,
output_index: cur.outputIndex,
content_index: 0,
summary_index: 0,
text,
});
emit("response.reasoning_summary_part.done", {
item_id: cur.itemId,
output_index: cur.outputIndex,
summary_index: 0,
part: { type: "summary_text", text },
});
closeOpen();
break;
}
@@ -765,29 +1037,56 @@ export function encodeStream(
if (!state.open || state.open.kind !== "function_call") break;
const cur: OpenFunctionCall = state.open;
cur.argsText += ev.delta;
emit("response.function_call_arguments.delta", {
item_id: cur.itemId,
output_index: cur.outputIndex,
delta: ev.delta,
});
if (cur.customWireName) {
emit("response.custom_tool_call_input.delta", {
item_id: cur.itemId,
output_index: cur.outputIndex,
delta: ev.delta,
});
} else {
emit("response.function_call_arguments.delta", {
item_id: cur.itemId,
output_index: cur.outputIndex,
delta: ev.delta,
});
}
break;
}
case "toolcall_end": {
if (!state.open || state.open.kind !== "function_call") break;
const cur: OpenFunctionCall = state.open;
// Finalize from the canonical ToolCall. arguments live as an object on the omp side;
// the wire wants the JSON string the model emitted, which streamed deltas accumulated.
const argsJson = cur.argsText || JSON.stringify(ev.toolCall.arguments ?? {});
cur.argsText = argsJson;
cur.callId = ev.toolCall.id;
cur.name = ev.toolCall.name;
if (ev.toolCall.thoughtSignature) cur.itemId = ev.toolCall.thoughtSignature;
emit("response.function_call_arguments.done", {
item_id: cur.itemId,
output_index: cur.outputIndex,
arguments: argsJson,
name: cur.name,
});
// Promote possibly-late info from the canonical ToolCall.
const tc = ev.toolCall;
if (tc.customWireName && !cur.customWireName) cur.customWireName = tc.customWireName;
if (tc.thoughtSignature) cur.itemId = tc.thoughtSignature;
cur.callId = tc.id;
cur.name = cur.customWireName ?? tc.name;
if (cur.customWireName) {
// Custom tool: raw input string. Streamed deltas accumulated
// the wire-level body; fall back to `arguments.input` from
// the finalized ToolCall when nothing streamed (rare).
const rawInput =
cur.argsText ||
(typeof tc.arguments?.input === "string" ? (tc.arguments.input as string) : "");
cur.argsText = rawInput;
emit("response.custom_tool_call_input.done", {
item_id: cur.itemId,
output_index: cur.outputIndex,
input: rawInput,
name: cur.name,
});
} else {
// Standard JSON tool: arguments object on the omp side, the
// wire wants the JSON string the model emitted (= streamed deltas).
const argsJson = cur.argsText || JSON.stringify(tc.arguments ?? {});
cur.argsText = argsJson;
emit("response.function_call_arguments.done", {
item_id: cur.itemId,
output_index: cur.outputIndex,
arguments: argsJson,
name: cur.name,
});
}
closeOpen();
break;
}
@@ -810,12 +1109,7 @@ export function encodeStream(
type: "response.failed",
sequence_number: seq(),
response: {
id: responseId,
object: "response",
created_at: createdAt,
status: "failed",
model: requestedModelId,
output: finishedItems,
...responseSnapshot("failed", finishedItems),
error: { message: failureMessage.errorMessage ?? "stream failed" },
},
}),
@@ -171,6 +171,7 @@ type OpenAIResponsesSamplingParams = ResponseCreateParamsStreaming & {
min_p?: number;
presence_penalty?: number;
repetition_penalty?: number;
stream_options?: { include_obfuscation?: boolean };
};
/**
@@ -404,9 +405,14 @@ function buildParams(
prompt_cache_key: promptCacheKey,
prompt_cache_retention: promptCacheKey ? getPromptCacheRetention(model.baseUrl, cacheRetention) : undefined,
store: false,
stream_options: model.provider === "openai" ? { include_obfuscation: false } : undefined,
};
applyCommonResponsesSamplingParams(params, options, model.provider);
// TODO: openai responses has no top-level `stop`/`stop_sequences`; surface via reasoning.stop?
// `StreamOptions.stopSequences` is intentionally dropped for this provider.
// TODO: openai responses has no top-level `frequency_penalty` field as of the current SDK;
// `StreamOptions.frequencyPenalty` is intentionally dropped for this provider.
if (context.tools) {
params.tools = convertTools(context.tools, supportsStrictMode(model), model);
+12
View File
@@ -220,6 +220,18 @@ export interface StreamOptions {
minP?: number;
presencePenalty?: number;
repetitionPenalty?: number;
/**
* Stop sequences. Anthropic encodes as `stop_sequences` (array, max 4);
* OpenAI chat-completions encodes as `stop` (string or array of up to 4);
* OpenAI Responses API has no `stop` field today (silently dropped by the
* provider when present).
*/
stopSequences?: string[];
/**
* Frequency penalty (OpenAI). Penalizes new tokens based on existing frequency
* in the text so far. Range -2.0 to 2.0. Parallel to {@link presencePenalty}.
*/
frequencyPenalty?: number;
maxTokens?: number;
signal?: AbortSignal;
apiKey?: string;
+54
View File
@@ -0,0 +1,54 @@
/**
* Shared `host:port` parser used by the auth-broker and auth-gateway boot
* paths. Centralized so the two servers can't drift on what they accept (the
* gateway used to silently allow empty hostnames; this fixes it).
*/
export interface ParsedBind {
hostname: string;
port: number;
}
function parsePort(raw: string, bind: string): number {
if (!/^\d+$/.test(raw)) {
throw new Error(`Invalid bind '${bind}'; port must be an integer.`);
}
const port = Number.parseInt(raw, 10);
if (!Number.isFinite(port) || port < 0 || port > 65535) {
throw new Error(`Invalid bind '${bind}'; port out of range.`);
}
return port;
}
/**
* Parse a `host:port` (or bare `port`, which assumes loopback) string.
*
* Accepts:
* - `"4000"` → `127.0.0.1:4000`
* - `"0.0.0.0:4000"` → as written
* - `"[::1]:4000"` → as written (brackets retained, Bun handles them)
*
* Rejects:
* - empty input
* - empty hostname (`":4000"`)
* - non-integer / out-of-range port
*/
export function parseBind(raw: string): ParsedBind {
const trimmed = raw.trim();
if (trimmed.length === 0) {
throw new Error("Invalid bind; expected 'host:port' or 'port'.");
}
if (/^\d+$/.test(trimmed)) {
return { hostname: "127.0.0.1", port: parsePort(trimmed, raw) };
}
const lastColon = trimmed.lastIndexOf(":");
if (lastColon < 0) {
throw new Error(`Invalid bind '${raw}'; expected 'host:port' or 'port'.`);
}
const hostPart = trimmed.slice(0, lastColon);
const portPart = trimmed.slice(lastColon + 1);
if (hostPart.length === 0) {
throw new Error(`Invalid bind '${raw}'; host must not be empty.`);
}
return { hostname: hostPart, port: parsePort(portPart, raw) };
}
@@ -0,0 +1,184 @@
/**
* E2E test: exercise an Anthropic conversation through a live auth-gateway and
* assert prompt caching round-trips. Defends against regressions where the
* gateway either strips `cache_control` markers, places them on the wrong
* block, drops them from the upstream wire, or fails to surface
* `cache_creation_input_tokens` / `cache_read_input_tokens` in the response.
*
* Skips unless a local gateway is reachable at the default `127.0.0.1:4000`
* (override via `OMP_E2E_GATEWAY_URL`) AND the bearer token file exists at
* `~/.omp/auth-gateway.token`.
*
* To run: `bun --cwd packages/ai test test/auth-gateway-anthropic-caching.test.ts`
* with the gateway live (`omp auth-gateway serve` or pm2).
*/
import { describe, expect, it } from "bun:test";
import * as os from "node:os";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
interface AnthropicUsage {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
}
interface AnthropicResponse {
type?: string;
stop_reason?: string;
content?: Array<{ type: string; text?: string }>;
usage: AnthropicUsage;
error?: { type: string; message: string };
}
const GATEWAY_URL = Bun.env.OMP_E2E_GATEWAY_URL ?? "http://127.0.0.1:4000";
const TOKEN_PATH = path.join(os.homedir(), ".omp", "auth-gateway.token");
const MODEL = Bun.env.OMP_E2E_ANTHROPIC_MODEL ?? "claude-sonnet-4-5";
async function checkGatewayAvailable(): Promise<{ ok: boolean; token?: string; reason?: string }> {
let token: string;
try {
token = (await Bun.file(TOKEN_PATH).text()).trim();
} catch (err) {
if (isEnoent(err)) return { ok: false, reason: `no token at ${TOKEN_PATH}` };
throw err;
}
if (!token) return { ok: false, reason: `empty token at ${TOKEN_PATH}` };
try {
const res = await fetch(`${GATEWAY_URL}/healthz`, { signal: AbortSignal.timeout(2_000) });
if (!res.ok) return { ok: false, reason: `healthz returned ${res.status}` };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, reason: `healthz unreachable: ${msg}` };
}
return { ok: true, token };
}
const gateway = await checkGatewayAvailable();
// Build a system prompt that comfortably exceeds Anthropic's 1024-token cache
// floor for Sonnet. Using a deterministic repeated paragraph so cache keys are
// stable across runs of this test.
const SYSTEM_PARAGRAPH = `
You are a precise assistant participating in an automated end-to-end test of
the omp auth-gateway's Anthropic prompt-caching pipeline. The same system
prompt will be reused across two turns; the gateway must place a cache
breakpoint on the final system block so that the second request hits the
ephemeral cache instead of being re-tokenized from scratch. Always respond
with extreme brevity: a single short word or phrase, never more than five
tokens. Do not add filler, do not add explanations, do not add punctuation
beyond what is strictly necessary. If asked to confirm something, respond
with "yes". If asked to deny, respond with "no". If asked to repeat your
previous reply, repeat it verbatim. Reasoning, hedging, and conversational
preamble are strictly forbidden. This block is intentionally verbose so the
caching threshold is comfortably cleared on every run; please disregard the
verbosity itself and follow the brevity rule above.
`.trim();
const SYSTEM_TEXT = Array.from({ length: 12 }, () => SYSTEM_PARAGRAPH).join("\n\n");
interface MessageBlock {
role: "user" | "assistant";
content: string | Array<{ type: string; text?: string }>;
}
async function callGateway(body: unknown, token: string): Promise<AnthropicResponse> {
const res = await fetch(`${GATEWAY_URL}/v1/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify(body),
});
const text = await res.text();
let parsed: AnthropicResponse;
try {
parsed = JSON.parse(text) as AnthropicResponse;
} catch {
throw new Error(`gateway returned non-JSON (status=${res.status}): ${text.slice(0, 200)}`);
}
if (parsed.error) {
throw new Error(`gateway error: ${parsed.error.type}: ${parsed.error.message}`);
}
return parsed;
}
function extractAssistantText(res: AnthropicResponse): string {
const block = res.content?.find(c => c.type === "text");
return block?.text ?? "";
}
describe.skipIf(!gateway.ok)("auth-gateway: anthropic prompt caching e2e", () => {
if (!gateway.ok) {
// Surface the skip reason once so a quick rerun with `-v` shows it.
console.warn(`[skip] anthropic caching e2e: ${gateway.reason}`);
return;
}
const token = gateway.token;
if (!token) throw new Error("invariant: token must be present when gateway.ok is true");
it("writes the system prefix to ephemeral cache on turn 1 and reads it on turn 2", async () => {
// Per-run nonce ensures we always start with a cold cache. The bytes
// before the breakpoint must be unique to this run; otherwise a
// previously-warm Anthropic cache entry hits on turn 1 and we lose the
// ability to assert "first turn writes, second turn reads" cleanly.
const nonce = `${Date.now().toString(36)}-${crypto.randomUUID()}`;
const systemTextWithNonce = `${SYSTEM_TEXT}\n\n[run-nonce: ${nonce}]`;
const system = [{ type: "text", text: systemTextWithNonce, cache_control: { type: "ephemeral" } }];
// ── Turn 1 ───────────────────────────────────────────────────────
const turn1Messages: MessageBlock[] = [{ role: "user", content: "Respond with the single word: alpha" }];
const turn1 = await callGateway(
{
model: MODEL,
max_tokens: 32,
system,
messages: turn1Messages,
},
token,
);
const turn1Text = extractAssistantText(turn1);
expect(turn1Text.length).toBeGreaterThan(0);
// Anthropic populates cache_creation_input_tokens with the size of the
// content written to the cache. Above the 1024-token floor this MUST
// be > 0 on the first turn or the gateway stripped our cache_control.
const turn1Created = turn1.usage.cache_creation_input_tokens ?? 0;
const turn1Read = turn1.usage.cache_read_input_tokens ?? 0;
expect(turn1Created).toBeGreaterThan(0);
// First turn cannot hit the cache (nothing to read yet).
expect(turn1Read).toBe(0);
// ── Turn 2: append assistant + new user, re-send with same system ──
const turn2Messages: MessageBlock[] = [
...turn1Messages,
{ role: "assistant", content: turn1Text },
{ role: "user", content: "Respond with the single word: beta" },
];
const turn2 = await callGateway(
{
model: MODEL,
max_tokens: 32,
system,
messages: turn2Messages,
},
token,
);
const turn2Text = extractAssistantText(turn2);
expect(turn2Text.length).toBeGreaterThan(0);
// Second turn MUST read from the cache populated by turn 1. If
// cache_read_input_tokens is 0 the gateway either dropped the marker,
// rewrote the cached prefix bytes, or routed the request without
// Anthropic's cache-aware OAuth headers.
const turn2Read = turn2.usage.cache_read_input_tokens ?? 0;
expect(turn2Read).toBeGreaterThan(0);
// The cache read should cover at least the system block we wrote.
expect(turn2Read).toBeGreaterThanOrEqual(turn1Created);
}, 60_000);
});
@@ -118,7 +118,7 @@ describe("anthropic-messages parseRequest", () => {
expect(parsed.options.topP).toBe(0.9);
expect(parsed.options.stopSequences).toEqual(["\n\n"]);
expect(parsed.options.toolChoice).toBe("required");
expect(parsed.options.thinkingBudget).toBe(2048);
expect(parsed.options.explicitThinkingBudgetTokens).toBe(2048);
expect(parsed.options.extra).toBeUndefined();
expect(parsed.context.tools).toHaveLength(1);
@@ -198,22 +198,24 @@ describe("anthropic-messages parseRequest", () => {
expect(onlyResult.context.messages[0]!.role).toBe("toolResult");
});
it("rejects ambiguous user text before tool_result blocks", () => {
expect(() =>
parseRequest({
model: "m",
max_tokens: 8,
messages: [
{
role: "user",
content: [
{ type: "text", text: "this would replay before the tool result" },
{ type: "tool_result", tool_use_id: "t1", content: "ok" },
],
},
],
}),
).toThrow(/tool_result/i);
it("splits user text/image blocks into a separate UserMessage before a tool_result", () => {
const parsed = parseRequest({
model: "m",
max_tokens: 8,
messages: [
{
role: "user",
content: [
{ type: "text", text: "preface text" },
{ type: "tool_result", tool_use_id: "t1", content: "ok" },
],
},
],
});
// Expect a flush before the tool result: user("preface text") then toolResult(t1).
expect(parsed.context.messages).toHaveLength(2);
expect(parsed.context.messages[0]).toMatchObject({ role: "user", content: "preface text" });
expect(parsed.context.messages[1]!.role).toBe("toolResult");
});
it("rejects missing required fields and unsupported request controls", () => {
@@ -222,9 +224,8 @@ describe("anthropic-messages parseRequest", () => {
expect(() => parseRequest({ model: "m", max_tokens: 1 })).toThrow(/messages/);
const topK = parseRequest({ model: "m", max_tokens: 1, messages: [{ role: "user", content: "hi" }], top_k: 50 });
expect(topK.options.topK).toBe(50);
// `metadata` is tolerated permissively now (Anthropic clients ship it
// by default with `user_id`); it should parse without throwing and
// surface nothing on the parsed options.
// `metadata` is tolerated permissively and surfaced on options for
// downstream forwarding (Anthropic clients ship `metadata.user_id`).
const withMetadata = parseRequest({
model: "m",
max_tokens: 1,
@@ -232,6 +233,7 @@ describe("anthropic-messages parseRequest", () => {
metadata: { user_id: "u_1" },
});
expect(withMetadata.options.extra).toBeUndefined();
expect(withMetadata.options.metadata).toEqual({ user_id: "u_1" });
});
});
@@ -0,0 +1,193 @@
/**
* E2E test: send an Anthropic Messages request to an OPENAI CODEX backend
* through the auth-gateway and assert prompt caching survives the
* cross-protocol translate path in the other direction.
*
* Pipeline under test:
* client → POST /v1/messages (Anthropic shape, cache_control markers)
* → anthropic-messages parser → omp Context (cacheRetention derived)
* → pi-ai openai-codex-responses provider
* → upstream Codex (ChatGPT-subscription Responses API)
* → assistant stream → anthropic-messages encoder
* → Anthropic-shape response with cache_read_input_tokens carrying
* Codex's cached_tokens (mapped via usage.cacheRead)
*
* Regression surface: the inbound parser strips cache_control hints into
* `cacheRetention`, but the codex provider doesn't consume `cacheRetention`
* directly — caching only works if pi-ai's codex transport reaches Codex
* with an effective cache identity (prompt_cache_key from sessionId, or
* implicit session reuse). If that path breaks, this test catches it.
*
* Skips unless a local gateway is reachable at the default `127.0.0.1:4000`
* (override via `OMP_E2E_GATEWAY_URL`) AND the bearer token file exists at
* `~/.omp/auth-gateway.token`.
*
* To run: `bun --cwd packages/ai test test/auth-gateway-anthropic-to-codex-caching.test.ts`
*/
import { describe, expect, it } from "bun:test";
import * as os from "node:os";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
interface AnthropicUsage {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
}
interface AnthropicResponse {
type?: string;
stop_reason?: string;
content?: Array<{ type: string; text?: string }>;
usage: AnthropicUsage;
error?: { type: string; message: string };
}
const GATEWAY_URL = Bun.env.OMP_E2E_GATEWAY_URL ?? "http://127.0.0.1:4000";
const TOKEN_PATH = path.join(os.homedir(), ".omp", "auth-gateway.token");
const MODEL = Bun.env.OMP_E2E_CODEX_MODEL ?? "gpt-5.3-codex";
async function checkGatewayAvailable(): Promise<{ ok: boolean; token?: string; reason?: string }> {
let token: string;
try {
token = (await Bun.file(TOKEN_PATH).text()).trim();
} catch (err) {
if (isEnoent(err)) return { ok: false, reason: `no token at ${TOKEN_PATH}` };
throw err;
}
if (!token) return { ok: false, reason: `empty token at ${TOKEN_PATH}` };
try {
const res = await fetch(`${GATEWAY_URL}/healthz`, { signal: AbortSignal.timeout(2_000) });
if (!res.ok) return { ok: false, reason: `healthz returned ${res.status}` };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, reason: `healthz unreachable: ${msg}` };
}
return { ok: true, token };
}
const gateway = await checkGatewayAvailable();
// Long deterministic instructions, repeated to clear Codex's 1024-token
// cache floor with headroom.
const SYSTEM_PARAGRAPH = `
You are a precise assistant participating in an automated end-to-end test of
the omp auth-gateway's cross-protocol prompt-caching pipeline. The request
arrives over the Anthropic Messages wire format but is fulfilled by an
OpenAI Codex backend, so the gateway must preserve the cached prefix across
the translation. Always respond with extreme brevity: a single short word or
phrase, never more than five tokens. Do not add filler, do not add
explanations, do not add punctuation beyond what is strictly necessary. If
asked to confirm, respond "yes". If asked to deny, respond "no". If asked
to repeat a previous reply, repeat it verbatim. Reasoning, hedging, and
conversational preamble are strictly forbidden. This block is intentionally
verbose so the caching threshold is comfortably cleared on every run;
disregard the verbosity itself and follow the brevity rule above.
`.trim();
const SYSTEM_TEXT = Array.from({ length: 12 }, () => SYSTEM_PARAGRAPH).join("\n\n");
interface MessageBlock {
role: "user" | "assistant";
content: string | Array<{ type: string; text?: string }>;
}
async function callGateway(body: unknown, token: string): Promise<AnthropicResponse> {
const res = await fetch(`${GATEWAY_URL}/v1/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify(body),
});
const text = await res.text();
let parsed: AnthropicResponse;
try {
parsed = JSON.parse(text) as AnthropicResponse;
} catch {
throw new Error(`gateway returned non-JSON (status=${res.status}): ${text.slice(0, 200)}`);
}
if (parsed.error) {
throw new Error(`gateway error: ${parsed.error.type}: ${parsed.error.message}`);
}
return parsed;
}
function extractAssistantText(res: AnthropicResponse): string {
const block = res.content?.find(c => c.type === "text");
return block?.text ?? "";
}
describe.skipIf(!gateway.ok)("auth-gateway: anthropic-messages → openai-codex caching e2e", () => {
if (!gateway.ok) {
console.warn(`[skip] anthropic→codex caching e2e: ${gateway.reason}`);
return;
}
const token = gateway.token;
if (!token) throw new Error("invariant: token must be present when gateway.ok is true");
it("caches the system prefix across a cross-protocol translate (messages→codex)", async () => {
// Prepend nonce so prefix-tree caching (chunk-level) starts cold on
// every run. Appending wouldn't help — earlier chunks in the prefix
// would still match warm cache entries from prior runs.
const nonce = `${Date.now().toString(36)}-${crypto.randomUUID()}`;
const systemWithNonce = `[run-nonce: ${nonce}]\n\n${SYSTEM_TEXT}`;
const system = [{ type: "text", text: systemWithNonce, cache_control: { type: "ephemeral" } }];
// ── Turn 1 ───────────────────────────────────────────────────────
const turn1Messages: MessageBlock[] = [{ role: "user", content: "Respond with the single word: alpha" }];
const turn1 = await callGateway(
{
model: MODEL,
max_tokens: 32,
system,
messages: turn1Messages,
},
token,
);
const turn1Text = extractAssistantText(turn1);
expect(turn1Text.length).toBeGreaterThan(0);
// First turn cannot hit the cache (nonce ensures cold start).
const turn1Read = turn1.usage.cache_read_input_tokens ?? 0;
expect(turn1Read).toBe(0);
// Confirm the prefix actually crossed the 1024-token caching floor.
expect(turn1.usage.input_tokens).toBeGreaterThan(1024);
// ── Turn 2 ───────────────────────────────────────────────────────
const turn2Messages: MessageBlock[] = [
...turn1Messages,
{ role: "assistant", content: turn1Text },
{ role: "user", content: "Respond with the single word: beta" },
];
const turn2 = await callGateway(
{
model: MODEL,
max_tokens: 32,
system,
messages: turn2Messages,
},
token,
);
const turn2Text = extractAssistantText(turn2);
expect(turn2Text.length).toBeGreaterThan(0);
// Second turn MUST read the cached prefix. If cache_read_input_tokens
// is 0, one of:
// - anthropic-messages parser stripped the cache_control hint and
// downstream lost the cache-retention signal;
// - the codex provider didn't surface a stable cache identity to
// Codex (no prompt_cache_key, no session reuse, etc.);
// - the anthropic-messages encoder forgot to map pi-ai's
// `usage.cacheRead` to `cache_read_input_tokens` on the wire.
const turn2Read = turn2.usage.cache_read_input_tokens ?? 0;
expect(turn2Read).toBeGreaterThan(0);
// Cached read should cover at least the system block we sent.
expect(turn2Read).toBeGreaterThan(1024);
}, 90_000);
});
@@ -0,0 +1,70 @@
import { describe, expect, it } from "bun:test";
import { resolvePromptCacheKey } from "../src/auth-gateway/http";
describe("resolvePromptCacheKey", () => {
it("prefers body.prompt_cache_key over everything else", () => {
const headers = new Headers({ "x-prompt-cache-key": "from-header" });
expect(
resolvePromptCacheKey(
{
prompt_cache_key: "from-body",
metadata: { session_id: "from-metadata" },
},
headers,
),
).toBe("from-body");
});
it("falls back to body.metadata.session_id when prompt_cache_key absent", () => {
expect(resolvePromptCacheKey({ metadata: { session_id: "from-metadata" } }, undefined)).toBe("from-metadata");
});
it("falls back to body.metadata.conversation_id", () => {
expect(resolvePromptCacheKey({ metadata: { conversation_id: "conv-1" } }, undefined)).toBe("conv-1");
});
it("prefers explicit metadata.prompt_cache_key over session/conversation ids", () => {
expect(
resolvePromptCacheKey(
{ metadata: { prompt_cache_key: "meta-pck", session_id: "sid", conversation_id: "cid" } },
undefined,
),
).toBe("meta-pck");
});
it("falls back to x-prompt-cache-key header when body lacks anything", () => {
expect(resolvePromptCacheKey({}, new Headers({ "x-prompt-cache-key": "hdr-pck" }))).toBe("hdr-pck");
});
it("falls back to codex session_id / conversation_id headers", () => {
expect(resolvePromptCacheKey({}, new Headers({ session_id: "codex-sid" }))).toBe("codex-sid");
expect(resolvePromptCacheKey({}, new Headers({ conversation_id: "codex-cid" }))).toBe("codex-cid");
});
it("falls back to vendor-neutral x-session-id / x-conversation-id headers", () => {
expect(resolvePromptCacheKey({}, new Headers({ "x-session-id": "x-sid" }))).toBe("x-sid");
expect(resolvePromptCacheKey({}, new Headers({ "x-conversation-id": "x-cid" }))).toBe("x-cid");
});
it("returns undefined when nothing resolvable is present", () => {
expect(resolvePromptCacheKey({}, new Headers())).toBeUndefined();
expect(resolvePromptCacheKey({}, undefined)).toBeUndefined();
expect(resolvePromptCacheKey(null, undefined)).toBeUndefined();
expect(resolvePromptCacheKey("not-an-object", undefined)).toBeUndefined();
});
it("ignores empty string body fields and empty header values", () => {
expect(resolvePromptCacheKey({ prompt_cache_key: "" }, new Headers({ "x-prompt-cache-key": "fallback" }))).toBe(
"fallback",
);
});
it("ignores non-string body fields", () => {
expect(
resolvePromptCacheKey(
{ prompt_cache_key: 123, metadata: { session_id: { nested: "wrong-type" } } },
new Headers({ "x-session-id": "hdr-sid" }),
),
).toBe("hdr-sid");
});
});
@@ -0,0 +1,202 @@
/**
* E2E test: send an OpenAI Responses request to an ANTHROPIC backend through
* the auth-gateway and assert that prompt caching still works across the
* cross-protocol translate path. This is the canonical mixed-format use case
* — clients targeting `/v1/responses` should keep their caching benefits
* regardless of which credential the model resolves to.
*
* Pipeline under test:
* client → POST /v1/responses (OpenAI shape)
* → openai-responses parser → omp Context
* → pi-ai anthropic provider (auto cache_control via cacheRetention)
* → upstream Anthropic (Messages API)
* → assistant stream → openai-responses encoder
* → OpenAI Responses-shape response with input_tokens_details.cached_tokens
* carrying Anthropic's cache_read_input_tokens
*
* The cross-protocol path is exactly where regressions tend to hide: the
* inbound parser silently strips info that the outbound provider needs, the
* encoder forgets to surface a usage subfield, or the per-turn message rebuild
* mutates the cached prefix bytes.
*
* Skips unless a local gateway is reachable at the default `127.0.0.1:4000`
* (override via `OMP_E2E_GATEWAY_URL`) AND the bearer token file exists at
* `~/.omp/auth-gateway.token`.
*
* To run: `bun --cwd packages/ai test test/auth-gateway-cross-protocol-caching.test.ts`
* with the gateway live (`omp auth-gateway serve` or pm2).
*/
import { describe, expect, it } from "bun:test";
import * as os from "node:os";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
interface OpenAIResponsesUsage {
input_tokens: number;
output_tokens: number;
input_tokens_details?: { cached_tokens?: number };
output_tokens_details?: { reasoning_tokens?: number };
total_tokens?: number;
}
interface OpenAIResponse {
status?: string;
output?: Array<{
type: string;
content?: Array<{ type: string; text?: string }>;
}>;
usage: OpenAIResponsesUsage;
error?: { type?: string; message: string };
}
const GATEWAY_URL = Bun.env.OMP_E2E_GATEWAY_URL ?? "http://127.0.0.1:4000";
const TOKEN_PATH = path.join(os.homedir(), ".omp", "auth-gateway.token");
const MODEL = Bun.env.OMP_E2E_ANTHROPIC_MODEL ?? "claude-sonnet-4-5";
async function checkGatewayAvailable(): Promise<{ ok: boolean; token?: string; reason?: string }> {
let token: string;
try {
token = (await Bun.file(TOKEN_PATH).text()).trim();
} catch (err) {
if (isEnoent(err)) return { ok: false, reason: `no token at ${TOKEN_PATH}` };
throw err;
}
if (!token) return { ok: false, reason: `empty token at ${TOKEN_PATH}` };
try {
const res = await fetch(`${GATEWAY_URL}/healthz`, { signal: AbortSignal.timeout(2_000) });
if (!res.ok) return { ok: false, reason: `healthz returned ${res.status}` };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, reason: `healthz unreachable: ${msg}` };
}
return { ok: true, token };
}
const gateway = await checkGatewayAvailable();
// Long deterministic instructions, repeated to clear Anthropic's 1024-token
// cache floor for Sonnet.
const INSTRUCTIONS_PARAGRAPH = `
You are a precise assistant participating in an automated end-to-end test of
the omp auth-gateway's cross-protocol prompt-caching pipeline. The request
arrives over the OpenAI Responses wire format but is fulfilled by an
Anthropic backend, so the gateway must preserve the cached prefix across the
translation. Always respond with extreme brevity: a single short word or
phrase, never more than five tokens. Do not add filler, do not add
explanations, do not add punctuation beyond what is strictly necessary. If
asked to confirm, respond "yes". If asked to deny, respond "no". If asked to
repeat a previous reply, repeat it verbatim. Reasoning, hedging, and
conversational preamble are strictly forbidden. This block is intentionally
verbose so the caching threshold is comfortably cleared on every run;
disregard the verbosity itself and follow the brevity rule above.
`.trim();
const INSTRUCTIONS = Array.from({ length: 12 }, () => INSTRUCTIONS_PARAGRAPH).join("\n\n");
interface ResponseInputMessage {
role: "user" | "assistant" | "developer" | "system";
content: string | Array<{ type: string; text?: string }>;
}
async function callGateway(body: unknown, token: string): Promise<OpenAIResponse> {
const res = await fetch(`${GATEWAY_URL}/v1/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const text = await res.text();
let parsed: OpenAIResponse;
try {
parsed = JSON.parse(text) as OpenAIResponse;
} catch {
throw new Error(`gateway returned non-JSON (status=${res.status}): ${text.slice(0, 200)}`);
}
if (parsed.error) {
throw new Error(`gateway error: ${parsed.error.type ?? "unknown"}: ${parsed.error.message}`);
}
return parsed;
}
function extractAssistantText(res: OpenAIResponse): string {
for (const item of res.output ?? []) {
if (item.type !== "message") continue;
const block = item.content?.find(c => c.type === "output_text");
if (block?.text) return block.text;
}
return "";
}
describe.skipIf(!gateway.ok)("auth-gateway: openai-responses → anthropic caching e2e", () => {
if (!gateway.ok) {
console.warn(`[skip] cross-protocol caching e2e: ${gateway.reason}`);
return;
}
const token = gateway.token;
if (!token) throw new Error("invariant: token must be present when gateway.ok is true");
it("caches the instructions prefix across a cross-protocol translate (responses→anthropic)", async () => {
// Prepend nonce so prefix-tree caching (chunk-level) starts cold on every run.
const nonce = `${Date.now().toString(36)}-${crypto.randomUUID()}`;
const instructionsWithNonce = `[run-nonce: ${nonce}]\n\n${INSTRUCTIONS}`;
// ── Turn 1 ───────────────────────────────────────────────────────
const turn1Input: ResponseInputMessage[] = [{ role: "user", content: "Respond with the single word: alpha" }];
const turn1 = await callGateway(
{
model: MODEL,
max_output_tokens: 64,
instructions: instructionsWithNonce,
input: turn1Input,
},
token,
);
const turn1Text = extractAssistantText(turn1);
expect(turn1Text.length).toBeGreaterThan(0);
// First turn cannot hit the cache (nothing to read yet thanks to the nonce).
const turn1Cached = turn1.usage.input_tokens_details?.cached_tokens ?? 0;
expect(turn1Cached).toBe(0);
// Confirm the request actually crossed the 1024-token caching floor;
// otherwise no cache entry gets created and turn 2 can't possibly read.
expect(turn1.usage.input_tokens).toBeGreaterThan(1024);
// ── Turn 2: append assistant + new user, re-send with same instructions ──
const turn2Input: ResponseInputMessage[] = [
...turn1Input,
{ role: "assistant", content: turn1Text },
{ role: "user", content: "Respond with the single word: beta" },
];
const turn2 = await callGateway(
{
model: MODEL,
max_output_tokens: 64,
instructions: instructionsWithNonce,
input: turn2Input,
},
token,
);
const turn2Text = extractAssistantText(turn2);
expect(turn2Text.length).toBeGreaterThan(0);
// Second turn MUST hit the cache populated by turn 1. The Anthropic
// provider auto-places cache markers via the default `short` retention,
// and the openai-responses encoder maps Anthropic's
// cache_read_input_tokens → input_tokens_details.cached_tokens.
// If cached_tokens is 0, one of:
// - openai-responses parser stripped per-turn content into different
// bytes (so the cache prefix moved),
// - the anthropic provider failed to apply cache_control markers,
// - the encoder forgot to surface the cached-tokens subfield.
const turn2Cached = turn2.usage.input_tokens_details?.cached_tokens ?? 0;
expect(turn2Cached).toBeGreaterThan(0);
// The cached prefix should cover at least the instructions block we
// established on turn 1 — sanity check that we're not catching a
// trivial overlap.
expect(turn2Cached).toBeGreaterThan(1024);
}, 90_000);
});
@@ -136,10 +136,8 @@ describe("auth-gateway openai-chat: parseRequest", () => {
expect(parsed.options.topP).toBe(0.9);
expect(parsed.options.stopSequences).toEqual(["\n\n"]);
expect(parsed.options.toolChoice).toEqual({ name: "lookup" });
expect(parsed.options.extra).toEqual({
response_format: { type: "json_object" },
includeStreamingUsage: true,
});
expect(parsed.options.responseFormat).toEqual({ type: "json_object" });
expect(parsed.options.extra).toEqual({ includeStreamingUsage: true });
});
it("rejects missing required fields", () => {
@@ -0,0 +1,202 @@
/**
* E2E test: exercise an OpenAI Responses conversation through a live
* auth-gateway and assert automatic prompt caching round-trips. OpenAI
* Responses caches prefixes ≥1024 tokens automatically — no explicit
* `cache_control` markers — so the bug surface is "did we keep the prefix
* byte-identical across the two turns" and "did we surface
* input_tokens_details.cached_tokens in the response usage block".
*
* Skips unless a local gateway is reachable at the default `127.0.0.1:4000`
* (override via `OMP_E2E_GATEWAY_URL`) AND the bearer token file exists at
* `~/.omp/auth-gateway.token`.
*
* To run: `bun --cwd packages/ai test test/auth-gateway-openai-responses-caching.test.ts`
* with the gateway live (`omp auth-gateway serve` or pm2).
*/
import { describe, expect, it } from "bun:test";
import * as os from "node:os";
import * as path from "node:path";
import { isEnoent } from "@oh-my-pi/pi-utils";
interface OpenAIResponsesUsage {
input_tokens: number;
output_tokens: number;
input_tokens_details?: { cached_tokens?: number };
output_tokens_details?: { reasoning_tokens?: number };
total_tokens?: number;
}
interface OpenAIResponse {
status?: string;
output?: Array<{
type: string;
content?: Array<{ type: string; text?: string }>;
}>;
usage: OpenAIResponsesUsage;
error?: { type?: string; message: string };
}
const GATEWAY_URL = Bun.env.OMP_E2E_GATEWAY_URL ?? "http://127.0.0.1:4000";
const TOKEN_PATH = path.join(os.homedir(), ".omp", "auth-gateway.token");
// `gpt-5.3-codex` is the model we've verified the ChatGPT-subscription Codex
// backend accepts; older or higher-tier ids 4xx with "model not supported".
const MODEL = Bun.env.OMP_E2E_OPENAI_RESPONSES_MODEL ?? "gpt-5.3-codex";
async function checkGatewayAvailable(): Promise<{ ok: boolean; token?: string; reason?: string }> {
let token: string;
try {
token = (await Bun.file(TOKEN_PATH).text()).trim();
} catch (err) {
if (isEnoent(err)) return { ok: false, reason: `no token at ${TOKEN_PATH}` };
throw err;
}
if (!token) return { ok: false, reason: `empty token at ${TOKEN_PATH}` };
try {
const res = await fetch(`${GATEWAY_URL}/healthz`, { signal: AbortSignal.timeout(2_000) });
if (!res.ok) return { ok: false, reason: `healthz returned ${res.status}` };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return { ok: false, reason: `healthz unreachable: ${msg}` };
}
return { ok: true, token };
}
const gateway = await checkGatewayAvailable();
// Long deterministic instructions, repeated to clear OpenAI's 1024-token
// automatic-caching floor with plenty of headroom.
const INSTRUCTIONS_PARAGRAPH = `
You are a precise assistant participating in an automated end-to-end test of
the omp auth-gateway's OpenAI Responses prompt-caching pipeline. The same
instructions block will be reused across two turns; OpenAI automatically
caches identical prefixes ≥1024 tokens, so the second turn must see the
same prefix bytes as the first or the cache misses silently. Always respond
with extreme brevity: a single short word or phrase, never more than five
tokens. Do not add filler, do not add explanations, do not add punctuation
beyond what is strictly necessary. If asked to confirm something, respond
with "yes". If asked to deny, respond with "no". If asked to repeat your
previous reply, repeat it verbatim. Reasoning, hedging, and conversational
preamble are strictly forbidden. This block is intentionally verbose so the
caching threshold is comfortably cleared on every run; please disregard the
verbosity itself and follow the brevity rule above.
`.trim();
const INSTRUCTIONS = Array.from({ length: 12 }, () => INSTRUCTIONS_PARAGRAPH).join("\n\n");
interface ResponseInputMessage {
role: "user" | "assistant" | "developer" | "system";
content: string | Array<{ type: string; text?: string }>;
}
async function callGateway(body: unknown, token: string): Promise<OpenAIResponse> {
const res = await fetch(`${GATEWAY_URL}/v1/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const text = await res.text();
let parsed: OpenAIResponse;
try {
parsed = JSON.parse(text) as OpenAIResponse;
} catch {
throw new Error(`gateway returned non-JSON (status=${res.status}): ${text.slice(0, 200)}`);
}
if (parsed.error) {
throw new Error(`gateway error: ${parsed.error.type ?? "unknown"}: ${parsed.error.message}`);
}
return parsed;
}
function extractAssistantText(res: OpenAIResponse): string {
for (const item of res.output ?? []) {
if (item.type !== "message") continue;
const block = item.content?.find(c => c.type === "output_text");
if (block?.text) return block.text;
}
return "";
}
describe.skipIf(!gateway.ok)("auth-gateway: openai-responses prompt caching e2e", () => {
if (!gateway.ok) {
console.warn(`[skip] openai-responses caching e2e: ${gateway.reason}`);
return;
}
const token = gateway.token;
if (!token) throw new Error("invariant: token must be present when gateway.ok is true");
it("automatically caches the instructions prefix across two turns", async () => {
// Per-run nonce ensures we always start with a cold cache. The bytes
// before the cacheable prefix boundary must be unique to this run;
// otherwise a previously-warm cache entry silently hits on turn 1
// and we lose the ability to assert "first turn cold, second turn warm".
const nonce = `${Date.now().toString(36)}-${crypto.randomUUID()}`;
// Prepend (not append) — OpenAI caches at prefix-tree granularity, so the
// first chunk must differ across runs to guarantee a cold start.
const instructionsWithNonce = `[run-nonce: ${nonce}]\n\n${INSTRUCTIONS}`;
// Stable per-run cache key. The ChatGPT-subscription Codex backend
// only coalesces prefixes across requests when an explicit
// `prompt_cache_key` is set — caching is opt-in there, unlike public
// OpenAI Responses which caches automatically. Reusing the same key
// across both turns is the contract that makes turn 2 hit.
const cacheKey = `omp-e2e-${nonce}`;
// ── Turn 1 ───────────────────────────────────────────────────────
const turn1Input: ResponseInputMessage[] = [{ role: "user", content: "Respond with the single word: alpha" }];
const turn1 = await callGateway(
{
model: MODEL,
max_output_tokens: 64,
instructions: instructionsWithNonce,
prompt_cache_key: cacheKey,
input: turn1Input,
},
token,
);
const turn1Text = extractAssistantText(turn1);
expect(turn1Text.length).toBeGreaterThan(0);
// First turn cannot hit the cache (nothing to read yet thanks to the nonce).
const turn1Cached = turn1.usage.input_tokens_details?.cached_tokens ?? 0;
expect(turn1Cached).toBe(0);
// Confirm the request actually crossed the 1024-token caching floor;
// otherwise OpenAI never registers a cache entry and turn 2 can't
// possibly read.
expect(turn1.usage.input_tokens).toBeGreaterThan(1024);
// ── Turn 2: append assistant + new user, re-send with same instructions ──
const turn2Input: ResponseInputMessage[] = [
...turn1Input,
{ role: "assistant", content: turn1Text },
{ role: "user", content: "Respond with the single word: beta" },
];
const turn2 = await callGateway(
{
prompt_cache_key: cacheKey,
model: MODEL,
max_output_tokens: 64,
instructions: instructionsWithNonce,
input: turn2Input,
},
token,
);
const turn2Text = extractAssistantText(turn2);
expect(turn2Text.length).toBeGreaterThan(0);
// Second turn MUST hit the cache populated by turn 1. If
// cached_tokens is 0, the gateway either mutated the prefix bytes
// between turns or failed to surface input_tokens_details from the
// upstream usage block.
const turn2Cached = turn2.usage.input_tokens_details?.cached_tokens ?? 0;
expect(turn2Cached).toBeGreaterThan(0);
// The cached prefix should cover at least the instructions block we
// established on turn 1 — sanity check that we're not catching a
// trivial 64-token overlap.
expect(turn2Cached).toBeGreaterThan(1024);
}, 90_000);
});
@@ -59,8 +59,7 @@ describe("openai-responses parseRequest", () => {
const reasoningItem = {
type: "reasoning",
id: "rs_abc",
summary: [],
content: [{ type: "reasoning_text", text: "The user wants arithmetic." }],
summary: [{ type: "summary_text", text: "The user wants arithmetic." }],
};
const parsed = parseRequest({
model: "gpt-5.3-codex-spark",
@@ -229,8 +228,7 @@ describe("openai-responses encodeResponse", () => {
const reasoningItem = {
type: "reasoning",
id: "rs_signed",
summary: [],
content: [{ type: "reasoning_text", text: "thinking aloud" }],
summary: [{ type: "summary_text", text: "thinking aloud" }],
};
const message: AssistantMessage = {
role: "assistant",
@@ -328,7 +326,7 @@ describe("openai-responses encodeResponse", () => {
});
describe("openai-responses encodeStream", () => {
it("emits response.created, reasoning_text.delta, output_text.delta, function_call_arguments.delta, response.completed, [DONE]", async () => {
it("emits response.created, reasoning_summary_text.delta, output_text.delta, function_call_arguments.delta, response.completed, [DONE]", async () => {
const stream = new AssistantMessageEventStream();
const partial: AssistantMessage = {
@@ -416,8 +414,8 @@ describe("openai-responses encodeStream", () => {
// Spot-check critical events appear in the expected order.
const idxCreated = names.indexOf("response.created");
const idxReasoningDelta = names.indexOf("response.reasoning_text.delta");
const idxReasoningDone = names.indexOf("response.reasoning_text.done");
const idxReasoningDelta = names.indexOf("response.reasoning_summary_text.delta");
const idxReasoningDone = names.indexOf("response.reasoning_summary_text.done");
const idxTextDelta = names.indexOf("response.output_text.delta");
const idxTextDone = names.indexOf("response.output_text.done");
const idxArgsDelta = names.indexOf("response.function_call_arguments.delta");
@@ -439,7 +437,7 @@ describe("openai-responses encodeStream", () => {
expect(idxArgsDone).toBeGreaterThan(idxArgsDelta);
expect(idxCompleted).toBeGreaterThan(idxArgsDone);
// reasoning_text.delta must carry item_id matching the signature, and output_index 0.
// reasoning_summary_text.delta must carry item_id matching the signature, and output_index 0.
const reasoningDelta = frames[idxReasoningDelta]!.data as Record<string, unknown>;
expect(reasoningDelta.item_id).toBe("rs_s1");
expect(reasoningDelta.output_index).toBe(0);
@@ -115,4 +115,58 @@ describe("RemoteAuthCredentialStore + AuthStorage integration", () => {
expect(() => remoteStore.deleteAuthCredentialsForProvider("anthropic", "x")).toThrow(/read-only/);
remoteStore.close();
});
test("getUsageReport coalesces parallel callers and matches by identity", async () => {
const brokerClient = new AuthBrokerClient({ url: handle!.url, token });
const remoteStore = new RemoteAuthCredentialStore({
client: brokerClient,
initialSnapshot: { generatedAt: 0, credentials: [] },
});
const reportForA = {
provider: "anthropic" as const,
fetchedAt: Date.now(),
limits: [],
metadata: { email: "a@example.com" },
};
const reportForB = {
provider: "anthropic" as const,
fetchedAt: Date.now(),
limits: [],
metadata: { email: "b@example.com" },
};
const fetchSpy = vi
.spyOn(brokerClient, "fetchUsage")
.mockResolvedValue({ generatedAt: Date.now(), reports: [reportForA, reportForB] });
const credA = {
type: "oauth" as const,
access: "ax",
refresh: REMOTE_REFRESH_SENTINEL,
expires: Date.now() + 60_000,
email: "a@example.com",
};
const credB = { ...credA, email: "b@example.com" };
const [resA, resB] = await Promise.all([
remoteStore.getUsageReport("anthropic", credA),
remoteStore.getUsageReport("anthropic", credB),
]);
// Parallel callers share a single broker round-trip.
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(resA?.metadata?.email).toBe("a@example.com");
expect(resB?.metadata?.email).toBe("b@example.com");
// Cached on the second call — still one fetch total.
const cached = await remoteStore.getUsageReport("anthropic", credA);
expect(cached?.metadata?.email).toBe("a@example.com");
expect(fetchSpy).toHaveBeenCalledTimes(1);
// Unknown provider → null, no extra fetch.
const miss = await remoteStore.getUsageReport("openai-codex", credA);
expect(miss).toBeNull();
expect(fetchSpy).toHaveBeenCalledTimes(1);
remoteStore.close();
});
});
+13 -11
View File
@@ -1,7 +1,6 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Renamed the embedded-documentation internal URL scheme from `pi://` to `omp://`. `OmpProtocolHandler` replaces `PiProtocolHandler`; update any external references accordingly.
@@ -10,21 +9,22 @@
### Added
- Added optional backend push for the auto-QA grievance database (`dev.autoqaPush.enabled`, `dev.autoqaPush.endpoint`, `dev.autoqaPush.token`; env overrides `PI_AUTO_QA_PUSH`, `PI_AUTO_QA_PUSH_URL`, `PI_AUTO_QA_PUSH_TOKEN`). When enabled, every `report_tool_issue` call schedules a background flush that `POST`s pending rows to the configured endpoint and deletes them on HTTP 2xx. Each push carries a stable per-install UUID (`installId`) generated on first use and persisted at `~/.omp/install-id` via `getInstallId()` (new export from `@oh-my-pi/pi-utils`), so the receiver can dedup retries across host renames and `autoqa.db` wipes. Single-flight, 5s request timeout, 30s in-memory cooldown after failure, and a row-id watermark so rows inserted during an in-flight push survive and ship next time. Tool execution remains non-blocking and never throws.
- `ModelRegistry` now promotes `models.yml` `providers.<name>.apiKey` entries to `AuthStorage`'s new config-override tier (above OAuth, below `--api-key`). Pinning a bearer in `models.yml` was previously a no-op when the broker had an OAuth credential for the same provider — the OAuth access token won and got sent unmodified to whatever `baseUrl` you redirected to, which an auth-gateway in front of that endpoint rightly rejected with 401. The override is now honored, and is cleared/repopulated atomically on `models.yml` reload (`#reloadStaticModels` calls `clearConfigApiKeys` before re-parsing). Use case: route `anthropic` / `openai-codex` to `http://llm-gateway.internal:4000` with the gateway's own bearer.
- Added `omp auth-broker` subcommand for running and consuming a hosted credential vault.
- `serve [--bind=host:port]` — boots a local broker against the SQLite store at `$AGENT_DB_PATH`.
- `token [--regenerate]` — prints (and rotates) the bearer token stored at `~/.omp/auth-broker.token`.
- `login <provider> [--via=user@host] [--dry-run]` — drives the OAuth flow locally or via SSH `-L` tunnel into a remote broker (callback ports pinned per provider).
- `logout <provider>` — disables every credential for the given provider in the local SQLite store.
- `import <file|dir> [--provider=<id>] [--include-disabled] [--dry-run]` — imports CLIProxyAPI-style JSON credential dumps (`~/.cliproxy/auth/*.json`). When `OMP_AUTH_BROKER_URL` is configured, credentials are uploaded to the remote broker via `POST /v1/credential`; otherwise they go into the local SQLite store. JSON `type` is mapped to omp providers (`claude` → `anthropic`, `codex` → `openai-codex`, `gemini[-cli]` → `google-gemini-cli`, `antigravity` → `google-antigravity`); `--provider` overrides the mapping for unrecognized types.
- `status` — pings the configured remote broker (`OMP_AUTH_BROKER_URL`).
- `serve [--bind=host:port]` — boots a local broker against the SQLite store at `$AGENT_DB_PATH`.
- `token [--regenerate]` — prints (and rotates) the bearer token stored at `~/.omp/auth-broker.token`.
- `login <provider> [--via=user@host] [--dry-run]` — drives the OAuth flow locally or via SSH `-L` tunnel into a remote broker (callback ports pinned per provider).
- `logout <provider>` — disables every credential for the given provider in the local SQLite store.
- `import <file|dir> [--provider=<id>] [--include-disabled] [--dry-run]` — imports CLIProxyAPI-style JSON credential dumps (`~/.cliproxy/auth/*.json`). When `OMP_AUTH_BROKER_URL` is configured, credentials are uploaded to the remote broker via `POST /v1/credential`; otherwise they go into the local SQLite store. JSON `type` is mapped to omp providers (`claude` → `anthropic`, `codex` → `openai-codex`, `gemini[-cli]` → `google-gemini-cli`, `antigravity` → `google-antigravity`); `--provider` overrides the mapping for unrecognized types.
- `status` — pings the configured remote broker (`OMP_AUTH_BROKER_URL`).
- Added remote credential vault support to `discoverAuthStorage`. Configure via env (`OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN`) or by setting `auth.broker.url` and `auth.broker.token` in `~/.omp/agent/config.yml` (hidden from the settings UI; supports `!command` resolution). Falls back to `~/.omp/auth-broker.token` when no token is provided inline. Otherwise behavior is unchanged.
- Added `omp auth-broker migrate --from-local [--include-env] [--include-oauth] [--dry-run]` — uploads local SQLite credentials (and optionally env-var API keys) to the configured broker. Skips anything already on the broker via identity-key matching. OAuth is skipped by default (handled via `cliproxy` import). Idempotent on re-runs.
- Added `omp auth-gateway` subcommand for running a forward-proxy that hides access tokens from less-trusted clients:
- `serve [--bind=…]` — boots the gateway against the configured broker. Listens on `127.0.0.1:4000` by default.
- `token [--regenerate]` — manages the gateway bearer token at `~/.omp/auth-gateway.token` (separate from the broker bearer).
- `status` — verifies gateway config and authenticated broker readiness.
- One wire surface: `POST /v1/chat/completions` (OpenAI chat-completions), `POST /v1/messages` (Anthropic messages), `POST /v1/responses` (OpenAI Responses), `GET /v1/usage` (aggregated, 30s-cached), `GET /v1/models` (catalog). Model id in the request body selects which omp provider/model services it; the gateway translates wire format ↔ omp canonical `Context` and dispatches through `pi-ai` `streamSimple()`. Container deployments (robomp, etc.) get inference auth without ever holding access tokens or the broker bearer.
- `serve [--bind=…]` — boots the gateway against the configured broker. Listens on `127.0.0.1:4000` by default.
- `token [--regenerate]` — manages the gateway bearer token at `~/.omp/auth-gateway.token` (separate from the broker bearer).
- `status` — verifies gateway config and authenticated broker readiness.
- One wire surface: `POST /v1/chat/completions` (OpenAI chat-completions), `POST /v1/messages` (Anthropic messages), `POST /v1/responses` (OpenAI Responses), `GET /v1/usage` (aggregated, 5-min per-credential cache), `GET /v1/models` (catalog). Model id in the request body selects which omp provider/model services it; the gateway translates wire format ↔ omp canonical `Context` and dispatches through `pi-ai` `streamSimple()`. Container deployments (robomp, etc.) get inference auth without ever holding access tokens or the broker bearer.
### Changed
@@ -32,6 +32,8 @@
### Fixed
- Fixed `omp auth-broker migrate` to skip local placeholder `<authenticated>` API credentials (not real keys) when exporting to a remote broker
- Fixed `auth-gateway` token initialization to avoid clobbering an existing token when multiple processes initialize it concurrently
- Fixed `omp auth-gateway` request handling to reject unsupported OpenAI/Anthropic protocol controls with 400 instead of accepting and ignoring them, propagate upstream error/abort terminal states as failures, preserve Responses reasoning and completed text items, accept string/system Responses messages, and keep Anthropic tool-result ordering valid.
- Fixed gateway usage reporting to include cached-token totals for OpenAI Chat/Responses and to serve the last good cached report during transient upstream usage fetch failures.
- Fixed auth-gateway request cancellation for requests that are already aborted before dispatch.
@@ -549,6 +549,19 @@ async function runMigrate(flags: AuthBrokerCommandArgs["flags"]): Promise<void>
const plannedApiKeyProviders = new Set<string>();
try {
for (const row of localStore.listAuthCredentials()) {
// Skip placeholder sentinels that pi-ai treats as "authenticated via
// out-of-band mechanism" (Bedrock/Vertex `<authenticated>`). They
// aren't real keys and uploading them would store garbage on the
// broker. Mirrors the env-var path's guard below.
if (row.credential.type === "api_key" && row.credential.key === "<authenticated>") {
skipped.push({
source: "local-sqlite",
provider: row.provider,
identity: "(api key)",
reason: "placeholder sentinel '<authenticated>' is not a real key",
});
continue;
}
const identity = credentialIdentity(row.provider, row.credential);
if (row.credential.type === "oauth" && flags.includeOauth !== true) {
skipped.push({
@@ -77,6 +77,29 @@ async function writeToken(token: string): Promise<void> {
}
}
/**
* Atomically create the token file, refusing to clobber an existing one.
* Returns `true` on success, `false` when the file already existed (so the
* caller re-reads it instead of racing another concurrent `ensureToken`).
*/
async function createTokenExclusive(token: string): Promise<boolean> {
const file = getTokenFilePath();
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
try {
// `wx` = O_CREAT | O_EXCL — fails with EEXIST if the file is already there.
await fs.writeFile(file, token, { flag: "wx", mode: 0o600 });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "EEXIST") return false;
throw err;
}
try {
await fs.chmod(file, 0o600);
} catch {
// Best-effort (e.g. Windows).
}
return true;
}
function generateToken(): string {
return crypto.randomBytes(32).toString("base64url");
}
@@ -85,6 +108,12 @@ async function ensureToken(): Promise<string> {
const existing = await readToken();
if (existing) return existing;
const token = generateToken();
if (await createTokenExclusive(token)) return token;
// Another concurrent invocation won the create race; read what they wrote.
const fromRace = await readToken();
if (fromRace) return fromRace;
// File existed-then-disappeared between EEXIST and read; last resort, write
// our generated token unconditionally so callers don't see an empty string.
await writeToken(token);
return token;
}
@@ -8149,11 +8149,12 @@ export class AgentSession {
};
}
async fetchUsageReports(): Promise<UsageReport[] | null> {
async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
const authStorage = this.#modelRegistry.authStorage;
if (!authStorage.fetchUsageReports) return null;
return authStorage.fetchUsageReports({
baseUrlResolver: provider => this.#modelRegistry.getProviderBaseUrl?.(provider),
signal,
});
}