@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,286 @@
|
|
|
1
|
+
import { type } from "@oh-my-pi/omptype";
|
|
2
|
+
import * as AIError from "../error";
|
|
3
|
+
import type {
|
|
4
|
+
VideoAspectRatio,
|
|
5
|
+
VideoFrameImage,
|
|
6
|
+
VideoGenerationRequest,
|
|
7
|
+
VideoInputReference,
|
|
8
|
+
VideoJob,
|
|
9
|
+
VideoResolution,
|
|
10
|
+
} from "../video/types";
|
|
11
|
+
|
|
12
|
+
const submitRequestSchema = type({
|
|
13
|
+
model: "string > 0",
|
|
14
|
+
"prompt?": "unknown",
|
|
15
|
+
"duration?": "unknown",
|
|
16
|
+
"resolution?": "unknown",
|
|
17
|
+
"aspect_ratio?": "unknown",
|
|
18
|
+
"size?": "unknown",
|
|
19
|
+
"frame_images?": "unknown",
|
|
20
|
+
"input_references?": "unknown",
|
|
21
|
+
"generate_audio?": "unknown",
|
|
22
|
+
"seed?": "unknown",
|
|
23
|
+
"callback_url?": "unknown",
|
|
24
|
+
"provider?": "unknown",
|
|
25
|
+
"previous_job_id?": "unknown",
|
|
26
|
+
"session_id?": "unknown",
|
|
27
|
+
"trace?": "unknown",
|
|
28
|
+
"user?": "unknown",
|
|
29
|
+
"creativity?": "unknown",
|
|
30
|
+
"upscale_factor?": "unknown",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const gatewayJobIdSchema = type({ provider: "string > 0", modelId: "string > 0", upstreamId: "string > 0" });
|
|
34
|
+
|
|
35
|
+
const VIDEO_RESOLUTIONS: readonly VideoResolution[] = ["360p", "480p", "720p", "768p", "1080p", "1K", "2K", "4K"];
|
|
36
|
+
const VIDEO_ASPECT_RATIOS: readonly VideoAspectRatio[] = [
|
|
37
|
+
"16:9",
|
|
38
|
+
"9:16",
|
|
39
|
+
"1:1",
|
|
40
|
+
"4:3",
|
|
41
|
+
"3:4",
|
|
42
|
+
"3:2",
|
|
43
|
+
"2:3",
|
|
44
|
+
"21:9",
|
|
45
|
+
"9:21",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export class VideoWireError extends AIError.ValidationError {
|
|
49
|
+
readonly status: number;
|
|
50
|
+
|
|
51
|
+
constructor(status: number, message: string, options?: { cause?: unknown }) {
|
|
52
|
+
super(message, options);
|
|
53
|
+
this.name = "VideoWireError";
|
|
54
|
+
this.status = status;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface VideoParsedRequest {
|
|
59
|
+
modelId: string;
|
|
60
|
+
request: VideoGenerationRequest;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface GatewayJobIdentity {
|
|
64
|
+
provider: string;
|
|
65
|
+
modelId: string;
|
|
66
|
+
upstreamId: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function invalid(message: string): never {
|
|
70
|
+
throw new VideoWireError(400, `videos: ${message}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function optionalString(value: unknown, field: string, maxLength?: number): string | undefined {
|
|
74
|
+
if (value === undefined) return undefined;
|
|
75
|
+
if (typeof value !== "string" || value.length === 0) invalid(`${field} must be a non-empty string`);
|
|
76
|
+
if (maxLength !== undefined && value.length > maxLength) invalid(`${field} must not exceed ${maxLength} characters`);
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function optionalRecord(value: unknown, field: string): Record<string, unknown> | undefined {
|
|
81
|
+
if (value === undefined) return undefined;
|
|
82
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) invalid(`${field} must be an object`);
|
|
83
|
+
return value as Record<string, unknown>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function optionalInteger(value: unknown, field: string, minimum?: number): number | undefined {
|
|
87
|
+
if (value === undefined) return undefined;
|
|
88
|
+
if (typeof value !== "number" || !Number.isInteger(value) || (minimum !== undefined && value < minimum)) {
|
|
89
|
+
invalid(`${field} must be ${minimum === undefined ? "an integer" : `an integer >= ${minimum}`}`);
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function optionalNumber(value: unknown, field: string, exclusiveMinimum?: number): number | undefined {
|
|
95
|
+
if (value === undefined) return undefined;
|
|
96
|
+
if (
|
|
97
|
+
typeof value !== "number" ||
|
|
98
|
+
!Number.isFinite(value) ||
|
|
99
|
+
(exclusiveMinimum !== undefined && value <= exclusiveMinimum)
|
|
100
|
+
) {
|
|
101
|
+
invalid(`${field} must be a finite number${exclusiveMinimum === undefined ? "" : ` > ${exclusiveMinimum}`}`);
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function optionalBoolean(value: unknown, field: string): boolean | undefined {
|
|
107
|
+
if (value === undefined) return undefined;
|
|
108
|
+
if (typeof value !== "boolean") invalid(`${field} must be a boolean`);
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function assetUrl(value: unknown, field: string): { url: string } {
|
|
113
|
+
const record = optionalRecord(value, field);
|
|
114
|
+
const url = optionalString(record?.url, `${field}.url`);
|
|
115
|
+
if (!url) invalid(`${field}.url must be a non-empty string`);
|
|
116
|
+
return { url };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseFrameImages(value: unknown): VideoFrameImage[] | undefined {
|
|
120
|
+
if (value === undefined) return undefined;
|
|
121
|
+
if (!Array.isArray(value)) invalid("frame_images must be an array");
|
|
122
|
+
return value.map((entry, index) => {
|
|
123
|
+
const record = optionalRecord(entry, `frame_images[${index}]`)!;
|
|
124
|
+
if (record.type !== "image_url") invalid(`frame_images[${index}].type must be image_url`);
|
|
125
|
+
if (record.frame_type !== "first_frame" && record.frame_type !== "last_frame") {
|
|
126
|
+
invalid(`frame_images[${index}].frame_type must be first_frame or last_frame`);
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
type: "image_url",
|
|
130
|
+
imageUrl: assetUrl(record.image_url, `frame_images[${index}].image_url`),
|
|
131
|
+
frameType: record.frame_type,
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function parseInputReferences(value: unknown): VideoInputReference[] | undefined {
|
|
137
|
+
if (value === undefined) return undefined;
|
|
138
|
+
if (!Array.isArray(value)) invalid("input_references must be an array");
|
|
139
|
+
return value.map((entry, index) => {
|
|
140
|
+
const record = optionalRecord(entry, `input_references[${index}]`)!;
|
|
141
|
+
switch (record.type) {
|
|
142
|
+
case "image_url":
|
|
143
|
+
return {
|
|
144
|
+
type: "image_url",
|
|
145
|
+
imageUrl: assetUrl(record.image_url, `input_references[${index}].image_url`),
|
|
146
|
+
};
|
|
147
|
+
case "audio_url":
|
|
148
|
+
return {
|
|
149
|
+
type: "audio_url",
|
|
150
|
+
audioUrl: assetUrl(record.audio_url, `input_references[${index}].audio_url`),
|
|
151
|
+
};
|
|
152
|
+
case "video_url":
|
|
153
|
+
return {
|
|
154
|
+
type: "video_url",
|
|
155
|
+
videoUrl: assetUrl(record.video_url, `input_references[${index}].video_url`),
|
|
156
|
+
};
|
|
157
|
+
default:
|
|
158
|
+
return invalid(`input_references[${index}].type must be image_url, audio_url, or video_url`);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Parse and validate an OpenRouter-compatible video submit body. */
|
|
164
|
+
export function parseRequest(body: unknown): VideoParsedRequest {
|
|
165
|
+
const parsed = submitRequestSchema(body);
|
|
166
|
+
if (parsed instanceof type.errors) invalid(parsed.summary);
|
|
167
|
+
const prompt = optionalString(parsed.prompt, "prompt");
|
|
168
|
+
const duration = optionalInteger(parsed.duration, "duration", 1);
|
|
169
|
+
const resolution = optionalString(parsed.resolution, "resolution");
|
|
170
|
+
if (resolution !== undefined && !VIDEO_RESOLUTIONS.includes(resolution as VideoResolution)) {
|
|
171
|
+
invalid(`resolution must be one of ${VIDEO_RESOLUTIONS.join(", ")}`);
|
|
172
|
+
}
|
|
173
|
+
const aspectRatio = optionalString(parsed.aspect_ratio, "aspect_ratio");
|
|
174
|
+
if (aspectRatio !== undefined && !VIDEO_ASPECT_RATIOS.includes(aspectRatio as VideoAspectRatio)) {
|
|
175
|
+
invalid(`aspect_ratio must be one of ${VIDEO_ASPECT_RATIOS.join(", ")}`);
|
|
176
|
+
}
|
|
177
|
+
const callbackUrl = optionalString(parsed.callback_url, "callback_url");
|
|
178
|
+
if (callbackUrl !== undefined) {
|
|
179
|
+
let url: URL;
|
|
180
|
+
try {
|
|
181
|
+
url = new URL(callbackUrl);
|
|
182
|
+
} catch (error) {
|
|
183
|
+
throw new VideoWireError(400, "videos: callback_url must be a valid HTTPS URL", { cause: error });
|
|
184
|
+
}
|
|
185
|
+
if (url.protocol !== "https:") invalid("callback_url must be a valid HTTPS URL");
|
|
186
|
+
}
|
|
187
|
+
const provider = optionalRecord(parsed.provider, "provider");
|
|
188
|
+
const providerOptions = optionalRecord(provider?.options, "provider.options");
|
|
189
|
+
const previousJobId = optionalString(parsed.previous_job_id, "previous_job_id");
|
|
190
|
+
const sessionId = optionalString(parsed.session_id, "session_id", 256);
|
|
191
|
+
const user = optionalString(parsed.user, "user", 256);
|
|
192
|
+
const size = optionalString(parsed.size, "size");
|
|
193
|
+
const frameImages = parseFrameImages(parsed.frame_images);
|
|
194
|
+
const inputReferences = parseInputReferences(parsed.input_references);
|
|
195
|
+
const generateAudio = optionalBoolean(parsed.generate_audio, "generate_audio");
|
|
196
|
+
const seed = optionalInteger(parsed.seed, "seed");
|
|
197
|
+
const trace = optionalRecord(parsed.trace, "trace");
|
|
198
|
+
const creativity = optionalInteger(parsed.creativity, "creativity");
|
|
199
|
+
const upscaleFactor = optionalNumber(parsed.upscale_factor, "upscale_factor", 0);
|
|
200
|
+
return {
|
|
201
|
+
modelId: parsed.model,
|
|
202
|
+
request: {
|
|
203
|
+
...(prompt !== undefined && { prompt }),
|
|
204
|
+
...(duration !== undefined && { duration }),
|
|
205
|
+
...(resolution !== undefined && { resolution: resolution as VideoResolution }),
|
|
206
|
+
...(aspectRatio !== undefined && { aspectRatio: aspectRatio as VideoAspectRatio }),
|
|
207
|
+
...(size !== undefined && { size }),
|
|
208
|
+
...(frameImages !== undefined && { frameImages }),
|
|
209
|
+
...(inputReferences !== undefined && { inputReferences }),
|
|
210
|
+
...(generateAudio !== undefined && { generateAudio }),
|
|
211
|
+
...(seed !== undefined && { seed }),
|
|
212
|
+
...(callbackUrl !== undefined && { callbackUrl }),
|
|
213
|
+
...(provider !== undefined && { provider: providerOptions === undefined ? {} : { options: providerOptions } }),
|
|
214
|
+
...(previousJobId !== undefined && { previousJobId }),
|
|
215
|
+
...(sessionId !== undefined && { sessionId }),
|
|
216
|
+
...(trace !== undefined && { trace }),
|
|
217
|
+
...(user !== undefined && { user }),
|
|
218
|
+
...(creativity !== undefined && { creativity }),
|
|
219
|
+
...(upscaleFactor !== undefined && { upscaleFactor }),
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Encode all provider/model routing needed to resolve a future poll without gateway state. */
|
|
225
|
+
export function encodeGatewayJobId(identity: GatewayJobIdentity): string {
|
|
226
|
+
return new TextEncoder().encode(JSON.stringify(identity)).toBase64({ alphabet: "base64url", omitPadding: true });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Decode a stateless gateway job id, rejecting malformed or incomplete identities. */
|
|
230
|
+
export function decodeGatewayJobId(id: string): GatewayJobIdentity {
|
|
231
|
+
try {
|
|
232
|
+
const json = new TextDecoder().decode(Uint8Array.fromBase64(id, { alphabet: "base64url" }));
|
|
233
|
+
const parsed = gatewayJobIdSchema(JSON.parse(json));
|
|
234
|
+
if (parsed instanceof type.errors) invalid("invalid video job id");
|
|
235
|
+
return parsed;
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error instanceof VideoWireError) throw error;
|
|
238
|
+
throw new VideoWireError(400, "videos: invalid video job id", { cause: error });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function gatewayUrls(req: Request, gatewayId: string): { pollingUrl: string; contentUrl: string } {
|
|
243
|
+
const origin = new URL(req.url).origin;
|
|
244
|
+
const root = `${origin}/v1/videos/${encodeURIComponent(gatewayId)}`;
|
|
245
|
+
return { pollingUrl: root, contentUrl: `${root}/content` };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface VideoResponseBody {
|
|
249
|
+
id: string;
|
|
250
|
+
status: VideoJob["status"];
|
|
251
|
+
polling_url: string;
|
|
252
|
+
generation_id?: string;
|
|
253
|
+
unsigned_urls?: string[];
|
|
254
|
+
error?: string;
|
|
255
|
+
usage?: { cost: number };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function encodeJobResponse(job: VideoJob, req: Request, gatewayId: string): VideoResponseBody {
|
|
259
|
+
const urls = gatewayUrls(req, gatewayId);
|
|
260
|
+
return {
|
|
261
|
+
id: gatewayId,
|
|
262
|
+
status: job.status,
|
|
263
|
+
polling_url: urls.pollingUrl,
|
|
264
|
+
...(job.generationId !== undefined && { generation_id: job.generationId }),
|
|
265
|
+
...(job.contentUrls !== undefined && { unsigned_urls: job.contentUrls.map(() => urls.contentUrl) }),
|
|
266
|
+
...(job.error !== undefined && { error: job.error }),
|
|
267
|
+
...(job.usage !== undefined && { usage: { cost: job.usage.cost.total } }),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Rewrite a provider submit response so every future operation returns through this gateway. */
|
|
272
|
+
export function encodeSubmitResponse(job: VideoJob, req: Request, gatewayId: string): VideoResponseBody {
|
|
273
|
+
return encodeJobResponse(job, req, gatewayId);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Rewrite a provider poll response so content URLs and polling stay on this gateway. */
|
|
277
|
+
export function encodePollResponse(job: VideoJob, req: Request, gatewayId: string): VideoResponseBody {
|
|
278
|
+
return encodeJobResponse(job, req, gatewayId);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function formatError(status: number, errorType: string, message: string): Response {
|
|
282
|
+
return new Response(JSON.stringify({ error: { code: status, type: errorType, message } }), {
|
|
283
|
+
status,
|
|
284
|
+
headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
|
|
285
|
+
});
|
|
286
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Api, Model } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
import * as AIError from "../error";
|
|
3
|
+
import { rerankOpenRouter, type RerankOptions } from "./openrouter-rerank";
|
|
4
|
+
import type { RerankRequest, RerankResult } from "./types";
|
|
5
|
+
|
|
6
|
+
export * from "./openrouter-rerank";
|
|
7
|
+
export * from "./types";
|
|
8
|
+
|
|
9
|
+
/** Dispatch reranking through the transport selected by the catalog model. */
|
|
10
|
+
export function rerank(model: Model<Api>, request: RerankRequest, options: RerankOptions): Promise<RerankResult> {
|
|
11
|
+
if (model.api === "openrouter-rerank") return rerankOpenRouter(model, request, options);
|
|
12
|
+
throw new AIError.ConfigurationError(`Unsupported rerank API: ${model.api}`);
|
|
13
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { type } from "@oh-my-pi/omptype";
|
|
2
|
+
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
|
|
3
|
+
import type { Api, FetchImpl, Model, Usage } from "@oh-my-pi/pi-catalog/types";
|
|
4
|
+
import { type ApiKey, withAuth } from "../auth-retry";
|
|
5
|
+
import * as AIError from "../error";
|
|
6
|
+
import type { RerankRequest, RerankResult } from "./types";
|
|
7
|
+
|
|
8
|
+
export interface RerankOptions {
|
|
9
|
+
apiKey: ApiKey;
|
|
10
|
+
fetch?: FetchImpl;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Non-2xx response from an OpenRouter-compatible rerank endpoint. */
|
|
15
|
+
export class RerankApiError extends AIError.ProviderHttpError {
|
|
16
|
+
override readonly name = "RerankApiError";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const upstreamResponseSchema = type({
|
|
20
|
+
model: "string",
|
|
21
|
+
results: type({
|
|
22
|
+
index: "number",
|
|
23
|
+
relevance_score: "number",
|
|
24
|
+
"document?": "unknown",
|
|
25
|
+
}).array(),
|
|
26
|
+
"usage?": "object",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
interface UpstreamUsage {
|
|
30
|
+
total_tokens?: unknown;
|
|
31
|
+
cost?: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function finiteNumber(value: unknown): number | undefined {
|
|
35
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function decodeUsage(model: Model<Api>, raw: unknown): Usage {
|
|
39
|
+
const upstream = raw && typeof raw === "object" ? (raw as UpstreamUsage) : {};
|
|
40
|
+
const totalTokens = finiteNumber(upstream.total_tokens) ?? 0;
|
|
41
|
+
const reportedCost = finiteNumber(upstream.cost);
|
|
42
|
+
const usage: Usage = {
|
|
43
|
+
input: totalTokens,
|
|
44
|
+
output: 0,
|
|
45
|
+
cacheRead: 0,
|
|
46
|
+
cacheWrite: 0,
|
|
47
|
+
totalTokens,
|
|
48
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: reportedCost ?? 0 },
|
|
49
|
+
};
|
|
50
|
+
if (reportedCost === undefined) calculateCost(model, usage);
|
|
51
|
+
return usage;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function documentText(document: unknown): string | undefined {
|
|
55
|
+
if (typeof document === "string") return document;
|
|
56
|
+
if (document === null || typeof document !== "object" || !("text" in document)) return undefined;
|
|
57
|
+
return typeof document.text === "string" ? document.text : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function responseError(response: Response, model: Model<Api>): Promise<RerankApiError> {
|
|
61
|
+
const text = await response.text();
|
|
62
|
+
let detail = text;
|
|
63
|
+
let code: string | undefined;
|
|
64
|
+
try {
|
|
65
|
+
const parsed: unknown = JSON.parse(text);
|
|
66
|
+
if (parsed && typeof parsed === "object" && "error" in parsed) {
|
|
67
|
+
const error = parsed.error;
|
|
68
|
+
if (error && typeof error === "object") {
|
|
69
|
+
const envelope = error as { message?: unknown; code?: unknown; type?: unknown };
|
|
70
|
+
if (typeof envelope.message === "string") detail = envelope.message;
|
|
71
|
+
if (typeof envelope.code === "string" || typeof envelope.code === "number") code = String(envelope.code);
|
|
72
|
+
else if (typeof envelope.type === "string") code = envelope.type;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch {}
|
|
76
|
+
return new RerankApiError(
|
|
77
|
+
`${model.provider}/${model.id} rerank API error (${response.status}): ${detail || response.statusText}`,
|
|
78
|
+
response.status,
|
|
79
|
+
{ headers: response.headers, code },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Call an OpenRouter-compatible rerank endpoint. */
|
|
84
|
+
export async function rerankOpenRouter(
|
|
85
|
+
model: Model<Api>,
|
|
86
|
+
request: RerankRequest,
|
|
87
|
+
options: RerankOptions,
|
|
88
|
+
): Promise<RerankResult> {
|
|
89
|
+
const body = JSON.stringify({
|
|
90
|
+
model: model.id,
|
|
91
|
+
query: request.query,
|
|
92
|
+
documents: request.documents,
|
|
93
|
+
...(request.topN !== undefined && { top_n: request.topN }),
|
|
94
|
+
...(request.returnDocuments !== undefined && { return_documents: request.returnDocuments }),
|
|
95
|
+
});
|
|
96
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
97
|
+
const response = await withAuth(
|
|
98
|
+
options.apiKey,
|
|
99
|
+
async key => {
|
|
100
|
+
const attempt = await fetchImpl(`${model.baseUrl.replace(/\/+$/, "")}/rerank`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: {
|
|
103
|
+
Authorization: `Bearer ${key}`,
|
|
104
|
+
Accept: "application/json",
|
|
105
|
+
"Content-Type": "application/json",
|
|
106
|
+
},
|
|
107
|
+
body,
|
|
108
|
+
signal: options.signal,
|
|
109
|
+
});
|
|
110
|
+
if (!attempt.ok) throw await responseError(attempt, model);
|
|
111
|
+
return attempt;
|
|
112
|
+
},
|
|
113
|
+
{ signal: options.signal },
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const payload: unknown = await response.json();
|
|
117
|
+
const parsed = upstreamResponseSchema(payload);
|
|
118
|
+
if (parsed instanceof type.errors) {
|
|
119
|
+
throw new AIError.ProviderResponseError(
|
|
120
|
+
`${model.provider}/${model.id} rerank response is malformed: ${parsed.summary}`,
|
|
121
|
+
{ provider: model.provider, kind: "envelope" },
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
model: parsed.model,
|
|
126
|
+
results: parsed.results.map(result => {
|
|
127
|
+
const document = documentText(result.document);
|
|
128
|
+
return {
|
|
129
|
+
index: result.index,
|
|
130
|
+
relevanceScore: result.relevance_score,
|
|
131
|
+
...(document !== undefined && { document }),
|
|
132
|
+
};
|
|
133
|
+
}),
|
|
134
|
+
usage: decodeUsage(model, parsed.usage),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Usage } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
|
|
3
|
+
export interface RerankRequest {
|
|
4
|
+
query: string;
|
|
5
|
+
documents: string[];
|
|
6
|
+
topN?: number;
|
|
7
|
+
returnDocuments?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RerankResultItem {
|
|
11
|
+
index: number;
|
|
12
|
+
relevanceScore: number;
|
|
13
|
+
document?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RerankResult {
|
|
17
|
+
results: RerankResultItem[];
|
|
18
|
+
model: string;
|
|
19
|
+
usage: Usage;
|
|
20
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Api, Model } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
import * as AIError from "../error";
|
|
3
|
+
import { synthesizeOpenAiSpeech } from "./openai-speech";
|
|
4
|
+
import type { SpeechOptions, SpeechRequest, SpeechResult } from "./types";
|
|
5
|
+
import { synthesizeXaiSpeech } from "./xai-tts";
|
|
6
|
+
|
|
7
|
+
export * from "./openai-speech";
|
|
8
|
+
export * from "./transport";
|
|
9
|
+
export * from "./types";
|
|
10
|
+
export * from "./xai-tts";
|
|
11
|
+
|
|
12
|
+
/** Catalog APIs {@link synthesizeSpeech} serves; the `tts` tool and the gateway gate on these. */
|
|
13
|
+
export const SPEECH_APIS = ["xai-tts", "openai-speech"] as const;
|
|
14
|
+
export type SpeechApi = (typeof SPEECH_APIS)[number];
|
|
15
|
+
|
|
16
|
+
/** Whether a catalog API synthesizes speech through a cloud transport (local inference is tool-only). */
|
|
17
|
+
export function isSpeechApi(api: Api): api is SpeechApi {
|
|
18
|
+
return api === "xai-tts" || api === "openai-speech";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Synthesize speech through the transport selected by the catalog model's `api`. */
|
|
22
|
+
export function synthesizeSpeech(
|
|
23
|
+
model: Model<Api>,
|
|
24
|
+
request: SpeechRequest,
|
|
25
|
+
options: SpeechOptions,
|
|
26
|
+
): Promise<SpeechResult> {
|
|
27
|
+
switch (model.api) {
|
|
28
|
+
case "xai-tts":
|
|
29
|
+
return synthesizeXaiSpeech(model, request, options);
|
|
30
|
+
case "openai-speech":
|
|
31
|
+
return synthesizeOpenAiSpeech(model, request, options);
|
|
32
|
+
default:
|
|
33
|
+
throw new AIError.ConfigurationError(`Unsupported speech API: ${model.api}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Api, Model } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
import * as AIError from "../error";
|
|
3
|
+
import { postSpeechRequest } from "./transport";
|
|
4
|
+
import type { SpeechOptions, SpeechRequest, SpeechResult } from "./types";
|
|
5
|
+
|
|
6
|
+
export async function synthesizeOpenAiSpeech(
|
|
7
|
+
model: Model<Api>,
|
|
8
|
+
request: SpeechRequest,
|
|
9
|
+
options: SpeechOptions,
|
|
10
|
+
): Promise<SpeechResult> {
|
|
11
|
+
if (request.sampleRate !== undefined) {
|
|
12
|
+
throw new AIError.ValidationError("openai-speech does not support sampleRate");
|
|
13
|
+
}
|
|
14
|
+
if (request.bitRate !== undefined) {
|
|
15
|
+
throw new AIError.ValidationError("openai-speech does not support bitRate");
|
|
16
|
+
}
|
|
17
|
+
const payload: Record<string, unknown> = {
|
|
18
|
+
model: model.id,
|
|
19
|
+
input: request.text,
|
|
20
|
+
response_format: request.format,
|
|
21
|
+
...(request.voice !== undefined ? { voice: request.voice } : {}),
|
|
22
|
+
...(request.speed !== undefined ? { speed: request.speed } : {}),
|
|
23
|
+
...(request.instructions !== undefined ? { instructions: request.instructions } : {}),
|
|
24
|
+
};
|
|
25
|
+
return postSpeechRequest(model, "/audio/speech", payload, request.format, options);
|
|
26
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
|
|
2
|
+
import type { Api, Model, Usage } from "@oh-my-pi/pi-catalog/types";
|
|
3
|
+
import { USER_AGENT } from "@oh-my-pi/pi-utils";
|
|
4
|
+
import { withAuth } from "../auth-retry";
|
|
5
|
+
import * as AIError from "../error";
|
|
6
|
+
import { SPEECH_FORMAT_MIME_TYPES, type SpeechFormat, type SpeechOptions, type SpeechResult } from "./types";
|
|
7
|
+
|
|
8
|
+
const SPEECH_TIMEOUT_MS = 60_000;
|
|
9
|
+
|
|
10
|
+
export class SpeechApiError extends AIError.ProviderHttpError {
|
|
11
|
+
override readonly name = "SpeechApiError";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function postSpeechRequest(
|
|
15
|
+
model: Model<Api>,
|
|
16
|
+
path: string,
|
|
17
|
+
payload: Record<string, unknown>,
|
|
18
|
+
format: SpeechFormat,
|
|
19
|
+
options: SpeechOptions,
|
|
20
|
+
): Promise<SpeechResult> {
|
|
21
|
+
const timeoutSignal = AbortSignal.timeout(SPEECH_TIMEOUT_MS);
|
|
22
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
|
|
23
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
24
|
+
const label = `${model.provider}/${model.id}`;
|
|
25
|
+
const audio = await withAuth(
|
|
26
|
+
options.apiKey,
|
|
27
|
+
async key => {
|
|
28
|
+
const configuredHeaders = model.resolveHeaders ? await model.resolveHeaders(signal) : model.headers;
|
|
29
|
+
const response = await fetchImpl(`${model.baseUrl.replace(/\/+$/, "")}${path}`, {
|
|
30
|
+
method: "POST",
|
|
31
|
+
headers: {
|
|
32
|
+
...configuredHeaders,
|
|
33
|
+
Authorization: `Bearer ${key}`,
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
"User-Agent": USER_AGENT,
|
|
36
|
+
},
|
|
37
|
+
body: JSON.stringify(payload),
|
|
38
|
+
signal,
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok) {
|
|
41
|
+
const detail = await response.text();
|
|
42
|
+
throw new SpeechApiError(
|
|
43
|
+
`${label} speech API failed (${response.status}): ${detail.slice(0, 300)}`,
|
|
44
|
+
response.status,
|
|
45
|
+
{
|
|
46
|
+
headers: response.headers,
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
51
|
+
},
|
|
52
|
+
{ signal },
|
|
53
|
+
);
|
|
54
|
+
// Speech endpoints return only audio bytes. OpenRouter exposes a generation id,
|
|
55
|
+
// but neither it nor the OpenAI/xAI wires report token or billable-unit usage.
|
|
56
|
+
const usage: Usage = {
|
|
57
|
+
input: 0,
|
|
58
|
+
output: 0,
|
|
59
|
+
cacheRead: 0,
|
|
60
|
+
cacheWrite: 0,
|
|
61
|
+
totalTokens: 0,
|
|
62
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
63
|
+
};
|
|
64
|
+
calculateCost(model, usage);
|
|
65
|
+
return { audio, mimeType: SPEECH_FORMAT_MIME_TYPES[format], usage };
|
|
66
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { FetchImpl, Usage } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
import type { ApiKey } from "../auth-retry";
|
|
3
|
+
|
|
4
|
+
export const SPEECH_FORMATS = ["mp3", "wav", "pcm", "opus", "aac", "flac"] as const;
|
|
5
|
+
|
|
6
|
+
export type SpeechFormat = (typeof SPEECH_FORMATS)[number];
|
|
7
|
+
|
|
8
|
+
export const SPEECH_FORMAT_MIME_TYPES: Record<SpeechFormat, string> = {
|
|
9
|
+
mp3: "audio/mpeg",
|
|
10
|
+
wav: "audio/wav",
|
|
11
|
+
pcm: "audio/pcm",
|
|
12
|
+
opus: "audio/opus",
|
|
13
|
+
aac: "audio/aac",
|
|
14
|
+
flac: "audio/flac",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export interface SpeechRequest {
|
|
18
|
+
text: string;
|
|
19
|
+
voice?: string;
|
|
20
|
+
format: SpeechFormat;
|
|
21
|
+
speed?: number;
|
|
22
|
+
sampleRate?: number;
|
|
23
|
+
bitRate?: number;
|
|
24
|
+
instructions?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SpeechResult {
|
|
28
|
+
audio: Uint8Array;
|
|
29
|
+
mimeType: string;
|
|
30
|
+
usage: Usage;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SpeechOptions {
|
|
34
|
+
apiKey: ApiKey;
|
|
35
|
+
fetch?: FetchImpl;
|
|
36
|
+
signal?: AbortSignal;
|
|
37
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Api, Model } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
import * as AIError from "../error";
|
|
3
|
+
import { postSpeechRequest } from "./transport";
|
|
4
|
+
import type { SpeechOptions, SpeechRequest, SpeechResult } from "./types";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_XAI_VOICE_ID = "eve";
|
|
7
|
+
export const DEFAULT_XAI_SAMPLE_RATE = 24_000;
|
|
8
|
+
export const DEFAULT_XAI_BIT_RATE = 128_000;
|
|
9
|
+
export const XAI_MAX_TEXT_LENGTH = 15_000;
|
|
10
|
+
|
|
11
|
+
export async function synthesizeXaiSpeech(
|
|
12
|
+
model: Model<Api>,
|
|
13
|
+
request: SpeechRequest,
|
|
14
|
+
options: SpeechOptions,
|
|
15
|
+
): Promise<SpeechResult> {
|
|
16
|
+
if (request.text.length > XAI_MAX_TEXT_LENGTH) {
|
|
17
|
+
throw new AIError.ValidationError(`xai-tts input exceeds the ${XAI_MAX_TEXT_LENGTH}-character limit`);
|
|
18
|
+
}
|
|
19
|
+
if (request.format !== "mp3" && request.format !== "wav") {
|
|
20
|
+
throw new AIError.ValidationError(`xai-tts does not support ${request.format} output; use mp3 or wav`);
|
|
21
|
+
}
|
|
22
|
+
if (request.speed !== undefined) throw new AIError.ValidationError("xai-tts does not support speed");
|
|
23
|
+
if (request.instructions !== undefined) throw new AIError.ValidationError("xai-tts does not support instructions");
|
|
24
|
+
|
|
25
|
+
const sampleRate = request.sampleRate ?? DEFAULT_XAI_SAMPLE_RATE;
|
|
26
|
+
const bitRate = request.bitRate ?? DEFAULT_XAI_BIT_RATE;
|
|
27
|
+
const payload: Record<string, unknown> = {
|
|
28
|
+
text: request.text,
|
|
29
|
+
voice_id: request.voice ?? DEFAULT_XAI_VOICE_ID,
|
|
30
|
+
};
|
|
31
|
+
const codecOverridden = request.format !== "mp3";
|
|
32
|
+
const sampleRateOverridden = sampleRate !== DEFAULT_XAI_SAMPLE_RATE;
|
|
33
|
+
const bitRateOverridden = request.format === "mp3" && bitRate !== DEFAULT_XAI_BIT_RATE;
|
|
34
|
+
if (codecOverridden || sampleRateOverridden || bitRateOverridden) {
|
|
35
|
+
const outputFormat: Record<string, unknown> = { codec: request.format };
|
|
36
|
+
if (sampleRate > 0) outputFormat.sample_rate = sampleRate;
|
|
37
|
+
if (request.format === "mp3" && bitRate > 0) outputFormat.bit_rate = bitRate;
|
|
38
|
+
payload.output_format = outputFormat;
|
|
39
|
+
}
|
|
40
|
+
return postSpeechRequest(model, "/tts", payload, request.format, options);
|
|
41
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Api, Model } from "@oh-my-pi/pi-catalog/types";
|
|
2
|
+
import * as AIError from "../error";
|
|
3
|
+
import { transcribeOpenAI, type TranscriptionOptions } from "./openai-transcriptions";
|
|
4
|
+
import type { TranscriptionRequest, TranscriptionResult } from "./types";
|
|
5
|
+
|
|
6
|
+
export * from "./openai-transcriptions";
|
|
7
|
+
export * from "./types";
|
|
8
|
+
|
|
9
|
+
/** Dispatch an audio transcription through the transport selected by the catalog model. */
|
|
10
|
+
export function transcribeAudio(
|
|
11
|
+
model: Model<Api>,
|
|
12
|
+
request: TranscriptionRequest,
|
|
13
|
+
options: TranscriptionOptions,
|
|
14
|
+
): Promise<TranscriptionResult> {
|
|
15
|
+
if (model.api === "openai-transcriptions") return transcribeOpenAI(model, request, options);
|
|
16
|
+
throw new AIError.ConfigurationError(`Unsupported transcription API: ${model.api}`);
|
|
17
|
+
}
|