@heyramzi/utils 0.0.0-stage → 1.13.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.
- package/LICENSE +21 -0
- package/README.md +112 -2
- package/dist/ai/image.js +220 -0
- package/dist/ai/models.js +261 -0
- package/dist/analytics/client.js +57 -0
- package/dist/analytics/server.js +54 -0
- package/dist/analytics/svelte.js +24 -0
- package/dist/analytics/types.js +0 -0
- package/dist/analytics/umami/protocol.js +35 -0
- package/dist/analytics/umami/reporting.js +189 -0
- package/dist/auth/config/otp.js +4 -0
- package/dist/auth/config/rate-limits.js +20 -0
- package/dist/auth/config/trusted-origins.js +15 -0
- package/dist/auth/email/index.js +3 -0
- package/dist/auth/email/layout.js +84 -0
- package/dist/auth/email/resend.js +30 -0
- package/dist/auth/email/template.js +57 -0
- package/dist/auth/email/types.js +0 -0
- package/dist/auth/index.js +3 -0
- package/dist/cache.js +4 -0
- package/dist/cn.js +5 -0
- package/dist/collection.js +8 -0
- package/dist/crypto.js +117 -0
- package/dist/error.js +15 -0
- package/dist/errors/sentry/index.js +15 -0
- package/dist/errors/sentry/protocol.js +130 -0
- package/dist/errors/sentry/sveltekit.js +145 -0
- package/dist/format.js +62 -0
- package/dist/grab.js +112 -0
- package/dist/index.js +10 -0
- package/dist/logger-types.js +0 -0
- package/dist/logger.js +22 -0
- package/dist/math.js +3 -0
- package/dist/og/fonts.js +46 -0
- package/dist/og/index.js +3 -0
- package/dist/og/markup.js +31 -0
- package/dist/og/renderer.js +29 -0
- package/dist/og/types.js +0 -0
- package/dist/result.js +26 -0
- package/dist/string.js +32 -0
- package/dist/testimonials/render-content.js +15 -0
- package/dist/testimonials/server.js +21 -0
- package/dist/testimonials/types.js +0 -0
- package/dist/timing.js +20 -0
- package/dist/vite.js +265 -0
- package/package.json +201 -4
- package/src/ai/image-agnes.test.ts +130 -0
- package/src/ai/image.ts +435 -0
- package/src/ai/models.test.ts +98 -0
- package/src/ai/models.ts +450 -0
- package/src/analytics/client.test.ts +149 -0
- package/src/analytics/client.ts +129 -0
- package/src/analytics/server.test.ts +165 -0
- package/src/analytics/server.ts +88 -0
- package/src/analytics/svelte.test.ts +105 -0
- package/src/analytics/svelte.ts +58 -0
- package/src/analytics/types.ts +26 -0
- package/src/analytics/umami/protocol.test.ts +18 -0
- package/src/analytics/umami/protocol.ts +104 -0
- package/src/analytics/umami/reporting.test.ts +391 -0
- package/src/analytics/umami/reporting.ts +627 -0
- package/src/auth/config/otp.ts +8 -0
- package/src/auth/config/rate-limits.ts +11 -0
- package/src/auth/config/trusted-origins.ts +32 -0
- package/src/auth/email/index.ts +7 -0
- package/src/auth/email/layout.test.ts +73 -0
- package/src/auth/email/layout.ts +122 -0
- package/src/auth/email/resend.test.ts +35 -0
- package/src/auth/email/resend.ts +26 -0
- package/src/auth/email/template.ts +77 -0
- package/src/auth/email/types.ts +74 -0
- package/src/auth/index.ts +5 -0
- package/src/cache.test.ts +67 -0
- package/src/cache.ts +5 -0
- package/src/cn.test.ts +52 -0
- package/src/cn.ts +16 -0
- package/src/collection.test.ts +66 -0
- package/src/collection.ts +8 -0
- package/src/crypto.test.ts +86 -0
- package/src/crypto.ts +157 -0
- package/src/error.test.ts +79 -0
- package/src/error.ts +16 -0
- package/src/errors/sentry/index.ts +20 -0
- package/src/errors/sentry/protocol.test.ts +85 -0
- package/src/errors/sentry/protocol.ts +144 -0
- package/src/errors/sentry/sveltekit.test.ts +149 -0
- package/src/errors/sentry/sveltekit.ts +226 -0
- package/src/format.test.ts +225 -0
- package/src/format.ts +103 -0
- package/src/grab.d.ts +2 -0
- package/src/grab.js +112 -0
- package/src/index.ts +38 -0
- package/src/logger-types.ts +14 -0
- package/src/logger.ts +24 -0
- package/src/math.test.ts +41 -0
- package/src/math.ts +3 -0
- package/src/og/fonts.ts +69 -0
- package/src/og/index.ts +5 -0
- package/src/og/markup.test.ts +73 -0
- package/src/og/markup.ts +90 -0
- package/src/og/renderer.ts +42 -0
- package/src/og/types.ts +26 -0
- package/src/og/wawoff2.d.ts +6 -0
- package/src/package-imports.test.mjs +22 -0
- package/src/result.test.ts +149 -0
- package/src/result.ts +29 -0
- package/src/string.test.ts +205 -0
- package/src/string.ts +71 -0
- package/src/testimonials/render-content.ts +21 -0
- package/src/testimonials/server.ts +47 -0
- package/src/testimonials/types.ts +35 -0
- package/src/timing.test.ts +166 -0
- package/src/timing.ts +28 -0
- package/src/vite.d.ts +12 -0
- package/src/vite.js +265 -0
- package/src/vite.test.js +235 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
|
|
2
|
+
import { generateImage } from "#src/ai/image.js";
|
|
3
|
+
|
|
4
|
+
function mockFetch(replies: { ok?: boolean; status?: number; json?: unknown; bytes?: string }[]) {
|
|
5
|
+
const calls: { url: string; init?: RequestInit }[] = [];
|
|
6
|
+
let index = 0;
|
|
7
|
+
vi.stubGlobal(
|
|
8
|
+
"fetch",
|
|
9
|
+
vi.fn(async (url: string | URL, init?: RequestInit) => {
|
|
10
|
+
calls.push({ url: String(url), init });
|
|
11
|
+
const reply = replies[Math.min(index++, replies.length - 1)];
|
|
12
|
+
return {
|
|
13
|
+
ok: reply.ok ?? true,
|
|
14
|
+
status: reply.status ?? 200,
|
|
15
|
+
headers: new Headers({ "content-type": "image/webp" }),
|
|
16
|
+
json: async () => reply.json ?? {},
|
|
17
|
+
arrayBuffer: async () => new TextEncoder().encode(reply.bytes ?? "").buffer,
|
|
18
|
+
} as unknown as Response;
|
|
19
|
+
}),
|
|
20
|
+
);
|
|
21
|
+
return calls;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const OPTS = { prompt: "a poster", apiKey: "agnes_test" };
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
vi.unstubAllGlobals();
|
|
28
|
+
vi.unstubAllEnvs();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("generateImage on the agnes lane", () => {
|
|
32
|
+
it("posts to the Agnes images endpoint and returns decoded bytes at no cost", async () => {
|
|
33
|
+
const calls = mockFetch([{ json: { data: [{ b64_json: btoa("PNGDATA") }] } }]);
|
|
34
|
+
|
|
35
|
+
const image = await generateImage({ ...OPTS, provider: "agnes" });
|
|
36
|
+
|
|
37
|
+
expect(calls[0].url).toBe("https://apihub.agnes-ai.com/v1/images/generations");
|
|
38
|
+
expect(new TextDecoder().decode(image.bytes)).toBe("PNGDATA");
|
|
39
|
+
expect(image.priceUsd).toBe(0);
|
|
40
|
+
expect(image.config.provider).toBe("agnes");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// The tier is the output resolution, because that is how Agnes prices a frame.
|
|
44
|
+
it.each([
|
|
45
|
+
["small", "1K"],
|
|
46
|
+
["medium", "2K"],
|
|
47
|
+
["large", "4K"],
|
|
48
|
+
] as const)("maps the %s tier to size %s", async (tier, size) => {
|
|
49
|
+
const calls = mockFetch([{ json: { data: [{ b64_json: btoa("x") }] } }]);
|
|
50
|
+
|
|
51
|
+
await generateImage({ ...OPTS, provider: "agnes", tier });
|
|
52
|
+
|
|
53
|
+
expect(JSON.parse(String(calls[0].init?.body))).toMatchObject({ size, ratio: "1:1" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// The regression guard for a 5-minute hang. Asking Agnes for base64 makes it hold the connection without sending headers, and undici kills the request at exactly 300s with UND_ERR_HEADERS_TIMEOUT.
|
|
57
|
+
|
|
58
|
+
// The same prompt answers in 35s in url mode. Measured 31 Aug 2026.
|
|
59
|
+
it("never asks for base64, which is what made the request time out", async () => {
|
|
60
|
+
const calls = mockFetch([
|
|
61
|
+
{ json: { data: [{ url: "https://cdn.agnes/f.png" }] } },
|
|
62
|
+
{ bytes: "PNG" },
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
await generateImage({ ...OPTS, provider: "agnes" });
|
|
66
|
+
|
|
67
|
+
const body = JSON.parse(String(calls[0].init?.body));
|
|
68
|
+
expect(body.return_base64).toBeUndefined();
|
|
69
|
+
expect(body.extra_body).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Measured 9 Sep 2026: the lane carries text-to-image only, and reports a reference as `403 team not allowed to access model`, which reads as a dead lane rather than a
|
|
73
|
+
|
|
74
|
+
// missing capability. It used to send the reference as a data URI and let the API answer that; refusing before the call is what makes the error true,
|
|
75
|
+
|
|
76
|
+
// and no request is worth spending to be told this.
|
|
77
|
+
it("refuses a reference before spending a call, and says why", async () => {
|
|
78
|
+
const calls = mockFetch([{ json: { data: [{ b64_json: btoa("x") }] } }]);
|
|
79
|
+
|
|
80
|
+
await expect(
|
|
81
|
+
generateImage({
|
|
82
|
+
...OPTS,
|
|
83
|
+
provider: "agnes",
|
|
84
|
+
references: [{ bytes: new TextEncoder().encode("REF"), mediaType: "image/jpeg" }],
|
|
85
|
+
}),
|
|
86
|
+
).rejects.toThrow("draws from a prompt only");
|
|
87
|
+
|
|
88
|
+
expect(calls).toHaveLength(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("carries the aspect ratio through as `ratio`", async () => {
|
|
92
|
+
const calls = mockFetch([{ json: { data: [{ b64_json: btoa("x") }] } }]);
|
|
93
|
+
|
|
94
|
+
await generateImage({ ...OPTS, provider: "agnes", aspectRatio: "16:9" });
|
|
95
|
+
|
|
96
|
+
expect(JSON.parse(String(calls[0].init?.body)).ratio).toBe("16:9");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// An account that ignores `return_base64` answers with a url instead. Fetching
|
|
100
|
+
// it here keeps every caller on one return shape: bytes.
|
|
101
|
+
it("fetches the url when the account answers with one instead of base64", async () => {
|
|
102
|
+
const calls = mockFetch([
|
|
103
|
+
{ json: { data: [{ url: "https://cdn.agnes/frame.webp" }] } },
|
|
104
|
+
{ bytes: "WEBPDATA" },
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
const image = await generateImage({ ...OPTS, provider: "agnes" });
|
|
108
|
+
|
|
109
|
+
expect(calls[1].url).toBe("https://cdn.agnes/frame.webp");
|
|
110
|
+
expect(new TextDecoder().decode(image.bytes)).toBe("WEBPDATA");
|
|
111
|
+
expect(image.mediaType).toBe("image/webp");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("carries the provider's own message when it refuses", async () => {
|
|
115
|
+
mockFetch([{ ok: false, status: 400, json: { error: { message: "ratio not supported" } } }]);
|
|
116
|
+
|
|
117
|
+
await expect(generateImage({ ...OPTS, provider: "agnes" })).rejects.toThrow(
|
|
118
|
+
"ratio not supported",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("names the env var when no key is anywhere", async () => {
|
|
123
|
+
vi.stubEnv("AGNES_API_KEY", "");
|
|
124
|
+
mockFetch([{ json: {} }]);
|
|
125
|
+
|
|
126
|
+
await expect(generateImage({ prompt: "p", provider: "agnes" })).rejects.toThrow(
|
|
127
|
+
"AGNES_API_KEY",
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|
package/src/ai/image.ts
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { generateText, type LanguageModel } from "ai";
|
|
2
|
+
import {
|
|
3
|
+
gatewayId,
|
|
4
|
+
IMAGE_OUTPUT_TOKEN_PRICE_USD,
|
|
5
|
+
resolveImageModelConfig,
|
|
6
|
+
type ImageModelConfig,
|
|
7
|
+
type ModelTier,
|
|
8
|
+
type Provider,
|
|
9
|
+
} from "#src/ai/models.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One image call for every caller, on the same three tiers as text.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
* WHY it lives here and not in each app: an image call is not a `generateText` call with a prompt, it is a `generateText` call with a provider-specific `responseModalities` block,
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/*
|
|
20
|
+
* an aspect ratio nested one level deeper, a file part per reference, and a retry, because the model answers with prose instead of a picture often enough to lose a
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
* run.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* Four repos each wrote that by hand, each with its own key order in front of it, and that is how image traffic ended up bypassing the Cloudflare AI Gateway
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/*
|
|
32
|
+
* entirely: the bypass was never a decision, it was the fourth copy drifting.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export type ImageReference = {
|
|
36
|
+
/** Raw bytes of an image the model should look at rather than imagine. */
|
|
37
|
+
bytes: Uint8Array;
|
|
38
|
+
mediaType: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type GenerateImageOptions = {
|
|
42
|
+
prompt: string;
|
|
43
|
+
/** small samples, medium ships, large carries a face or a real logo. */
|
|
44
|
+
tier?: ModelTier;
|
|
45
|
+
provider?: Provider;
|
|
46
|
+
aspectRatio?: string;
|
|
47
|
+
references?: ImageReference[];
|
|
48
|
+
/** How many times to re-ask when the model answers with text. Default 3. */
|
|
49
|
+
attempts?: number;
|
|
50
|
+
/** OpenAI and Agnes: the key, when the environment does not carry it. */
|
|
51
|
+
apiKey?: string;
|
|
52
|
+
/**
|
|
53
|
+
* OpenAI: the pixel size asked for, which defaults from `aspectRatio`, because OpenAI takes a size from a fixed list rather than a ratio and the list has no 16:9 entry.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/*
|
|
57
|
+
* Agnes: the output resolution, which is how Agnes prices a frame, defaulting from the tier as 1K, 2K, 4K.
|
|
58
|
+
*/
|
|
59
|
+
size?: string;
|
|
60
|
+
/**
|
|
61
|
+
* The gateway the Google lane draws through, e.g. `createGateway({ apiKey: env.AI_GATEWAY_API_KEY })`.
|
|
62
|
+
* Left out, the AI SDK's default provider takes the string id, which reads AI_GATEWAY_API_KEY itself.
|
|
63
|
+
*/
|
|
64
|
+
gateway?: (modelId: string) => LanguageModel;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type GeneratedImage = {
|
|
68
|
+
bytes: Uint8Array;
|
|
69
|
+
/** Carried out rather than assumed: the model answers JPEG as often as PNG. */
|
|
70
|
+
mediaType: string;
|
|
71
|
+
config: ImageModelConfig;
|
|
72
|
+
/** What this frame cost, recorded at the moment it was drawn. */
|
|
73
|
+
priceUsd: number;
|
|
74
|
+
attempts: number;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The Gemini image request. Without `responseModalities` the model answers in prose and the
|
|
79
|
+
* call fails as "returned no image", so a dropped options block reads as a dead lane.
|
|
80
|
+
*/
|
|
81
|
+
function providerOptions(config: ImageModelConfig, aspectRatio?: string) {
|
|
82
|
+
if (config.provider !== "google") {
|
|
83
|
+
throw new Error(`Provider "${config.provider}" has no image request shape in @heyramzi/utils.`);
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
google: {
|
|
87
|
+
responseModalities: ["IMAGE"],
|
|
88
|
+
...(aspectRatio ? { imageConfig: { aspectRatio } } : {}),
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Base64 to bytes without Buffer, so the package stays runtime-agnostic. */
|
|
94
|
+
function decodeBase64(value: string): Uint8Array {
|
|
95
|
+
const binary = atob(value);
|
|
96
|
+
const bytes = new Uint8Array(binary.length);
|
|
97
|
+
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
|
98
|
+
return bytes;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ------------------------------------------------------------------- openai
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* OpenAI's image family, which is not a chat model and so cannot go through the AI SDK path above.
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
/*
|
|
108
|
+
* WHY a raw fetch rather than another adapter: `/v1/images/generations` and `/v1/images/edits` are their own endpoints with their own multipart shape, and the AI SDK's image surface does not carry the
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
/*
|
|
112
|
+
* two arguments that make this model worth calling here: several reference images at once, and the fidelity flag that decides whether a supplied logo survives the render.
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
* This lane was deleted on 1 Sep 2026 and restored the same day, so the one thing worth writing down is what the round trip settled:
|
|
117
|
+
*/
|
|
118
|
+
|
|
119
|
+
/*
|
|
120
|
+
* the default provider is a separate question from whether the transport exists. Nothing reaches here unless a caller names `provider: "openai"`.
|
|
121
|
+
*/
|
|
122
|
+
|
|
123
|
+
/*
|
|
124
|
+
* The tier means quality, not a different checkpoint, because OpenAI bills the tokens a frame returns.
|
|
125
|
+
*/
|
|
126
|
+
const OPENAI_QUALITY: Record<ModelTier, string> = {
|
|
127
|
+
small: "low",
|
|
128
|
+
medium: "medium",
|
|
129
|
+
large: "high",
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The size a ratio asks for.
|
|
134
|
+
*/
|
|
135
|
+
|
|
136
|
+
/*
|
|
137
|
+
* OpenAI takes a size from a list and the list has no 16:9 entry, so a widescreen frame is drawn at the widest landscape bracket and cropped by the caller.
|
|
138
|
+
*/
|
|
139
|
+
|
|
140
|
+
/*
|
|
141
|
+
* Left unmapped, a ratio falls through to `auto` rather than to a wrong shape.
|
|
142
|
+
*/
|
|
143
|
+
const OPENAI_SIZE_BY_RATIO: Record<string, string> = {
|
|
144
|
+
"16:9": "1536x1024",
|
|
145
|
+
"3:2": "1536x1024",
|
|
146
|
+
"1:1": "1024x1024",
|
|
147
|
+
"2:3": "1024x1536",
|
|
148
|
+
"9:16": "1024x1536",
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
type OpenAiImageResponse = {
|
|
152
|
+
data?: { b64_json?: string }[];
|
|
153
|
+
usage?: { output_tokens?: number };
|
|
154
|
+
error?: { message?: string; param?: string | null };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
function openAiCost(config: ImageModelConfig, outputTokens: number | undefined): number {
|
|
158
|
+
const perToken = IMAGE_OUTPUT_TOKEN_PRICE_USD[config.modelId];
|
|
159
|
+
if (!perToken || !outputTokens) return config.pricePerImage;
|
|
160
|
+
return outputTokens * perToken;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The key, from the caller or from the environment.
|
|
165
|
+
*/
|
|
166
|
+
|
|
167
|
+
/*
|
|
168
|
+
* WHY the environment is reached through `globalThis`: this package ships without node types on purpose, so it runs in a worker as readily as in a script,
|
|
169
|
+
*/
|
|
170
|
+
|
|
171
|
+
/*
|
|
172
|
+
* and a bare `process.env` would not compile.
|
|
173
|
+
*/
|
|
174
|
+
function openAiKey(explicit?: string): string {
|
|
175
|
+
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
|
|
176
|
+
?.env;
|
|
177
|
+
const apiKey = explicit ?? env?.OPENAI_API_KEY;
|
|
178
|
+
if (!apiKey) throw new Error("OPENAI_API_KEY is not set, so no OpenAI image can be drawn.");
|
|
179
|
+
return apiKey;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function postOpenAi(
|
|
183
|
+
path: string,
|
|
184
|
+
body: BodyInit,
|
|
185
|
+
headers: Record<string, string>,
|
|
186
|
+
apiKey: string,
|
|
187
|
+
) {
|
|
188
|
+
const response = await fetch(`https://api.openai.com/v1/${path}`, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { Authorization: `Bearer ${apiKey}`, ...headers },
|
|
191
|
+
body,
|
|
192
|
+
});
|
|
193
|
+
const json = (await response.json().catch(() => ({}))) as OpenAiImageResponse;
|
|
194
|
+
return { ok: response.ok, status: response.status, json };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* One frame from OpenAI, with the references attached when there are any.
|
|
199
|
+
*/
|
|
200
|
+
|
|
201
|
+
/*
|
|
202
|
+
* `input_fidelity` is what keeps a supplied card, mark or screenshot reproduced rather than reinterpreted, and it is the reason references go to `/edits` instead of being described in the prompt.
|
|
203
|
+
*/
|
|
204
|
+
|
|
205
|
+
/*
|
|
206
|
+
* It is dropped and the call retried when the API says it does not know the parameter, so a model that has not shipped it yet degrades to a normal
|
|
207
|
+
*/
|
|
208
|
+
|
|
209
|
+
/*
|
|
210
|
+
* edit rather than failing the run.
|
|
211
|
+
*/
|
|
212
|
+
async function generateOpenAiImage(
|
|
213
|
+
config: ImageModelConfig,
|
|
214
|
+
options: GenerateImageOptions,
|
|
215
|
+
tier: ModelTier,
|
|
216
|
+
): Promise<GeneratedImage> {
|
|
217
|
+
const size = options.size ?? OPENAI_SIZE_BY_RATIO[options.aspectRatio ?? ""] ?? "auto";
|
|
218
|
+
const quality = OPENAI_QUALITY[tier];
|
|
219
|
+
const references = options.references ?? [];
|
|
220
|
+
const apiKey = openAiKey(options.apiKey);
|
|
221
|
+
|
|
222
|
+
async function call(withFidelity: boolean) {
|
|
223
|
+
if (references.length === 0) {
|
|
224
|
+
return postOpenAi(
|
|
225
|
+
"images/generations",
|
|
226
|
+
JSON.stringify({ model: config.sdkId, prompt: options.prompt, size, quality, n: 1 }),
|
|
227
|
+
{ "Content-Type": "application/json" },
|
|
228
|
+
apiKey,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
const form = new FormData();
|
|
232
|
+
form.set("model", config.sdkId);
|
|
233
|
+
form.set("prompt", options.prompt);
|
|
234
|
+
form.set("size", size);
|
|
235
|
+
form.set("quality", quality);
|
|
236
|
+
form.set("n", "1");
|
|
237
|
+
if (withFidelity) form.set("input_fidelity", "high");
|
|
238
|
+
for (const [index, reference] of references.entries()) {
|
|
239
|
+
const extension = reference.mediaType.split("/")[1] ?? "png";
|
|
240
|
+
form.append(
|
|
241
|
+
"image[]",
|
|
242
|
+
new Blob([reference.bytes as unknown as ArrayBuffer], { type: reference.mediaType }),
|
|
243
|
+
`reference-${index + 1}.${extension}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
return postOpenAi("images/edits", form, {}, apiKey);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let result = await call(references.length > 0);
|
|
250
|
+
const unknownParam = !result.ok && (result.json.error?.message ?? "").includes("input_fidelity");
|
|
251
|
+
if (unknownParam) result = await call(false);
|
|
252
|
+
|
|
253
|
+
if (!result.ok) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`${config.modelId} refused the request (${result.status}): ${result.json.error?.message ?? "no message"}`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const base64 = result.json.data?.[0]?.b64_json;
|
|
260
|
+
if (!base64) throw new Error(`${config.modelId} returned no image data.`);
|
|
261
|
+
|
|
262
|
+
return {
|
|
263
|
+
bytes: decodeBase64(base64),
|
|
264
|
+
// The Images API answers PNG unless asked otherwise, and it is asked for
|
|
265
|
+
// nothing else here because a thumbnail is composited afterwards.
|
|
266
|
+
mediaType: "image/png",
|
|
267
|
+
config,
|
|
268
|
+
priceUsd: openAiCost(config, result.json.usage?.output_tokens),
|
|
269
|
+
attempts: 1,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Agnes Image 2.5 Flash: the free lane.
|
|
275
|
+
*/
|
|
276
|
+
|
|
277
|
+
/*
|
|
278
|
+
* WHY it is a branch rather than a router adapter, like OpenAI's above: Agnes answers on `/v1/images/generations`, the OpenAI Images shape, not the chat-with-image-parts shape the Gemini lane uses,
|
|
279
|
+
*/
|
|
280
|
+
|
|
281
|
+
/*
|
|
282
|
+
* so there is no language model for an adapter to hand back. And it is reached directly, because Cloudflare AI Gateway answers `Invalid provider` for every spelling of agnes.
|
|
283
|
+
*/
|
|
284
|
+
|
|
285
|
+
/*
|
|
286
|
+
* The tier is the output resolution, which is how Agnes prices an image. Every tier is $0 today; see the catalog entry for what happens when that ends.
|
|
287
|
+
*/
|
|
288
|
+
const AGNES_SIZE: Record<ModelTier, string> = {
|
|
289
|
+
small: "1K",
|
|
290
|
+
medium: "2K",
|
|
291
|
+
large: "4K",
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const AGNES_BASE_URL = "https://apihub.agnes-ai.com/v1";
|
|
295
|
+
|
|
296
|
+
function agnesKey(explicit?: string): string {
|
|
297
|
+
// This package ships without node types on purpose, so a bare `process.env`
|
|
298
|
+
// would not compile; `globalThis` is the route that does.
|
|
299
|
+
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
|
|
300
|
+
?.env;
|
|
301
|
+
const key = explicit ?? env?.AGNES_API_KEY;
|
|
302
|
+
if (!key) {
|
|
303
|
+
throw new Error("The agnes image lane needs an API key: pass `apiKey`, or set AGNES_API_KEY.");
|
|
304
|
+
}
|
|
305
|
+
return key;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function generateAgnesImage(
|
|
309
|
+
config: ImageModelConfig,
|
|
310
|
+
options: GenerateImageOptions,
|
|
311
|
+
tier: ModelTier,
|
|
312
|
+
): Promise<GeneratedImage> {
|
|
313
|
+
const references = options.references ?? [];
|
|
314
|
+
// WHY a reference is refused HERE rather than on the wire, measured 9 Sep 2026.
|
|
315
|
+
|
|
316
|
+
// Agnes carries text-to-image only, and it reports that as `403 team not allowed to access model: agnes-image-2.5-flash` the moment the body carries an `image` array.
|
|
317
|
+
|
|
318
|
+
// The status names the model, so the lane reads as down, or as the wrong id, and a caller spends a run finding out it is neither.
|
|
319
|
+
|
|
320
|
+
// This says the one true thing instead.
|
|
321
|
+
if (references.length > 0) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`${config.modelId} draws from a prompt only: it refuses a reference image with a 403 that blames the model. Draw through a gateway for anything conditioned on a frame.`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
// WHY base64 is NOT requested, measured 31 Aug 2026. Asking for it
|
|
327
|
+
|
|
328
|
+
// (`return_base64: true` / `extra_body.response_format: "b64_json"`) makes Agnes hold the connection open without sending headers: the same prompt that answers in 35s in url mode died at exactly 300,003ms with undici's
|
|
329
|
+
|
|
330
|
+
// `UND_ERR_HEADERS_TIMEOUT`, `HTTP/2: "headers timeout after 300000"`. curl survives it because it has no such default.
|
|
331
|
+
|
|
332
|
+
// So the frame is always fetched from the url, which costs one extra round trip and always finishes.
|
|
333
|
+
const body: Record<string, unknown> = {
|
|
334
|
+
model: config.sdkId,
|
|
335
|
+
prompt: options.prompt,
|
|
336
|
+
size: options.size ?? AGNES_SIZE[tier],
|
|
337
|
+
ratio: options.aspectRatio ?? "1:1",
|
|
338
|
+
};
|
|
339
|
+
const response = await fetch(`${AGNES_BASE_URL}/images/generations`, {
|
|
340
|
+
method: "POST",
|
|
341
|
+
headers: {
|
|
342
|
+
Authorization: `Bearer ${agnesKey(options.apiKey)}`,
|
|
343
|
+
"Content-Type": "application/json",
|
|
344
|
+
},
|
|
345
|
+
body: JSON.stringify(body),
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const json = (await response.json().catch(() => ({}))) as {
|
|
349
|
+
data?: { b64_json?: string; url?: string }[];
|
|
350
|
+
error?: { message?: string };
|
|
351
|
+
message?: string;
|
|
352
|
+
};
|
|
353
|
+
if (!response.ok) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`${config.modelId} refused the request (${response.status}): ${json.error?.message ?? json.message ?? "no message"}`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const first = json.data?.[0];
|
|
360
|
+
// Kept for an account that answers base64 anyway; the request no longer asks.
|
|
361
|
+
if (first?.b64_json) {
|
|
362
|
+
return {
|
|
363
|
+
bytes: decodeBase64(first.b64_json),
|
|
364
|
+
mediaType: "image/png",
|
|
365
|
+
config,
|
|
366
|
+
priceUsd: config.pricePerImage,
|
|
367
|
+
attempts: 1,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
// The normal path. Fetching it here rather than handing a URL out keeps every
|
|
371
|
+
// caller on one return shape: bytes.
|
|
372
|
+
if (first?.url) {
|
|
373
|
+
const image = await fetch(first.url);
|
|
374
|
+
if (!image.ok) {
|
|
375
|
+
throw new Error(`${config.modelId} returned a url that did not fetch (${image.status}).`);
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
bytes: new Uint8Array(await image.arrayBuffer()),
|
|
379
|
+
mediaType: image.headers.get("content-type") ?? "image/png",
|
|
380
|
+
config,
|
|
381
|
+
priceUsd: config.pricePerImage,
|
|
382
|
+
attempts: 1,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
throw new Error(`${config.modelId} returned no image data.`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function generateImage(options: GenerateImageOptions): Promise<GeneratedImage> {
|
|
390
|
+
const tier = options.tier ?? "medium";
|
|
391
|
+
const config = resolveImageModelConfig(tier, options.provider);
|
|
392
|
+
// OpenAI's and Agnes's image models aren't language models, so they take their own REST lanes.
|
|
393
|
+
if (config.provider === "openai") return generateOpenAiImage(config, options, tier);
|
|
394
|
+
if (config.provider === "agnes") return generateAgnesImage(config, options, tier);
|
|
395
|
+
const id = gatewayId(config);
|
|
396
|
+
const model = options.gateway ? options.gateway(id) : id;
|
|
397
|
+
const attempts = options.attempts ?? 3;
|
|
398
|
+
|
|
399
|
+
let lastText = "";
|
|
400
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
401
|
+
const result = await generateText({
|
|
402
|
+
model,
|
|
403
|
+
messages: [
|
|
404
|
+
{
|
|
405
|
+
role: "user",
|
|
406
|
+
content: [
|
|
407
|
+
{ type: "text", text: options.prompt },
|
|
408
|
+
...(options.references ?? []).map((reference) => ({
|
|
409
|
+
type: "file" as const,
|
|
410
|
+
data: reference.bytes,
|
|
411
|
+
mediaType: reference.mediaType,
|
|
412
|
+
})),
|
|
413
|
+
],
|
|
414
|
+
},
|
|
415
|
+
],
|
|
416
|
+
providerOptions: providerOptions(config, options.aspectRatio),
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const file = result.files.find((entry) => entry.mediaType.startsWith("image/"));
|
|
420
|
+
if (file) {
|
|
421
|
+
return {
|
|
422
|
+
bytes: file.uint8Array,
|
|
423
|
+
mediaType: file.mediaType,
|
|
424
|
+
config,
|
|
425
|
+
priceUsd: config.pricePerImage,
|
|
426
|
+
attempts: attempt,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
lastText = (result.text ?? "").slice(0, 160);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
throw new Error(
|
|
433
|
+
`${config.modelId} returned no image after ${attempts} attempts${lastText ? `: ${lastText}` : ""}`,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import {
|
|
3
|
+
getModelConfig,
|
|
4
|
+
imagePriceUsd,
|
|
5
|
+
MODEL_CATALOG,
|
|
6
|
+
MODEL_IDS,
|
|
7
|
+
model,
|
|
8
|
+
resolveModelConfig,
|
|
9
|
+
} from "#src/ai/models.js";
|
|
10
|
+
|
|
11
|
+
describe("MODEL_IDS", () => {
|
|
12
|
+
it("has entries for all provider-tier combinations", () => {
|
|
13
|
+
const providers = ["xai", "google", "anthropic"] as const;
|
|
14
|
+
const tiers = ["small", "medium", "large"] as const;
|
|
15
|
+
for (const p of providers) {
|
|
16
|
+
for (const t of tiers) {
|
|
17
|
+
expect(MODEL_IDS[p][t]).toBeTypeOf("string");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("MODEL_CATALOG", () => {
|
|
24
|
+
it("has a config entry for every model ID", () => {
|
|
25
|
+
const providers = ["xai", "google", "anthropic"] as const;
|
|
26
|
+
const tiers = ["small", "medium", "large"] as const;
|
|
27
|
+
for (const p of providers) {
|
|
28
|
+
for (const t of tiers) {
|
|
29
|
+
const id = MODEL_IDS[p][t];
|
|
30
|
+
const config = MODEL_CATALOG[id];
|
|
31
|
+
expect(config).toBeDefined();
|
|
32
|
+
expect(config.provider).toBe(p);
|
|
33
|
+
expect(config.pricing.input).toBeGreaterThan(0);
|
|
34
|
+
expect(config.pricing.output).toBeGreaterThan(0);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("MODEL_CATALOG ids apps name", () => {
|
|
41
|
+
it("prices every id the Studio apps hardcoded, under the right provider", () => {
|
|
42
|
+
const expected = {
|
|
43
|
+
"claude-haiku-5-5": "anthropic",
|
|
44
|
+
"claude-sonnet-5-5": "anthropic",
|
|
45
|
+
"claude-opus-5-5": "anthropic",
|
|
46
|
+
"claude-fable-5-1": "anthropic",
|
|
47
|
+
"gpt-5.6-luna": "openai",
|
|
48
|
+
"gpt-6-luna": "openai",
|
|
49
|
+
"gpt-6.1-sol": "openai",
|
|
50
|
+
"gemini-3.6-flash": "google",
|
|
51
|
+
} as const;
|
|
52
|
+
for (const [id, provider] of Object.entries(expected)) {
|
|
53
|
+
const config = getModelConfig(id);
|
|
54
|
+
expect(config.provider).toBe(provider);
|
|
55
|
+
expect(config.sdkId).toBe(id);
|
|
56
|
+
expect(config.pricing.input).toBeGreaterThan(0);
|
|
57
|
+
expect(config.pricing.output).toBeGreaterThan(config.pricing.input);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("addresses Fable on the gateway with a dot, like the other Claude ids", () => {
|
|
62
|
+
expect(getModelConfig("claude-fable-5-1").gatewayId).toBe("anthropic/claude-fable-5.1");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("model", () => {
|
|
67
|
+
it("answers a provider-tier pair with the id the gateway knows it by", () => {
|
|
68
|
+
expect(model("medium", "anthropic")).toBe("anthropic/claude-sonnet-5.5");
|
|
69
|
+
expect(model("small", "xai")).toBe("spacexai/grok-4.7");
|
|
70
|
+
expect(model("large", "google")).toBe("google/gemini-3.8-flash");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("getModelConfig", () => {
|
|
75
|
+
it("returns full config for a short model ID", () => {
|
|
76
|
+
const config = getModelConfig("claude-sonnet-5-5");
|
|
77
|
+
expect(config.provider).toBe("anthropic");
|
|
78
|
+
expect(config.tier).toBe("medium");
|
|
79
|
+
expect(config.sdkId).toBe("claude-sonnet-5-5");
|
|
80
|
+
expect(config.pricing.input).toBe(2.0);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("resolveModelConfig", () => {
|
|
85
|
+
it("answers a bare tier with the default provider", () => {
|
|
86
|
+
expect(resolveModelConfig("medium").modelId).toBe("grok-4.7");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("imagePriceUsd", () => {
|
|
91
|
+
it("prices a frame filed under its sdk id as well as its model id", () => {
|
|
92
|
+
expect(imagePriceUsd("gemini-nano-banana-2.1")).toBe(0.067);
|
|
93
|
+
// The 2.0 ids left the catalog with the upgrade; frames drawn on them
|
|
94
|
+
// keep the price stored at draw time and no longer price off it.
|
|
95
|
+
expect(imagePriceUsd("gemini-3.1-flash-image")).toBeNull();
|
|
96
|
+
expect(imagePriceUsd("some-model-nobody-priced")).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
});
|