@oh-my-pi/pi-ai 18.4.11 → 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 +6 -0
- package/dist/types/auth-gateway/dispatch.d.ts +6 -2
- package/dist/types/auth-gateway/index.d.ts +1 -0
- package/dist/types/auth-gateway/routes/embeddings.d.ts +2 -2
- package/dist/types/auth-gateway/routes/images.d.ts +3 -3
- package/dist/types/auth-gateway/routes/rerank.d.ts +2 -2
- package/dist/types/auth-gateway/routes/speech.d.ts +2 -2
- package/dist/types/auth-gateway/routes/systemone.d.ts +2 -2
- package/dist/types/auth-gateway/routes/transcriptions.d.ts +2 -2
- package/dist/types/auth-gateway/routes/video.d.ts +4 -4
- package/dist/types/auth-gateway/server.d.ts +20 -3
- package/dist/types/auth-gateway/stdio.d.ts +12 -0
- package/dist/types/index.d.ts +1 -1
- package/package.json +6 -6
- package/src/auth-gateway/dispatch.ts +6 -2
- package/src/auth-gateway/index.ts +1 -0
- package/src/auth-gateway/routes/embeddings.ts +2 -2
- package/src/auth-gateway/routes/images.ts +4 -4
- package/src/auth-gateway/routes/rerank.ts +2 -2
- package/src/auth-gateway/routes/speech.ts +2 -2
- package/src/auth-gateway/routes/systemone.ts +6 -2
- package/src/auth-gateway/routes/transcriptions.ts +2 -2
- package/src/auth-gateway/routes/video.ts +7 -7
- package/src/auth-gateway/server.ts +144 -118
- package/src/auth-gateway/stdio.ts +132 -0
- package/src/index.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
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
|
+
|
|
5
11
|
## [18.4.11] - 2026-10-02
|
|
6
12
|
|
|
7
13
|
### 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
|
-
|
|
9
|
-
|
|
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,3 +1,3 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type AuthGatewayRouteOptions } from "../dispatch.js";
|
|
2
2
|
/** OpenAI-compatible `POST /v1/embeddings` gateway handler. */
|
|
3
|
-
export declare function handleEmbeddings(bootOpts:
|
|
3
|
+
export declare function handleEmbeddings(bootOpts: AuthGatewayRouteOptions, req: Request, peer: string): Promise<Response>;
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
export declare function handleImageGenerations(bootOpts:
|
|
3
|
-
export declare function handleImageEdits(bootOpts:
|
|
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
|
|
1
|
+
import { type AuthGatewayRouteOptions } from "../dispatch.js";
|
|
2
2
|
/** OpenRouter-compatible `POST /v1/rerank` gateway handler. */
|
|
3
|
-
export declare function handleRerank(bootOpts:
|
|
3
|
+
export declare function handleRerank(bootOpts: AuthGatewayRouteOptions, req: Request, peer: string): Promise<Response>;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { type
|
|
2
|
-
export declare function handleSpeech(bootOpts:
|
|
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
|
|
2
|
-
export declare function handleSystemOne(bootOpts:
|
|
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
|
|
1
|
+
import { type AuthGatewayRouteOptions } from "../dispatch.js";
|
|
2
2
|
/** OpenAI-compatible `POST /v1/audio/transcriptions` gateway handler. */
|
|
3
|
-
export declare function handleTranscriptions(bootOpts:
|
|
3
|
+
export declare function handleTranscriptions(bootOpts: AuthGatewayRouteOptions, req: Request, peer: string): Promise<Response>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type AuthGatewayRouteOptions } from "../dispatch.js";
|
|
2
2
|
/** OpenRouter-compatible `POST /v1/videos` asynchronous video submit handler. */
|
|
3
|
-
export declare function handleVideoSubmit(bootOpts:
|
|
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:
|
|
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:
|
|
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>;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
159
|
-
"@oh-my-pi/pi-catalog": "18.4.
|
|
160
|
-
"@oh-my-pi/pi-natives": "18.4.
|
|
161
|
-
"@oh-my-pi/pi-utils": "18.4.
|
|
162
|
-
"@oh-my-pi/pi-wire": "18.4.
|
|
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"
|
|
@@ -20,8 +20,9 @@ import type { AuthGatewayServerOptions } from "./types";
|
|
|
20
20
|
|
|
21
21
|
export type ModelResolver = (modelId: string) => Model<Api> | undefined;
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
@@ -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
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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(
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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:
|
|
917
|
-
peer,
|
|
918
|
-
error: String(error),
|
|
938
|
+
path: loggablePath(pathname),
|
|
939
|
+
peer: socketPeer,
|
|
919
940
|
});
|
|
920
|
-
return withCors(json(
|
|
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
|
-
|
|
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";
|