@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.
Files changed (93) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/types/auth-gateway/dispatch.d.ts +80 -0
  3. package/dist/types/auth-gateway/http.d.ts +5 -6
  4. package/dist/types/auth-gateway/index.d.ts +1 -0
  5. package/dist/types/auth-gateway/routes/embeddings.d.ts +3 -0
  6. package/dist/types/auth-gateway/routes/images.d.ts +3 -0
  7. package/dist/types/auth-gateway/routes/rerank.d.ts +3 -0
  8. package/dist/types/auth-gateway/routes/speech.d.ts +2 -0
  9. package/dist/types/auth-gateway/routes/systemone.d.ts +2 -0
  10. package/dist/types/auth-gateway/routes/transcriptions.d.ts +3 -0
  11. package/dist/types/auth-gateway/routes/video.d.ts +7 -0
  12. package/dist/types/auth-gateway/server.d.ts +10 -16
  13. package/dist/types/embeddings/index.d.ts +7 -0
  14. package/dist/types/embeddings/openai-embeddings.d.ts +15 -0
  15. package/dist/types/embeddings/types.d.ts +15 -0
  16. package/dist/types/images/google-antigravity.d.ts +9 -0
  17. package/dist/types/images/google-generative-ai.d.ts +3 -0
  18. package/dist/types/images/index.d.ts +14 -0
  19. package/dist/types/images/openai-hosted.d.ts +3 -0
  20. package/dist/types/images/openai-images.d.ts +5 -0
  21. package/dist/types/images/openrouter-images.d.ts +3 -0
  22. package/dist/types/images/shared.d.ts +34 -0
  23. package/dist/types/images/types.d.ts +31 -0
  24. package/dist/types/index.d.ts +7 -1
  25. package/dist/types/judgment/typesafe.d.ts +2 -0
  26. package/dist/types/providers/embeddings-server.d.ts +32 -0
  27. package/dist/types/providers/images-server.d.ts +22 -0
  28. package/dist/types/providers/rerank-server.d.ts +35 -0
  29. package/dist/types/providers/speech-server.d.ts +8 -0
  30. package/dist/types/providers/systemone-server.d.ts +26 -0
  31. package/dist/types/providers/transcriptions-server.d.ts +32 -0
  32. package/dist/types/providers/video-server.d.ts +39 -0
  33. package/dist/types/rerank/index.d.ts +7 -0
  34. package/dist/types/rerank/openrouter-rerank.d.ts +15 -0
  35. package/dist/types/rerank/types.d.ts +17 -0
  36. package/dist/types/speech/index.d.ts +13 -0
  37. package/dist/types/speech/openai-speech.d.ts +3 -0
  38. package/dist/types/speech/transport.d.ts +7 -0
  39. package/dist/types/speech/types.d.ts +24 -0
  40. package/dist/types/speech/xai-tts.d.ts +7 -0
  41. package/dist/types/transcription/index.d.ts +7 -0
  42. package/dist/types/transcription/openai-transcriptions.d.ts +15 -0
  43. package/dist/types/transcription/types.d.ts +41 -0
  44. package/dist/types/video/index.d.ts +11 -0
  45. package/dist/types/video/openrouter-video.d.ts +19 -0
  46. package/dist/types/video/types.d.ts +62 -0
  47. package/package.json +30 -6
  48. package/src/auth-gateway/dispatch.ts +273 -0
  49. package/src/auth-gateway/http.ts +6 -7
  50. package/src/auth-gateway/index.ts +1 -0
  51. package/src/auth-gateway/routes/embeddings.ts +98 -0
  52. package/src/auth-gateway/routes/images.ts +131 -0
  53. package/src/auth-gateway/routes/rerank.ts +87 -0
  54. package/src/auth-gateway/routes/speech.ts +101 -0
  55. package/src/auth-gateway/routes/systemone.ts +116 -0
  56. package/src/auth-gateway/routes/transcriptions.ts +98 -0
  57. package/src/auth-gateway/routes/video.ts +243 -0
  58. package/src/auth-gateway/server.ts +123 -260
  59. package/src/embeddings/index.ts +17 -0
  60. package/src/embeddings/openai-embeddings.ts +141 -0
  61. package/src/embeddings/types.ts +14 -0
  62. package/src/error/rate-limit.ts +1 -1
  63. package/src/images/google-antigravity.ts +180 -0
  64. package/src/images/google-generative-ai.ts +92 -0
  65. package/src/images/index.ts +59 -0
  66. package/src/images/openai-hosted.ts +185 -0
  67. package/src/images/openai-images.ts +110 -0
  68. package/src/images/openrouter-images.ts +33 -0
  69. package/src/images/shared.ts +193 -0
  70. package/src/images/types.ts +36 -0
  71. package/src/index.ts +7 -1
  72. package/src/judgment/typesafe.ts +5 -0
  73. package/src/providers/embeddings-server.ts +151 -0
  74. package/src/providers/images-server.ts +159 -0
  75. package/src/providers/rerank-server.ts +166 -0
  76. package/src/providers/speech-server.ts +53 -0
  77. package/src/providers/systemone-server.ts +73 -0
  78. package/src/providers/transcriptions-server.ts +243 -0
  79. package/src/providers/video-server.ts +286 -0
  80. package/src/rerank/index.ts +13 -0
  81. package/src/rerank/openrouter-rerank.ts +136 -0
  82. package/src/rerank/types.ts +20 -0
  83. package/src/speech/index.ts +35 -0
  84. package/src/speech/openai-speech.ts +26 -0
  85. package/src/speech/transport.ts +66 -0
  86. package/src/speech/types.ts +37 -0
  87. package/src/speech/xai-tts.ts +41 -0
  88. package/src/transcription/index.ts +17 -0
  89. package/src/transcription/openai-transcriptions.ts +133 -0
  90. package/src/transcription/types.ts +46 -0
  91. package/src/video/index.ts +34 -0
  92. package/src/video/openrouter-video.ts +210 -0
  93. package/src/video/types.ts +72 -0
