@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,141 @@
1
+ import { calculateCost } from "@oh-my-pi/pi-catalog/models";
2
+ import type { Api, FetchImpl, Model, Usage } from "@oh-my-pi/pi-catalog/types";
3
+ import { type } from "@oh-my-pi/omptype";
4
+ import { type ApiKey, withAuth } from "../auth-retry";
5
+ import * as AIError from "../error";
6
+ import type { EmbeddingRequest, EmbeddingResult } from "./types";
7
+
8
+ export interface EmbeddingOptions {
9
+ apiKey: ApiKey;
10
+ fetch?: FetchImpl;
11
+ signal?: AbortSignal;
12
+ }
13
+
14
+ /** Non-2xx response from an OpenAI-compatible embeddings endpoint. */
15
+ export class EmbeddingApiError extends AIError.ProviderHttpError {
16
+ override readonly name = "EmbeddingApiError";
17
+ }
18
+
19
+ const upstreamResponseSchema = type({
20
+ data: "object[]",
21
+ model: "string",
22
+ "usage?": "object",
23
+ });
24
+
25
+ interface UpstreamUsage {
26
+ prompt_tokens?: unknown;
27
+ total_tokens?: unknown;
28
+ cost?: unknown;
29
+ }
30
+
31
+ function finiteNumber(value: unknown): number | undefined {
32
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
33
+ }
34
+
35
+ function decodeUsage(model: Model<Api>, raw: unknown): Usage {
36
+ const upstream = raw && typeof raw === "object" ? (raw as UpstreamUsage) : {};
37
+ const input = finiteNumber(upstream.prompt_tokens) ?? 0;
38
+ const totalTokens = finiteNumber(upstream.total_tokens) ?? input;
39
+ const reportedCost = finiteNumber(upstream.cost);
40
+ const usage: Usage = {
41
+ input,
42
+ output: 0,
43
+ cacheRead: 0,
44
+ cacheWrite: 0,
45
+ totalTokens,
46
+ ...(reportedCost !== undefined && { credits: { cost: reportedCost } }),
47
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: reportedCost ?? 0 },
48
+ };
49
+ if (reportedCost === undefined) calculateCost(model, usage);
50
+ return usage;
51
+ }
52
+
53
+ function decodeEmbeddings(data: object[], model: Model<Api>): EmbeddingResult["embeddings"] {
54
+ return data.map((raw, position) => {
55
+ const index = Reflect.get(raw, "index");
56
+ const embedding = Reflect.get(raw, "embedding");
57
+ if (!Number.isInteger(index) || index < 0) {
58
+ throw new AIError.ProviderResponseError(
59
+ `${model.provider}/${model.id} embeddings response has an invalid index at data[${position}]`,
60
+ { provider: model.provider, kind: "envelope" },
61
+ );
62
+ }
63
+ if (
64
+ typeof embedding !== "string" &&
65
+ (!Array.isArray(embedding) || !embedding.every(value => typeof value === "number" && Number.isFinite(value)))
66
+ ) {
67
+ throw new AIError.ProviderResponseError(
68
+ `${model.provider}/${model.id} embeddings response has an invalid vector at data[${position}]`,
69
+ { provider: model.provider, kind: "envelope" },
70
+ );
71
+ }
72
+ return { index, embedding };
73
+ });
74
+ }
75
+
76
+ async function responseError(response: Response, model: Model<Api>): Promise<EmbeddingApiError> {
77
+ const text = await response.text();
78
+ let detail = text;
79
+ let code: string | undefined;
80
+ try {
81
+ const parsed: unknown = JSON.parse(text);
82
+ if (parsed && typeof parsed === "object" && "error" in parsed) {
83
+ const error = parsed.error;
84
+ if (error && typeof error === "object") {
85
+ const envelope = error as { message?: unknown; code?: unknown; type?: unknown };
86
+ if (typeof envelope.message === "string") detail = envelope.message;
87
+ if (typeof envelope.code === "string") code = envelope.code;
88
+ else if (typeof envelope.type === "string") code = envelope.type;
89
+ }
90
+ }
91
+ } catch {}
92
+ return new EmbeddingApiError(
93
+ `${model.provider}/${model.id} embeddings API error (${response.status}): ${detail || response.statusText}`,
94
+ response.status,
95
+ { headers: response.headers, code },
96
+ );
97
+ }
98
+
99
+ /** Call an OpenAI/OpenRouter-compatible embeddings endpoint. */
100
+ export async function embedOpenAI(
101
+ model: Model<Api>,
102
+ request: EmbeddingRequest,
103
+ options: EmbeddingOptions,
104
+ ): Promise<EmbeddingResult> {
105
+ const fetchImpl = options.fetch ?? fetch;
106
+ const body = {
107
+ model: model.id,
108
+ input: request.input,
109
+ encoding_format: request.encodingFormat,
110
+ ...(request.dimensions !== undefined && { dimensions: request.dimensions }),
111
+ ...(request.user !== undefined && { user: request.user }),
112
+ };
113
+ const response = await withAuth(
114
+ options.apiKey,
115
+ async key => {
116
+ const attempt = await fetchImpl(`${model.baseUrl.replace(/\/+$/, "")}/embeddings`, {
117
+ method: "POST",
118
+ headers: { Authorization: `Bearer ${key}`, Accept: "application/json", "Content-Type": "application/json" },
119
+ body: JSON.stringify(body),
120
+ signal: options.signal,
121
+ });
122
+ if (!attempt.ok) throw await responseError(attempt, model);
123
+ return attempt;
124
+ },
125
+ { signal: options.signal },
126
+ );
127
+
128
+ const raw: unknown = await response.json();
129
+ const parsed = upstreamResponseSchema(raw);
130
+ if (parsed instanceof type.errors) {
131
+ throw new AIError.ProviderResponseError(
132
+ `${model.provider}/${model.id} embeddings response is malformed: ${parsed.summary}`,
133
+ { provider: model.provider, kind: "envelope" },
134
+ );
135
+ }
136
+ return {
137
+ embeddings: decodeEmbeddings(parsed.data, model),
138
+ model: parsed.model,
139
+ usage: decodeUsage(model, parsed.usage),
140
+ };
141
+ }
@@ -0,0 +1,14 @@
1
+ import type { Usage } from "@oh-my-pi/pi-catalog/types";
2
+
3
+ export interface EmbeddingRequest {
4
+ input: string | string[] | number[] | number[][];
5
+ dimensions?: number;
6
+ encodingFormat: "float" | "base64";
7
+ user?: string;
8
+ }
9
+
10
+ export interface EmbeddingResult {
11
+ embeddings: Array<{ index: number; embedding: number[] | string }>;
12
+ model: string;
13
+ usage: Usage;
14
+ }
@@ -306,7 +306,7 @@ export function calculateRateLimitBackoffMs(reason: RateLimitReason): number {
306
306
 
307
307
  /** Detect usage/quota limit errors in error messages (persistent, requires credential switch). */
308
308
  const USAGE_LIMIT_PATTERN =
309
- /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?(?:exceeded|reached|insufficient)|额度不足|额度耗尽|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?(?:balance|quota)|balance.?exhausted|run out of credits|out of credits|spending[- _]?limit|personal-team-blocked|clinepass limit|free limit reached on model|access_terminated_error/i;
309
+ /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?(?:exceeded|reached|insufficient)|额度不足|额度耗尽|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?(?:balance|quota)|balance.?exhausted|run out of credits|out of credits|out of (?:extra )?usage|spending[- _]?limit|personal-team-blocked|clinepass limit|free limit reached on model|access_terminated_error/i;
310
310
 
311
311
  /**
312
312
  * HTTP status codes that, absent richer body classification, represent an
@@ -0,0 +1,180 @@
1
+ import {
2
+ ANTIGRAVITY_PRIMARY_ENDPOINT,
3
+ ANTIGRAVITY_SANDBOX_ENDPOINT,
4
+ fetchAntigravityImageModel,
5
+ } from "@oh-my-pi/pi-catalog/discovery/antigravity";
6
+ import type { Model } from "@oh-my-pi/pi-catalog/types";
7
+ import { getAntigravityUserAgent } from "@oh-my-pi/pi-catalog/wire/gemini-headers";
8
+ import { readSseJson } from "@oh-my-pi/pi-utils";
9
+ import { withAuth } from "../auth-retry";
10
+ import * as AIError from "../error";
11
+ import { errorMessage, ImageApiError, usageFromWire } from "./shared";
12
+ import type { GeneratedImage, ImageGenerationOptions, ImageGenerationRequest, ImageGenerationResult } from "./types";
13
+
14
+ const IMAGE_SYSTEM_INSTRUCTION =
15
+ "You are an AI image generator. Generate images based on user descriptions. Focus on creating high-quality, visually appealing images that match the user's request.";
16
+
17
+ interface AntigravityCredentials {
18
+ accessToken: string;
19
+ projectId: string;
20
+ }
21
+
22
+ interface AntigravityTarget {
23
+ model: string;
24
+ endpoints: string[];
25
+ }
26
+
27
+ interface AntigravityChunk {
28
+ response?: {
29
+ candidates?: Array<{
30
+ content?: { parts?: Array<{ text?: string; inlineData?: { data?: string; mimeType?: string } }> };
31
+ }>;
32
+ usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number };
33
+ };
34
+ }
35
+
36
+ export function parseAntigravityCredentials(raw: string): AntigravityCredentials | undefined {
37
+ try {
38
+ const parsed = JSON.parse(raw) as { token?: unknown; projectId?: unknown };
39
+ if (typeof parsed.token === "string" && typeof parsed.projectId === "string") {
40
+ return { accessToken: parsed.token, projectId: parsed.projectId };
41
+ }
42
+ } catch {
43
+ // Report the same validation error below.
44
+ }
45
+ return undefined;
46
+ }
47
+
48
+ function antigravityEndpoints(model: Model): string[] {
49
+ const configured = model.baseUrl.replace(/\/+$/, "");
50
+ return [...new Set([configured, ANTIGRAVITY_PRIMARY_ENDPOINT, ANTIGRAVITY_SANDBOX_ENDPOINT])];
51
+ }
52
+
53
+ async function resolveTarget(
54
+ model: Model,
55
+ credentials: AntigravityCredentials,
56
+ fetchImpl: ImageGenerationOptions["fetch"],
57
+ signal?: AbortSignal,
58
+ ): Promise<AntigravityTarget> {
59
+ const endpoints = antigravityEndpoints(model);
60
+ const advertised = await fetchAntigravityImageModel({
61
+ token: credentials.accessToken,
62
+ endpoint: endpoints.length === 1 ? endpoints[0] : undefined,
63
+ userAgent: getAntigravityUserAgent(),
64
+ signal,
65
+ fetcher: fetchImpl,
66
+ });
67
+ return advertised
68
+ ? {
69
+ model: advertised.id,
70
+ endpoints: [advertised.endpoint, ...endpoints.filter(endpoint => endpoint !== advertised.endpoint)],
71
+ }
72
+ : { model: model.requestModelId ?? model.id, endpoints };
73
+ }
74
+
75
+ function buildRequest(request: ImageGenerationRequest, model: string, projectId: string): Record<string, unknown> {
76
+ const parts: Array<{ text?: string; inlineData?: GeneratedImage }> = (request.inputImages ?? []).map(image => ({
77
+ inlineData: image,
78
+ }));
79
+ parts.push({ text: request.prompt });
80
+ const imageConfig =
81
+ request.aspectRatio || request.imageSize
82
+ ? { aspectRatio: request.aspectRatio, imageSize: request.imageSize }
83
+ : undefined;
84
+ return {
85
+ project: projectId,
86
+ model,
87
+ request: {
88
+ contents: [{ role: "user", parts }],
89
+ systemInstruction: { parts: [{ text: IMAGE_SYSTEM_INSTRUCTION }] },
90
+ generationConfig: {
91
+ responseModalities: ["IMAGE"],
92
+ ...(imageConfig ? { imageConfig } : {}),
93
+ candidateCount: request.count ?? 1,
94
+ },
95
+ safetySettings: [
96
+ { category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_ONLY_HIGH" },
97
+ { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" },
98
+ { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_ONLY_HIGH" },
99
+ { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_ONLY_HIGH" },
100
+ { category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "BLOCK_ONLY_HIGH" },
101
+ ],
102
+ },
103
+ requestType: "agent",
104
+ requestId: `agent-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
105
+ userAgent: "antigravity",
106
+ };
107
+ }
108
+
109
+ async function parseSse(response: Response, signal?: AbortSignal): Promise<ImageGenerationResult> {
110
+ if (!response.body) {
111
+ throw new AIError.ProviderResponseError("Antigravity image response has no body", { kind: "empty-body" });
112
+ }
113
+ const images: GeneratedImage[] = [];
114
+ const texts: string[] = [];
115
+ let usage = usageFromWire(undefined);
116
+ for await (const chunk of readSseJson<AntigravityChunk>(response.body, signal)) {
117
+ for (const candidate of chunk.response?.candidates ?? []) {
118
+ for (const part of candidate.content?.parts ?? []) {
119
+ if (part.text) texts.push(part.text);
120
+ if (part.inlineData?.data && part.inlineData.mimeType) {
121
+ images.push({ data: part.inlineData.data, mimeType: part.inlineData.mimeType });
122
+ }
123
+ }
124
+ }
125
+ const metadata = chunk.response?.usageMetadata;
126
+ if (metadata) {
127
+ usage = usageFromWire({
128
+ input_tokens: metadata.promptTokenCount,
129
+ output_tokens: metadata.candidatesTokenCount,
130
+ });
131
+ }
132
+ }
133
+ const text = texts.join(" ").trim();
134
+ return { images, ...(text ? { text } : {}), usage };
135
+ }
136
+
137
+ export async function generateAntigravityImage(
138
+ model: Model,
139
+ request: ImageGenerationRequest,
140
+ options: ImageGenerationOptions,
141
+ ): Promise<ImageGenerationResult> {
142
+ const fetchImpl = options.fetch ?? fetch;
143
+ const response = await withAuth(
144
+ options.apiKey,
145
+ async rawKey => {
146
+ const credentials = parseAntigravityCredentials(rawKey);
147
+ if (!credentials) {
148
+ throw new AIError.ValidationError("Antigravity image credentials must contain token and projectId");
149
+ }
150
+ const target = await resolveTarget(model, credentials, fetchImpl, options.signal);
151
+ const body = buildRequest(request, target.model, credentials.projectId);
152
+ let lastError: ImageApiError | undefined;
153
+ for (let index = 0; index < target.endpoints.length; index++) {
154
+ const result = await fetchImpl(`${target.endpoints[index]}/v1internal:streamGenerateContent?alt=sse`, {
155
+ method: "POST",
156
+ headers: {
157
+ Authorization: `Bearer ${credentials.accessToken}`,
158
+ "Content-Type": "application/json",
159
+ Accept: "text/event-stream",
160
+ "User-Agent": getAntigravityUserAgent(),
161
+ },
162
+ body: JSON.stringify(body),
163
+ signal: options.signal,
164
+ });
165
+ if (result.ok) return result;
166
+ const text = await result.text();
167
+ lastError = new ImageApiError(
168
+ `${model.provider}/${model.id} image request failed (${result.status}): ${errorMessage(text)}`,
169
+ result.status,
170
+ { headers: result.headers },
171
+ );
172
+ const retryable = result.status === 429 || result.status >= 500;
173
+ if (!retryable || index === target.endpoints.length - 1) throw lastError;
174
+ }
175
+ throw lastError ?? new AIError.ProviderResponseError("Antigravity image request failed");
176
+ },
177
+ { signal: options.signal },
178
+ );
179
+ return parseSse(response, options.signal);
180
+ }
@@ -0,0 +1,92 @@
1
+ import type { Model } from "@oh-my-pi/pi-catalog/types";
2
+ import { withAuth } from "../auth-retry";
3
+ import * as AIError from "../error";
4
+ import { errorMessage, ImageApiError, imageBaseUrl, modelHeaders, usageFromWire } from "./shared";
5
+ import type { GeneratedImage, ImageGenerationOptions, ImageGenerationRequest, ImageGenerationResult } from "./types";
6
+
7
+ interface GeminiPart {
8
+ text?: string;
9
+ inlineData?: { data?: string; mimeType?: string };
10
+ }
11
+
12
+ interface GeminiResponse {
13
+ candidates?: Array<{ content?: { parts?: GeminiPart[] } }>;
14
+ usageMetadata?: { promptTokenCount?: number; candidatesTokenCount?: number; totalTokenCount?: number };
15
+ }
16
+
17
+ export async function generateGoogleImage(
18
+ model: Model,
19
+ request: ImageGenerationRequest,
20
+ options: ImageGenerationOptions,
21
+ ): Promise<ImageGenerationResult> {
22
+ const fetchImpl = options.fetch ?? fetch;
23
+ const parts: Array<{ text?: string; inlineData?: GeneratedImage }> = (request.inputImages ?? []).map(image => ({
24
+ inlineData: image,
25
+ }));
26
+ parts.push({ text: request.prompt });
27
+ const imageConfig =
28
+ request.aspectRatio || request.imageSize
29
+ ? { aspectRatio: request.aspectRatio, imageSize: request.imageSize }
30
+ : undefined;
31
+ const body = {
32
+ contents: [{ role: "user", parts }],
33
+ generationConfig: {
34
+ responseModalities: ["IMAGE"],
35
+ ...(imageConfig ? { imageConfig } : {}),
36
+ ...(request.count ? { candidateCount: request.count } : {}),
37
+ },
38
+ };
39
+ const response = await withAuth(
40
+ options.apiKey,
41
+ async key => {
42
+ const result = await fetchImpl(
43
+ `${imageBaseUrl(model)}/models/${encodeURIComponent(model.requestModelId ?? model.id)}:generateContent`,
44
+ {
45
+ method: "POST",
46
+ headers: {
47
+ ...(await modelHeaders(model, options.signal)),
48
+ "Content-Type": "application/json",
49
+ "x-goog-api-key": key,
50
+ },
51
+ body: JSON.stringify(body),
52
+ signal: options.signal,
53
+ },
54
+ );
55
+ const text = await result.text();
56
+ if (!result.ok) {
57
+ throw new ImageApiError(
58
+ `${model.provider}/${model.id} image request failed (${result.status}): ${errorMessage(text)}`,
59
+ result.status,
60
+ { headers: result.headers },
61
+ );
62
+ }
63
+ try {
64
+ return JSON.parse(text) as GeminiResponse;
65
+ } catch (cause) {
66
+ throw new AIError.ProviderResponseError("Gemini image API returned malformed JSON", {
67
+ provider: model.provider,
68
+ kind: "envelope",
69
+ cause,
70
+ });
71
+ }
72
+ },
73
+ { signal: options.signal },
74
+ );
75
+ const responseParts = response.candidates?.flatMap(candidate => candidate.content?.parts ?? []) ?? [];
76
+ const images: GeneratedImage[] = [];
77
+ const textParts: string[] = [];
78
+ for (const part of responseParts) {
79
+ if (part.text) textParts.push(part.text);
80
+ if (part.inlineData?.data && part.inlineData.mimeType) {
81
+ images.push({ data: part.inlineData.data, mimeType: part.inlineData.mimeType });
82
+ }
83
+ }
84
+ const text = textParts.join("\n").trim();
85
+ const wireUsage = response.usageMetadata;
86
+ const usage = usageFromWire(
87
+ wireUsage
88
+ ? { input_tokens: wireUsage.promptTokenCount, output_tokens: wireUsage.candidatesTokenCount }
89
+ : undefined,
90
+ );
91
+ return { images, ...(text ? { text } : {}), usage };
92
+ }
@@ -0,0 +1,59 @@
1
+ import type { Api, Model } from "@oh-my-pi/pi-catalog/types";
2
+ import * as AIError from "../error";
3
+ import { generateAntigravityImage } from "./google-antigravity";
4
+ import { generateGoogleImage } from "./google-generative-ai";
5
+ import { generateOpenAIImage } from "./openai-images";
6
+ import { generateHostedImage } from "./openai-hosted";
7
+ import { generateOpenRouterImage } from "./openrouter-images";
8
+ import type { ImageGenerationOptions, ImageGenerationRequest, ImageGenerationResult } from "./types";
9
+
10
+ export * from "./google-antigravity";
11
+ export * from "./google-generative-ai";
12
+ export * from "./openai-hosted";
13
+ export * from "./openai-images";
14
+ export * from "./openrouter-images";
15
+ export * from "./types";
16
+
17
+ /** Catalog APIs {@link generateImage} serves; the hosted Responses pair needs an explicit carrier model. */
18
+ export type ImageGenerationApi =
19
+ | "openai-images"
20
+ | "openrouter-images"
21
+ | "google-generative-ai"
22
+ | "google-gemini-cli"
23
+ | "openai-responses"
24
+ | "openai-codex-responses";
25
+
26
+ /** Whether a catalog API generates images through one of the pi-ai image clients. */
27
+ export function isImageGenerationApi(api: Api): api is ImageGenerationApi {
28
+ return (
29
+ api === "openai-images" ||
30
+ api === "openrouter-images" ||
31
+ api === "google-generative-ai" ||
32
+ api === "google-gemini-cli" ||
33
+ api === "openai-responses" ||
34
+ api === "openai-codex-responses"
35
+ );
36
+ }
37
+
38
+ /** Generate (or edit, when `request.inputImages` is set) images through the transport selected by the model's `api`. */
39
+ export async function generateImage(
40
+ model: Model<Api>,
41
+ request: ImageGenerationRequest,
42
+ options: ImageGenerationOptions,
43
+ ): Promise<ImageGenerationResult> {
44
+ switch (model.api) {
45
+ case "openai-images":
46
+ return generateOpenAIImage(model, request, options);
47
+ case "openrouter-images":
48
+ return generateOpenRouterImage(model, request, options);
49
+ case "google-generative-ai":
50
+ return generateGoogleImage(model, request, options);
51
+ case "google-gemini-cli":
52
+ return generateAntigravityImage(model, request, options);
53
+ case "openai-responses":
54
+ case "openai-codex-responses":
55
+ return generateHostedImage(model, request, options);
56
+ default:
57
+ throw new AIError.ValidationError(`Image generation does not support model API ${model.api}`);
58
+ }
59
+ }
@@ -0,0 +1,185 @@
1
+ import type { Api, Model } from "@oh-my-pi/pi-catalog/types";
2
+ import {
3
+ applyCodexResidencyHeader,
4
+ CODEX_BASE_URL,
5
+ getCodexAccountId,
6
+ OPENAI_HEADER_VALUES,
7
+ OPENAI_HEADERS,
8
+ URL_PATHS,
9
+ } from "@oh-my-pi/pi-catalog/wire/codex";
10
+ import { parseImageMetadata, readSseJson, USER_AGENT } from "@oh-my-pi/pi-utils";
11
+ import { withAuth } from "../auth-retry";
12
+ import * as AIError from "../error";
13
+ import { errorMessage, ImageApiError, modelHeaders, resolveOpenAIImageSize, toDataUrl, usageFromWire } from "./shared";
14
+ import type { GeneratedImage, ImageGenerationOptions, ImageGenerationRequest, ImageGenerationResult } from "./types";
15
+
16
+ const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
17
+ const IMAGE_SYSTEM_INSTRUCTION =
18
+ "You are an AI image generator. Generate images based on user descriptions. Focus on creating high-quality, visually appealing images that match the user's request.";
19
+
20
+ interface HostedOutput {
21
+ type: "image_generation_call" | "message";
22
+ result?: string;
23
+ content?: Array<{ type?: string; text?: string; refusal?: string }>;
24
+ }
25
+
26
+ interface HostedResponse {
27
+ output?: HostedOutput[];
28
+ usage?: { input_tokens?: number; output_tokens?: number };
29
+ error?: { message?: string };
30
+ }
31
+
32
+ interface HostedEvent {
33
+ type?: string;
34
+ item?: HostedOutput;
35
+ response?: HostedResponse;
36
+ error?: { message?: string };
37
+ message?: string;
38
+ }
39
+
40
+ function responsesUrl(model: Model<Api>): string {
41
+ const fallback =
42
+ model.api === "openai-codex-responses" || model.provider === "openai-codex"
43
+ ? CODEX_BASE_URL
44
+ : DEFAULT_OPENAI_BASE_URL;
45
+ const baseUrl = (model.baseUrl || fallback).replace(/\/+$/, "");
46
+ if (model.api !== "openai-codex-responses" && model.provider !== "openai-codex") {
47
+ return `${baseUrl}/responses`;
48
+ }
49
+ const baseWithSlash = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
50
+ return new URL(URL_PATHS.RESPONSES.slice(1), baseWithSlash)
51
+ .toString()
52
+ .replace(URL_PATHS.RESPONSES, URL_PATHS.CODEX_RESPONSES);
53
+ }
54
+
55
+ async function headers(
56
+ carrier: Model<Api>,
57
+ key: string,
58
+ sessionId: string | undefined,
59
+ signal?: AbortSignal,
60
+ ): Promise<Headers> {
61
+ const result = new Headers(await modelHeaders(carrier, signal));
62
+ result.set("Content-Type", "application/json");
63
+ result.set("Authorization", `Bearer ${key}`);
64
+ if (carrier.api === "openai-codex-responses" || carrier.provider === "openai-codex") {
65
+ const accountId = getCodexAccountId(key);
66
+ result.delete("x-api-key");
67
+ if (accountId) result.set(OPENAI_HEADERS.ACCOUNT_ID, accountId);
68
+ applyCodexResidencyHeader(result, key);
69
+ result.set(OPENAI_HEADERS.BETA, OPENAI_HEADER_VALUES.BETA_RESPONSES);
70
+ result.set(OPENAI_HEADERS.ORIGINATOR, OPENAI_HEADER_VALUES.ORIGINATOR_CODEX);
71
+ result.set("User-Agent", USER_AGENT);
72
+ if (sessionId) {
73
+ result.set(OPENAI_HEADERS.CONVERSATION_ID, sessionId);
74
+ result.set(OPENAI_HEADERS.SESSION_ID, sessionId);
75
+ }
76
+ }
77
+ return result;
78
+ }
79
+
80
+ function collectResponse(response: HostedResponse): ImageGenerationResult {
81
+ const images: GeneratedImage[] = [];
82
+ const texts: string[] = [];
83
+ for (const output of response.output ?? []) {
84
+ if (output.type === "image_generation_call" && output.result) {
85
+ const bytes = Buffer.from(output.result, "base64");
86
+ images.push({ data: output.result, mimeType: parseImageMetadata(bytes)?.mimeType ?? "image/webp" });
87
+ }
88
+ if (output.type === "message") {
89
+ for (const part of output.content ?? []) {
90
+ if (part.type === "output_text" && part.text) texts.push(part.text);
91
+ if (part.type === "refusal" && part.refusal) texts.push(part.refusal);
92
+ }
93
+ }
94
+ }
95
+ const text = texts.join("\n").trim();
96
+ return { images, ...(text ? { text } : {}), usage: usageFromWire(response.usage) };
97
+ }
98
+
99
+ async function parseSse(response: Response, signal?: AbortSignal): Promise<ImageGenerationResult> {
100
+ if (!response.body) {
101
+ throw new AIError.ProviderResponseError("OpenAI hosted image response has no body", { kind: "empty-body" });
102
+ }
103
+ const fallbackOutput: HostedOutput[] = [];
104
+ let completed: HostedResponse | undefined;
105
+ for await (const event of readSseJson<HostedEvent>(response.body, signal)) {
106
+ if (event.type === "error") {
107
+ throw new AIError.ProviderResponseError(
108
+ event.error?.message ?? event.message ?? "OpenAI image request failed",
109
+ );
110
+ }
111
+ if (event.type === "response.failed") {
112
+ throw new AIError.ProviderResponseError(event.response?.error?.message ?? "OpenAI image request failed");
113
+ }
114
+ if (event.type === "response.output_item.done" && event.item) fallbackOutput.push(event.item);
115
+ if ((event.type === "response.completed" || event.type === "response.done") && event.response) {
116
+ completed = event.response;
117
+ }
118
+ }
119
+ return collectResponse(completed?.output?.length ? completed : { output: fallbackOutput, usage: completed?.usage });
120
+ }
121
+
122
+ export async function generateHostedImage(
123
+ model: Model,
124
+ request: ImageGenerationRequest,
125
+ options: ImageGenerationOptions,
126
+ ): Promise<ImageGenerationResult> {
127
+ const carrier = options.carrier;
128
+ if (!carrier) throw new AIError.ValidationError("Hosted image generation requires an explicit carrier model");
129
+ if (carrier.api !== "openai-responses" && carrier.api !== "openai-codex-responses") {
130
+ throw new AIError.ValidationError(`Hosted image carrier API ${carrier.api} is unsupported`);
131
+ }
132
+ const stream = carrier.api === "openai-codex-responses";
133
+ const content: Array<Record<string, unknown>> = [{ type: "input_text", text: request.prompt }];
134
+ for (const image of request.inputImages ?? []) {
135
+ content.push({ type: "input_image", detail: "auto", image_url: toDataUrl(image) });
136
+ }
137
+ const size = resolveOpenAIImageSize(request.aspectRatio, request.imageSize);
138
+ const tool = {
139
+ type: "image_generation",
140
+ action: content.length > 1 ? "edit" : "generate",
141
+ output_format: "webp",
142
+ ...(size ? { size } : {}),
143
+ ...(model.api === "openai-responses" ? { model: model.requestModelId ?? model.id } : {}),
144
+ };
145
+ const body = {
146
+ model: carrier.requestModelId ?? carrier.id,
147
+ input: [{ role: "user", content }],
148
+ tools: [tool],
149
+ tool_choice: { type: "image_generation" },
150
+ store: false,
151
+ ...(stream ? { instructions: IMAGE_SYSTEM_INSTRUCTION, stream: true } : {}),
152
+ };
153
+ const fetchImpl = options.fetch ?? fetch;
154
+ return withAuth(
155
+ options.apiKey,
156
+ async key => {
157
+ const response = await fetchImpl(responsesUrl(carrier), {
158
+ method: "POST",
159
+ headers: await headers(carrier, key, options.sessionId, options.signal),
160
+ body: JSON.stringify(body),
161
+ signal: options.signal,
162
+ });
163
+ if (!response.ok) {
164
+ const text = await response.text();
165
+ throw new ImageApiError(
166
+ `OpenAI image request failed (${response.status}): ${errorMessage(text)}`,
167
+ response.status,
168
+ { headers: response.headers },
169
+ );
170
+ }
171
+ if (stream || response.headers.get("content-type")?.includes("text/event-stream")) {
172
+ return parseSse(response, options.signal);
173
+ }
174
+ const value = (await response.json()) as HostedResponse;
175
+ if (value.error) throw new AIError.ProviderResponseError(value.error.message ?? "OpenAI image request failed");
176
+ if (!Array.isArray(value.output)) {
177
+ throw new AIError.ProviderResponseError("OpenAI image request returned a malformed response", {
178
+ kind: "envelope",
179
+ });
180
+ }
181
+ return collectResponse(value);
182
+ },
183
+ { signal: options.signal },
184
+ );
185
+ }