@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,159 @@
|
|
|
1
|
+
import { type } from "@oh-my-pi/omptype";
|
|
2
|
+
import * as AIError from "../error";
|
|
3
|
+
import type { ImageGenerationRequest, ImageGenerationResult } from "../images/types";
|
|
4
|
+
|
|
5
|
+
const imageJsonRequestSchema = type({
|
|
6
|
+
model: "string > 0",
|
|
7
|
+
prompt: "string > 0",
|
|
8
|
+
"n?": "unknown",
|
|
9
|
+
"size?": "unknown",
|
|
10
|
+
"image_size?": "unknown",
|
|
11
|
+
"aspect_ratio?": "unknown",
|
|
12
|
+
"response_format?": "unknown",
|
|
13
|
+
"stream?": "unknown",
|
|
14
|
+
"input_references?": "unknown",
|
|
15
|
+
"images?": "unknown",
|
|
16
|
+
"image?": "unknown",
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export type ImageRequestKind = "generations" | "edits";
|
|
20
|
+
|
|
21
|
+
export interface ImagesParsedRequest {
|
|
22
|
+
modelId: string;
|
|
23
|
+
request: ImageGenerationRequest;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ImagesResponseBody {
|
|
27
|
+
created: number;
|
|
28
|
+
data: Array<{ b64_json: string; revised_prompt?: string }>;
|
|
29
|
+
usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number; cost: number };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validation(message: string): never {
|
|
33
|
+
throw new AIError.ValidationError(`images: ${message}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function optionalString(value: unknown, field: string): string | undefined {
|
|
37
|
+
if (value === undefined || value === null) return undefined;
|
|
38
|
+
if (typeof value !== "string" || value.length === 0) validation(`${field} must be a non-empty string`);
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function optionalCount(value: unknown): number | undefined {
|
|
43
|
+
if (value === undefined || value === null) return undefined;
|
|
44
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) validation("n must be a positive integer");
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function decodeInputReference(value: unknown): { data: string; mimeType: string } {
|
|
49
|
+
let raw: unknown = value;
|
|
50
|
+
if (value !== null && typeof value === "object") {
|
|
51
|
+
raw = "url" in value ? value.url : undefined;
|
|
52
|
+
const imageUrl = "image_url" in value ? value.image_url : undefined;
|
|
53
|
+
if (raw === undefined && imageUrl !== null && typeof imageUrl === "object" && "url" in imageUrl) {
|
|
54
|
+
raw = imageUrl.url;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (typeof raw !== "string" || raw.length === 0) validation("input image must be base64 or a data URL");
|
|
58
|
+
const match = raw.match(/^data:([^;]+);base64,(.+)$/s);
|
|
59
|
+
if (match) return { data: match[2] ?? "", mimeType: match[1] ?? "image/png" };
|
|
60
|
+
if (/^[A-Za-z0-9+/]+={0,2}$/.test(raw)) return { data: raw, mimeType: "image/png" };
|
|
61
|
+
validation("input image URLs are not supported; send a data URL or base64 bytes");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseJson(body: unknown, kind: ImageRequestKind): ImagesParsedRequest {
|
|
65
|
+
const parsed = imageJsonRequestSchema(body);
|
|
66
|
+
if (parsed instanceof type.errors) validation(parsed.summary);
|
|
67
|
+
if (parsed.stream === true) validation("streaming image responses are not supported");
|
|
68
|
+
if (parsed.response_format !== undefined && parsed.response_format !== "b64_json") {
|
|
69
|
+
validation('response_format must be "b64_json"; URL responses are not supported');
|
|
70
|
+
}
|
|
71
|
+
const input = parsed.input_references ?? parsed.images ?? parsed.image;
|
|
72
|
+
const values = input === undefined ? [] : Array.isArray(input) ? input : [input];
|
|
73
|
+
if (kind === "edits" && values.length === 0) validation("image edit requires at least one input image");
|
|
74
|
+
const aspectRatio = optionalString(parsed.aspect_ratio, "aspect_ratio");
|
|
75
|
+
const imageSize = optionalString(
|
|
76
|
+
parsed.image_size ?? parsed.size,
|
|
77
|
+
parsed.image_size === undefined ? "size" : "image_size",
|
|
78
|
+
);
|
|
79
|
+
const count = optionalCount(parsed.n);
|
|
80
|
+
return {
|
|
81
|
+
modelId: parsed.model,
|
|
82
|
+
request: {
|
|
83
|
+
prompt: parsed.prompt,
|
|
84
|
+
...(values.length > 0 ? { inputImages: values.map(decodeInputReference) } : {}),
|
|
85
|
+
...(aspectRatio ? { aspectRatio } : {}),
|
|
86
|
+
...(imageSize ? { imageSize } : {}),
|
|
87
|
+
...(count ? { count } : {}),
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function parseMultipart(form: FormData): Promise<ImagesParsedRequest> {
|
|
93
|
+
const model = form.get("model");
|
|
94
|
+
const prompt = form.get("prompt");
|
|
95
|
+
if (typeof model !== "string" || model.length === 0) validation("model must be a non-empty string");
|
|
96
|
+
if (typeof prompt !== "string" || prompt.length === 0) validation("prompt must be a non-empty string");
|
|
97
|
+
const responseFormat = form.get("response_format");
|
|
98
|
+
if (responseFormat !== null && responseFormat !== "b64_json") {
|
|
99
|
+
validation('response_format must be "b64_json"; URL responses are not supported');
|
|
100
|
+
}
|
|
101
|
+
const stream = form.get("stream");
|
|
102
|
+
if (stream === "true") validation("streaming image responses are not supported");
|
|
103
|
+
const entries = [...form.getAll("image"), ...form.getAll("image[]")];
|
|
104
|
+
if (entries.length === 0) validation("image edit requires at least one image file");
|
|
105
|
+
const inputImages: Array<{ data: string; mimeType: string }> = [];
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
if (typeof entry === "string") validation("image must be a file part");
|
|
108
|
+
const bytes = new Uint8Array(await entry.arrayBuffer());
|
|
109
|
+
inputImages.push({ data: bytes.toBase64(), mimeType: entry.type || "image/png" });
|
|
110
|
+
}
|
|
111
|
+
const nValue = form.get("n");
|
|
112
|
+
let count: number | undefined;
|
|
113
|
+
if (nValue !== null) {
|
|
114
|
+
if (typeof nValue !== "string" || !/^\d+$/.test(nValue)) validation("n must be a positive integer");
|
|
115
|
+
count = optionalCount(Number(nValue));
|
|
116
|
+
}
|
|
117
|
+
const size = form.get("size");
|
|
118
|
+
if (size !== null && typeof size !== "string") validation("size must be a string");
|
|
119
|
+
return {
|
|
120
|
+
modelId: model,
|
|
121
|
+
request: {
|
|
122
|
+
prompt,
|
|
123
|
+
inputImages,
|
|
124
|
+
...(size ? { imageSize: size } : {}),
|
|
125
|
+
...(count ? { count } : {}),
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function parseRequest(body: unknown | FormData, kind: ImageRequestKind): Promise<ImagesParsedRequest> {
|
|
131
|
+
if (body instanceof FormData) {
|
|
132
|
+
if (kind !== "edits") validation("multipart requests are supported only for image edits");
|
|
133
|
+
return parseMultipart(body);
|
|
134
|
+
}
|
|
135
|
+
return parseJson(body, kind);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function encodeResponse(result: ImageGenerationResult, _requestedModelId: string): ImagesResponseBody {
|
|
139
|
+
return {
|
|
140
|
+
created: Math.floor(Date.now() / 1000),
|
|
141
|
+
data: result.images.map(image => ({ b64_json: image.data })),
|
|
142
|
+
usage: {
|
|
143
|
+
prompt_tokens: result.usage.input,
|
|
144
|
+
completion_tokens: result.usage.output,
|
|
145
|
+
total_tokens: result.usage.totalTokens,
|
|
146
|
+
cost: result.usage.cost.total,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function formatError(status: number, errorType: string, message: string): Response {
|
|
152
|
+
return new Response(JSON.stringify({ error: { code: status, type: errorType, message } }), {
|
|
153
|
+
status,
|
|
154
|
+
headers: {
|
|
155
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
156
|
+
"Cache-Control": "no-store",
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { type } from "@oh-my-pi/omptype";
|
|
2
|
+
import * as AIError from "../error";
|
|
3
|
+
import type { RerankRequest, RerankResult } from "../rerank/types";
|
|
4
|
+
|
|
5
|
+
export const MAX_RERANK_BODY_BYTES = 8 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
const rerankRequestSchema = type({
|
|
8
|
+
model: "string > 0",
|
|
9
|
+
query: "string",
|
|
10
|
+
documents: "unknown[]",
|
|
11
|
+
"top_n?": "unknown",
|
|
12
|
+
"return_documents?": "unknown",
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export interface RerankObjectDocument {
|
|
16
|
+
text: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type RerankWireDocument = string | RerankObjectDocument;
|
|
20
|
+
|
|
21
|
+
export interface RerankParsedRequest {
|
|
22
|
+
modelId: string;
|
|
23
|
+
request: RerankRequest;
|
|
24
|
+
originalDocuments: RerankWireDocument[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class RerankWireError extends AIError.ValidationError {
|
|
28
|
+
readonly status: number;
|
|
29
|
+
|
|
30
|
+
constructor(status: number, message: string, options?: { cause?: unknown }) {
|
|
31
|
+
super(message, options);
|
|
32
|
+
this.name = "RerankWireError";
|
|
33
|
+
this.status = status;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validation(message: string): never {
|
|
38
|
+
throw new RerankWireError(400, `rerank: ${message}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseTopN(value: unknown): number | undefined {
|
|
42
|
+
if (value === undefined || value === null) return undefined;
|
|
43
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
44
|
+
validation("top_n must be a positive integer");
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseReturnDocuments(value: unknown): boolean | undefined {
|
|
50
|
+
if (value === undefined || value === null) return undefined;
|
|
51
|
+
if (typeof value !== "boolean") validation("return_documents must be a boolean");
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseDocument(value: unknown, index: number): RerankWireDocument {
|
|
56
|
+
if (typeof value === "string") return value;
|
|
57
|
+
if (value === null || typeof value !== "object" || !("text" in value) || typeof value.text !== "string") {
|
|
58
|
+
validation(`documents[${index}] must be a string or an object with a text string`);
|
|
59
|
+
}
|
|
60
|
+
return { text: value.text };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readBody(req: Request): Promise<Uint8Array> {
|
|
64
|
+
const contentLength = req.headers.get("content-length");
|
|
65
|
+
if (contentLength !== null) {
|
|
66
|
+
const declared = Number(contentLength);
|
|
67
|
+
if (Number.isFinite(declared) && declared > MAX_RERANK_BODY_BYTES) {
|
|
68
|
+
throw new RerankWireError(413, "Request payload exceeds the 8 MiB limit");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const reader = req.body?.getReader();
|
|
72
|
+
if (!reader) return new Uint8Array();
|
|
73
|
+
const chunks: Uint8Array[] = [];
|
|
74
|
+
let total = 0;
|
|
75
|
+
for (;;) {
|
|
76
|
+
const { done, value } = await reader.read();
|
|
77
|
+
if (done) break;
|
|
78
|
+
total += value.byteLength;
|
|
79
|
+
if (total > MAX_RERANK_BODY_BYTES) {
|
|
80
|
+
await reader.cancel();
|
|
81
|
+
throw new RerankWireError(413, "Request payload exceeds the 8 MiB limit");
|
|
82
|
+
}
|
|
83
|
+
chunks.push(value);
|
|
84
|
+
}
|
|
85
|
+
if (chunks.length === 1) return chunks[0]!;
|
|
86
|
+
const bytes = new Uint8Array(total);
|
|
87
|
+
let offset = 0;
|
|
88
|
+
for (const chunk of chunks) {
|
|
89
|
+
bytes.set(chunk, offset);
|
|
90
|
+
offset += chunk.byteLength;
|
|
91
|
+
}
|
|
92
|
+
return bytes;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Parse an OpenRouter-compatible JSON rerank request. */
|
|
96
|
+
export async function parseRequest(req: Request): Promise<RerankParsedRequest> {
|
|
97
|
+
const contentType = req.headers.get("content-type")?.toLowerCase() ?? "";
|
|
98
|
+
if (!contentType.startsWith("application/json")) {
|
|
99
|
+
throw new RerankWireError(400, "Content-Type must be application/json");
|
|
100
|
+
}
|
|
101
|
+
const bytes = await readBody(req);
|
|
102
|
+
let body: unknown;
|
|
103
|
+
try {
|
|
104
|
+
body = JSON.parse(new TextDecoder().decode(bytes));
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw new RerankWireError(400, "Invalid JSON body", { cause: error });
|
|
107
|
+
}
|
|
108
|
+
const parsed = rerankRequestSchema(body);
|
|
109
|
+
if (parsed instanceof type.errors) validation(parsed.summary);
|
|
110
|
+
if (parsed.documents.length === 0) validation("documents must contain at least one document");
|
|
111
|
+
const originalDocuments = parsed.documents.map(parseDocument);
|
|
112
|
+
const topN = parseTopN(parsed.top_n);
|
|
113
|
+
const returnDocuments = parseReturnDocuments(parsed.return_documents);
|
|
114
|
+
return {
|
|
115
|
+
modelId: parsed.model,
|
|
116
|
+
request: {
|
|
117
|
+
query: parsed.query,
|
|
118
|
+
documents: originalDocuments.map(document => (typeof document === "string" ? document : document.text)),
|
|
119
|
+
...(topN !== undefined && { topN }),
|
|
120
|
+
...(returnDocuments !== undefined && { returnDocuments }),
|
|
121
|
+
},
|
|
122
|
+
originalDocuments,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface RerankResponseBody {
|
|
127
|
+
model: string;
|
|
128
|
+
results: Array<{
|
|
129
|
+
index: number;
|
|
130
|
+
relevance_score: number;
|
|
131
|
+
document?: RerankObjectDocument;
|
|
132
|
+
}>;
|
|
133
|
+
usage: { total_tokens: number; cost: number };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Encode a canonical result as the OpenRouter rerank response shape. */
|
|
137
|
+
export function encodeResponse(
|
|
138
|
+
result: RerankResult,
|
|
139
|
+
requestedModelId: string,
|
|
140
|
+
originalDocuments: readonly RerankWireDocument[],
|
|
141
|
+
returnDocuments: boolean,
|
|
142
|
+
): RerankResponseBody {
|
|
143
|
+
return {
|
|
144
|
+
model: requestedModelId,
|
|
145
|
+
results: result.results.map(item => {
|
|
146
|
+
const original = originalDocuments[item.index];
|
|
147
|
+
const document =
|
|
148
|
+
typeof original === "string"
|
|
149
|
+
? { text: original }
|
|
150
|
+
: (original ?? (item.document ? { text: item.document } : undefined));
|
|
151
|
+
return {
|
|
152
|
+
index: item.index,
|
|
153
|
+
relevance_score: item.relevanceScore,
|
|
154
|
+
...(returnDocuments && document !== undefined && { document }),
|
|
155
|
+
};
|
|
156
|
+
}),
|
|
157
|
+
usage: { total_tokens: result.usage.totalTokens, cost: result.usage.cost.total },
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function formatError(status: number, errorType: string, message: string): Response {
|
|
162
|
+
return new Response(JSON.stringify({ error: { code: status, type: errorType, message } }), {
|
|
163
|
+
status,
|
|
164
|
+
headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type } from "@oh-my-pi/omptype";
|
|
2
|
+
import * as AIError from "../error";
|
|
3
|
+
import type { SpeechRequest, SpeechResult } from "../speech/types";
|
|
4
|
+
|
|
5
|
+
const speechRequestSchema = type({
|
|
6
|
+
model: "string > 0",
|
|
7
|
+
input: "string > 0",
|
|
8
|
+
"voice?": "string > 0",
|
|
9
|
+
"response_format?": "'mp3' | 'wav' | 'pcm' | 'opus' | 'aac' | 'flac'",
|
|
10
|
+
"speed?": "number",
|
|
11
|
+
"instructions?": "string",
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export interface SpeechParsedRequest {
|
|
15
|
+
modelId: string;
|
|
16
|
+
request: SpeechRequest;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function parseRequest(body: unknown, _headers?: Headers): SpeechParsedRequest {
|
|
20
|
+
const parsed = speechRequestSchema(body);
|
|
21
|
+
if (parsed instanceof type.errors) throw new AIError.ValidationError(`speech: ${parsed.summary}`);
|
|
22
|
+
return {
|
|
23
|
+
modelId: parsed.model,
|
|
24
|
+
request: {
|
|
25
|
+
text: parsed.input,
|
|
26
|
+
format: parsed.response_format ?? "mp3",
|
|
27
|
+
...(parsed.voice !== undefined ? { voice: parsed.voice } : {}),
|
|
28
|
+
...(parsed.speed !== undefined ? { speed: parsed.speed } : {}),
|
|
29
|
+
...(parsed.instructions !== undefined ? { instructions: parsed.instructions } : {}),
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function encodeResponse(result: SpeechResult, _requestedModelId: string): Response {
|
|
35
|
+
return new Response(result.audio, {
|
|
36
|
+
status: 200,
|
|
37
|
+
headers: {
|
|
38
|
+
"Content-Type": result.mimeType,
|
|
39
|
+
"Cache-Control": "no-store",
|
|
40
|
+
"X-Content-Type-Options": "nosniff",
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatError(status: number, errorType: string, message: string): Response {
|
|
46
|
+
return new Response(JSON.stringify({ error: { code: status, type: errorType, message } }), {
|
|
47
|
+
status,
|
|
48
|
+
headers: {
|
|
49
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
50
|
+
"Cache-Control": "no-store",
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server side of TypeSafe's System One wire format (`POST /v1/systemone`),
|
|
3
|
+
* served by the auth-gateway so TypeSafe SDKs and omp's own `TypeSafeJudge`
|
|
4
|
+
* can point `TYPESAFE_BASE_URL` at the gateway and never hold the real key.
|
|
5
|
+
*
|
|
6
|
+
* Requests are validated only as far as routing needs — the `model` id and
|
|
7
|
+
* the shape of the `questions` map — and otherwise forwarded verbatim, so
|
|
8
|
+
* structured `instructions`/`criteria` and forward-compatible fields reach
|
|
9
|
+
* the upstream untouched. Validation failures answer `422` like TypeSafe.
|
|
10
|
+
*/
|
|
11
|
+
import { type } from "@oh-my-pi/omptype";
|
|
12
|
+
import * as AIError from "../error";
|
|
13
|
+
import type { JudgmentRequest, JudgmentResult } from "../judgment/types";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* TypeSafe accepts `instructions` and `criteria` as strings or structured
|
|
17
|
+
* JSON (https://docs.typesafe.ai/api); only `type` decides how an answer is
|
|
18
|
+
* read back, so that is all the gateway pins down.
|
|
19
|
+
*/
|
|
20
|
+
const systemOneRequestSchema = type({
|
|
21
|
+
model: "string > 0",
|
|
22
|
+
state: "string | object",
|
|
23
|
+
questions: type({ "[string]": { type: "'noul' | 'choice' | 'score'", instructions: "string | object" } }),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export interface SystemOneParsedRequest {
|
|
27
|
+
modelId: string;
|
|
28
|
+
request: JudgmentRequest;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `POST /v1/systemone` response body; `cost` (USD) appears only when the route billed one. */
|
|
32
|
+
export interface SystemOneResponseBody {
|
|
33
|
+
model: string;
|
|
34
|
+
answers: JudgmentResult["answers"];
|
|
35
|
+
usage: { input_tokens: number; output_tokens: number; cost?: number };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Validate the routing-relevant parts of a System One request body.
|
|
40
|
+
*
|
|
41
|
+
* @throws {AIError.ValidationError} naming the offending field when `model`,
|
|
42
|
+
* `state`, or `questions` is missing or malformed.
|
|
43
|
+
*/
|
|
44
|
+
export function parseRequest(body: unknown): SystemOneParsedRequest {
|
|
45
|
+
const parsed = systemOneRequestSchema(body);
|
|
46
|
+
if (parsed instanceof type.errors) throw new AIError.ValidationError(`systemone: ${parsed.summary}`);
|
|
47
|
+
// `req.json()` output is JSON by construction, and the typed `Questions`
|
|
48
|
+
// contract models string instructions only; the judge forwards the request
|
|
49
|
+
// verbatim and reads back nothing but each question's `type`.
|
|
50
|
+
const request = { state: parsed.state, questions: parsed.questions } as JudgmentRequest;
|
|
51
|
+
return { modelId: parsed.model, request };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Re-encode a judgment result in TypeSafe's response shape. */
|
|
55
|
+
export function encodeResponse(result: JudgmentResult): SystemOneResponseBody {
|
|
56
|
+
const usage: SystemOneResponseBody["usage"] = {
|
|
57
|
+
input_tokens: result.usage.input,
|
|
58
|
+
output_tokens: result.usage.output,
|
|
59
|
+
};
|
|
60
|
+
if (result.usage.cost.total > 0) usage.cost = result.usage.cost.total;
|
|
61
|
+
return { model: result.model, answers: result.answers, usage };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** TypeSafe answers errors with a status code and a JSON body; the envelope mirrors the other gateway routes. */
|
|
65
|
+
export function formatError(status: number, type: string, message: string): Response {
|
|
66
|
+
return new Response(JSON.stringify({ error: { type, message } }), {
|
|
67
|
+
status,
|
|
68
|
+
headers: {
|
|
69
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
70
|
+
"Cache-Control": "no-store",
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { type } from "@oh-my-pi/omptype";
|
|
2
|
+
import * as AIError from "../error";
|
|
3
|
+
import type {
|
|
4
|
+
TranscriptionRequest,
|
|
5
|
+
TranscriptionResponseFormat,
|
|
6
|
+
TranscriptionResult,
|
|
7
|
+
TranscriptionTimestampGranularity,
|
|
8
|
+
} from "../transcription/types";
|
|
9
|
+
|
|
10
|
+
export const MAX_TRANSCRIPTION_BODY_BYTES = 25 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
const jsonRequestSchema = type({
|
|
13
|
+
model: "string > 0",
|
|
14
|
+
input_audio: { data: "string > 0", format: "string > 0" },
|
|
15
|
+
"language?": "string",
|
|
16
|
+
"prompt?": "string",
|
|
17
|
+
"temperature?": "number",
|
|
18
|
+
"response_format?": "string",
|
|
19
|
+
"timestamp_granularities?": "string[]",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const MIME_TYPES: Readonly<Record<string, string>> = {
|
|
23
|
+
wav: "audio/wav",
|
|
24
|
+
wave: "audio/wav",
|
|
25
|
+
mp3: "audio/mpeg",
|
|
26
|
+
mpeg: "audio/mpeg",
|
|
27
|
+
flac: "audio/flac",
|
|
28
|
+
m4a: "audio/mp4",
|
|
29
|
+
mp4: "audio/mp4",
|
|
30
|
+
ogg: "audio/ogg",
|
|
31
|
+
opus: "audio/ogg",
|
|
32
|
+
webm: "audio/webm",
|
|
33
|
+
aac: "audio/aac",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export class TranscriptionWireError extends AIError.ValidationError {
|
|
37
|
+
readonly status: number;
|
|
38
|
+
|
|
39
|
+
constructor(status: number, message: string, options?: { cause?: unknown }) {
|
|
40
|
+
super(message, options);
|
|
41
|
+
this.name = "TranscriptionWireError";
|
|
42
|
+
this.status = status;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface TranscriptionParsedRequest {
|
|
47
|
+
modelId: string;
|
|
48
|
+
request: TranscriptionRequest;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function responseFormat(value: string | undefined): TranscriptionResponseFormat {
|
|
52
|
+
const resolved = value ?? "json";
|
|
53
|
+
if (resolved !== "json" && resolved !== "verbose_json") {
|
|
54
|
+
throw new TranscriptionWireError(400, `Unsupported response_format: ${resolved}`);
|
|
55
|
+
}
|
|
56
|
+
return resolved;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function timestampGranularities(values: readonly string[]): TranscriptionTimestampGranularity[] | undefined {
|
|
60
|
+
if (values.length === 0) return undefined;
|
|
61
|
+
const granularities: TranscriptionTimestampGranularity[] = [];
|
|
62
|
+
for (const value of values) {
|
|
63
|
+
if (value !== "word" && value !== "segment") {
|
|
64
|
+
throw new TranscriptionWireError(400, `Unsupported timestamp granularity: ${value}`);
|
|
65
|
+
}
|
|
66
|
+
if (!granularities.includes(value)) granularities.push(value);
|
|
67
|
+
}
|
|
68
|
+
return granularities;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function mimeTypeFor(format: string): string {
|
|
72
|
+
return MIME_TYPES[format.trim().toLowerCase().replace(/^\./, "")] ?? "application/octet-stream";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function mimeTypeForFile(file: File): string {
|
|
76
|
+
const partType = file.type.trim();
|
|
77
|
+
if (partType && partType !== "application/octet-stream") return partType;
|
|
78
|
+
const dot = file.name.lastIndexOf(".");
|
|
79
|
+
return dot >= 0 ? mimeTypeFor(file.name.slice(dot + 1)) : "application/octet-stream";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function stringField(form: { get(name: string): unknown }, name: string): string | undefined {
|
|
83
|
+
const value = form.get(name);
|
|
84
|
+
if (value === null) return undefined;
|
|
85
|
+
if (typeof value !== "string") throw new TranscriptionWireError(400, `${name} must be a string`);
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseTemperature(value: string | undefined): number | undefined {
|
|
90
|
+
if (value === undefined) return undefined;
|
|
91
|
+
const parsed = Number(value);
|
|
92
|
+
if (!Number.isFinite(parsed)) throw new TranscriptionWireError(400, "temperature must be a finite number");
|
|
93
|
+
return parsed;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function readBody(req: Request): Promise<Uint8Array> {
|
|
97
|
+
const contentLength = req.headers.get("content-length");
|
|
98
|
+
if (contentLength !== null) {
|
|
99
|
+
const declared = Number(contentLength);
|
|
100
|
+
if (Number.isFinite(declared) && declared > MAX_TRANSCRIPTION_BODY_BYTES) {
|
|
101
|
+
throw new TranscriptionWireError(413, "Request payload exceeds the 25 MB limit");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const reader = req.body?.getReader();
|
|
105
|
+
if (!reader) return new Uint8Array();
|
|
106
|
+
const chunks: Uint8Array[] = [];
|
|
107
|
+
let total = 0;
|
|
108
|
+
for (;;) {
|
|
109
|
+
const { done, value } = await reader.read();
|
|
110
|
+
if (done) break;
|
|
111
|
+
total += value.byteLength;
|
|
112
|
+
if (total > MAX_TRANSCRIPTION_BODY_BYTES) {
|
|
113
|
+
await reader.cancel();
|
|
114
|
+
throw new TranscriptionWireError(413, "Request payload exceeds the 25 MB limit");
|
|
115
|
+
}
|
|
116
|
+
chunks.push(value);
|
|
117
|
+
}
|
|
118
|
+
if (chunks.length === 1) return chunks[0]!;
|
|
119
|
+
const bytes = new Uint8Array(total);
|
|
120
|
+
let offset = 0;
|
|
121
|
+
for (const chunk of chunks) {
|
|
122
|
+
bytes.set(chunk, offset);
|
|
123
|
+
offset += chunk.byteLength;
|
|
124
|
+
}
|
|
125
|
+
return bytes;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function parseMultipart(bytes: Uint8Array, contentType: string): Promise<TranscriptionParsedRequest> {
|
|
129
|
+
const form = await new Response(bytes, { headers: { "Content-Type": contentType } }).formData().catch(error => {
|
|
130
|
+
throw new TranscriptionWireError(400, "Invalid multipart/form-data body", { cause: error });
|
|
131
|
+
});
|
|
132
|
+
const file = form.get("file");
|
|
133
|
+
if (!(file instanceof File)) throw new TranscriptionWireError(400, "file must be an audio upload");
|
|
134
|
+
const modelId = stringField(form, "model")?.trim();
|
|
135
|
+
if (!modelId) throw new TranscriptionWireError(400, "model must be a non-empty string");
|
|
136
|
+
const responseFormatValue = responseFormat(stringField(form, "response_format"));
|
|
137
|
+
const granularities = timestampGranularities(
|
|
138
|
+
form
|
|
139
|
+
.getAll("timestamp_granularities[]")
|
|
140
|
+
.concat(form.getAll("timestamp_granularities"))
|
|
141
|
+
.map(value => {
|
|
142
|
+
if (typeof value !== "string") {
|
|
143
|
+
throw new TranscriptionWireError(400, "timestamp_granularities[] must contain strings");
|
|
144
|
+
}
|
|
145
|
+
return value;
|
|
146
|
+
}),
|
|
147
|
+
);
|
|
148
|
+
return {
|
|
149
|
+
modelId,
|
|
150
|
+
request: {
|
|
151
|
+
audio: new Uint8Array(await file.arrayBuffer()),
|
|
152
|
+
mimeType: mimeTypeForFile(file),
|
|
153
|
+
fileName: file.name || undefined,
|
|
154
|
+
language: stringField(form, "language"),
|
|
155
|
+
prompt: stringField(form, "prompt"),
|
|
156
|
+
temperature: parseTemperature(stringField(form, "temperature")),
|
|
157
|
+
responseFormat: responseFormatValue,
|
|
158
|
+
timestampGranularities: granularities,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function decodeBase64(data: string): Uint8Array {
|
|
164
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(data) || data.length % 4 === 1) {
|
|
165
|
+
throw new TranscriptionWireError(400, "input_audio.data must be valid base64");
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
return Uint8Array.fromBase64(data);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
throw new TranscriptionWireError(400, "input_audio.data must be valid base64", { cause: error });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseJson(bytes: Uint8Array): TranscriptionParsedRequest {
|
|
175
|
+
let body: unknown;
|
|
176
|
+
try {
|
|
177
|
+
body = JSON.parse(new TextDecoder().decode(bytes));
|
|
178
|
+
} catch (error) {
|
|
179
|
+
throw new TranscriptionWireError(400, "Invalid JSON body", { cause: error });
|
|
180
|
+
}
|
|
181
|
+
const parsed = jsonRequestSchema(body);
|
|
182
|
+
if (parsed instanceof type.errors) throw new TranscriptionWireError(400, `transcriptions: ${parsed.summary}`);
|
|
183
|
+
const audio = decodeBase64(parsed.input_audio.data);
|
|
184
|
+
if (audio.byteLength === 0) throw new TranscriptionWireError(400, "input_audio.data must not be empty");
|
|
185
|
+
const format = parsed.input_audio.format.trim().toLowerCase().replace(/^\./, "");
|
|
186
|
+
if (!format) throw new TranscriptionWireError(400, "input_audio.format must not be empty");
|
|
187
|
+
return {
|
|
188
|
+
modelId: parsed.model,
|
|
189
|
+
request: {
|
|
190
|
+
audio,
|
|
191
|
+
mimeType: mimeTypeFor(format),
|
|
192
|
+
fileName: `audio.${format}`,
|
|
193
|
+
language: parsed.language,
|
|
194
|
+
prompt: parsed.prompt,
|
|
195
|
+
temperature: parsed.temperature,
|
|
196
|
+
responseFormat: responseFormat(parsed.response_format),
|
|
197
|
+
timestampGranularities: timestampGranularities(parsed.timestamp_granularities ?? []),
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Parse either OpenAI multipart uploads or OpenRouter base64 JSON requests. */
|
|
203
|
+
export async function parseRequest(req: Request): Promise<TranscriptionParsedRequest> {
|
|
204
|
+
const contentType = req.headers.get("content-type")?.trim() ?? "";
|
|
205
|
+
const bytes = await readBody(req);
|
|
206
|
+
if (contentType.toLowerCase().startsWith("multipart/form-data")) return parseMultipart(bytes, contentType);
|
|
207
|
+
if (contentType.toLowerCase().startsWith("application/json")) return parseJson(bytes);
|
|
208
|
+
throw new TranscriptionWireError(400, "Content-Type must be multipart/form-data or application/json");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface TranscriptionResponseBody {
|
|
212
|
+
text: string;
|
|
213
|
+
language?: string;
|
|
214
|
+
duration?: number;
|
|
215
|
+
segments?: TranscriptionResult["segments"];
|
|
216
|
+
words?: TranscriptionResult["words"];
|
|
217
|
+
usage: { input_tokens: number; output_tokens: number; total_tokens: number; cost: number; seconds?: number };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Encode a canonical result as the shared OpenAI/OpenRouter transcription response. */
|
|
221
|
+
export function encodeResponse(result: TranscriptionResult): TranscriptionResponseBody {
|
|
222
|
+
return {
|
|
223
|
+
text: result.text,
|
|
224
|
+
...(result.language !== undefined && { language: result.language }),
|
|
225
|
+
...(result.duration !== undefined && { duration: result.duration }),
|
|
226
|
+
...(result.segments !== undefined && { segments: result.segments }),
|
|
227
|
+
...(result.words !== undefined && { words: result.words }),
|
|
228
|
+
usage: {
|
|
229
|
+
input_tokens: result.usage.input,
|
|
230
|
+
output_tokens: result.usage.output,
|
|
231
|
+
total_tokens: result.usage.totalTokens,
|
|
232
|
+
cost: result.usage.cost.total,
|
|
233
|
+
...(result.seconds !== undefined && { seconds: result.seconds }),
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function formatError(status: number, errorType: string, message: string): Response {
|
|
239
|
+
return new Response(JSON.stringify({ error: { code: status, type: errorType, message } }), {
|
|
240
|
+
status,
|
|
241
|
+
headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
|
|
242
|
+
});
|
|
243
|
+
}
|