@@ -0,0 +1,110 @@
1
+ import type { Model } from "@oh-my-pi/pi-catalog/types";
2
+ import * as AIError from "../error";
3
+ import {
4
+ decodeImageResponse,
5
+ imageBaseUrl,
6
+ postJson,
7
+ postMultipart,
8
+ resolveOpenAIImageSize,
9
+ toDataUrl,
10
+ } from "./shared";
11
+ import type { ImageGenerationOptions, ImageGenerationRequest, ImageGenerationResult } from "./types";
12
+
13
+ export const XAI_MAX_EDIT_IMAGES = 3;
14
+
15
+ export function resolveXAIResolution(imageSize?: string): "1k" | "2k" {
16
+ return !imageSize || imageSize === "1024x1024" ? "1k" : "2k";
17
+ }
18
+
19
+ export async function generateOpenAIImage(
20
+ model: Model,
21
+ request: ImageGenerationRequest,
22
+ options: ImageGenerationOptions,
23
+ ): Promise<ImageGenerationResult> {
24
+ const fetchImpl = options.fetch ?? fetch;
25
+ const size = resolveOpenAIImageSize(request.aspectRatio, request.imageSize);
26
+ const count = request.count ?? 1;
27
+ const isXAI = model.provider === "xai" || model.provider === "xai-oauth";
28
+ const generationBody = isXAI
29
+ ? {
30
+ model: model.requestModelId ?? model.id,
31
+ prompt: request.prompt,
32
+ aspect_ratio: request.aspectRatio ?? "1:1",
33
+ resolution: resolveXAIResolution(request.imageSize),
34
+ n: count,
35
+ response_format: "b64_json",
36
+ }
37
+ : {
38
+ model: model.requestModelId ?? model.id,
39
+ prompt: request.prompt,
40
+ n: count,
41
+ response_format: "b64_json",
42
+ ...(size ? { size } : {}),
43
+ };
44
+ const references = (request.inputImages ?? []).map(image => ({ type: "image_url", url: toDataUrl(image) }));
45
+ if (isXAI && references.length > XAI_MAX_EDIT_IMAGES) {
46
+ throw new AIError.ValidationError(
47
+ `${model.provider} image edits accept up to ${XAI_MAX_EDIT_IMAGES} reference images; got ${references.length}`,
48
+ );
49
+ }
50
+ const [firstReference, ...remainingReferences] = references;
51
+ const body = isXAI
52
+ ? remainingReferences.length === 0
53
+ ? { ...generationBody, image: firstReference }
54
+ : { ...generationBody, images: references }
55
+ : { ...generationBody, input_references: references };
56
+ const baseUrl = imageBaseUrl(model);
57
+ let response: unknown;
58
+ if (references.length === 0) {
59
+ response = await postJson({
60
+ model,
61
+ url: `${baseUrl}/images/generations`,
62
+ body: generationBody,
63
+ apiKey: options.apiKey,
64
+ fetch: fetchImpl,
65
+ signal: options.signal,
66
+ });
67
+ } else {
68
+ try {
69
+ if (model.provider === "openai") {
70
+ const form = new FormData();
71
+ form.set("model", model.requestModelId ?? model.id);
72
+ form.set("prompt", request.prompt);
73
+ form.set("n", String(count));
74
+ form.set("response_format", "b64_json");
75
+ if (size) form.set("size", size);
76
+ for (const image of request.inputImages ?? []) {
77
+ form.append("image", new File([Buffer.from(image.data, "base64")], "image", { type: image.mimeType }));
78
+ }
79
+ response = await postMultipart({
80
+ model,
81
+ url: `${baseUrl}/images/edits`,
82
+ body: form,
83
+ apiKey: options.apiKey,
84
+ fetch: fetchImpl,
85
+ signal: options.signal,
86
+ });
87
+ } else {
88
+ response = await postJson({
89
+ model,
90
+ url: `${baseUrl}/images/edits`,
91
+ body,
92
+ apiKey: options.apiKey,
93
+ fetch: fetchImpl,
94
+ signal: options.signal,
95
+ });
96
+ }
97
+ } catch (error) {
98
+ if (!(error instanceof AIError.ProviderHttpError) || error.status !== 404) throw error;
99
+ response = await postJson({
100
+ model,
101
+ url: `${baseUrl}/images/generations`,
102
+ body,
103
+ apiKey: options.apiKey,
104
+ fetch: fetchImpl,
105
+ signal: options.signal,
106
+ });
107
+ }
108
+ }
109
+ return decodeImageResponse(response, fetchImpl, options.signal);
110
+ }
@@ -0,0 +1,33 @@
1
+ import type { Model } from "@oh-my-pi/pi-catalog/types";
2
+ import { decodeImageResponse, imageBaseUrl, postJson, toDataUrl } from "./shared";
3
+ import type { ImageGenerationOptions, ImageGenerationRequest, ImageGenerationResult } from "./types";
4
+
5
+ export async function generateOpenRouterImage(
6
+ model: Model,
7
+ request: ImageGenerationRequest,
8
+ options: ImageGenerationOptions,
9
+ ): Promise<ImageGenerationResult> {
10
+ const fetchImpl = options.fetch ?? fetch;
11
+ const inputReferences = (request.inputImages ?? []).map(image => ({
12
+ type: "image_url",
13
+ image_url: { url: toDataUrl(image) },
14
+ }));
15
+ const body = {
16
+ model: model.requestModelId ?? model.id,
17
+ prompt: request.prompt,
18
+ n: request.count ?? 1,
19
+ response_format: "b64_json",
20
+ ...(request.aspectRatio ? { aspect_ratio: request.aspectRatio } : {}),
21
+ ...(request.imageSize ? { image_size: request.imageSize } : {}),
22
+ ...(inputReferences.length > 0 ? { input_references: inputReferences } : {}),
23
+ };
24
+ const response = await postJson({
25
+ model,
26
+ url: `${imageBaseUrl(model)}/images`,
27
+ body,
28
+ apiKey: options.apiKey,
29
+ fetch: fetchImpl,
30
+ signal: options.signal,
31
+ });
32
+ return decodeImageResponse(response, fetchImpl, options.signal);
33
+ }
@@ -0,0 +1,193 @@
1
+ import type { FetchImpl, Model, Usage } from "@oh-my-pi/pi-catalog/types";
2
+ import { parseImageMetadata, USER_AGENT } from "@oh-my-pi/pi-utils";
3
+ import type { ApiKey } from "../auth-retry";
4
+ import { withAuth } from "../auth-retry";
5
+ import * as AIError from "../error";
6
+ import type { GeneratedImage } from "./types";
7
+
8
+ export class ImageApiError extends AIError.ProviderHttpError {
9
+ override readonly name = "ImageApiError";
10
+ }
11
+
12
+ export function emptyUsage(input = 0, output = 0, cost = 0): Usage {
13
+ return {
14
+ input,
15
+ output,
16
+ cacheRead: 0,
17
+ cacheWrite: 0,
18
+ totalTokens: input + output,
19
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: cost },
20
+ };
21
+ }
22
+
23
+ export function usageFromWire(value: unknown): Usage {
24
+ if (value === null || typeof value !== "object") return emptyUsage();
25
+ const usage = value as Record<string, unknown>;
26
+ const number = (key: string): number => (typeof usage[key] === "number" ? usage[key] : 0);
27
+ return emptyUsage(
28
+ number("input_tokens") || number("prompt_tokens"),
29
+ number("output_tokens") || number("completion_tokens"),
30
+ number("cost"),
31
+ );
32
+ }
33
+
34
+ export function imageBaseUrl(model: Model): string {
35
+ if (!model.baseUrl) throw new AIError.ValidationError(`Image model ${model.provider}/${model.id} has no base URL`);
36
+ return model.baseUrl.replace(/\/+$/, "");
37
+ }
38
+
39
+ export async function modelHeaders(model: Model, signal?: AbortSignal): Promise<Record<string, string>> {
40
+ return { ...model.headers, ...(await model.resolveHeaders?.(signal)) };
41
+ }
42
+
43
+ export function errorMessage(rawText: string): string {
44
+ try {
45
+ const parsed = JSON.parse(rawText) as { detail?: string; error?: { message?: string } };
46
+ return parsed.detail ?? parsed.error?.message ?? rawText;
47
+ } catch {
48
+ return rawText;
49
+ }
50
+ }
51
+
52
+ async function parseImageApiResponse(model: Model, response: Response): Promise<unknown> {
53
+ const text = await response.text();
54
+ if (!response.ok) {
55
+ throw new ImageApiError(
56
+ `${model.provider}/${model.id} image request failed (${response.status}): ${errorMessage(text)}`,
57
+ response.status,
58
+ { headers: response.headers },
59
+ );
60
+ }
61
+ try {
62
+ return JSON.parse(text) as unknown;
63
+ } catch (cause) {
64
+ throw new AIError.ProviderResponseError("Image API returned malformed JSON", {
65
+ provider: model.provider,
66
+ kind: "envelope",
67
+ cause,
68
+ });
69
+ }
70
+ }
71
+
72
+ export async function postJson(options: {
73
+ model: Model;
74
+ url: string;
75
+ body: unknown;
76
+ apiKey: ApiKey;
77
+ fetch: FetchImpl;
78
+ signal?: AbortSignal;
79
+ }): Promise<unknown> {
80
+ return withAuth(
81
+ options.apiKey,
82
+ async key => {
83
+ const response = await options.fetch(options.url, {
84
+ method: "POST",
85
+ headers: {
86
+ ...(await modelHeaders(options.model, options.signal)),
87
+ Authorization: `Bearer ${key}`,
88
+ "Content-Type": "application/json",
89
+ "User-Agent": USER_AGENT,
90
+ },
91
+ body: JSON.stringify(options.body),
92
+ signal: options.signal,
93
+ });
94
+ return parseImageApiResponse(options.model, response);
95
+ },
96
+ { signal: options.signal },
97
+ );
98
+ }
99
+
100
+ export async function postMultipart(options: {
101
+ model: Model;
102
+ url: string;
103
+ body: FormData;
104
+ apiKey: ApiKey;
105
+ fetch: FetchImpl;
106
+ signal?: AbortSignal;
107
+ }): Promise<unknown> {
108
+ return withAuth(
109
+ options.apiKey,
110
+ async key => {
111
+ const response = await options.fetch(options.url, {
112
+ method: "POST",
113
+ headers: {
114
+ ...(await modelHeaders(options.model, options.signal)),
115
+ Authorization: `Bearer ${key}`,
116
+ "User-Agent": USER_AGENT,
117
+ },
118
+ body: options.body,
119
+ signal: options.signal,
120
+ });
121
+ return parseImageApiResponse(options.model, response);
122
+ },
123
+ { signal: options.signal },
124
+ );
125
+ }
126
+
127
+ async function imageFromUrl(url: string, fetch: FetchImpl, signal?: AbortSignal): Promise<GeneratedImage> {
128
+ const response = await fetch(url, { signal });
129
+ if (!response.ok) {
130
+ const text = await response.text();
131
+ throw new ImageApiError(`Image download failed (${response.status}): ${text}`, response.status, {
132
+ headers: response.headers,
133
+ });
134
+ }
135
+ const mimeType = response.headers.get("content-type")?.split(";")[0];
136
+ if (!mimeType?.startsWith("image/")) {
137
+ throw new AIError.ProviderResponseError(`Image URL returned unsupported content type: ${mimeType ?? "missing"}`, {
138
+ kind: "envelope",
139
+ });
140
+ }
141
+ const bytes = new Uint8Array(await response.arrayBuffer());
142
+ return { data: bytes.toBase64(), mimeType };
143
+ }
144
+
145
+ export async function decodeImageResponse(
146
+ value: unknown,
147
+ fetch: FetchImpl,
148
+ signal?: AbortSignal,
149
+ ): Promise<{ images: GeneratedImage[]; usage: Usage }> {
150
+ if (value === null || typeof value !== "object") {
151
+ throw new AIError.ProviderResponseError("Image API returned a malformed response", { kind: "envelope" });
152
+ }
153
+ const root = value as { data?: unknown; usage?: unknown };
154
+ if (!Array.isArray(root.data)) {
155
+ throw new AIError.ProviderResponseError("Image API response is missing data", { kind: "envelope" });
156
+ }
157
+ const images: GeneratedImage[] = [];
158
+ for (const item of root.data) {
159
+ if (item === null || typeof item !== "object") continue;
160
+ const image = item as { b64_json?: unknown; url?: unknown; media_type?: unknown };
161
+ if (typeof image.b64_json === "string" && image.b64_json.length > 0) {
162
+ const bytes = Buffer.from(image.b64_json, "base64");
163
+ const mimeType =
164
+ typeof image.media_type === "string"
165
+ ? image.media_type
166
+ : (parseImageMetadata(bytes)?.mimeType ?? "image/png");
167
+ images.push({ data: image.b64_json, mimeType });
168
+ } else if (typeof image.url === "string" && image.url.length > 0) {
169
+ images.push(await imageFromUrl(image.url, fetch, signal));
170
+ }
171
+ }
172
+ return { images, usage: usageFromWire(root.usage) };
173
+ }
174
+
175
+ export function toDataUrl(image: GeneratedImage): string {
176
+ return `data:${image.mimeType};base64,${image.data}`;
177
+ }
178
+
179
+ export function resolveOpenAIImageSize(aspectRatio?: string, imageSize?: string): string | undefined {
180
+ if (imageSize) return imageSize;
181
+ switch (aspectRatio) {
182
+ case "1:1":
183
+ return "1024x1024";
184
+ case "3:4":
185
+ case "9:16":
186
+ return "1024x1536";
187
+ case "4:3":
188
+ case "16:9":
189
+ return "1536x1024";
190
+ default:
191
+ return undefined;
192
+ }
193
+ }
@@ -0,0 +1,36 @@
1
+ import type { Api, FetchImpl, Model, Usage } from "@oh-my-pi/pi-catalog/types";
2
+ import type { ApiKey } from "../auth-retry";
3
+
4
+ export interface ImageInput {
5
+ data: string;
6
+ mimeType: string;
7
+ }
8
+
9
+ export interface ImageGenerationRequest {
10
+ prompt: string;
11
+ inputImages?: ImageInput[];
12
+ aspectRatio?: string;
13
+ imageSize?: string;
14
+ count?: number;
15
+ }
16
+
17
+ export interface GeneratedImage {
18
+ data: string;
19
+ mimeType: string;
20
+ }
21
+
22
+ export interface ImageGenerationResult {
23
+ images: GeneratedImage[];
24
+ text?: string;
25
+ usage: Usage;
26
+ }
27
+
28
+ export interface ImageGenerationOptions {
29
+ apiKey: ApiKey;
30
+ fetch?: FetchImpl;
31
+ signal?: AbortSignal;
32
+ /** Chat model that carries a Responses `image_generation` tool call. */
33
+ carrier?: Model<Api>;
34
+ /** Stable provider session id, used by the Codex Responses carrier. */
35
+ sessionId?: string;
36
+ }
package/src/index.ts CHANGED
@@ -1,12 +1,18 @@
1
1
  export { type Type, type } from "@oh-my-pi/omptype";
