@oh-my-pi/pi-ai 18.2.7 → 18.2.8
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 +16 -0
- package/dist/types/auth-gateway/dispatch.d.ts +80 -0
- package/dist/types/auth-gateway/http.d.ts +5 -6
- package/dist/types/auth-gateway/index.d.ts +1 -0
- package/dist/types/auth-gateway/routes/embeddings.d.ts +3 -0
- package/dist/types/auth-gateway/routes/images.d.ts +3 -0
- package/dist/types/auth-gateway/routes/rerank.d.ts +3 -0
- package/dist/types/auth-gateway/routes/speech.d.ts +2 -0
- package/dist/types/auth-gateway/routes/systemone.d.ts +2 -0
- package/dist/types/auth-gateway/routes/transcriptions.d.ts +3 -0
- package/dist/types/auth-gateway/routes/video.d.ts +7 -0
- package/dist/types/auth-gateway/server.d.ts +10 -16
- package/dist/types/embeddings/index.d.ts +7 -0
- package/dist/types/embeddings/openai-embeddings.d.ts +15 -0
- package/dist/types/embeddings/types.d.ts +15 -0
- package/dist/types/images/google-antigravity.d.ts +9 -0
- package/dist/types/images/google-generative-ai.d.ts +3 -0
- package/dist/types/images/index.d.ts +14 -0
- package/dist/types/images/openai-hosted.d.ts +3 -0
- package/dist/types/images/openai-images.d.ts +5 -0
- package/dist/types/images/openrouter-images.d.ts +3 -0
- package/dist/types/images/shared.d.ts +34 -0
- package/dist/types/images/types.d.ts +31 -0
- package/dist/types/index.d.ts +7 -1
- package/dist/types/judgment/typesafe.d.ts +2 -0
- package/dist/types/providers/embeddings-server.d.ts +32 -0
- package/dist/types/providers/images-server.d.ts +22 -0
- package/dist/types/providers/rerank-server.d.ts +35 -0
- package/dist/types/providers/speech-server.d.ts +8 -0
- package/dist/types/providers/systemone-server.d.ts +26 -0
- package/dist/types/providers/transcriptions-server.d.ts +32 -0
- package/dist/types/providers/video-server.d.ts +39 -0
- package/dist/types/rerank/index.d.ts +7 -0
- package/dist/types/rerank/openrouter-rerank.d.ts +15 -0
- package/dist/types/rerank/types.d.ts +17 -0
- package/dist/types/speech/index.d.ts +13 -0
- package/dist/types/speech/openai-speech.d.ts +3 -0
- package/dist/types/speech/transport.d.ts +7 -0
- package/dist/types/speech/types.d.ts +24 -0
- package/dist/types/speech/xai-tts.d.ts +7 -0
- package/dist/types/transcription/index.d.ts +7 -0
- package/dist/types/transcription/openai-transcriptions.d.ts +15 -0
- package/dist/types/transcription/types.d.ts +41 -0
- package/dist/types/video/index.d.ts +11 -0
- package/dist/types/video/openrouter-video.d.ts +19 -0
- package/dist/types/video/types.d.ts +62 -0
- package/package.json +30 -6
- package/src/auth-gateway/dispatch.ts +273 -0
- package/src/auth-gateway/http.ts +6 -7
- package/src/auth-gateway/index.ts +1 -0
- package/src/auth-gateway/routes/embeddings.ts +98 -0
- package/src/auth-gateway/routes/images.ts +131 -0
- package/src/auth-gateway/routes/rerank.ts +87 -0
- package/src/auth-gateway/routes/speech.ts +101 -0
- package/src/auth-gateway/routes/systemone.ts +116 -0
- package/src/auth-gateway/routes/transcriptions.ts +98 -0
- package/src/auth-gateway/routes/video.ts +243 -0
- package/src/auth-gateway/server.ts +123 -260
- package/src/embeddings/index.ts +17 -0
- package/src/embeddings/openai-embeddings.ts +141 -0
- package/src/embeddings/types.ts +14 -0
- package/src/error/rate-limit.ts +1 -1
- package/src/images/google-antigravity.ts +180 -0
- package/src/images/google-generative-ai.ts +92 -0
- package/src/images/index.ts +59 -0
- package/src/images/openai-hosted.ts +185 -0
- package/src/images/openai-images.ts +110 -0
- package/src/images/openrouter-images.ts +33 -0
- package/src/images/shared.ts +193 -0
- package/src/images/types.ts +36 -0
- package/src/index.ts +7 -1
- package/src/judgment/typesafe.ts +5 -0
- package/src/providers/embeddings-server.ts +151 -0
- package/src/providers/images-server.ts +159 -0
- package/src/providers/rerank-server.ts +166 -0
- package/src/providers/speech-server.ts +53 -0
- package/src/providers/systemone-server.ts +73 -0
- package/src/providers/transcriptions-server.ts +243 -0
- package/src/providers/video-server.ts +286 -0
- package/src/rerank/index.ts +13 -0
- package/src/rerank/openrouter-rerank.ts +136 -0
- package/src/rerank/types.ts +20 -0
- package/src/speech/index.ts +35 -0
- package/src/speech/openai-speech.ts +26 -0
- package/src/speech/transport.ts +66 -0
- package/src/speech/types.ts +37 -0
- package/src/speech/xai-tts.ts +41 -0
- package/src/transcription/index.ts +17 -0
- package/src/transcription/openai-transcriptions.ts +133 -0
- package/src/transcription/types.ts +46 -0
- package/src/video/index.ts +34 -0
- package/src/video/openrouter-video.ts +210 -0
- package/src/video/types.ts +72 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Usage } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
export interface RerankRequest {
|
|
3
|
+
query: string;
|
|
4
|
+
documents: string[];
|
|
5
|
+
topN?: number;
|
|
6
|
+
returnDocuments?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface RerankResultItem {
|
|
9
|
+
index: number;
|
|
10
|
+
relevanceScore: number;
|
|
11
|
+
document?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface RerankResult {
|
|
14
|
+
results: RerankResultItem[];
|
|
15
|
+
model: string;
|
|
16
|
+
usage: Usage;
|
|
17
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Api, Model } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
import type { SpeechOptions, SpeechRequest, SpeechResult } from "./types.js";
|
|
3
|
+
export * from "./openai-speech.js";
|
|
4
|
+
export * from "./transport.js";
|
|
5
|
+
export * from "./types.js";
|
|
6
|
+
export * from "./xai-tts.js";
|
|
7
|
+
/** Catalog APIs {@link synthesizeSpeech} serves; the `tts` tool and the gateway gate on these. */
|
|
8
|
+
export declare const SPEECH_APIS: readonly ["xai-tts", "openai-speech"];
|
|
9
|
+
export type SpeechApi = (typeof SPEECH_APIS)[number];
|
|
10
|
+
/** Whether a catalog API synthesizes speech through a cloud transport (local inference is tool-only). */
|
|
11
|
+
export declare function isSpeechApi(api: Api): api is SpeechApi;
|
|
12
|
+
/** Synthesize speech through the transport selected by the catalog model's `api`. */
|
|
13
|
+
export declare function synthesizeSpeech(model: Model<Api>, request: SpeechRequest, options: SpeechOptions): Promise<SpeechResult>;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { Api, Model } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
import type { SpeechOptions, SpeechRequest, SpeechResult } from "./types.js";
|
|
3
|
+
export declare function synthesizeOpenAiSpeech(model: Model<Api>, request: SpeechRequest, options: SpeechOptions): Promise<SpeechResult>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Api, Model } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
import * as AIError from "../error/index.js";
|
|
3
|
+
import { type SpeechFormat, type SpeechOptions, type SpeechResult } from "./types.js";
|
|
4
|
+
export declare class SpeechApiError extends AIError.ProviderHttpError {
|
|
5
|
+
readonly name = "SpeechApiError";
|
|
6
|
+
}
|
|
7
|
+
export declare function postSpeechRequest(model: Model<Api>, path: string, payload: Record<string, unknown>, format: SpeechFormat, options: SpeechOptions): Promise<SpeechResult>;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { FetchImpl, Usage } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
import type { ApiKey } from "../auth-retry.js";
|
|
3
|
+
export declare const SPEECH_FORMATS: readonly ["mp3", "wav", "pcm", "opus", "aac", "flac"];
|
|
4
|
+
export type SpeechFormat = (typeof SPEECH_FORMATS)[number];
|
|
5
|
+
export declare const SPEECH_FORMAT_MIME_TYPES: Record<SpeechFormat, string>;
|
|
6
|
+
export interface SpeechRequest {
|
|
7
|
+
text: string;
|
|
8
|
+
voice?: string;
|
|
9
|
+
format: SpeechFormat;
|
|
10
|
+
speed?: number;
|
|
11
|
+
sampleRate?: number;
|
|
12
|
+
bitRate?: number;
|
|
13
|
+
instructions?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface SpeechResult {
|
|
16
|
+
audio: Uint8Array;
|
|
17
|
+
mimeType: string;
|
|
18
|
+
usage: Usage;
|
|
19
|
+
}
|
|
20
|
+
export interface SpeechOptions {
|
|
21
|
+
apiKey: ApiKey;
|
|
22
|
+
fetch?: FetchImpl;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Api, Model } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
import type { SpeechOptions, SpeechRequest, SpeechResult } from "./types.js";
|
|
3
|
+
export declare const DEFAULT_XAI_VOICE_ID = "eve";
|
|
4
|
+
export declare const DEFAULT_XAI_SAMPLE_RATE = 24000;
|
|
5
|
+
export declare const DEFAULT_XAI_BIT_RATE = 128000;
|
|
6
|
+
export declare const XAI_MAX_TEXT_LENGTH = 15000;
|
|
7
|
+
export declare function synthesizeXaiSpeech(model: Model<Api>, request: SpeechRequest, options: SpeechOptions): Promise<SpeechResult>;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Api, Model } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
import { type TranscriptionOptions } from "./openai-transcriptions.js";
|
|
3
|
+
import type { TranscriptionRequest, TranscriptionResult } from "./types.js";
|
|
4
|
+
export * from "./openai-transcriptions.js";
|
|
5
|
+
export * from "./types.js";
|
|
6
|
+
/** Dispatch an audio transcription through the transport selected by the catalog model. */
|
|
7
|
+
export declare function transcribeAudio(model: Model<Api>, request: TranscriptionRequest, options: TranscriptionOptions): Promise<TranscriptionResult>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Api, FetchImpl, Model } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
import { type ApiKey } from "../auth-retry.js";
|
|
3
|
+
import * as AIError from "../error/index.js";
|
|
4
|
+
import type { TranscriptionRequest, TranscriptionResult } from "./types.js";
|
|
5
|
+
export interface TranscriptionOptions {
|
|
6
|
+
apiKey: ApiKey;
|
|
7
|
+
fetch?: FetchImpl;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
}
|
|
10
|
+
/** Non-2xx response from an OpenAI-compatible transcription endpoint. */
|
|
11
|
+
export declare class TranscriptionApiError extends AIError.ProviderHttpError {
|
|
12
|
+
readonly name = "TranscriptionApiError";
|
|
13
|
+
}
|
|
14
|
+
/** Call an OpenAI/OpenRouter-compatible multipart transcription endpoint. */
|
|
15
|
+
export declare function transcribeOpenAI(model: Model<Api>, request: TranscriptionRequest, options: TranscriptionOptions): Promise<TranscriptionResult>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Usage } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
export type TranscriptionResponseFormat = "json" | "verbose_json";
|
|
3
|
+
export type TranscriptionTimestampGranularity = "word" | "segment";
|
|
4
|
+
export interface TranscriptionRequest {
|
|
5
|
+
audio: Uint8Array;
|
|
6
|
+
mimeType: string;
|
|
7
|
+
fileName?: string;
|
|
8
|
+
language?: string;
|
|
9
|
+
prompt?: string;
|
|
10
|
+
temperature?: number;
|
|
11
|
+
responseFormat: TranscriptionResponseFormat;
|
|
12
|
+
timestampGranularities?: TranscriptionTimestampGranularity[];
|
|
13
|
+
}
|
|
14
|
+
/** Provider-specific segment fields are preserved alongside the normalized timestamps and text. */
|
|
15
|
+
export interface TranscriptionSegment {
|
|
16
|
+
id?: number | string;
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
text: string;
|
|
20
|
+
speaker?: number | string;
|
|
21
|
+
[key: string]: unknown;
|
|
22
|
+
}
|
|
23
|
+
/** Provider-specific word fields are preserved alongside the normalized timestamps and spelling. */
|
|
24
|
+
export interface TranscriptionWord {
|
|
25
|
+
word: string;
|
|
26
|
+
start: number;
|
|
27
|
+
end: number;
|
|
28
|
+
speaker?: number | string;
|
|
29
|
+
confidence?: number;
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
export interface TranscriptionResult {
|
|
33
|
+
text: string;
|
|
34
|
+
language?: string;
|
|
35
|
+
duration?: number;
|
|
36
|
+
segments?: TranscriptionSegment[];
|
|
37
|
+
words?: TranscriptionWord[];
|
|
38
|
+
/** Provider-billed audio duration, which may be present without verbose output. */
|
|
39
|
+
seconds?: number;
|
|
40
|
+
usage: Usage;
|
|
41
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Api, Model } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
import { type VideoOptions } from "./openrouter-video.js";
|
|
3
|
+
import type { VideoContent, VideoGenerationRequest, VideoJob } from "./types.js";
|
|
4
|
+
export * from "./openrouter-video.js";
|
|
5
|
+
export * from "./types.js";
|
|
6
|
+
/** Submit an asynchronous video generation job through the model's transport. */
|
|
7
|
+
export declare function submitVideo(model: Model<Api>, request: VideoGenerationRequest, options: VideoOptions): Promise<VideoJob>;
|
|
8
|
+
/** Poll an asynchronous video generation job through the model's transport. */
|
|
9
|
+
export declare function pollVideo(model: Model<Api>, jobId: string, options: VideoOptions): Promise<VideoJob>;
|
|
10
|
+
/** Stream generated video content through the model's transport. */
|
|
11
|
+
export declare function downloadVideo(model: Model<Api>, jobId: string, options: VideoOptions): Promise<VideoContent>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Api, FetchImpl, Model } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
import { type ApiKey } from "../auth-retry.js";
|
|
3
|
+
import * as AIError from "../error/index.js";
|
|
4
|
+
import type { VideoContent, VideoGenerationRequest, VideoJob } from "./types.js";
|
|
5
|
+
export interface VideoOptions {
|
|
6
|
+
apiKey: ApiKey;
|
|
7
|
+
fetch?: FetchImpl;
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
}
|
|
10
|
+
/** Non-2xx response from an OpenRouter video endpoint. */
|
|
11
|
+
export declare class VideoApiError extends AIError.ProviderHttpError {
|
|
12
|
+
readonly name = "VideoApiError";
|
|
13
|
+
}
|
|
14
|
+
/** Submit an asynchronous OpenRouter video generation job. */
|
|
15
|
+
export declare function submitOpenRouterVideo(model: Model<Api>, request: VideoGenerationRequest, options: VideoOptions): Promise<VideoJob>;
|
|
16
|
+
/** Poll an asynchronous OpenRouter video generation job. */
|
|
17
|
+
export declare function pollOpenRouterVideo(model: Model<Api>, jobId: string, options: VideoOptions): Promise<VideoJob>;
|
|
18
|
+
/** Stream generated video bytes from OpenRouter without buffering them in memory. */
|
|
19
|
+
export declare function downloadOpenRouterVideo(model: Model<Api>, jobId: string, options: VideoOptions): Promise<VideoContent>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Usage } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
export type VideoResolution = "360p" | "480p" | "720p" | "768p" | "1080p" | "1K" | "2K" | "4K";
|
|
3
|
+
export type VideoAspectRatio = "16:9" | "9:16" | "1:1" | "4:3" | "3:4" | "3:2" | "2:3" | "21:9" | "9:21";
|
|
4
|
+
export interface VideoImageReference {
|
|
5
|
+
type: "image_url";
|
|
6
|
+
imageUrl: {
|
|
7
|
+
url: string;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
export interface VideoFrameImage extends VideoImageReference {
|
|
11
|
+
frameType: "first_frame" | "last_frame";
|
|
12
|
+
}
|
|
13
|
+
export interface VideoAudioReference {
|
|
14
|
+
type: "audio_url";
|
|
15
|
+
audioUrl: {
|
|
16
|
+
url: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export interface VideoVideoReference {
|
|
20
|
+
type: "video_url";
|
|
21
|
+
videoUrl: {
|
|
22
|
+
url: string;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export type VideoInputReference = VideoImageReference | VideoAudioReference | VideoVideoReference;
|
|
26
|
+
/** Canonical camel-case form of OpenRouter's asynchronous video submit request. */
|
|
27
|
+
export interface VideoGenerationRequest {
|
|
28
|
+
prompt?: string;
|
|
29
|
+
duration?: number;
|
|
30
|
+
resolution?: VideoResolution;
|
|
31
|
+
aspectRatio?: VideoAspectRatio;
|
|
32
|
+
size?: string;
|
|
33
|
+
frameImages?: VideoFrameImage[];
|
|
34
|
+
inputReferences?: VideoInputReference[];
|
|
35
|
+
generateAudio?: boolean;
|
|
36
|
+
seed?: number;
|
|
37
|
+
callbackUrl?: string;
|
|
38
|
+
provider?: {
|
|
39
|
+
options?: Record<string, unknown>;
|
|
40
|
+
};
|
|
41
|
+
previousJobId?: string;
|
|
42
|
+
sessionId?: string;
|
|
43
|
+
trace?: Record<string, unknown>;
|
|
44
|
+
user?: string;
|
|
45
|
+
creativity?: number;
|
|
46
|
+
upscaleFactor?: number;
|
|
47
|
+
}
|
|
48
|
+
export type VideoJobStatus = "queued" | "processing" | "pending" | "in_progress" | "completed" | "failed" | "cancelled" | "expired";
|
|
49
|
+
export interface VideoJob {
|
|
50
|
+
id: string;
|
|
51
|
+
status: VideoJobStatus;
|
|
52
|
+
pollingUrl?: string;
|
|
53
|
+
generationId?: string;
|
|
54
|
+
contentUrls?: string[];
|
|
55
|
+
error?: string;
|
|
56
|
+
usage?: Usage;
|
|
57
|
+
}
|
|
58
|
+
export interface VideoContent {
|
|
59
|
+
body: ReadableStream<Uint8Array>;
|
|
60
|
+
contentType: string;
|
|
61
|
+
contentLength?: number;
|
|
62
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-ai",
|
|
3
|
-
"version": "18.2.
|
|
3
|
+
"version": "18.2.8",
|
|
4
4
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -45,10 +45,34 @@
|
|
|
45
45
|
"types": "./dist/types/error/index.d.ts",
|
|
46
46
|
"import": "./src/error/index.ts"
|
|
47
47
|
},
|
|
48
|
+
"./embeddings": {
|
|
49
|
+
"types": "./dist/types/embeddings/index.d.ts",
|
|
50
|
+
"import": "./src/embeddings/index.ts"
|
|
51
|
+
},
|
|
48
52
|
"./judgment": {
|
|
49
53
|
"types": "./dist/types/judgment/index.d.ts",
|
|
50
54
|
"import": "./src/judgment/index.ts"
|
|
51
55
|
},
|
|
56
|
+
"./rerank": {
|
|
57
|
+
"types": "./dist/types/rerank/index.d.ts",
|
|
58
|
+
"import": "./src/rerank/index.ts"
|
|
59
|
+
},
|
|
60
|
+
"./speech": {
|
|
61
|
+
"types": "./dist/types/speech/index.d.ts",
|
|
62
|
+
"import": "./src/speech/index.ts"
|
|
63
|
+
},
|
|
64
|
+
"./images": {
|
|
65
|
+
"types": "./dist/types/images/index.d.ts",
|
|
66
|
+
"import": "./src/images/index.ts"
|
|
67
|
+
},
|
|
68
|
+
"./transcription": {
|
|
69
|
+
"types": "./dist/types/transcription/index.d.ts",
|
|
70
|
+
"import": "./src/transcription/index.ts"
|
|
71
|
+
},
|
|
72
|
+
"./video": {
|
|
73
|
+
"types": "./dist/types/video/index.d.ts",
|
|
74
|
+
"import": "./src/video/index.ts"
|
|
75
|
+
},
|
|
52
76
|
"./*": {
|
|
53
77
|
"types": "./dist/types/*.d.ts",
|
|
54
78
|
"import": "./src/*.ts"
|
|
@@ -128,11 +152,11 @@
|
|
|
128
152
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
129
153
|
},
|
|
130
154
|
"dependencies": {
|
|
131
|
-
"@oh-my-pi/omptype": "18.2.
|
|
132
|
-
"@oh-my-pi/pi-catalog": "18.2.
|
|
133
|
-
"@oh-my-pi/pi-natives": "18.2.
|
|
134
|
-
"@oh-my-pi/pi-utils": "18.2.
|
|
135
|
-
"@oh-my-pi/pi-wire": "18.2.
|
|
155
|
+
"@oh-my-pi/omptype": "18.2.8",
|
|
156
|
+
"@oh-my-pi/pi-catalog": "18.2.8",
|
|
157
|
+
"@oh-my-pi/pi-natives": "18.2.8",
|
|
158
|
+
"@oh-my-pi/pi-utils": "18.2.8",
|
|
159
|
+
"@oh-my-pi/pi-wire": "18.2.8"
|
|
136
160
|
},
|
|
137
161
|
"devDependencies": {
|
|
138
162
|
"@types/bun": "^1.3.14"
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request plumbing shared by every auth-gateway route.
|
|
3
|
+
*
|
|
4
|
+
* A route module (`server.ts` chat/pi-native handlers, `routes/*.ts` for
|
|
5
|
+
* judgments, images, speech, transcription, …) owns only its wire format:
|
|
6
|
+
* parse the body, pick a model, call the pi-ai client, encode the reply.
|
|
7
|
+
* Everything credential-shaped lives here so each route drives the same
|
|
8
|
+
* broker-backed rotation policy and the same usage ledger.
|
|
9
|
+
*/
|
|
10
|
+
import { extractHttpStatusFromError, logger } from "@oh-my-pi/pi-utils";
|
|
11
|
+
import type { ApiKeyResolver } from "../auth-retry";
|
|
12
|
+
import type { AuthStorage } from "../auth-storage";
|
|
13
|
+
import * as AIError from "../error";
|
|
14
|
+
import { classifyGatewayError, type GatewayErrorClassification } from "../error/gateway";
|
|
15
|
+
import { isUsageLimitOutcome } from "../error/rate-limit";
|
|
16
|
+
import type { Api, FetchImpl, Model, Usage } from "../types";
|
|
17
|
+
import type { ClientUsageIdentity } from "../usage";
|
|
18
|
+
import { extractProviderRetryHint } from "../utils/retry-after";
|
|
19
|
+
import type { AuthGatewayServerOptions } from "./types";
|
|
20
|
+
|
|
21
|
+
export type ModelResolver = (modelId: string) => Model<Api> | undefined;
|
|
22
|
+
|
|
23
|
+
export interface AuthGatewayBootOptions extends AuthGatewayServerOptions {
|
|
24
|
+
/** Source of credentials. Caller wires this to a broker-backed AuthStorage. */
|
|
25
|
+
storage: AuthStorage;
|
|
26
|
+
/**
|
|
27
|
+
* Resolve a client-requested model id to a pi-ai Model. Caller supplies
|
|
28
|
+
* this from a ModelRegistry (lives in `coding-agent` to avoid an inverse
|
|
29
|
+
* dependency in `pi-ai`).
|
|
30
|
+
*/
|
|
31
|
+
resolveModel: ModelResolver;
|
|
32
|
+
/** Optional supplier for `/v1/models` listing. Returns the full model array. */
|
|
33
|
+
listModels?: () => Iterable<Model<Api>>;
|
|
34
|
+
/** Upstream transport for every provider call; defaults to global `fetch`. Test seam. */
|
|
35
|
+
fetch?: FetchImpl;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The client's own session key, or `undefined` when it sent none. A blank key
|
|
40
|
+
* counts as none: honouring it would collapse every caller that sends an empty
|
|
41
|
+
* key into one shared credential-sticky, prefix-cache and provider-session
|
|
42
|
+
* bucket.
|
|
43
|
+
*/
|
|
44
|
+
export function normalizeClientSessionKey(clientKey: string | undefined): string | undefined {
|
|
45
|
+
return clientKey !== undefined && clientKey.trim().length > 0 ? clientKey : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Stable identity of the account a request's credential belongs to.
|
|
50
|
+
*
|
|
51
|
+
* `markUsageLimitReached` and the auth-retry resolver switch a session to a
|
|
52
|
+
* sibling credential, so the provider state retained for that session can
|
|
53
|
+
* outlive the account that taught it. OAuth rows expose an account id / email
|
|
54
|
+
* that survives token refresh — fingerprinting the bearer instead would look
|
|
55
|
+
* like a rotation every time a token refreshes and discard the retained
|
|
56
|
+
* lessons for nothing. Key-based rows fall back to a hash of the key, never
|
|
57
|
+
* the key itself: this value is held for the lifetime of the entry.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveGatewayAccount(
|
|
60
|
+
storage: AuthStorage,
|
|
61
|
+
provider: string,
|
|
62
|
+
sessionId: string,
|
|
63
|
+
apiKey: string,
|
|
64
|
+
): string {
|
|
65
|
+
const identity = storage.getOAuthAccountIdentity(provider, sessionId);
|
|
66
|
+
if (identity) {
|
|
67
|
+
return `oauth:${JSON.stringify([
|
|
68
|
+
identity.accountId ?? "",
|
|
69
|
+
identity.email ?? "",
|
|
70
|
+
identity.projectId ?? "",
|
|
71
|
+
identity.orgId ?? "",
|
|
72
|
+
])}`;
|
|
73
|
+
}
|
|
74
|
+
return `key:${Bun.hash(apiKey).toString(36)}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the credential for one request from broker-backed storage.
|
|
79
|
+
*
|
|
80
|
+
* pi-ai clients never consult `AuthStorage`; the gateway resolves the bearer
|
|
81
|
+
* (an OAuth access token refreshed through the broker when needed) and hands
|
|
82
|
+
* it to the client. Returns the key, or the error classification the route
|
|
83
|
+
* should encode in its own envelope: storage failures map through
|
|
84
|
+
* {@link classifyGatewayError}, a provider without any credential is a 401.
|
|
85
|
+
*/
|
|
86
|
+
export async function resolveGatewayApiKey(
|
|
87
|
+
storage: AuthStorage,
|
|
88
|
+
model: Model<Api>,
|
|
89
|
+
sessionId: string,
|
|
90
|
+
signal: AbortSignal,
|
|
91
|
+
peer: string,
|
|
92
|
+
): Promise<string | GatewayErrorClassification> {
|
|
93
|
+
let apiKey: string | undefined;
|
|
94
|
+
try {
|
|
95
|
+
apiKey = await storage.getApiKey(model.provider, sessionId, { modelId: model.id, signal });
|
|
96
|
+
} catch (error) {
|
|
97
|
+
const classified = classifyGatewayError(error);
|
|
98
|
+
logger.warn("auth-gateway getApiKey threw", { provider: model.provider, peer, error: classified.message });
|
|
99
|
+
return classified;
|
|
100
|
+
}
|
|
101
|
+
if (apiKey) return apiKey;
|
|
102
|
+
return {
|
|
103
|
+
status: 401,
|
|
104
|
+
type: "authentication_error",
|
|
105
|
+
message: `No credential available for provider ${model.provider}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Hook fired by a pi-ai client when the upstream request fails in a way
|
|
111
|
+
* that's rotatable — today that's HTTP 401 (credential is bad) and
|
|
112
|
+
* usage-limit phrasing matched by {@link isUsageLimitError} (Codex's
|
|
113
|
+
* `usage_limit_reached`, Anthropic's `usage_limit_reached`, Google's
|
|
114
|
+
* `resource_exhausted`, …). The two cases need different storage actions:
|
|
115
|
+
*
|
|
116
|
+
* - **usage-limit** → {@link AuthStorage.markUsageLimitReached}. Marks just
|
|
117
|
+
* the current session's credential as temporarily blocked (honouring
|
|
118
|
+
* `retry-after` / `resets_at` hints when present) and returns `true` only
|
|
119
|
+
* when a sibling credential is still available. Burning the credential
|
|
120
|
+
* with `invalidateCredentialMatching` here would orphan accounts whose
|
|
121
|
+
* reset window is several hours away — exactly the bug this helper exists
|
|
122
|
+
* to avoid.
|
|
123
|
+
* - **auth-failure** → {@link AuthStorage.invalidateCredentialMatching}.
|
|
124
|
+
* Suspect/delete the row so it doesn't get re-picked next request.
|
|
125
|
+
*
|
|
126
|
+
* In both branches we return the next `getApiKey` result (sticky on the
|
|
127
|
+
* same `sessionId`) so the client can transparently retry the pre-emit
|
|
128
|
+
* failure with a fresh credential. Returning `undefined` aborts the retry
|
|
129
|
+
* and surfaces the original error to the caller.
|
|
130
|
+
*/
|
|
131
|
+
async function refreshGatewayApiKeyAfterAuthError(
|
|
132
|
+
storage: AuthStorage,
|
|
133
|
+
model: Model<Api>,
|
|
134
|
+
sessionId: string,
|
|
135
|
+
provider: string,
|
|
136
|
+
oldKey: string,
|
|
137
|
+
error: unknown,
|
|
138
|
+
signal: AbortSignal,
|
|
139
|
+
format: string,
|
|
140
|
+
peer: string,
|
|
141
|
+
): Promise<string | undefined> {
|
|
142
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
143
|
+
const status = extractHttpStatusFromError(error);
|
|
144
|
+
if (AIError.isUsageLimit(error) || isUsageLimitOutcome(status, message)) {
|
|
145
|
+
const retryAfterMs = extractProviderRetryHint(provider, message);
|
|
146
|
+
const { switched, retryAtMs } = await storage.markUsageLimitReached(provider, sessionId, {
|
|
147
|
+
retryAfterMs,
|
|
148
|
+
providerTimed: retryAfterMs !== undefined,
|
|
149
|
+
baseUrl: model.baseUrl,
|
|
150
|
+
modelId: model.id,
|
|
151
|
+
apiKey: oldKey,
|
|
152
|
+
signal,
|
|
153
|
+
});
|
|
154
|
+
logger.debug("auth-gateway retrying provider request after usage-limit block", {
|
|
155
|
+
format,
|
|
156
|
+
provider,
|
|
157
|
+
peer,
|
|
158
|
+
switched,
|
|
159
|
+
retryAfterMs,
|
|
160
|
+
retryAtMs,
|
|
161
|
+
error: message,
|
|
162
|
+
});
|
|
163
|
+
if (!switched) return undefined;
|
|
164
|
+
return storage.getApiKey(provider, sessionId, { modelId: model.id, signal });
|
|
165
|
+
}
|
|
166
|
+
await storage.invalidateCredentialMatching(provider, oldKey, { sessionId, signal });
|
|
167
|
+
logger.debug("auth-gateway retrying provider request after credential invalidation", {
|
|
168
|
+
format,
|
|
169
|
+
provider,
|
|
170
|
+
peer,
|
|
171
|
+
error: message,
|
|
172
|
+
});
|
|
173
|
+
return storage.getApiKey(provider, sessionId, { modelId: model.id, signal });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Build the {@link ApiKeyResolver} handed to a pi-ai client for a gateway
|
|
178
|
+
* request. Drives the central a/b/c auth-retry policy server-side:
|
|
179
|
+
*
|
|
180
|
+
* - initial resolve → the credential already resolved for this request.
|
|
181
|
+
* - step (b) `!lastChance` → force-refresh the SAME session-sticky credential
|
|
182
|
+
* (a peer/broker may have rotated its token out from under our cached copy).
|
|
183
|
+
* - step (c) `lastChance` → {@link refreshGatewayApiKeyAfterAuthError} switches
|
|
184
|
+
* to a sibling (usage-limit block vs credential invalidation by error class).
|
|
185
|
+
*
|
|
186
|
+
* `lastKey` tracks the most recent bearer so the switch step invalidates the
|
|
187
|
+
* credential that actually failed. `onResolvedKey` observes every rotation;
|
|
188
|
+
* routes that retain provider session state use it to re-key the account
|
|
189
|
+
* lease, one-shot routes pass `undefined`.
|
|
190
|
+
*/
|
|
191
|
+
export function buildGatewayApiKeyResolver(
|
|
192
|
+
storage: AuthStorage,
|
|
193
|
+
model: Model<Api>,
|
|
194
|
+
sessionId: string,
|
|
195
|
+
initialKey: string,
|
|
196
|
+
requestSignal: AbortSignal,
|
|
197
|
+
format: string,
|
|
198
|
+
peer: string,
|
|
199
|
+
onResolvedKey?: (apiKey: string) => void,
|
|
200
|
+
): ApiKeyResolver {
|
|
201
|
+
let lastKey = initialKey;
|
|
202
|
+
return async ({ lastChance, error, signal }) => {
|
|
203
|
+
const sig = signal ?? requestSignal;
|
|
204
|
+
if (error === undefined) {
|
|
205
|
+
lastKey = initialKey;
|
|
206
|
+
return initialKey;
|
|
207
|
+
}
|
|
208
|
+
if (!lastChance) {
|
|
209
|
+
const refreshed = await storage.getApiKey(model.provider, sessionId, {
|
|
210
|
+
modelId: model.id,
|
|
211
|
+
signal: sig,
|
|
212
|
+
forceRefresh: true,
|
|
213
|
+
});
|
|
214
|
+
lastKey = refreshed ?? lastKey;
|
|
215
|
+
if (refreshed) onResolvedKey?.(refreshed);
|
|
216
|
+
return refreshed;
|
|
217
|
+
}
|
|
218
|
+
const next = await refreshGatewayApiKeyAfterAuthError(
|
|
219
|
+
storage,
|
|
220
|
+
model,
|
|
221
|
+
sessionId,
|
|
222
|
+
model.provider,
|
|
223
|
+
lastKey,
|
|
224
|
+
error,
|
|
225
|
+
sig,
|
|
226
|
+
format,
|
|
227
|
+
peer,
|
|
228
|
+
);
|
|
229
|
+
lastKey = next ?? lastKey;
|
|
230
|
+
if (next) onResolvedKey?.(next);
|
|
231
|
+
return next;
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Attribute one settled upstream request to the originating client via the
|
|
237
|
+
* broker's observed-usage channel (`AuthStorage.recordObservedUsage`, batched
|
|
238
|
+
* by the remote store). Error/aborted turns still record — the provider
|
|
239
|
+
* billed whatever tokens the partial turn consumed; zero-usage results
|
|
240
|
+
* (pre-flight failures) are skipped. `at` defaults to now.
|
|
241
|
+
*/
|
|
242
|
+
export function recordGatewayUsage(
|
|
243
|
+
storage: AuthStorage,
|
|
244
|
+
model: Model<Api>,
|
|
245
|
+
client: ClientUsageIdentity,
|
|
246
|
+
usage: Usage,
|
|
247
|
+
at?: number,
|
|
248
|
+
): void {
|
|
249
|
+
if (usage.input + usage.output + usage.cacheRead + usage.cacheWrite === 0) return;
|
|
250
|
+
storage.recordObservedUsage({
|
|
251
|
+
provider: model.provider,
|
|
252
|
+
model: model.id,
|
|
253
|
+
at,
|
|
254
|
+
usage: { input: usage.input, output: usage.output, cacheRead: usage.cacheRead, cacheWrite: usage.cacheWrite },
|
|
255
|
+
costUsd: usage.cost.total,
|
|
256
|
+
client,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* An `AbortController` that follows the inbound request's abort signal. Routes
|
|
262
|
+
* abort it themselves when the response body is cancelled mid-stream, which
|
|
263
|
+
* `req.signal` alone does not observe.
|
|
264
|
+
*/
|
|
265
|
+
export function mirrorRequestAbort(req: Request): AbortController {
|
|
266
|
+
const controller = new AbortController();
|
|
267
|
+
if (req.signal.aborted) {
|
|
268
|
+
controller.abort(req.signal.reason);
|
|
269
|
+
} else {
|
|
270
|
+
req.signal.addEventListener("abort", () => controller.abort(req.signal.reason), { once: true });
|
|
271
|
+
}
|
|
272
|
+
return controller;
|
|
273
|
+
}
|
package/src/auth-gateway/http.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import { timingSafeEqual as nodeTimingSafeEqual } from "node:crypto";
|
|
8
8
|
import * as os from "node:os";
|
|
9
9
|
import { getInstallId } from "@oh-my-pi/pi-utils";
|
|
10
|
-
import type { Api,
|
|
10
|
+
import type { Api, Model } from "../types";
|
|
11
11
|
import type { ClientUsageIdentity } from "../usage";
|
|
12
12
|
|
|
13
13
|
const JSON_HEADERS = {
|
|
@@ -28,14 +28,13 @@ export function json(status: number, body: unknown, headers?: Record<string, str
|
|
|
28
28
|
* `request-id` (surfaced as `_request_id` by the OpenAI and Anthropic SDKs,
|
|
29
29
|
* matches the gateway log line), LiteLLM's model-resolution and cost headers,
|
|
30
30
|
* and OpenAI's `openai-processing-ms`. Model/request-id headers are always
|
|
31
|
-
* present; `
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* only the identity headers.
|
|
31
|
+
* present; `costUsd` — known only once a non-streaming response has settled —
|
|
32
|
+
* adds the computed cost, and `startedAt` the wall time. Streaming responses
|
|
33
|
+
* send headers before usage exists, so they carry only the identity headers.
|
|
35
34
|
*/
|
|
36
35
|
export function gatewayResponseHeaders(
|
|
37
36
|
model: Model<Api>,
|
|
38
|
-
info: { requestId: string;
|
|
37
|
+
info: { requestId: string; costUsd?: number; startedAt?: number },
|
|
39
38
|
): Record<string, string> {
|
|
40
39
|
const headers: Record<string, string> = {
|
|
41
40
|
"x-request-id": info.requestId,
|
|
@@ -43,7 +42,7 @@ export function gatewayResponseHeaders(
|
|
|
43
42
|
"x-litellm-model-id": model.id,
|
|
44
43
|
};
|
|
45
44
|
if (model.baseUrl) headers["x-litellm-model-api-base"] = model.baseUrl;
|
|
46
|
-
if (info.
|
|
45
|
+
if (info.costUsd !== undefined) headers["x-litellm-response-cost"] = info.costUsd.toString();
|
|
47
46
|
if (info.startedAt !== undefined) {
|
|
48
47
|
const elapsed = (performance.now() - info.startedAt).toFixed(0);
|
|
49
48
|
headers["x-litellm-response-duration-ms"] = elapsed;
|