@oh-my-pi/pi-ai 18.4.10 → 18.4.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.4.12] - 2026-10-02
6
+
7
+ ### Added
8
+
9
+ - Added `createAuthGatewayRouter`, the auth-gateway's routes without the HTTP listener, and `serveAuthGatewayStdio`, which serves them as JSON lines (`{"id", "path", "body"}` in, `{"id", "status", "body"}` out) for a parent process.
10
+
11
+ ## [18.4.11] - 2026-10-02
12
+
13
+ ### Fixed
14
+
15
+ - Fixed Cursor cached prompt token accounting to prevent duplicate input-token and cost reporting on cached turns.
16
+ - Fixed auth-broker credential handling so late token-refresh responses cannot restore logged-out credentials or overwrite a newer login.
17
+
5
18
  ## [18.4.10] - 2026-10-02
6
19
 
7
20
  ### Fixed
@@ -5,8 +5,9 @@ import type { Api, FetchImpl, Model, Usage } from "../types.js";
5
5
  import type { ClientUsageIdentity } from "../usage.js";
6
6
  import type { AuthGatewayServerOptions } from "./types.js";
7
7
  export type ModelResolver = (modelId: string) => Model<Api> | undefined;
8
- export interface AuthGatewayBootOptions extends AuthGatewayServerOptions {
9
- /** Source of credentials. Caller wires this to a broker-backed AuthStorage. */
8
+ /** What the gateway's routes need, whatever transport carries the requests. */
9
+ export interface AuthGatewayRouteOptions {
10
+ /** Source of credentials: broker-backed for `serve`, the CLI's own for `stdio`. */
10
11
  storage: AuthStorage;
11
12
  /**
12
13
  * Resolve a client-requested model id to a pi-ai Model. Caller supplies
@@ -19,6 +20,9 @@ export interface AuthGatewayBootOptions extends AuthGatewayServerOptions {
19
20
  /** Upstream transport for every provider call; defaults to global `fetch`. Test seam. */
20
21
  fetch?: FetchImpl;
21
22
  }
23
+ /** The HTTP server's options: the routes' plus its listener and inbound auth. */
24
+ export interface AuthGatewayBootOptions extends AuthGatewayServerOptions, AuthGatewayRouteOptions {
25
+ }
22
26
  /**
23
27
  * The client's own session key, or `undefined` when it sent none. A blank key
24
28
  * counts as none: honouring it would collapse every caller that sends an empty
@@ -1,5 +1,6 @@
1
1
  export * from "./dispatch.js";
2
2
  export * from "./http.js";
3
3
  export * from "./session-state.js";
4
+ export * from "./stdio.js";
4
5
  export * from "./server.js";
5
6
  export * from "./types.js";
@@ -1,3 +1,3 @@
1
- import { type AuthGatewayBootOptions } from "../dispatch.js";
1
+ import { type AuthGatewayRouteOptions } from "../dispatch.js";
2
2
  /** OpenAI-compatible `POST /v1/embeddings` gateway handler. */
3
- export declare function handleEmbeddings(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response>;
3
+ export declare function handleEmbeddings(bootOpts: AuthGatewayRouteOptions, req: Request, peer: string): Promise<Response>;
@@ -1,3 +1,3 @@
1
- import { type AuthGatewayBootOptions } from "../dispatch.js";
2
- export declare function handleImageGenerations(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response>;
3
- export declare function handleImageEdits(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response>;
1
+ import { type AuthGatewayRouteOptions } from "../dispatch.js";
2
+ export declare function handleImageGenerations(bootOpts: AuthGatewayRouteOptions, req: Request, peer: string): Promise<Response>;
3
+ export declare function handleImageEdits(bootOpts: AuthGatewayRouteOptions, req: Request, peer: string): Promise<Response>;
@@ -1,3 +1,3 @@
1
- import { type AuthGatewayBootOptions } from "../dispatch.js";
1
+ import { type AuthGatewayRouteOptions } from "../dispatch.js";
2
2
  /** OpenRouter-compatible `POST /v1/rerank` gateway handler. */
3
- export declare function handleRerank(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response>;
3
+ export declare function handleRerank(bootOpts: AuthGatewayRouteOptions, req: Request, peer: string): Promise<Response>;
@@ -1,2 +1,2 @@
1
- import { type AuthGatewayBootOptions } from "../dispatch.js";
2
- export declare function handleSpeech(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response>;
1
+ import { type AuthGatewayRouteOptions } from "../dispatch.js";
2
+ export declare function handleSpeech(bootOpts: AuthGatewayRouteOptions, req: Request, peer: string): Promise<Response>;
@@ -1,2 +1,2 @@
1
- import { type AuthGatewayBootOptions } from "../dispatch.js";
2
- export declare function handleSystemOne(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response>;
1
+ import { type AuthGatewayRouteOptions } from "../dispatch.js";
2
+ export declare function handleSystemOne(bootOpts: AuthGatewayRouteOptions, req: Request, peer: string): Promise<Response>;
@@ -1,3 +1,3 @@
1
- import { type AuthGatewayBootOptions } from "../dispatch.js";
1
+ import { type AuthGatewayRouteOptions } from "../dispatch.js";
2
2
  /** OpenAI-compatible `POST /v1/audio/transcriptions` gateway handler. */
3
- export declare function handleTranscriptions(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response>;
3
+ export declare function handleTranscriptions(bootOpts: AuthGatewayRouteOptions, req: Request, peer: string): Promise<Response>;
@@ -1,7 +1,7 @@
1
- import { type AuthGatewayBootOptions } from "../dispatch.js";
1
+ import { type AuthGatewayRouteOptions } from "../dispatch.js";
2
2
  /** OpenRouter-compatible `POST /v1/videos` asynchronous video submit handler. */
3
- export declare function handleVideoSubmit(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response>;
3
+ export declare function handleVideoSubmit(bootOpts: AuthGatewayRouteOptions, req: Request, peer: string): Promise<Response>;
4
4
  /** OpenRouter-compatible `GET /v1/videos/:id` asynchronous video poll handler. */
5
- export declare function handleVideoPoll(bootOpts: AuthGatewayBootOptions, req: Request, peer: string, gatewayId: string): Promise<Response>;
5
+ export declare function handleVideoPoll(bootOpts: AuthGatewayRouteOptions, req: Request, peer: string, gatewayId: string): Promise<Response>;
6
6
  /** OpenRouter-compatible `GET /v1/videos/:id/content` streaming video content handler. */
7
- export declare function handleVideoContent(bootOpts: AuthGatewayBootOptions, req: Request, peer: string, gatewayId: string): Promise<Response>;
7
+ export declare function handleVideoContent(bootOpts: AuthGatewayRouteOptions, req: Request, peer: string, gatewayId: string): Promise<Response>;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * omp auth-gateway HTTP server.
2
+ * omp auth-gateway router and HTTP server.
3
3
  *
4
4
  * Accepts any provider-format request (OpenAI chat-completions, Anthropic
5
5
  * messages, OpenAI Responses) and dispatches through pi-ai's `streamSimple()`
@@ -23,8 +23,25 @@
23
23
  * POST /v1/audio/transcriptions → speech-to-text, multipart or JSON base64 in (routes/transcriptions)
24
24
  *
25
25
  * Chat routes live in this file; every other modality is a `routes/*` module
26
- * built on the shared plumbing in `dispatch.ts`.
26
+ * built on the shared plumbing in `dispatch.ts`. {@link createAuthGatewayRouter}
27
+ * answers the `/v1/*` routes for any transport: {@link startAuthGateway}
28
+ * serves them over HTTP behind bearer auth, `stdio.ts` over JSON lines.
27
29
  */
28
- import { type AuthGatewayBootOptions } from "./dispatch.js";
30
+ import { type AuthGatewayBootOptions, type AuthGatewayRouteOptions } from "./dispatch.js";
29
31
  import type { AuthGatewayServerHandle } from "./types.js";
32
+ /** The gateway's `/v1/*` routes, for a transport that has already admitted the caller. */
33
+ export interface AuthGatewayRouter {
34
+ /** Answers one request; `peer` names the caller in logs. Never rejects: a crashed route answers 500. */
35
+ route(req: Request, peer: string): Promise<Response>;
36
+ /**
37
+ * Closes the retained provider session state (Codex WebSockets, GitLab Duo
38
+ * workflows), whose sockets and timers would otherwise keep the process alive.
39
+ */
40
+ close(): void;
41
+ }
42
+ /**
43
+ * The gateway's routes over `opts`, owning their per-session provider state:
44
+ * two routers in one process never share (or tear down) each other's.
45
+ */
46
+ export declare function createAuthGatewayRouter(opts: AuthGatewayRouteOptions): AuthGatewayRouter;
30
47
  export declare function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServerHandle;
@@ -0,0 +1,12 @@
1
+ export interface AuthGatewayStdioOptions {
2
+ /** Request lines. */
3
+ input: ReadableStream<Uint8Array>;
4
+ /** Writes one response line, newline included. */
5
+ write(line: string): void;
6
+ /** Answers one request; never rejects (the gateway router's `route`, or a wrapper around it). */
7
+ route(req: Request): Promise<Response>;
8
+ /** Reported on the ready line. */
9
+ version?: string;
10
+ }
11
+ /** Serves `opts.input` until it ends and every request it carried is answered. */
12
+ export declare function serveAuthGatewayStdio(opts: AuthGatewayStdioOptions): Promise<void>;
@@ -1,7 +1,7 @@
1
1
  export { type Type, type } from "@oh-my-pi/omptype";
2
2
  export * from "./api-registry.js";
3
3
  export type * from "./auth-broker/index.js";
4
- export type { AuthGatewayBootOptions, ModelResolver } from "./auth-gateway/dispatch.js";
4
+ export type { AuthGatewayBootOptions, AuthGatewayRouteOptions, ModelResolver } from "./auth-gateway/dispatch.js";
5
5
  export * from "./auth-gateway/types.js";
6
6
  export * from "./auth-retry.js";
7
7
  export * from "./auth-storage.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.4.10",
3
+ "version": "18.4.12",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.4.10",
159
- "@oh-my-pi/pi-catalog": "18.4.10",
160
- "@oh-my-pi/pi-natives": "18.4.10",
161
- "@oh-my-pi/pi-utils": "18.4.10",
162
- "@oh-my-pi/pi-wire": "18.4.10"
158
+ "@oh-my-pi/omptype": "18.4.12",
159
+ "@oh-my-pi/pi-catalog": "18.4.12",
160
+ "@oh-my-pi/pi-natives": "18.4.12",
161
+ "@oh-my-pi/pi-utils": "18.4.12",
162
+ "@oh-my-pi/pi-wire": "18.4.12"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -17,6 +17,7 @@ import {
17
17
  type OAuthCredential,
18
18
  type OAuthRefreshReason,
19
19
  REMOTE_REFRESH_SENTINEL,
20
+ type RemoteOAuthCredential,
20
21
  type StoredAuthCredential,
21
22
  type StoredCredentialBlock,
22
23
  } from "../auth/types";
@@ -849,15 +850,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
849
850
 
850
851
  async markCredentialSuspect(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<void> {
851
852
  this.#noteActivity();
852
- const { entry } = await this.#client.refreshCredential(credentialId, opts.signal, "auth-recovery");
853
- if (entry.credential.type !== "oauth") {
854
- throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
855
- }
856
- if (!this.#applyCredentialEntry(entry)) {
857
- throw new AIError.AuthBrokerError(
858
- `Broker refreshed credential id=${credentialId} outside the configured account pool`,
859
- );
860
- }
853
+ await this.#refreshThroughBroker(credentialId, opts.signal, "auth-recovery");
861
854
  this.#maybeRefreshSnapshot("suspect credential refresh");
862
855
  }
863
856
 
@@ -1168,21 +1161,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1168
1161
  reason?: OAuthRefreshReason,
1169
1162
  ): Promise<OAuthCredentials> {
1170
1163
  this.#noteActivity();
1171
- const { entry } = await this.#client.refreshCredential(credentialId, signal, reason);
1172
- if (entry.credential.type !== "oauth") {
1173
- throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
1174
- }
1175
- if (!this.#applyCredentialEntry(entry)) {
1176
- throw new AIError.AuthBrokerError(
1177
- `Broker refreshed credential id=${credentialId} outside the configured account pool`,
1178
- );
1179
- }
1164
+ const refreshed = await this.#refreshThroughBroker(credentialId, signal, reason);
1180
1165
  if (!this.#streamingActive) {
1181
1166
  await this.refreshSnapshot().catch(error => {
1182
1167
  logger.debug("auth-broker snapshot refresh after credential refresh failed", { error: String(error) });
1183
1168
  });
1184
1169
  }
1185
- const refreshed = entry.credential;
1186
1170
  return {
1187
1171
  access: refreshed.access,
1188
1172
  refresh: REMOTE_REFRESH_SENTINEL,
@@ -1194,6 +1178,38 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1194
1178
  };
1195
1179
  }
1196
1180
 
1181
+ /**
1182
+ * Refresh one credential through the broker and apply the reply. If this
1183
+ * client's copy changed to something else while the request was in flight,
1184
+ * the reply may be stale, so the broker's current row wins: a logout stays
1185
+ * logged out and a newer login is kept.
1186
+ */
1187
+ async #refreshThroughBroker(
1188
+ credentialId: number,
1189
+ signal?: AbortSignal,
1190
+ reason?: OAuthRefreshReason,
1191
+ ): Promise<RemoteOAuthCredential> {
1192
+ const local = () => this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
1193
+ const before = JSON.stringify(local()?.credential);
1194
+ let { entry } = await this.#client.refreshCredential(credentialId, signal, reason);
1195
+ const current = JSON.stringify(local()?.credential);
1196
+ if (current !== before && current !== JSON.stringify(entry.credential)) {
1197
+ await this.refreshSnapshot();
1198
+ const latest = local();
1199
+ if (!latest) throw new AIError.AuthBrokerError(`Credential id=${credentialId} was removed during refresh`);
1200
+ entry = latest;
1201
+ }
1202
+ if (entry.credential.type !== "oauth") {
1203
+ throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
1204
+ }
1205
+ if (!this.#applyCredentialEntry(entry)) {
1206
+ throw new AIError.AuthBrokerError(
1207
+ `Broker refreshed credential id=${credentialId} outside the configured account pool`,
1208
+ );
1209
+ }
1210
+ return entry.credential;
1211
+ }
1212
+
1197
1213
  /**
1198
1214
  * Store-level hook consumed by `AuthStorage.usage.reports()` — proxies
1199
1215
  * to the broker's `/v1/usage` endpoint. Shared per-credential caches and
@@ -20,8 +20,9 @@ import type { AuthGatewayServerOptions } from "./types";
20
20
 
21
21
  export type ModelResolver = (modelId: string) => Model<Api> | undefined;
22
22
 
23
- export interface AuthGatewayBootOptions extends AuthGatewayServerOptions {
24
- /** Source of credentials. Caller wires this to a broker-backed AuthStorage. */
23
+ /** What the gateway's routes need, whatever transport carries the requests. */
24
+ export interface AuthGatewayRouteOptions {
25
+ /** Source of credentials: broker-backed for `serve`, the CLI's own for `stdio`. */
25
26
  storage: AuthStorage;
26
27
  /**
27
28
  * Resolve a client-requested model id to a pi-ai Model. Caller supplies
@@ -35,6 +36,9 @@ export interface AuthGatewayBootOptions extends AuthGatewayServerOptions {
35
36
  fetch?: FetchImpl;
36
37
  }
37
38
 
39
+ /** The HTTP server's options: the routes' plus its listener and inbound auth. */
40
+ export interface AuthGatewayBootOptions extends AuthGatewayServerOptions, AuthGatewayRouteOptions {}
41
+
38
42
  /**
39
43
  * The client's own session key, or `undefined` when it sent none. A blank key
40
44
  * counts as none: honouring it would collapse every caller that sends an empty
@@ -1,5 +1,6 @@
1
1
  export * from "./dispatch";
2
2
  export * from "./http";
3
3
  export * from "./session-state";
4
+ export * from "./stdio";
4
5
  export * from "./server";
5
6
  export * from "./types";
@@ -4,7 +4,7 @@ import { classifyGatewayError } from "../../error/gateway";
4
4
  import * as embeddings from "../../providers/embeddings-server";
5
5
  import { deterministicUuid } from "../../utils/deterministic-id";
6
6
  import {
7
- type AuthGatewayBootOptions,
7
+ type AuthGatewayRouteOptions,
8
8
  buildGatewayApiKeyResolver,
9
9
  mirrorRequestAbort,
10
10
  recordGatewayUsage,
@@ -14,7 +14,7 @@ import { gatewayResponseHeaders, json, resolveClientIdentity } from "../http";
14
14
 
15
15
  /** OpenAI-compatible `POST /v1/embeddings` gateway handler. */
16
16
  export async function handleEmbeddings(
17
- bootOpts: AuthGatewayBootOptions,
17
+ bootOpts: AuthGatewayRouteOptions,
18
18
  req: Request,
19
19
  peer: string,
20
20
  ): Promise<Response> {
@@ -5,7 +5,7 @@ import { generateImage } from "../../images";
5
5
  import * as imagesServer from "../../providers/images-server";
6
6
  import { deterministicUuid } from "../../utils/deterministic-id";
7
7
  import {
8
- type AuthGatewayBootOptions,
8
+ type AuthGatewayRouteOptions,
9
9
  buildGatewayApiKeyResolver,
10
10
  mirrorRequestAbort,
11
11
  recordGatewayUsage,
@@ -23,7 +23,7 @@ function isGatewayImageApi(api: string): boolean {
23
23
  }
24
24
 
25
25
  async function handleImages(
26
- bootOpts: AuthGatewayBootOptions,
26
+ bootOpts: AuthGatewayRouteOptions,
27
27
  req: Request,
28
28
  peer: string,
29
29
  kind: imagesServer.ImageRequestKind,
@@ -119,13 +119,13 @@ async function handleImages(
119
119
  }
120
120
 
121
121
  export function handleImageGenerations(
122
- bootOpts: AuthGatewayBootOptions,
122
+ bootOpts: AuthGatewayRouteOptions,
123
123
  req: Request,
124
124
  peer: string,
125
125
  ): Promise<Response> {
126
126
  return handleImages(bootOpts, req, peer, "generations");
127
127
  }
128
128
 
129
- export function handleImageEdits(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response> {
129
+ export function handleImageEdits(bootOpts: AuthGatewayRouteOptions, req: Request, peer: string): Promise<Response> {
130
130
  return handleImages(bootOpts, req, peer, "edits");
131
131
  }
@@ -4,7 +4,7 @@ import * as rerankWire from "../../providers/rerank-server";
4
4
  import { rerank } from "../../rerank";
5
5
  import { deterministicUuid } from "../../utils/deterministic-id";
6
6
  import {
7
- type AuthGatewayBootOptions,
7
+ type AuthGatewayRouteOptions,
8
8
  buildGatewayApiKeyResolver,
9
9
  mirrorRequestAbort,
10
10
  recordGatewayUsage,
@@ -13,7 +13,7 @@ import {
13
13
  import { gatewayResponseHeaders, json, resolveClientIdentity } from "../http";
14
14
 
15
15
  /** OpenRouter-compatible `POST /v1/rerank` gateway handler. */
16
- export async function handleRerank(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response> {
16
+ export async function handleRerank(bootOpts: AuthGatewayRouteOptions, req: Request, peer: string): Promise<Response> {
17
17
  const startedAt = performance.now();
18
18
  const requestId = crypto.randomUUID();
19
19
  const controller = mirrorRequestAbort(req);
@@ -4,7 +4,7 @@ import * as speechWire from "../../providers/speech-server";
4
4
  import { isSpeechApi, synthesizeSpeech } from "../../speech";
5
5
  import { deterministicUuid } from "../../utils/deterministic-id";
6
6
  import {
7
- type AuthGatewayBootOptions,
7
+ type AuthGatewayRouteOptions,
8
8
  buildGatewayApiKeyResolver,
9
9
  mirrorRequestAbort,
10
10
  recordGatewayUsage,
@@ -12,7 +12,7 @@ import {
12
12
  } from "../dispatch";
13
13
  import { gatewayResponseHeaders, resolveClientIdentity } from "../http";
14
14
 
15
- export async function handleSpeech(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response> {
15
+ export async function handleSpeech(bootOpts: AuthGatewayRouteOptions, req: Request, peer: string): Promise<Response> {
16
16
  const startedAt = performance.now();
17
17
  const requestId = crypto.randomUUID();
18
18
  const controller = mirrorRequestAbort(req);
@@ -17,7 +17,7 @@ import { isJudgmentApi, TypeSafeJudge } from "../../judgment/typesafe";
17
17
  import * as systemOne from "../../providers/systemone-server";
18
18
  import { deterministicUuid } from "../../utils/deterministic-id";
19
19
  import {
20
- type AuthGatewayBootOptions,
20
+ type AuthGatewayRouteOptions,
21
21
  buildGatewayApiKeyResolver,
22
22
  mirrorRequestAbort,
23
23
  recordGatewayUsage,
@@ -25,7 +25,11 @@ import {
25
25
  } from "../dispatch";
26
26
  import { gatewayResponseHeaders, json, resolveClientIdentity } from "../http";
27
27
 
28
- export async function handleSystemOne(bootOpts: AuthGatewayBootOptions, req: Request, peer: string): Promise<Response> {
28
+ export async function handleSystemOne(
29
+ bootOpts: AuthGatewayRouteOptions,
30
+ req: Request,
31
+ peer: string,
32
+ ): Promise<Response> {
29
33
  const startedAt = performance.now();
30
34
  const requestId = crypto.randomUUID();
31
35
  const controller = mirrorRequestAbort(req);
@@ -4,7 +4,7 @@ import * as transcriptions from "../../providers/transcriptions-server";
4
4
  import { transcribeAudio } from "../../transcription";
5
5
  import { deterministicUuid } from "../../utils/deterministic-id";
6
6
  import {
7
- type AuthGatewayBootOptions,
7
+ type AuthGatewayRouteOptions,
8
8
  buildGatewayApiKeyResolver,
9
9
  mirrorRequestAbort,
10
10
  recordGatewayUsage,
@@ -14,7 +14,7 @@ import { gatewayResponseHeaders, json, resolveClientIdentity } from "../http";
14
14
 
15
15
  /** OpenAI-compatible `POST /v1/audio/transcriptions` gateway handler. */
16
16
  export async function handleTranscriptions(
17
- bootOpts: AuthGatewayBootOptions,
17
+ bootOpts: AuthGatewayRouteOptions,
18
18
  req: Request,
19
19
  peer: string,
20
20
  ): Promise<Response> {
@@ -7,7 +7,7 @@ import { downloadVideo, pollVideo, submitVideo } from "../../video";
7
7
  import type { VideoJob } from "../../video/types";
8
8
  import { deterministicUuid } from "../../utils/deterministic-id";
9
9
  import {
10
- type AuthGatewayBootOptions,
10
+ type AuthGatewayRouteOptions,
11
11
  buildGatewayApiKeyResolver,
12
12
  mirrorRequestAbort,
13
13
  resolveGatewayApiKey,
@@ -27,7 +27,7 @@ function aborted(): Response {
27
27
  }
28
28
 
29
29
  async function resolveVideoJob(
30
- bootOpts: AuthGatewayBootOptions,
30
+ bootOpts: AuthGatewayRouteOptions,
31
31
  req: Request,
32
32
  peer: string,
33
33
  gatewayId: string,
@@ -63,7 +63,7 @@ async function resolveVideoJob(
63
63
  return { model, upstreamId: identity.upstreamId, sessionId, apiKey, controller };
64
64
  }
65
65
 
66
- function videoOptions(bootOpts: AuthGatewayBootOptions, resolved: ResolvedVideoRequest, peer: string) {
66
+ function videoOptions(bootOpts: AuthGatewayRouteOptions, resolved: ResolvedVideoRequest, peer: string) {
67
67
  return {
68
68
  apiKey: buildGatewayApiKeyResolver(
69
69
  bootOpts.storage,
@@ -97,7 +97,7 @@ function logVideoRequest(
97
97
  }
98
98
 
99
99
  function recordCompletedUsage(
100
- bootOpts: AuthGatewayBootOptions,
100
+ bootOpts: AuthGatewayRouteOptions,
101
101
  resolved: ResolvedVideoRequest,
102
102
  req: Request,
103
103
  job: VideoJob,
@@ -114,7 +114,7 @@ function recordCompletedUsage(
114
114
 
115
115
  /** OpenRouter-compatible `POST /v1/videos` asynchronous video submit handler. */
116
116
  export async function handleVideoSubmit(
117
- bootOpts: AuthGatewayBootOptions,
117
+ bootOpts: AuthGatewayRouteOptions,
118
118
  req: Request,
119
119
  peer: string,
120
120
  ): Promise<Response> {
@@ -186,7 +186,7 @@ export async function handleVideoSubmit(
186
186
 
187
187
  /** OpenRouter-compatible `GET /v1/videos/:id` asynchronous video poll handler. */
188
188
  export async function handleVideoPoll(
189
- bootOpts: AuthGatewayBootOptions,
189
+ bootOpts: AuthGatewayRouteOptions,
190
190
  req: Request,
191
191
  peer: string,
192
192
  gatewayId: string,
@@ -218,7 +218,7 @@ export async function handleVideoPoll(
218
218
 
219
219
  /** OpenRouter-compatible `GET /v1/videos/:id/content` streaming video content handler. */
220
220
  export async function handleVideoContent(
221
- bootOpts: AuthGatewayBootOptions,
221
+ bootOpts: AuthGatewayRouteOptions,
222
222
  req: Request,
223
223
  peer: string,
224
224
  gatewayId: string,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * omp auth-gateway HTTP server.
2
+ * omp auth-gateway router and HTTP server.
3
3
  *
4
4
  * Accepts any provider-format request (OpenAI chat-completions, Anthropic
5
5
  * messages, OpenAI Responses) and dispatches through pi-ai's `streamSimple()`
@@ -23,7 +23,9 @@
23
23
  * POST /v1/audio/transcriptions → speech-to-text, multipart or JSON base64 in (routes/transcriptions)
24
24
  *
25
25
  * Chat routes live in this file; every other modality is a `routes/*` module
26
- * built on the shared plumbing in `dispatch.ts`.
26
+ * built on the shared plumbing in `dispatch.ts`. {@link createAuthGatewayRouter}
27
+ * answers the `/v1/*` routes for any transport: {@link startAuthGateway}
28
+ * serves them over HTTP behind bearer auth, `stdio.ts` over JSON lines.
27
29
  */
28
30
 
29
31
  import { Effort } from "@oh-my-pi/pi-catalog/effort";
@@ -41,6 +43,7 @@ import { deterministicUuid } from "../utils/deterministic-id";
41
43
  import { parseBind } from "../utils/parse-bind";
42
44
  import {
43
45
  type AuthGatewayBootOptions,
46
+ type AuthGatewayRouteOptions,
44
47
  buildGatewayApiKeyResolver,
45
48
  mirrorRequestAbort,
46
49
  normalizeClientSessionKey,
@@ -249,7 +252,7 @@ function chatRouteRejection(model: Model<Api>): string | undefined {
249
252
 
250
253
  async function handleFormatEndpoint(
251
254
  route: { module: FormatModule; label: string },
252
- bootOpts: AuthGatewayBootOptions,
255
+ bootOpts: AuthGatewayRouteOptions,
253
256
  req: Request,
254
257
  peer: string,
255
258
  sessionStates: AuthGatewaySessionStateStore,
@@ -493,7 +496,7 @@ async function handleFormatEndpoint(
493
496
  * path.
494
497
  */
495
498
  async function handlePiNative(
496
- bootOpts: AuthGatewayBootOptions,
499
+ bootOpts: AuthGatewayRouteOptions,
497
500
  req: Request,
498
501
  peer: string,
499
502
  sessionStates: AuthGatewaySessionStateStore,
@@ -740,7 +743,7 @@ interface ModelListRow {
740
743
  supports_tools?: boolean;
741
744
  }
742
745
 
743
- function handleModelsList(opts: AuthGatewayBootOptions): Response {
746
+ function handleModelsList(opts: AuthGatewayRouteOptions): Response {
744
747
  const seen = new Set<string>();
745
748
  const data: ModelListRow[] = [];
746
749
  for (const model of opts.listModels?.() ?? []) {
@@ -786,13 +789,132 @@ const LOGGABLE_PATHS: Record<string, true> = {
786
789
  "/v1/models": true,
787
790
  };
788
791
 
792
+ /** Only static routes reach logs verbatim; dynamic or unknown paths may carry caller-supplied secrets. */
793
+ function loggablePath(pathname: string): string {
794
+ return Object.hasOwn(FORMAT_ROUTES, pathname) || Object.hasOwn(LOGGABLE_PATHS, pathname) ? pathname : "<unrouted>";
795
+ }
796
+
797
+ /** The gateway's `/v1/*` routes, for a transport that has already admitted the caller. */
798
+ export interface AuthGatewayRouter {
799
+ /** Answers one request; `peer` names the caller in logs. Never rejects: a crashed route answers 500. */
800
+ route(req: Request, peer: string): Promise<Response>;
801
+ /**
802
+ * Closes the retained provider session state (Codex WebSockets, GitLab Duo
803
+ * workflows), whose sockets and timers would otherwise keep the process alive.
804
+ */
805
+ close(): void;
806
+ }
807
+
808
+ /**
809
+ * The gateway's routes over `opts`, owning their per-session provider state:
810
+ * two routers in one process never share (or tear down) each other's.
811
+ */
812
+ export function createAuthGatewayRouter(opts: AuthGatewayRouteOptions): AuthGatewayRouter {
813
+ const sessionStates = new AuthGatewaySessionStateStore();
814
+ const route = async (req: Request, peer: string): Promise<Response> => {
815
+ const pathname = new URL(req.url).pathname;
816
+ try {
817
+ // Aggregated usage — backed by AuthStorage's 5-min per-credential cache.
818
+ // Same shape as the broker's `/v1/usage`, so widget/llm-git speak to either with the
819
+ // same client struct.
820
+ if (req.method === "GET" && pathname === "/v1/usage") {
821
+ return await handleUsage(opts.storage, req.signal);
822
+ }
823
+
824
+ // Per-credential auth probe — diagnoses which row in a multi-account
825
+ // pool is producing 401s. Aggregated `/v1/usage` silently drops failed
826
+ // credentials, so we need a separate endpoint that captures errors.
827
+ if (req.method === "GET" && pathname === "/v1/credentials/check") {
828
+ return await handleCredentialsCheck(opts.storage, req.signal);
829
+ }
830
+
831
+ // Provider-format dispatch.
832
+ const formatRoute = FORMAT_ROUTES[pathname];
833
+ if (formatRoute && req.method === "POST") {
834
+ return await handleFormatEndpoint(formatRoute, opts, req, peer, sessionStates);
835
+ }
836
+
837
+ // Pi-native fast path. Same auth + provider plumbing as the
838
+ // foreign-wire routes, just without the wire-format translation.
839
+ if (req.method === "POST" && pathname === "/v1/pi/stream") {
840
+ return await handlePiNative(opts, req, peer, sessionStates);
841
+ }
842
+
843
+ // TypeSafe System One judgments (jev). TypeSafe SDKs and omp's own
844
+ // judge point `TYPESAFE_BASE_URL` at the gateway; OpenRouter SDKs
845
+ // reach the same handler through their Decisions path.
846
+ if (req.method === "POST" && (pathname === "/v1/systemone" || pathname === "/alpha/decisions")) {
847
+ return await handleSystemOne(opts, req, peer);
848
+ }
849
+
850
+ // Image generation: OpenAI `/v1/images/generations` + OpenRouter `/v1/images`
851
+ // (JSON), and OpenAI multipart / OpenRouter JSON edits.
852
+ if (req.method === "POST" && (pathname === "/v1/images/generations" || pathname === "/v1/images")) {
853
+ return await handleImageGenerations(opts, req, peer);
854
+ }
855
+ if (req.method === "POST" && pathname === "/v1/images/edits") {
856
+ return await handleImageEdits(opts, req, peer);
857
+ }
858
+
859
+ // Text-to-speech, OpenAI/OpenRouter wire; answers raw audio bytes.
860
+ if (req.method === "POST" && pathname === "/v1/audio/speech") {
861
+ return await handleSpeech(opts, req, peer);
862
+ }
863
+
864
+ // Speech-to-text, OpenAI multipart or OpenRouter JSON base64 wire.
865
+ if (req.method === "POST" && pathname === "/v1/audio/transcriptions") {
866
+ return await handleTranscriptions(opts, req, peer);
867
+ }
868
+
869
+ // Embeddings, OpenAI wire (OpenRouter is compatible).
870
+ if (req.method === "POST" && pathname === "/v1/embeddings") {
871
+ return await handleEmbeddings(opts, req, peer);
872
+ }
873
+
874
+ // Rerank, OpenRouter wire.
875
+ if (req.method === "POST" && pathname === "/v1/rerank") {
876
+ return await handleRerank(opts, req, peer);
877
+ }
878
+
879
+ // Video generation, OpenRouter's asynchronous wire: submit, then poll
880
+ // and download by the gateway-issued job id (stateless — the id
881
+ // encodes provider, model, and upstream job).
882
+ if (req.method === "POST" && pathname === "/v1/videos") {
883
+ return await handleVideoSubmit(opts, req, peer);
884
+ }
885
+ const videoJob = req.method === "GET" ? VIDEO_JOB_PATH.exec(pathname) : null;
886
+ if (videoJob) {
887
+ const gatewayId = decodeURIComponent(videoJob[1]);
888
+ const handler = videoJob[2] ? handleVideoContent : handleVideoPoll;
889
+ return await handler(opts, req, peer, gatewayId);
890
+ }
891
+
892
+ // Model catalog.
893
+ if (req.method === "GET" && pathname === "/v1/models") {
894
+ return handleModelsList(opts);
895
+ }
896
+
897
+ // Route-table miss: no format module to defer to, so we emit a
898
+ // plain JSON 404 rather than guessing at a protocol-specific envelope.
899
+ return json(404, { error: `No route: ${req.method} ${pathname}` });
900
+ } catch (error) {
901
+ logger.error("auth-gateway handler crashed", {
902
+ method: req.method,
903
+ path: loggablePath(pathname),
904
+ peer,
905
+ error: String(error),
906
+ });
907
+ return json(500, { error: "internal error" });
908
+ }
909
+ };
910
+ return { route, close: () => sessionStates.close() };
911
+ }
912
+
789
913
  export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServerHandle {
790
914
  const bind = parseBind(opts.bind ?? DEFAULT_AUTH_GATEWAY_BIND);
791
915
  const tokens = new Set<string>(opts.bearerTokens);
792
916
  const version = opts.version;
793
- // Owned by this server instance so two gateways in one process never share
794
- // (or tear down) each other's provider state, and so `close()` can drain it.
795
- const sessionStates = new AuthGatewaySessionStateStore();
917
+ const router = createAuthGatewayRouter(opts);
796
918
 
797
919
  const server = Bun.serve({
798
920
  hostname: bind.hostname,
@@ -800,125 +922,29 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
800
922
  fetch: async (req, server): Promise<Response> => {
801
923
  const url = new URL(req.url);
802
924
  const pathname = url.pathname;
803
- // Only static routes reach logs verbatim; dynamic or unknown paths may carry caller-supplied secrets.
804
- const logPath =
805
- Object.hasOwn(FORMAT_ROUTES, pathname) || Object.hasOwn(LOGGABLE_PATHS, pathname) ? pathname : "<unrouted>";
806
925
  const socketPeer = server.requestIP(req)?.address ?? "unknown";
807
- let peer = socketPeer;
808
926
  // CORS preflight is always answered without auth — browsers send
809
927
  // preflights pre-authentication and a 401 here breaks the actual
810
928
  // request before the bearer is ever attached.
811
929
  if (req.method === "OPTIONS") {
812
930
  return new Response(null, { status: 204, headers: corsHeaders(req) });
813
931
  }
814
- try {
815
- if (req.method === "GET" && pathname === "/healthz") {
816
- return withCors(json(200, { ok: true, version }), req);
817
- }
818
- if (!isAuthorized(req, tokens)) {
819
- logger.info("auth-gateway request unauthorized", {
820
- method: req.method,
821
- path: logPath,
822
- peer: socketPeer,
823
- });
824
- return withCors(json(401, { error: "unauthorized" }), req);
825
- }
826
- if (hasMisplacedBearer(req, url, tokens)) {
827
- return withCors(json(400, { error: "gateway bearer token outside Authorization" }), req);
828
- }
829
- peer = resolvePeer(req, socketPeer, opts.trustProxyHeaders);
830
- // Aggregated usage — backed by AuthStorage's 5-min per-credential cache.
831
- // Same shape as the broker's `/v1/usage`, so widget/llm-git speak to either with the
832
- // same client struct.
833
- if (req.method === "GET" && pathname === "/v1/usage") {
834
- return withCors(await handleUsage(opts.storage, req.signal), req);
835
- }
836
-
837
- // Per-credential auth probe — diagnoses which row in a multi-account
838
- // pool is producing 401s. Aggregated `/v1/usage` silently drops failed
839
- // credentials, so we need a separate endpoint that captures errors.
840
- if (req.method === "GET" && pathname === "/v1/credentials/check") {
841
- return withCors(await handleCredentialsCheck(opts.storage, req.signal), req);
842
- }
843
-
844
- // Provider-format dispatch.
845
- const formatRoute = FORMAT_ROUTES[pathname];
846
- if (formatRoute && req.method === "POST") {
847
- return withCors(await handleFormatEndpoint(formatRoute, opts, req, peer, sessionStates), req);
848
- }
849
-
850
- // Pi-native fast path. Same auth + provider plumbing as the
851
- // foreign-wire routes, just without the wire-format translation.
852
- if (req.method === "POST" && pathname === "/v1/pi/stream") {
853
- return withCors(await handlePiNative(opts, req, peer, sessionStates), req);
854
- }
855
-
856
- // TypeSafe System One judgments (jev). TypeSafe SDKs and omp's own
857
- // judge point `TYPESAFE_BASE_URL` at the gateway; OpenRouter SDKs
858
- // reach the same handler through their Decisions path.
859
- if (req.method === "POST" && (pathname === "/v1/systemone" || pathname === "/alpha/decisions")) {
860
- return withCors(await handleSystemOne(opts, req, peer), req);
861
- }
862
-
863
- // Image generation: OpenAI `/v1/images/generations` + OpenRouter `/v1/images`
864
- // (JSON), and OpenAI multipart / OpenRouter JSON edits.
865
- if (req.method === "POST" && (pathname === "/v1/images/generations" || pathname === "/v1/images")) {
866
- return withCors(await handleImageGenerations(opts, req, peer), req);
867
- }
868
- if (req.method === "POST" && pathname === "/v1/images/edits") {
869
- return withCors(await handleImageEdits(opts, req, peer), req);
870
- }
871
-
872
- // Text-to-speech, OpenAI/OpenRouter wire; answers raw audio bytes.
873
- if (req.method === "POST" && pathname === "/v1/audio/speech") {
874
- return withCors(await handleSpeech(opts, req, peer), req);
875
- }
876
-
877
- // Speech-to-text, OpenAI multipart or OpenRouter JSON base64 wire.
878
- if (req.method === "POST" && pathname === "/v1/audio/transcriptions") {
879
- return withCors(await handleTranscriptions(opts, req, peer), req);
880
- }
881
-
882
- // Embeddings, OpenAI wire (OpenRouter is compatible).
883
- if (req.method === "POST" && pathname === "/v1/embeddings") {
884
- return withCors(await handleEmbeddings(opts, req, peer), req);
885
- }
886
-
887
- // Rerank, OpenRouter wire.
888
- if (req.method === "POST" && pathname === "/v1/rerank") {
889
- return withCors(await handleRerank(opts, req, peer), req);
890
- }
891
-
892
- // Video generation, OpenRouter's asynchronous wire: submit, then poll
893
- // and download by the gateway-issued job id (stateless — the id
894
- // encodes provider, model, and upstream job).
895
- if (req.method === "POST" && pathname === "/v1/videos") {
896
- return withCors(await handleVideoSubmit(opts, req, peer), req);
897
- }
898
- const videoJob = req.method === "GET" ? VIDEO_JOB_PATH.exec(pathname) : null;
899
- if (videoJob) {
900
- const gatewayId = decodeURIComponent(videoJob[1]);
901
- const handler = videoJob[2] ? handleVideoContent : handleVideoPoll;
902
- return withCors(await handler(opts, req, peer, gatewayId), req);
903
- }
904
-
905
- // Model catalog.
906
- if (req.method === "GET" && pathname === "/v1/models") {
907
- return withCors(handleModelsList(opts), req);
908
- }
909
-
910
- // Route-table miss: no format module to defer to, so we emit a
911
- // plain JSON 404 rather than guessing at a protocol-specific envelope.
912
- return withCors(json(404, { error: `No route: ${req.method} ${pathname}` }), req);
913
- } catch (error) {
914
- logger.error("auth-gateway handler crashed", {
932
+ if (req.method === "GET" && pathname === "/healthz") {
933
+ return withCors(json(200, { ok: true, version }), req);
934
+ }
935
+ if (!isAuthorized(req, tokens)) {
936
+ logger.info("auth-gateway request unauthorized", {
915
937
  method: req.method,
916
- path: logPath,
917
- peer,
918
- error: String(error),
938
+ path: loggablePath(pathname),
939
+ peer: socketPeer,
919
940
  });
920
- return withCors(json(500, { error: "internal error" }), req);
941
+ return withCors(json(401, { error: "unauthorized" }), req);
942
+ }
943
+ if (hasMisplacedBearer(req, url, tokens)) {
944
+ return withCors(json(400, { error: "gateway bearer token outside Authorization" }), req);
921
945
  }
946
+ const peer = resolvePeer(req, socketPeer, opts.trustProxyHeaders);
947
+ return withCors(await router.route(req, peer), req);
922
948
  },
923
949
  // Max-out Bun's idle timeout. Long thinking-budget calls can sit idle
924
950
  // for minutes before the first token arrives; the default kills them.
@@ -936,7 +962,7 @@ export function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServe
936
962
  // Drain after the listener is down: the retained provider states own
937
963
  // sockets and timers (Codex WebSockets, GitLab Duo workflows), so the
938
964
  // process can't settle until each one is closed.
939
- sessionStates.close();
965
+ router.close();
940
966
  },
941
967
  };
942
968
  }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The auth-gateway's routes over JSON lines, for a parent process that owns
3
+ * the gateway as its child: one request per input line, one response per
4
+ * output line.
5
+ *
6
+ * ```text
7
+ * → {"id": 1, "path": "/v1/chat/completions", "body": {"model": "…", "messages": […]}}
8
+ * ← {"id": 1, "status": 200, "body": {"choices": […]}}
9
+ * ```
10
+ *
11
+ * The first output line is `{"ready": true, "version": "…"}`. `method`
12
+ * defaults to `POST` when a request carries a `body` and to `GET` otherwise.
13
+ * Requests run concurrently and answer in completion order, matched by `id`
14
+ * (a string or number). A JSON response body is embedded as JSON, a text one
15
+ * (a `stream: true` reply's SSE) as a string, anything else (audio, video) as
16
+ * base64 with `"encoding": "base64"`. A line that is not a request answers
17
+ * status 400 with the `id` it carried, if any. Serving ends once input ends
18
+ * and every request has been answered.
19
+ */
20
+ import { logger, readLines } from "@oh-my-pi/pi-utils";
21
+
22
+ /** Request ids are opaque to the transport: echoed back as received. */
23
+ type StdioId = string | number;
24
+
25
+ /** One response line. */
26
+ interface StdioResponse {
27
+ id: StdioId | null;
28
+ status: number;
29
+ body: unknown;
30
+ encoding?: "base64";
31
+ }
32
+
33
+ export interface AuthGatewayStdioOptions {
34
+ /** Request lines. */
35
+ input: ReadableStream<Uint8Array>;
36
+ /** Writes one response line, newline included. */
37
+ write(line: string): void;
38
+ /** Answers one request; never rejects (the gateway router's `route`, or a wrapper around it). */
39
+ route(req: Request): Promise<Response>;
40
+ /** Reported on the ready line. */
41
+ version?: string;
42
+ }
43
+
44
+ /** Origin of the `Request`s built from input lines; only the path reaches the routes. */
45
+ const STDIO_ORIGIN = "http://stdio";
46
+
47
+ /** Serves `opts.input` until it ends and every request it carried is answered. */
48
+ export async function serveAuthGatewayStdio(opts: AuthGatewayStdioOptions): Promise<void> {
49
+ const send = (response: StdioResponse): void => opts.write(`${JSON.stringify(response)}\n`);
50
+ opts.write(`${JSON.stringify({ ready: true, version: opts.version })}\n`);
51
+ const inFlight = new Set<Promise<void>>();
52
+ const decoder = new TextDecoder();
53
+ for await (const line of readLines(opts.input)) {
54
+ const text = decoder.decode(line).trim();
55
+ if (!text) continue;
56
+ const request = parseRequest(text);
57
+ if ("error" in request) {
58
+ send({
59
+ id: request.id,
60
+ status: 400,
61
+ body: { error: { message: request.error, type: "invalid_request_error" } },
62
+ });
63
+ continue;
64
+ }
65
+ const answered = opts
66
+ .route(request.req)
67
+ .then(readResponse)
68
+ .then(
69
+ response => send({ id: request.id, ...response }),
70
+ (error: unknown) => {
71
+ logger.error("auth-gateway stdio request failed", { error: String(error) });
72
+ send({
73
+ id: request.id,
74
+ status: 500,
75
+ body: { error: { message: "internal error", type: "server_error" } },
76
+ });
77
+ },
78
+ );
79
+ inFlight.add(answered);
80
+ void answered.finally(() => inFlight.delete(answered));
81
+ }
82
+ await Promise.all(inFlight);
83
+ }
84
+
85
+ /** A request line as a `Request`, or why it isn't one (with its `id` when it had a usable one). */
86
+ function parseRequest(text: string): { id: StdioId; req: Request } | { id: StdioId | null; error: string } {
87
+ let frame: unknown;
88
+ try {
89
+ frame = JSON.parse(text);
90
+ } catch (error) {
91
+ return { id: null, error: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}` };
92
+ }
93
+ if (typeof frame !== "object" || frame === null || Array.isArray(frame)) {
94
+ return { id: null, error: "A request is a JSON object" };
95
+ }
96
+ const { id, method, path, body } = frame as Record<string, unknown>;
97
+ if (typeof id !== "string" && typeof id !== "number") {
98
+ return { id: null, error: "`id` must be a string or a number" };
99
+ }
100
+ if (typeof path !== "string" || !path.startsWith("/")) {
101
+ return { id, error: "`path` must be an absolute path such as /v1/chat/completions" };
102
+ }
103
+ if (method !== undefined && typeof method !== "string") {
104
+ return { id, error: "`method` must be a string" };
105
+ }
106
+ const init: RequestInit =
107
+ body === undefined
108
+ ? { method: method ?? "GET" }
109
+ : { method: method ?? "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) };
110
+ try {
111
+ return { id, req: new Request(`${STDIO_ORIGIN}${path}`, init) };
112
+ } catch (error) {
113
+ return { id, error: error instanceof Error ? error.message : String(error) };
114
+ }
115
+ }
116
+
117
+ /** A whole response as a line's `status`/`body`/`encoding`. */
118
+ async function readResponse(response: Response): Promise<Omit<StdioResponse, "id">> {
119
+ const type = response.headers.get("content-type") ?? "";
120
+ const bytes = new Uint8Array(await response.arrayBuffer());
121
+ if (bytes.length === 0) return { status: response.status, body: null };
122
+ if (type.includes("json")) {
123
+ const text = new TextDecoder().decode(bytes);
124
+ try {
125
+ return { status: response.status, body: JSON.parse(text) };
126
+ } catch {
127
+ return { status: response.status, body: text };
128
+ }
129
+ }
130
+ if (type.startsWith("text/")) return { status: response.status, body: new TextDecoder().decode(bytes) };
131
+ return { status: response.status, body: bytes.toBase64(), encoding: "base64" };
132
+ }
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { type Type, type } from "@oh-my-pi/omptype";
2
2
  export * from "./api-registry";
3
3
  export type * from "./auth-broker";
4
- export type { AuthGatewayBootOptions, ModelResolver } from "./auth-gateway/dispatch";
4
+ export type { AuthGatewayBootOptions, AuthGatewayRouteOptions, ModelResolver } from "./auth-gateway/dispatch";
5
5
  export * from "./auth-gateway/types";
6
6
  export * from "./auth-retry";
7
7
  export * from "./auth-storage";
@@ -5341,7 +5341,9 @@ export function processInteractionUpdate(
5341
5341
  * summed 22 against a final 36 — and never report input, cache, or reasoning
5342
5342
  * tokens, so every bucket the final frame reports replaces the streamed
5343
5343
  * estimate. Unreported counters decode as `undefined`; a frame that reports
5344
- * nothing at all leaves the streamed totals untouched.
5344
+ * nothing at all leaves the streamed totals untouched. `inputTokens` counts the
5345
+ * whole prompt, cache hits and writes included, so fresh input is what remains
5346
+ * after both are taken out.
5345
5347
  */
5346
5348
  function applyTurnEndedUsage(usage: Usage, update: TurnEndedUpdate): void {
5347
5349
  const input = Number(update.inputTokens ?? 0n);
@@ -5350,7 +5352,7 @@ function applyTurnEndedUsage(usage: Usage, update: TurnEndedUpdate): void {
5350
5352
  const cacheWrite = Number(update.cacheWriteTokens ?? 0n);
5351
5353
  const reasoning = Number(update.reasoningTokens ?? 0n);
5352
5354
  if (input <= 0 && output <= 0 && cacheRead <= 0 && cacheWrite <= 0) return;
5353
- if (input > 0) usage.input = input;
5355
+ if (input > 0) usage.input = Math.max(input - cacheRead - cacheWrite, 0);
5354
5356
  if (output > 0) usage.output = output;
5355
5357
  if (cacheRead > 0) usage.cacheRead = cacheRead;
5356
5358
  if (cacheWrite > 0) usage.cacheWrite = cacheWrite;