2
2
  export * from "./api-registry";
3
3
  export type * from "./auth-broker";
4
- export type { AuthGatewayBootOptions, ModelResolver } from "./auth-gateway/server";
4
+ export type { AuthGatewayBootOptions, ModelResolver } from "./auth-gateway/dispatch";
5
5
  export * from "./auth-gateway/types";
6
6
  export * from "./auth-retry";
7
7
  export * from "./auth-storage";
8
8
  export * from "./error/rate-limit";
9
+ export * from "./embeddings";
10
+ export * from "./images";
9
11
  export * from "./judgment";
12
+ export * from "./rerank";
13
+ export * from "./speech";
14
+ export * from "./transcription";
15
+ export * from "./video";
10
16
  export * from "./oneshot-retry";
11
17
  export * from "./provider-details";
12
18
  export * from "./provider-session-state";
@@ -66,6 +66,8 @@ export interface TypeSafeJudgeOptions {
66
66
  baseUrl?: string;
67
67
  /** Defaults to {@link typesafeModel}. */
68
68
  model?: string;
69
+ /** Static headers attached to judgment requests (e.g. proxy routing, gateway auth). */
70
+ headers?: Record<string, string>;
69
71
  fetch?: FetchImpl;
70
72
  /** Per-attempt timeout; defaults to {@link DEFAULT_TIMEOUT_MS}. */
71
73
  timeoutMs?: number;
@@ -102,6 +104,7 @@ export class TypeSafeJudge implements Judge {
102
104
  readonly model: string;
103
105
  readonly baseUrl: string;
104
106
  readonly #apiKey: ApiKey;
107
+ readonly #headers: Record<string, string> | undefined;
105
108
  readonly #fetch: FetchImpl;
106
109
  readonly #timeoutMs: number;
107
110
 
@@ -111,6 +114,7 @@ export class TypeSafeJudge implements Judge {
111
114
  this.provider = options.provider ?? TYPESAFE_PROVIDER;
112
115
  this.baseUrl = (options.baseUrl ?? typesafeBaseUrl()).replace(/\/+$/, "");
113
116
  this.model = options.model ?? typesafeModel();
117
+ this.#headers = options.headers;
114
118
  this.#fetch = options.fetch ?? fetch;
115
119
  this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
116
120
  this.label = `${this.provider}/${this.model}`;
@@ -145,6 +149,7 @@ export class TypeSafeJudge implements Judge {
145
149
  async #attempt<T>(path: string, body: string, key: string, signal: AbortSignal | undefined): Promise<T> {
146
150
  const url = `${this.baseUrl}${path}`;
147
151
  const headers: Record<string, string> = {
152
+ ...this.#headers,
148
153
  Authorization: `Bearer ${key}`,
149
154
  Accept: "application/json",
150
155
  "Content-Type": "application/json",
@@ -0,0 +1,151 @@
1
+ import { type } from "@oh-my-pi/omptype";
2
+ import * as AIError from "../error";
3
+ import type { EmbeddingRequest, EmbeddingResult } from "../embeddings/types";
4
+
5
+ export const MAX_EMBEDDINGS_BODY_BYTES = 8 * 1024 * 1024;
6
+
7
+ const embeddingRequestSchema = type({
8
+ model: "string > 0",
9
+ input: "unknown",
10
+ "dimensions?": "unknown",
11
+ "encoding_format?": "unknown",
12
+ "user?": "unknown",
13
+ });
14
+
15
+ export class EmbeddingsWireError extends AIError.ValidationError {
16
+ readonly status: number;
17
+
18
+ constructor(status: number, message: string, options?: { cause?: unknown }) {
19
+ super(message, options);
20
+ this.name = "EmbeddingsWireError";
21
+ this.status = status;
22
+ }
23
+ }
24
+
25
+ export interface EmbeddingsParsedRequest {
26
+ modelId: string;
27
+ request: EmbeddingRequest;
28
+ }
29
+
30
+ function invalid(message: string): never {
31
+ throw new EmbeddingsWireError(400, `embeddings: ${message}`);
32
+ }
33
+
34
+ function parseInput(value: unknown): EmbeddingRequest["input"] {
35
+ if (typeof value === "string") {
36
+ if (value.length === 0) invalid("input must not be empty");
37
+ return value;
38
+ }
39
+ if (!Array.isArray(value) || value.length === 0) invalid("input must be a non-empty string or array");
40
+ if (value.every(item => typeof item === "string" && item.length > 0)) return value;
41
+ if (value.every(item => typeof item === "number" && Number.isFinite(item))) return value;
42
+ if (
43
+ value.every(
44
+ item =>
45
+ Array.isArray(item) &&
46
+ item.length > 0 &&
47
+ item.every(token => typeof token === "number" && Number.isFinite(token)),
48
+ )
49
+ ) {
50
+ return value;
51
+ }
52
+ invalid("input arrays must contain non-empty strings, finite numbers, or non-empty arrays of finite numbers");
53
+ }
54
+
55
+ async function readBody(req: Request): Promise<Uint8Array> {
56
+ const contentLength = req.headers.get("content-length");
57
+ if (contentLength !== null) {
58
+ const declared = Number(contentLength);
59
+ if (Number.isFinite(declared) && declared > MAX_EMBEDDINGS_BODY_BYTES) {
60
+ throw new EmbeddingsWireError(413, "Request payload exceeds the 8 MiB limit");
61
+ }
62
+ }
63
+ const reader = req.body?.getReader();
64
+ if (!reader) return new Uint8Array();
65
+ const chunks: Uint8Array[] = [];
66
+ let total = 0;
67
+ for (;;) {
68
+ const { done, value } = await reader.read();
69
+ if (done) break;
70
+ total += value.byteLength;
71
+ if (total > MAX_EMBEDDINGS_BODY_BYTES) {
72
+ await reader.cancel();
73
+ throw new EmbeddingsWireError(413, "Request payload exceeds the 8 MiB limit");
74
+ }
75
+ chunks.push(value);
76
+ }
77
+ if (chunks.length === 1) return chunks[0]!;
78
+ const bytes = new Uint8Array(total);
79
+ let offset = 0;
80
+ for (const chunk of chunks) {
81
+ bytes.set(chunk, offset);
82
+ offset += chunk.byteLength;
83
+ }
84
+ return bytes;
85
+ }
86
+
87
+ /** Parse an OpenAI-compatible embeddings request with a bounded JSON body. */
88
+ export async function parseRequest(req: Request): Promise<EmbeddingsParsedRequest> {
89
+ const contentType = req.headers.get("content-type")?.toLowerCase() ?? "";
90
+ if (!contentType.startsWith("application/json")) {
91
+ throw new EmbeddingsWireError(400, "Content-Type must be application/json");
92
+ }
93
+ const bytes = await readBody(req);
94
+ let body: unknown;
95
+ try {
96
+ body = JSON.parse(new TextDecoder().decode(bytes));
97
+ } catch (error) {
98
+ throw new EmbeddingsWireError(400, "Invalid JSON body", { cause: error });
99
+ }
100
+ const parsed = embeddingRequestSchema(body);
101
+ if (parsed instanceof type.errors) throw new EmbeddingsWireError(400, `embeddings: ${parsed.summary}`);
102
+ const dimensions = parsed.dimensions;
103
+ if (dimensions !== undefined && (!Number.isInteger(dimensions) || (dimensions as number) < 1)) {
104
+ invalid("dimensions must be a positive integer");
105
+ }
106
+ const encodingFormat = parsed.encoding_format ?? "float";
107
+ if (encodingFormat !== "float" && encodingFormat !== "base64") {
108
+ invalid('encoding_format must be "float" or "base64"');
109
+ }
110
+ if (parsed.user !== undefined && (typeof parsed.user !== "string" || parsed.user.length === 0)) {
111
+ invalid("user must be a non-empty string");
112
+ }
113
+ return {
114
+ modelId: parsed.model,
115
+ request: {
116
+ input: parseInput(parsed.input),
117
+ encodingFormat,
118
+ ...(dimensions !== undefined && { dimensions: dimensions as number }),
119
+ ...(parsed.user !== undefined && { user: parsed.user }),
120
+ },
121
+ };
122
+ }
123
+
124
+ export interface EmbeddingsResponseBody {
125
+ object: "list";
126
+ data: Array<{ object: "embedding"; index: number; embedding: number[] | string }>;
127
+ model: string;
128
+ usage: { prompt_tokens: number; total_tokens: number; cost?: number };
129
+ }
130
+
131
+ /** Encode a canonical result as an OpenAI/OpenRouter embeddings response. */
132
+ export function encodeResponse(result: EmbeddingResult, requestedModelId: string): EmbeddingsResponseBody {
133
+ const billedCost = result.usage.credits?.cost;
134
+ return {
135
+ object: "list",
136
+ data: result.embeddings.map(item => ({ object: "embedding", ...item })),
137
+ model: requestedModelId,
138
+ usage: {
139
+ prompt_tokens: result.usage.input,
140
+ total_tokens: result.usage.totalTokens,
141
+ ...(billedCost !== undefined && { cost: billedCost }),
142
+ },
143
+ };
144
+ }
145
+
146
+ export function formatError(status: number, errorType: string, message: string): Response {
147
+ return new Response(JSON.stringify({ error: { code: status, type: errorType, message } }), {
148
+ status,
149
+ headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
150
+ });
151
+ }