@astrofoundry/pi-astro 0.5.1 → 0.6.0

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.
@@ -0,0 +1,313 @@
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
3
+ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
4
+ import { GoogleGenAI } from "@google/genai";
5
+ import { StringEnum } from "@mariozechner/pi-ai";
6
+ import { Type } from "typebox";
7
+ import { persistApiKey, resolveExistingApiKey } from "./credentials.ts";
8
+ import {
9
+ ALL_MODELS,
10
+ DEFAULT_MODEL,
11
+ GEMINI_ASPECT_RATIOS,
12
+ GEMINI_IMAGE_SIZES,
13
+ IMAGEN_ASPECT_RATIOS,
14
+ IMAGEN_IMAGE_SIZES,
15
+ PERSON_GENERATION_VALUES,
16
+ isGeminiNative,
17
+ isImagen,
18
+ type ImageModel,
19
+ } from "./models.ts";
20
+ import { estimateCost, formatUsd, hasPricing } from "./pricing.ts";
21
+
22
+ const params = Type.Object({
23
+ prompt: Type.String({ description: "Text prompt describing the image to generate." }),
24
+ model: Type.Optional(
25
+ StringEnum(ALL_MODELS as unknown as readonly [string, ...string[]], {
26
+ description: `Model. Default: ${DEFAULT_MODEL}.`,
27
+ }),
28
+ ),
29
+ aspect_ratio: Type.Optional(Type.String({ description: "Aspect ratio; validated per model family." })),
30
+ image_size: Type.Optional(
31
+ StringEnum(["1K", "2K", "4K"] as const, { description: "Output resolution. 4K is Gemini-native only." }),
32
+ ),
33
+ number_of_images: Type.Optional(
34
+ Type.Integer({ minimum: 1, maximum: 4, description: "Imagen only. 1-4." }),
35
+ ),
36
+ negative_prompt: Type.Optional(Type.String({ description: "Imagen only." })),
37
+ person_generation: Type.Optional(
38
+ StringEnum(PERSON_GENERATION_VALUES as unknown as readonly [string, ...string[]], {
39
+ description: "People policy: dont_allow | allow_adult | allow_all.",
40
+ }),
41
+ ),
42
+ seed: Type.Optional(Type.Integer({ description: "Imagen only. Reproducibility seed." })),
43
+ input_images: Type.Optional(
44
+ Type.Array(Type.String(), {
45
+ description: "Gemini native only. Absolute paths to images for editing.",
46
+ }),
47
+ ),
48
+ save_to: Type.Optional(Type.String({ description: "Optional directory to write generated PNG(s)." })),
49
+ skip_confirm: Type.Optional(Type.Boolean({ description: "Skip the cost confirmation prompt." })),
50
+ });
51
+
52
+ type Params = {
53
+ prompt: string;
54
+ model?: string;
55
+ aspect_ratio?: string;
56
+ image_size?: "1K" | "2K" | "4K";
57
+ number_of_images?: number;
58
+ negative_prompt?: string;
59
+ person_generation?: (typeof PERSON_GENERATION_VALUES)[number];
60
+ seed?: number;
61
+ input_images?: string[];
62
+ save_to?: string;
63
+ skip_confirm?: boolean;
64
+ };
65
+
66
+ async function ensureApiKey(ctx: ExtensionContext): Promise<string> {
67
+ const existing = resolveExistingApiKey();
68
+ if (existing) return existing;
69
+
70
+ if (!ctx.hasUI) {
71
+ throw new Error("Gemini API key is not configured and no interactive UI is available to ask for one.");
72
+ }
73
+
74
+ const pasted = await ctx.ui.input("Paste your Gemini API key:");
75
+ if (!pasted || !pasted.trim()) {
76
+ throw new Error("No API key provided.");
77
+ }
78
+ const key = pasted.trim();
79
+
80
+ const save = await ctx.ui.confirm("Save for future use?", "The key will be kept locally for later calls.");
81
+ if (save) {
82
+ persistApiKey(key);
83
+ }
84
+ return key;
85
+ }
86
+
87
+ function validateForModel(model: ImageModel, p: Params): void {
88
+ if (isImagen(model)) {
89
+ if (p.aspect_ratio && !(IMAGEN_ASPECT_RATIOS as readonly string[]).includes(p.aspect_ratio)) {
90
+ throw new Error(
91
+ `aspect_ratio "${p.aspect_ratio}" is not supported for Imagen. Valid: ${IMAGEN_ASPECT_RATIOS.join(", ")}.`,
92
+ );
93
+ }
94
+ if (p.image_size && !(IMAGEN_IMAGE_SIZES as readonly string[]).includes(p.image_size)) {
95
+ throw new Error(
96
+ `image_size "${p.image_size}" is not supported for Imagen. Valid: ${IMAGEN_IMAGE_SIZES.join(", ")}.`,
97
+ );
98
+ }
99
+ if (p.input_images && p.input_images.length > 0) {
100
+ throw new Error("Imagen does not support input_images (text-to-image only). Use a Gemini-native model.");
101
+ }
102
+ } else if (isGeminiNative(model)) {
103
+ if (p.aspect_ratio && !(GEMINI_ASPECT_RATIOS as readonly string[]).includes(p.aspect_ratio)) {
104
+ throw new Error(
105
+ `aspect_ratio "${p.aspect_ratio}" is not supported for Gemini native. Valid: ${GEMINI_ASPECT_RATIOS.join(", ")}.`,
106
+ );
107
+ }
108
+ if (p.image_size && !(GEMINI_IMAGE_SIZES as readonly string[]).includes(p.image_size)) {
109
+ throw new Error(
110
+ `image_size "${p.image_size}" is not supported for Gemini native. Valid: ${GEMINI_IMAGE_SIZES.join(", ")}.`,
111
+ );
112
+ }
113
+ if (p.number_of_images !== undefined && p.number_of_images !== 1) {
114
+ throw new Error("Gemini native produces a single image per call; number_of_images only applies to Imagen.");
115
+ }
116
+ if (p.negative_prompt) {
117
+ throw new Error("negative_prompt is Imagen-only.");
118
+ }
119
+ if (p.seed !== undefined) {
120
+ throw new Error("seed is Imagen-only.");
121
+ }
122
+ }
123
+ }
124
+
125
+ interface DecodedImage {
126
+ data: string; // base64
127
+ mimeType: string;
128
+ }
129
+
130
+ async function callGeminiNative(
131
+ ai: GoogleGenAI,
132
+ model: string,
133
+ p: Params,
134
+ signal: AbortSignal | undefined,
135
+ ): Promise<DecodedImage[]> {
136
+ const imageConfig: Record<string, string> = {};
137
+ if (p.aspect_ratio) imageConfig.aspectRatio = p.aspect_ratio;
138
+ if (p.image_size) imageConfig.imageSize = p.image_size;
139
+ if (p.person_generation) imageConfig.personGeneration = p.person_generation;
140
+
141
+ const contents: Array<Record<string, unknown>> = [{ text: p.prompt }];
142
+ for (const path of p.input_images ?? []) {
143
+ const buffer = readFileSync(path);
144
+ const mimeType = path.toLowerCase().endsWith(".jpg") || path.toLowerCase().endsWith(".jpeg")
145
+ ? "image/jpeg"
146
+ : path.toLowerCase().endsWith(".webp")
147
+ ? "image/webp"
148
+ : "image/png";
149
+ contents.push({
150
+ inlineData: {
151
+ mimeType,
152
+ data: buffer.toString("base64"),
153
+ },
154
+ });
155
+ }
156
+
157
+ const response = await ai.models.generateContent({
158
+ model,
159
+ contents,
160
+ config: {
161
+ responseModalities: ["Text", "Image"],
162
+ ...(Object.keys(imageConfig).length > 0 ? { imageConfig } : {}),
163
+ ...(signal ? { abortSignal: signal } : {}),
164
+ },
165
+ });
166
+
167
+ const images: DecodedImage[] = [];
168
+ for (const candidate of response.candidates ?? []) {
169
+ for (const part of candidate.content?.parts ?? []) {
170
+ if (part.inlineData?.data && part.inlineData.mimeType) {
171
+ images.push({ data: part.inlineData.data, mimeType: part.inlineData.mimeType });
172
+ }
173
+ }
174
+ }
175
+ return images;
176
+ }
177
+
178
+ async function callImagen(
179
+ ai: GoogleGenAI,
180
+ model: string,
181
+ p: Params,
182
+ signal: AbortSignal | undefined,
183
+ ): Promise<DecodedImage[]> {
184
+ const config: Record<string, unknown> = {};
185
+ if (p.aspect_ratio) config.aspectRatio = p.aspect_ratio;
186
+ if (p.image_size) config.imageSize = p.image_size;
187
+ if (p.number_of_images !== undefined) config.numberOfImages = p.number_of_images;
188
+ if (p.negative_prompt) config.negativePrompt = p.negative_prompt;
189
+ if (p.person_generation) config.personGeneration = p.person_generation;
190
+ if (p.seed !== undefined) config.seed = p.seed;
191
+ if (signal) config.abortSignal = signal;
192
+
193
+ const response = await ai.models.generateImages({
194
+ model,
195
+ prompt: p.prompt,
196
+ ...(Object.keys(config).length > 0 ? { config } : {}),
197
+ });
198
+
199
+ const images: DecodedImage[] = [];
200
+ for (const generated of response.generatedImages ?? []) {
201
+ if (generated.image?.imageBytes) {
202
+ images.push({
203
+ data: generated.image.imageBytes,
204
+ mimeType: generated.image.mimeType ?? "image/png",
205
+ });
206
+ }
207
+ }
208
+ return images;
209
+ }
210
+
211
+ function saveImages(images: DecodedImage[], dir: string, prompt: string): string[] {
212
+ const slug = prompt
213
+ .toLowerCase()
214
+ .replace(/[^a-z0-9]+/g, "-")
215
+ .replace(/^-+|-+$/g, "")
216
+ .slice(0, 40) || "image";
217
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
218
+ const paths: string[] = [];
219
+ for (let i = 0; i < images.length; i++) {
220
+ const ext = images[i].mimeType === "image/jpeg" ? "jpg" : "png";
221
+ const base = images.length > 1 ? `${slug}-${ts}-${i + 1}.${ext}` : `${slug}-${ts}.${ext}`;
222
+ const full = `${dir.replace(/\/$/, "")}/${base}`;
223
+ writeFileSync(full, Buffer.from(images[i].data, "base64"));
224
+ paths.push(full);
225
+ }
226
+ return paths;
227
+ }
228
+
229
+ export default function geminiImageExtension(pi: ExtensionAPI): void {
230
+ pi.registerTool({
231
+ name: "gemini_image",
232
+ label: "Gemini Image",
233
+ description:
234
+ "Generate or edit images via Google's Gemini (native) and Imagen models. Supports aspect ratios, resolutions, image editing, negative prompts, and seeds.",
235
+ promptGuidelines: [
236
+ "Use gemini-2.5-flash-image (default) for general-purpose generation.",
237
+ "Use gemini-3-pro-image-preview for studio-quality 4K, complex layouts, or precise text rendering.",
238
+ "Use imagen-4.0-*-generate-001 models for batch text-to-image (1-4 per call) or when negative_prompt / seed is needed.",
239
+ "Image editing (input_images) is only supported by Gemini native models.",
240
+ "4K output is only supported by Gemini native models.",
241
+ ],
242
+ parameters: params,
243
+ async execute(_toolCallId, input, signal, _onUpdate, ctx) {
244
+ const p = input as Params;
245
+ const model = (p.model as ImageModel | undefined) ?? DEFAULT_MODEL;
246
+
247
+ if (!(ALL_MODELS as readonly string[]).includes(model)) {
248
+ throw new Error(`Unknown model "${model}". Valid: ${ALL_MODELS.join(", ")}.`);
249
+ }
250
+
251
+ validateForModel(model, p);
252
+
253
+ const apiKey = await ensureApiKey(ctx);
254
+
255
+ const numImages = isImagen(model) ? p.number_of_images ?? 1 : 1;
256
+
257
+ if (!p.skip_confirm && hasPricing(model) && ctx.hasUI) {
258
+ const est = estimateCost({
259
+ model,
260
+ prompt: p.prompt,
261
+ numberOfImages: numImages,
262
+ imageSize: p.image_size,
263
+ });
264
+ const proceed = await ctx.ui.confirm(
265
+ "Confirm image generation",
266
+ `Model: ${model}\nImages: ${numImages}${p.image_size ? `\nSize: ${p.image_size}` : ""}\nEstimated: ~${formatUsd(est.estimatedUsd)} (${est.breakdown})`,
267
+ );
268
+ if (!proceed) {
269
+ throw new Error("User cancelled image generation.");
270
+ }
271
+ }
272
+
273
+ const ai = new GoogleGenAI({ apiKey });
274
+
275
+ const images = isImagen(model)
276
+ ? await callImagen(ai, model, p, signal)
277
+ : await callGeminiNative(ai, model, p, signal);
278
+
279
+ if (images.length === 0) {
280
+ throw new Error("Model returned no images.");
281
+ }
282
+
283
+ const savedPaths = p.save_to ? saveImages(images, p.save_to, p.prompt) : [];
284
+
285
+ const summaryLines = [
286
+ `Generated ${images.length} image${images.length > 1 ? "s" : ""} with ${model}.`,
287
+ `Prompt: "${p.prompt}"`,
288
+ ];
289
+ if (p.aspect_ratio) summaryLines.push(`Aspect ratio: ${p.aspect_ratio}`);
290
+ if (p.image_size) summaryLines.push(`Size: ${p.image_size}`);
291
+ if (p.negative_prompt) summaryLines.push(`Negative: "${p.negative_prompt}"`);
292
+ if (p.person_generation) summaryLines.push(`People: ${p.person_generation}`);
293
+ if (p.seed !== undefined) summaryLines.push(`Seed: ${p.seed}`);
294
+ if (savedPaths.length > 0) summaryLines.push(`Saved to:\n ${savedPaths.join("\n ")}`);
295
+
296
+ const content: AgentToolResult<unknown>["content"] = images.map((img) => ({
297
+ type: "image",
298
+ data: img.data,
299
+ mimeType: img.mimeType,
300
+ }));
301
+ content.push({ type: "text", text: summaryLines.join("\n") });
302
+
303
+ return {
304
+ content,
305
+ details: {
306
+ model,
307
+ images: images.length,
308
+ savedPaths,
309
+ },
310
+ };
311
+ },
312
+ });
313
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ ALL_MODELS,
4
+ DEFAULT_MODEL,
5
+ GEMINI_ASPECT_RATIOS,
6
+ GEMINI_NATIVE_MODELS,
7
+ IMAGEN_ASPECT_RATIOS,
8
+ IMAGEN_MODELS,
9
+ isGeminiNative,
10
+ isImagen,
11
+ } from "./models.ts";
12
+
13
+ describe("models", () => {
14
+ it("has 3 Gemini native + 3 Imagen = 6 models", () => {
15
+ expect(GEMINI_NATIVE_MODELS).toHaveLength(3);
16
+ expect(IMAGEN_MODELS).toHaveLength(3);
17
+ expect(ALL_MODELS).toHaveLength(6);
18
+ });
19
+
20
+ it("default is gemini-2.5-flash-image", () => {
21
+ expect(DEFAULT_MODEL).toBe("gemini-2.5-flash-image");
22
+ });
23
+
24
+ it("Gemini aspect ratios include 4:5 and 5:4 (missing from the junior's impl)", () => {
25
+ expect(GEMINI_ASPECT_RATIOS).toContain("4:5");
26
+ expect(GEMINI_ASPECT_RATIOS).toContain("5:4");
27
+ });
28
+
29
+ it("Imagen has 5 aspect ratios (no 2:3 / 3:2 / 4:5 / 5:4 / 21:9)", () => {
30
+ expect(IMAGEN_ASPECT_RATIOS).toHaveLength(5);
31
+ expect(IMAGEN_ASPECT_RATIOS).not.toContain("2:3");
32
+ });
33
+
34
+ it("isGeminiNative narrows correctly", () => {
35
+ expect(isGeminiNative("gemini-2.5-flash-image")).toBe(true);
36
+ expect(isGeminiNative("imagen-4.0-generate-001")).toBe(false);
37
+ expect(isGeminiNative("unknown")).toBe(false);
38
+ });
39
+
40
+ it("isImagen narrows correctly", () => {
41
+ expect(isImagen("imagen-4.0-ultra-generate-001")).toBe(true);
42
+ expect(isImagen("gemini-3-pro-image-preview")).toBe(false);
43
+ expect(isImagen("unknown")).toBe(false);
44
+ });
45
+ });
@@ -0,0 +1,50 @@
1
+ export const GEMINI_NATIVE_MODELS = [
2
+ "gemini-2.5-flash-image",
3
+ "gemini-3.1-flash-image-preview",
4
+ "gemini-3-pro-image-preview",
5
+ ] as const;
6
+
7
+ export const IMAGEN_MODELS = [
8
+ "imagen-4.0-fast-generate-001",
9
+ "imagen-4.0-generate-001",
10
+ "imagen-4.0-ultra-generate-001",
11
+ ] as const;
12
+
13
+ export type GeminiNativeModel = (typeof GEMINI_NATIVE_MODELS)[number];
14
+ export type ImagenModel = (typeof IMAGEN_MODELS)[number];
15
+ export type ImageModel = GeminiNativeModel | ImagenModel;
16
+
17
+ export const ALL_MODELS: readonly ImageModel[] = [
18
+ ...GEMINI_NATIVE_MODELS,
19
+ ...IMAGEN_MODELS,
20
+ ];
21
+
22
+ export const DEFAULT_MODEL: ImageModel = "gemini-2.5-flash-image";
23
+
24
+ export function isGeminiNative(model: string): model is GeminiNativeModel {
25
+ return (GEMINI_NATIVE_MODELS as readonly string[]).includes(model);
26
+ }
27
+
28
+ export function isImagen(model: string): model is ImagenModel {
29
+ return (IMAGEN_MODELS as readonly string[]).includes(model);
30
+ }
31
+
32
+ export const GEMINI_ASPECT_RATIOS = [
33
+ "1:1",
34
+ "2:3",
35
+ "3:2",
36
+ "3:4",
37
+ "4:3",
38
+ "4:5",
39
+ "5:4",
40
+ "9:16",
41
+ "16:9",
42
+ "21:9",
43
+ ] as const;
44
+
45
+ export const IMAGEN_ASPECT_RATIOS = ["1:1", "3:4", "4:3", "9:16", "16:9"] as const;
46
+
47
+ export const GEMINI_IMAGE_SIZES = ["1K", "2K", "4K"] as const;
48
+ export const IMAGEN_IMAGE_SIZES = ["1K", "2K"] as const;
49
+
50
+ export const PERSON_GENERATION_VALUES = ["dont_allow", "allow_adult", "allow_all"] as const;
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { estimateCost, formatUsd, hasPricing } from "./pricing.ts";
3
+
4
+ describe("estimateCost", () => {
5
+ it("Imagen 4 Fast: $0.02 × N images", () => {
6
+ const e = estimateCost({
7
+ model: "imagen-4.0-fast-generate-001",
8
+ prompt: "cat",
9
+ numberOfImages: 3,
10
+ });
11
+ expect(e.estimatedUsd).toBeCloseTo(0.06, 5);
12
+ expect(e.breakdown).toContain("0.02");
13
+ });
14
+
15
+ it("Imagen 4 Standard single image", () => {
16
+ const e = estimateCost({
17
+ model: "imagen-4.0-generate-001",
18
+ prompt: "x",
19
+ numberOfImages: 1,
20
+ });
21
+ expect(e.estimatedUsd).toBeCloseTo(0.04, 5);
22
+ });
23
+
24
+ it("Imagen 4 Ultra", () => {
25
+ const e = estimateCost({
26
+ model: "imagen-4.0-ultra-generate-001",
27
+ prompt: "x",
28
+ numberOfImages: 4,
29
+ });
30
+ expect(e.estimatedUsd).toBeCloseTo(0.24, 5);
31
+ });
32
+
33
+ it("Gemini 3 Pro 4K is more expensive than 2K", () => {
34
+ const at4k = estimateCost({
35
+ model: "gemini-3-pro-image-preview",
36
+ prompt: "small",
37
+ numberOfImages: 1,
38
+ imageSize: "4K",
39
+ });
40
+ const at2k = estimateCost({
41
+ model: "gemini-3-pro-image-preview",
42
+ prompt: "small",
43
+ numberOfImages: 1,
44
+ imageSize: "2K",
45
+ });
46
+ expect(at4k.estimatedUsd).toBeGreaterThan(at2k.estimatedUsd);
47
+ });
48
+
49
+ it("Gemini 3 Pro prompt tokens contribute to cost (long prompt > short)", () => {
50
+ const longPrompt = "x".repeat(10_000);
51
+ const shortEst = estimateCost({
52
+ model: "gemini-3-pro-image-preview",
53
+ prompt: "x",
54
+ numberOfImages: 1,
55
+ });
56
+ const longEst = estimateCost({
57
+ model: "gemini-3-pro-image-preview",
58
+ prompt: longPrompt,
59
+ numberOfImages: 1,
60
+ });
61
+ expect(longEst.estimatedUsd).toBeGreaterThan(shortEst.estimatedUsd);
62
+ });
63
+
64
+ it("Flash estimate scales linearly with numberOfImages", () => {
65
+ const one = estimateCost({
66
+ model: "gemini-2.5-flash-image",
67
+ prompt: "x",
68
+ numberOfImages: 1,
69
+ });
70
+ const four = estimateCost({
71
+ model: "gemini-2.5-flash-image",
72
+ prompt: "x",
73
+ numberOfImages: 4,
74
+ });
75
+ // Output dominates; roughly 4x
76
+ expect(four.estimatedUsd).toBeGreaterThan(3.5 * one.estimatedUsd * 0.99);
77
+ });
78
+ });
79
+
80
+ describe("formatUsd", () => {
81
+ it("renders sub-cent as <$0.01", () => {
82
+ expect(formatUsd(0.002)).toBe("<$0.01");
83
+ });
84
+
85
+ it("renders 4 decimals for normal amounts", () => {
86
+ expect(formatUsd(0.1234)).toBe("$0.1234");
87
+ });
88
+ });
89
+
90
+ describe("hasPricing", () => {
91
+ it("returns true for all known models", () => {
92
+ expect(hasPricing("gemini-2.5-flash-image")).toBe(true);
93
+ expect(hasPricing("imagen-4.0-fast-generate-001")).toBe(true);
94
+ });
95
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Gemini image-generation price table.
3
+ *
4
+ * Last verified: 2026-04-24 via ai.google.dev/gemini-api/docs/pricing.
5
+ * Prices drift — edit when re-checked.
6
+ */
7
+
8
+ import type { ImageModel } from "./models.ts";
9
+
10
+ interface ImagenPrice {
11
+ kind: "flat";
12
+ perImage: number; // USD
13
+ }
14
+
15
+ interface GeminiNativePrice {
16
+ kind: "token";
17
+ // $ per 1M input tokens
18
+ inputPer1M: number;
19
+ // $ per output image at 1K/2K resolution
20
+ outputPerImage1Kor2K: number;
21
+ // $ per output image at 4K resolution
22
+ outputPerImage4K: number;
23
+ // Approx output tokens per image (for reference; not used in estimate)
24
+ tokensPerImage: number;
25
+ }
26
+
27
+ type ModelPrice = ImagenPrice | GeminiNativePrice;
28
+
29
+ const PRICES: Record<ImageModel, ModelPrice> = {
30
+ "imagen-4.0-fast-generate-001": { kind: "flat", perImage: 0.02 },
31
+ "imagen-4.0-generate-001": { kind: "flat", perImage: 0.04 },
32
+ "imagen-4.0-ultra-generate-001": { kind: "flat", perImage: 0.06 },
33
+ "gemini-2.5-flash-image": {
34
+ kind: "token",
35
+ inputPer1M: 0.3,
36
+ outputPerImage1Kor2K: 0.039,
37
+ outputPerImage4K: 0.039,
38
+ tokensPerImage: 1290,
39
+ },
40
+ "gemini-3.1-flash-image-preview": {
41
+ kind: "token",
42
+ inputPer1M: 0.3,
43
+ outputPerImage1Kor2K: 0.039,
44
+ outputPerImage4K: 0.078,
45
+ tokensPerImage: 1290,
46
+ },
47
+ "gemini-3-pro-image-preview": {
48
+ kind: "token",
49
+ inputPer1M: 2.0,
50
+ outputPerImage1Kor2K: 0.134,
51
+ outputPerImage4K: 0.24,
52
+ tokensPerImage: 1290,
53
+ },
54
+ };
55
+
56
+ export interface EstimateInput {
57
+ model: ImageModel;
58
+ prompt: string;
59
+ numberOfImages: number;
60
+ imageSize?: "1K" | "2K" | "4K";
61
+ inputImagesBytes?: number;
62
+ }
63
+
64
+ export interface EstimateResult {
65
+ estimatedUsd: number;
66
+ breakdown: string;
67
+ }
68
+
69
+ const CHARS_PER_TOKEN_APPROX = 4;
70
+
71
+ export function estimateCost(input: EstimateInput): EstimateResult {
72
+ const price = PRICES[input.model];
73
+ if (price.kind === "flat") {
74
+ const usd = price.perImage * input.numberOfImages;
75
+ return {
76
+ estimatedUsd: usd,
77
+ breakdown: `${input.numberOfImages} × $${price.perImage.toFixed(2)}/image`,
78
+ };
79
+ }
80
+
81
+ const inputTokens = Math.ceil(input.prompt.length / CHARS_PER_TOKEN_APPROX);
82
+ const inputCost = (inputTokens / 1_000_000) * price.inputPer1M;
83
+ const perImage =
84
+ input.imageSize === "4K" ? price.outputPerImage4K : price.outputPerImage1Kor2K;
85
+ const outputCost = perImage * input.numberOfImages;
86
+ const total = inputCost + outputCost;
87
+ return {
88
+ estimatedUsd: total,
89
+ breakdown:
90
+ `${input.numberOfImages} × $${perImage.toFixed(3)} (${input.imageSize ?? "1K/2K"}) + ` +
91
+ `~${inputTokens} input tokens`,
92
+ };
93
+ }
94
+
95
+ export function formatUsd(usd: number): string {
96
+ if (usd < 0.01) return `<$0.01`;
97
+ return `$${usd.toFixed(4)}`;
98
+ }
99
+
100
+ export function hasPricing(model: ImageModel): boolean {
101
+ return model in PRICES;
102
+ }