@heyramzi/utils 0.0.0-stage → 1.12.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/package.json +103 -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 +88 -0
- package/src/errors/sentry/sveltekit.ts +188 -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
package/src/ai/models.ts
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
export type Provider = string;
|
|
2
|
+
export type ModelTier = "small" | "medium" | "large";
|
|
3
|
+
export type ModelId = string;
|
|
4
|
+
|
|
5
|
+
export type ModelPricing = { input: number; output: number };
|
|
6
|
+
|
|
7
|
+
export type ModelConfig = {
|
|
8
|
+
modelId: ModelId;
|
|
9
|
+
sdkId: string;
|
|
10
|
+
provider: Provider;
|
|
11
|
+
tier: ModelTier;
|
|
12
|
+
pricing: ModelPricing;
|
|
13
|
+
/**
|
|
14
|
+
* What this model is called on a multi-provider gateway that addresses every vendor through one endpoint (Vercel AI Gateway), which is `<vendor slug>/<model>`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/*
|
|
18
|
+
* WHY it cannot be derived from `sdkId` in every case, measured against the live Vercel catalog on 31 Aug 2026: Anthropic's own ids separate the version with dashes (`claude-haiku-5-5`) where
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/*
|
|
22
|
+
* the gateway uses a dot (`anthropic/claude-haiku-5.5`), and a model whose `sdkId` already carries a slash (`openai/gpt-oss-120b`, served by groq) would resolve to a doubled path.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
* Left absent the resolver builds `<slug>/<sdkId>`, which is right for google and openai.
|
|
27
|
+
*/
|
|
28
|
+
gatewayId?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ModelMatrix = Partial<Record<Provider, Partial<Record<ModelTier, ModelId>>>>;
|
|
32
|
+
|
|
33
|
+
export type EmbeddingConfig = {
|
|
34
|
+
modelId: string;
|
|
35
|
+
provider: Provider;
|
|
36
|
+
dimensions: number;
|
|
37
|
+
/** See `ModelConfig.gatewayId`. */
|
|
38
|
+
gatewayId?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const MODEL_IDS = {
|
|
42
|
+
xai: { large: "grok-4.7", medium: "grok-4.7", small: "grok-4.7" },
|
|
43
|
+
// 3.1 Pro answered 404 on the gateway and scored below 3.8 Flash on Artificial Analysis, 8 Oct 2026.
|
|
44
|
+
google: { large: "gemini-3.8-flash", medium: "gemini-3.8-flash", small: "gemini-3.5-flash-lite" },
|
|
45
|
+
anthropic: { large: "claude-opus-5-5", medium: "claude-sonnet-5-5", small: "claude-haiku-5-5" },
|
|
46
|
+
// WHY 120b sits on two tiers: Groq publishes a price for gpt-oss-120b and
|
|
47
|
+
|
|
48
|
+
// gpt-oss-20b and the models between them move often, so the matrix uses the two that are priced rather than a third whose cost would have to be guessed.
|
|
49
|
+
groq: {
|
|
50
|
+
large: "openai/gpt-oss-120b",
|
|
51
|
+
medium: "openai/gpt-oss-120b",
|
|
52
|
+
small: "openai/gpt-oss-20b",
|
|
53
|
+
},
|
|
54
|
+
} as const satisfies Record<Provider, Record<ModelTier, ModelId>>;
|
|
55
|
+
|
|
56
|
+
export const MODEL_CATALOG: Record<ModelId, ModelConfig> = {
|
|
57
|
+
"grok-4.7": {
|
|
58
|
+
modelId: "grok-4.7",
|
|
59
|
+
sdkId: "grok-4.7",
|
|
60
|
+
provider: "xai",
|
|
61
|
+
tier: "large",
|
|
62
|
+
pricing: { input: 2.0, output: 6.0 },
|
|
63
|
+
// The gateway lists Grok under `spacexai/`. `xai/` still answers, but only as an unlisted alias.
|
|
64
|
+
gatewayId: "spacexai/grok-4.7",
|
|
65
|
+
},
|
|
66
|
+
// WHY the pricing is not the number Google is currently charging: 3.8 Flash
|
|
67
|
+
|
|
68
|
+
// launched at an introductory $0.75/$3.75 that expires 2026-12-31 and reverts to the pair below, which is also what 3.6 Flash cost.
|
|
69
|
+
|
|
70
|
+
// Recording the rate that outlives the discount keeps a cost estimate from halving overnight in January.
|
|
71
|
+
"gemini-3.8-flash": {
|
|
72
|
+
modelId: "gemini-3.8-flash",
|
|
73
|
+
sdkId: "gemini-3.8-flash",
|
|
74
|
+
provider: "google",
|
|
75
|
+
tier: "medium",
|
|
76
|
+
pricing: { input: 1.5, output: 7.5 },
|
|
77
|
+
},
|
|
78
|
+
"gemini-3.5-flash-lite": {
|
|
79
|
+
modelId: "gemini-3.5-flash-lite",
|
|
80
|
+
sdkId: "gemini-3.5-flash-lite",
|
|
81
|
+
provider: "google",
|
|
82
|
+
tier: "small",
|
|
83
|
+
pricing: { input: 0.3, output: 2.5 },
|
|
84
|
+
},
|
|
85
|
+
"claude-opus-5-5": {
|
|
86
|
+
modelId: "claude-opus-5-5",
|
|
87
|
+
sdkId: "claude-opus-5-5",
|
|
88
|
+
gatewayId: "anthropic/claude-opus-5.5",
|
|
89
|
+
provider: "anthropic",
|
|
90
|
+
tier: "large",
|
|
91
|
+
pricing: { input: 4.0, output: 20.0 },
|
|
92
|
+
},
|
|
93
|
+
"claude-sonnet-5-5": {
|
|
94
|
+
modelId: "claude-sonnet-5-5",
|
|
95
|
+
sdkId: "claude-sonnet-5-5",
|
|
96
|
+
gatewayId: "anthropic/claude-sonnet-5.5",
|
|
97
|
+
provider: "anthropic",
|
|
98
|
+
tier: "medium",
|
|
99
|
+
pricing: { input: 2.0, output: 10.0 },
|
|
100
|
+
},
|
|
101
|
+
// Prompts past 100K input tokens bill at $0.50 / $2.50.
|
|
102
|
+
"claude-haiku-5-5": {
|
|
103
|
+
modelId: "claude-haiku-5-5",
|
|
104
|
+
sdkId: "claude-haiku-5-5",
|
|
105
|
+
gatewayId: "anthropic/claude-haiku-5.5",
|
|
106
|
+
provider: "anthropic",
|
|
107
|
+
tier: "small",
|
|
108
|
+
pricing: { input: 0.1, output: 0.5 },
|
|
109
|
+
},
|
|
110
|
+
// List rate $10 / $50 per 1M tokens, the one upsys's ai-spend script bills against.
|
|
111
|
+
"claude-fable-5-1": {
|
|
112
|
+
modelId: "claude-fable-5-1",
|
|
113
|
+
sdkId: "claude-fable-5-1",
|
|
114
|
+
gatewayId: "anthropic/claude-fable-5.1",
|
|
115
|
+
provider: "anthropic",
|
|
116
|
+
tier: "large",
|
|
117
|
+
pricing: { input: 10.0, output: 50.0 },
|
|
118
|
+
},
|
|
119
|
+
// 3.6 Flash cost the same $1.50 / $7.50 that 3.8 Flash reverts to.
|
|
120
|
+
"gemini-3.6-flash": {
|
|
121
|
+
modelId: "gemini-3.6-flash",
|
|
122
|
+
sdkId: "gemini-3.6-flash",
|
|
123
|
+
provider: "google",
|
|
124
|
+
tier: "medium",
|
|
125
|
+
pricing: { input: 1.5, output: 7.5 },
|
|
126
|
+
},
|
|
127
|
+
// Short-context rate from OpenAI's pricing page, 31 Aug 2026.
|
|
128
|
+
"gpt-5.6-luna": {
|
|
129
|
+
modelId: "gpt-5.6-luna",
|
|
130
|
+
sdkId: "gpt-5.6-luna",
|
|
131
|
+
provider: "openai",
|
|
132
|
+
tier: "small",
|
|
133
|
+
pricing: { input: 0.2, output: 1.2 },
|
|
134
|
+
},
|
|
135
|
+
// Kilo's live catalogue, read 8 Oct 2026.
|
|
136
|
+
"gpt-6-luna": {
|
|
137
|
+
modelId: "gpt-6-luna",
|
|
138
|
+
sdkId: "gpt-6-luna",
|
|
139
|
+
provider: "openai",
|
|
140
|
+
tier: "small",
|
|
141
|
+
pricing: { input: 0.1, output: 0.5 },
|
|
142
|
+
},
|
|
143
|
+
"gpt-6.1-sol": {
|
|
144
|
+
modelId: "gpt-6.1-sol",
|
|
145
|
+
sdkId: "gpt-6.1-sol",
|
|
146
|
+
provider: "openai",
|
|
147
|
+
tier: "large",
|
|
148
|
+
pricing: { input: 2.0, output: 10.0 },
|
|
149
|
+
},
|
|
150
|
+
// Prices read from Groq's published rates on 23 Aug 2026.
|
|
151
|
+
|
|
152
|
+
// The batch API and cached input are both cheaper; the synchronous rate is recorded because that is what every call in this package makes.
|
|
153
|
+
"openai/gpt-oss-120b": {
|
|
154
|
+
modelId: "openai/gpt-oss-120b",
|
|
155
|
+
sdkId: "openai/gpt-oss-120b",
|
|
156
|
+
gatewayId: "openai/gpt-oss-120b",
|
|
157
|
+
provider: "groq",
|
|
158
|
+
tier: "large",
|
|
159
|
+
pricing: { input: 0.15, output: 0.6 },
|
|
160
|
+
},
|
|
161
|
+
"openai/gpt-oss-20b": {
|
|
162
|
+
modelId: "openai/gpt-oss-20b",
|
|
163
|
+
sdkId: "openai/gpt-oss-20b",
|
|
164
|
+
gatewayId: "openai/gpt-oss-20b",
|
|
165
|
+
provider: "groq",
|
|
166
|
+
tier: "small",
|
|
167
|
+
pricing: { input: 0.075, output: 0.3 },
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/** The provider `model(tier)` answers with when the caller names none. */
|
|
172
|
+
export const DEFAULT_PROVIDER: Provider = "xai";
|
|
173
|
+
|
|
174
|
+
export const EMBEDDING_MODEL: EmbeddingConfig = {
|
|
175
|
+
modelId: "gemini-embedding-2-preview",
|
|
176
|
+
gatewayId: "google/gemini-embedding-2",
|
|
177
|
+
provider: "google",
|
|
178
|
+
dimensions: 768,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const EMBEDDING_DIMENSIONS = EMBEDDING_MODEL.dimensions;
|
|
182
|
+
|
|
183
|
+
/** The id the AI gateway knows a model by: `generateText({ model: gatewayId(config) })`. */
|
|
184
|
+
export function gatewayId(config: {
|
|
185
|
+
provider: Provider;
|
|
186
|
+
sdkId: string;
|
|
187
|
+
gatewayId?: string;
|
|
188
|
+
}): string {
|
|
189
|
+
return config.gatewayId ?? `${config.provider}/${config.sdkId}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function getModelConfig(id: ModelId): ModelConfig {
|
|
193
|
+
const config = MODEL_CATALOG[id];
|
|
194
|
+
if (!config) {
|
|
195
|
+
throw new Error(`Missing model config for model "${id}".`);
|
|
196
|
+
}
|
|
197
|
+
return config;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** What a tier resolves to, for a price or a badge. */
|
|
201
|
+
export function resolveModelConfig(
|
|
202
|
+
tier: ModelTier,
|
|
203
|
+
provider: Provider = DEFAULT_PROVIDER,
|
|
204
|
+
): ModelConfig {
|
|
205
|
+
const ids: ModelMatrix = MODEL_IDS;
|
|
206
|
+
const modelId = ids[provider]?.[tier];
|
|
207
|
+
if (!modelId) {
|
|
208
|
+
throw new Error(`Missing model ID for provider "${provider}" and tier "${tier}".`);
|
|
209
|
+
}
|
|
210
|
+
return getModelConfig(modelId);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The gateway id for a tier: `generateText({ model: model("medium") })`. */
|
|
214
|
+
export function model(tier: ModelTier, provider: Provider = DEFAULT_PROVIDER): string {
|
|
215
|
+
return gatewayId(resolveModelConfig(tier, provider));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// --------------------------------------------------------------------- images
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Image generation, on the same three tiers as text.
|
|
222
|
+
*/
|
|
223
|
+
|
|
224
|
+
/*
|
|
225
|
+
* WHY the tiers exist: every caller used to name a model id by hand (`gemini-3.1-flash-image-preview` in three upsys files, `gemini-3-pro-image` in a fourth),
|
|
226
|
+
*/
|
|
227
|
+
|
|
228
|
+
/*
|
|
229
|
+
* so the choice between a $0.034 frame and a $0.134 one was scattered across four repos and nothing recorded what a frame cost.
|
|
230
|
+
*/
|
|
231
|
+
|
|
232
|
+
/*
|
|
233
|
+
* A tier says what the frame is FOR (a sample, a shipped asset, a hero) and the catalog says which model and what it costs.
|
|
234
|
+
*/
|
|
235
|
+
|
|
236
|
+
/*
|
|
237
|
+
* An image model here is a language model that answers with image parts, not an `experimental_generateImage` model: that is what the Gemini image family is,
|
|
238
|
+
*/
|
|
239
|
+
|
|
240
|
+
/*
|
|
241
|
+
* and it is why references, multi-turn edits and text-in-image all work.
|
|
242
|
+
*/
|
|
243
|
+
export type ImageModelConfig = {
|
|
244
|
+
modelId: ModelId;
|
|
245
|
+
sdkId: string;
|
|
246
|
+
provider: Provider;
|
|
247
|
+
tier: ModelTier;
|
|
248
|
+
/**
|
|
249
|
+
* USD for one returned image at 1K, which is the 1,120 image-token bracket every caller asks for.
|
|
250
|
+
*/
|
|
251
|
+
|
|
252
|
+
/*
|
|
253
|
+
* Google bills an image as a fixed token count on the output side, so a frame's cost is a property of the model and its resolution, never of the request.
|
|
254
|
+
*/
|
|
255
|
+
pricePerImage: number;
|
|
256
|
+
/** See `ModelConfig.gatewayId`. */
|
|
257
|
+
gatewayId?: string;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
export type ImageModelMatrix = Partial<Record<Provider, Partial<Record<ModelTier, ModelId>>>>;
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Verified 26 Aug 2026 by calling each id through the Cloudflare AI Gateway and reading an image back, and priced off ai.google.dev/gemini-api/docs/pricing on the same day:
|
|
264
|
+
*/
|
|
265
|
+
|
|
266
|
+
/* $30, $60 and $120 per 1M image tokens. */
|
|
267
|
+
|
|
268
|
+
/* 8 Oct 2026: Nano Banana 2 Lite on small and medium, 2.1 on large. Pro has no tier. */
|
|
269
|
+
export const IMAGE_MODEL_IDS: ImageModelMatrix = {
|
|
270
|
+
google: {
|
|
271
|
+
small: "gemini-3.1-flash-lite-image",
|
|
272
|
+
medium: "gemini-3.1-flash-lite-image",
|
|
273
|
+
large: "gemini-nano-banana-2.1",
|
|
274
|
+
},
|
|
275
|
+
// WHY one model id on three tiers: OpenAI prices an image by the tokens it returns, so the tier is the `quality` on the request rather than a different checkpoint.
|
|
276
|
+
|
|
277
|
+
// `image.ts` maps tier to quality; the catalog carries the price.
|
|
278
|
+
openai: {
|
|
279
|
+
small: "gpt-image-2",
|
|
280
|
+
medium: "gpt-image-2",
|
|
281
|
+
large: "gpt-image-2",
|
|
282
|
+
},
|
|
283
|
+
// WHY one model on three tiers, and WHY the tier still means something: Agnes
|
|
284
|
+
|
|
285
|
+
// prices an image by output resolution, so the tier is the `size` on the request rather than a different checkpoint. `image.ts` maps tier to size.
|
|
286
|
+
|
|
287
|
+
// Every tier is currently $0, which is the entire reason this lane exists.
|
|
288
|
+
agnes: {
|
|
289
|
+
small: "agnes-image-2.5-flash",
|
|
290
|
+
medium: "agnes-image-2.5-flash",
|
|
291
|
+
large: "agnes-image-2.5-flash",
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
export const IMAGE_MODEL_CATALOG: Record<ModelId, ImageModelConfig> = {
|
|
296
|
+
"gemini-3.1-flash-lite-image": {
|
|
297
|
+
modelId: "gemini-3.1-flash-lite-image",
|
|
298
|
+
sdkId: "gemini-3.1-flash-lite-image",
|
|
299
|
+
provider: "google",
|
|
300
|
+
tier: "small",
|
|
301
|
+
pricePerImage: 0.034,
|
|
302
|
+
},
|
|
303
|
+
// Google published 2.1 under one GA id, so there's no preview to pin. If one appears,
|
|
304
|
+
// the 25 Aug 2026 preview-pinning call moves the sdkId there.
|
|
305
|
+
"gemini-nano-banana-2.1": {
|
|
306
|
+
modelId: "gemini-nano-banana-2.1",
|
|
307
|
+
sdkId: "gemini-nano-banana-2.1",
|
|
308
|
+
provider: "google",
|
|
309
|
+
tier: "large",
|
|
310
|
+
pricePerImage: 0.067,
|
|
311
|
+
},
|
|
312
|
+
"gemini-3-pro-image": {
|
|
313
|
+
modelId: "gemini-3-pro-image",
|
|
314
|
+
sdkId: "gemini-3-pro-image",
|
|
315
|
+
provider: "google",
|
|
316
|
+
tier: "large",
|
|
317
|
+
pricePerImage: 0.134,
|
|
318
|
+
},
|
|
319
|
+
/**
|
|
320
|
+
* OpenAI's image model, on the Images REST API rather than the chat modality.
|
|
321
|
+
*/
|
|
322
|
+
|
|
323
|
+
/*
|
|
324
|
+
* WHY it is back after being deleted on 1 Sep 2026: it was removed that morning on *"We should never ever use anywhere GPT image."* and asked for again the
|
|
325
|
+
*/
|
|
326
|
+
|
|
327
|
+
/*
|
|
328
|
+
* same afternoon, by name, for the blog hero set.
|
|
329
|
+
*/
|
|
330
|
+
|
|
331
|
+
/*
|
|
332
|
+
* The lane is a capability and the default is a separate question, so the capability returns and `DEFAULT_IMAGE_PROVIDER` stays `google`: nothing draws here unless a caller names `provider: "openai"`.
|
|
333
|
+
*/
|
|
334
|
+
|
|
335
|
+
/*
|
|
336
|
+
* WHY it earns the naming: the Images REST ids are a different and far cheaper product than the chat-modality `openai/gpt-5.4-image-2` that the 31 Aug price ruling was actually about,
|
|
337
|
+
*/
|
|
338
|
+
|
|
339
|
+
/*
|
|
340
|
+
* and the youtube-thumbnail skill's 26 Aug test recorded "excellent tile lighting and a correctly reproduced card" from it.
|
|
341
|
+
*/
|
|
342
|
+
|
|
343
|
+
/*
|
|
344
|
+
* Its one standing ban is narrower and still holds: never a frame carrying a real person's face, because identity drifts across a wall of frames.
|
|
345
|
+
*/
|
|
346
|
+
|
|
347
|
+
/*
|
|
348
|
+
* WHY the price is a floor and not the price: OpenAI bills image output by token ($30 per 1M for this model,
|
|
349
|
+
*/
|
|
350
|
+
|
|
351
|
+
/*
|
|
352
|
+
* off platform.openai.com/docs/pricing on 26 Aug 2026), so what a frame costs depends on its size and its quality.
|
|
353
|
+
*/
|
|
354
|
+
|
|
355
|
+
/*
|
|
356
|
+
* `generateImage` overwrites `priceUsd` with the cost computed from the usage the API returns, and this number is only what a caller sees before that.
|
|
357
|
+
*/
|
|
358
|
+
"gpt-image-2": {
|
|
359
|
+
modelId: "gpt-image-2",
|
|
360
|
+
sdkId: "gpt-image-2",
|
|
361
|
+
provider: "openai",
|
|
362
|
+
tier: "medium",
|
|
363
|
+
pricePerImage: 0.032,
|
|
364
|
+
},
|
|
365
|
+
/**
|
|
366
|
+
* Agnes Image 2.5 Flash, the free image lane, and text-to-image ONLY.
|
|
367
|
+
*/
|
|
368
|
+
|
|
369
|
+
/*
|
|
370
|
+
* WHY the caveat is on the row: it refuses a reference image, measured 9 Sep 2026, and reports that as `403 team not allowed to access model`,
|
|
371
|
+
*/
|
|
372
|
+
|
|
373
|
+
/*
|
|
374
|
+
* which reads as a dead lane rather than a missing capability. `image.ts` refuses a reference before the call so the error says what is true.
|
|
375
|
+
*/
|
|
376
|
+
|
|
377
|
+
/*
|
|
378
|
+
* So this row draws a frame from nothing; anything conditioned on a frame is a gateway call.
|
|
379
|
+
*/
|
|
380
|
+
|
|
381
|
+
/*
|
|
382
|
+
* Ramzi kept it on 9 Sep 2026 for exactly what it is good at: "For the image free lane you can still use Agnes."
|
|
383
|
+
*/
|
|
384
|
+
|
|
385
|
+
/*
|
|
386
|
+
* WHY the price is 0 and the list price is in the comment rather than the field: Agnes publishes $10 / 1,000 images at 1K rising to $24 at 4K,
|
|
387
|
+
*/
|
|
388
|
+
|
|
389
|
+
/*
|
|
390
|
+
* and is charging $0 for every resolution tier and every reference image as a standing promotion (read off wiki.agnes-ai.com/en/docs/pricing, 31 Aug 2026).
|
|
391
|
+
*/
|
|
392
|
+
|
|
393
|
+
/*
|
|
394
|
+
* Recording 0 is what makes a caller's cost report true today; the day the promotion ends this row is the one line to change.
|
|
395
|
+
*/
|
|
396
|
+
|
|
397
|
+
/*
|
|
398
|
+
* WHY it is not routed through a gateway: Cloudflare AI Gateway answers `Invalid provider` for agnes, agnes-ai and agnesai, so there is no gateway route to take.
|
|
399
|
+
*/
|
|
400
|
+
|
|
401
|
+
/*
|
|
402
|
+
* This is a direct lane and it carries its own key.
|
|
403
|
+
*/
|
|
404
|
+
"agnes-image-2.5-flash": {
|
|
405
|
+
modelId: "agnes-image-2.5-flash",
|
|
406
|
+
sdkId: "agnes-image-2.5-flash",
|
|
407
|
+
provider: "agnes",
|
|
408
|
+
tier: "medium",
|
|
409
|
+
pricePerImage: 0,
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/** USD per 1M image output tokens, per model. Used to cost a frame from its usage. */
|
|
414
|
+
export const IMAGE_OUTPUT_TOKEN_PRICE_USD: Record<ModelId, number> = {
|
|
415
|
+
"gpt-image-2": 30 / 1_000_000,
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
/** The provider a tier resolves to when the caller names none. */
|
|
419
|
+
export const DEFAULT_IMAGE_PROVIDER: Provider = "google";
|
|
420
|
+
|
|
421
|
+
/** What an image tier resolves to, for a price or the call itself. */
|
|
422
|
+
export function resolveImageModelConfig(
|
|
423
|
+
tier: ModelTier,
|
|
424
|
+
provider: Provider = DEFAULT_IMAGE_PROVIDER,
|
|
425
|
+
): ImageModelConfig {
|
|
426
|
+
const modelId = IMAGE_MODEL_IDS[provider]?.[tier];
|
|
427
|
+
const config = modelId ? IMAGE_MODEL_CATALOG[modelId] : undefined;
|
|
428
|
+
if (!config) {
|
|
429
|
+
throw new Error(`Missing image model for provider "${provider}" and tier "${tier}".`);
|
|
430
|
+
}
|
|
431
|
+
return config;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* What one frame off `modelId` costs, or null for a model with no published rate.
|
|
436
|
+
*/
|
|
437
|
+
|
|
438
|
+
/*
|
|
439
|
+
* A stored frame keeps the price it was drawn at: prices move on a release, so never recompute an old frame's cost from this table.
|
|
440
|
+
*/
|
|
441
|
+
export function imagePriceUsd(modelId: ModelId): number | null {
|
|
442
|
+
const direct = IMAGE_MODEL_CATALOG[modelId];
|
|
443
|
+
if (direct) return direct.pricePerImage;
|
|
444
|
+
// A frame filed under the sdk id it was drawn on (`...-preview`) still has to
|
|
445
|
+
// price, or the wall shows a blank beside a real cost.
|
|
446
|
+
return (
|
|
447
|
+
Object.values(IMAGE_MODEL_CATALOG).find((config) => config.sdkId === modelId)?.pricePerImage ??
|
|
448
|
+
null
|
|
449
|
+
);
|
|
450
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the browser analytics entrypoint.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
|
6
|
+
|
|
7
|
+
import { initAnalytics, setProfileId, track, trackRevenue } from "#src/analytics/client.js";
|
|
8
|
+
|
|
9
|
+
let umamiTrack: ReturnType<typeof vi.fn>;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
vi.clearAllMocks();
|
|
13
|
+
umamiTrack = vi.fn();
|
|
14
|
+
vi.stubGlobal("window", {
|
|
15
|
+
umami: { track: umamiTrack, identify: vi.fn() },
|
|
16
|
+
location: { hostname: "example.com" },
|
|
17
|
+
});
|
|
18
|
+
vi.stubGlobal("document", {
|
|
19
|
+
head: { appendChild: vi.fn() },
|
|
20
|
+
createElement: () => ({ defer: false, src: "", dataset: {} }),
|
|
21
|
+
querySelector: () => null,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("initAnalytics", () => {
|
|
30
|
+
it("starts tracking once initialized", () => {
|
|
31
|
+
initAnalytics({
|
|
32
|
+
websiteId: "w1",
|
|
33
|
+
host: "https://umami.example.com",
|
|
34
|
+
autoInjectScript: false,
|
|
35
|
+
});
|
|
36
|
+
track("ready");
|
|
37
|
+
expect(umamiTrack).toHaveBeenCalledWith("ready");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("injects umami script tag with data-* attributes from options", () => {
|
|
41
|
+
const appendChild = vi.fn();
|
|
42
|
+
const dataset: Record<string, string> = {};
|
|
43
|
+
const fakeScript = { defer: false, src: "", dataset } as unknown as HTMLScriptElement;
|
|
44
|
+
vi.stubGlobal("document", {
|
|
45
|
+
head: { appendChild },
|
|
46
|
+
createElement: () => fakeScript,
|
|
47
|
+
querySelector: () => null,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
initAnalytics({
|
|
51
|
+
websiteId: "w-perf",
|
|
52
|
+
host: "https://umami.example.com",
|
|
53
|
+
performance: true,
|
|
54
|
+
tag: "v2",
|
|
55
|
+
beforeSend: "umamiBeforeSend",
|
|
56
|
+
domains: ["app.com", "www.app.com"],
|
|
57
|
+
doNotTrack: true,
|
|
58
|
+
autoTrack: false,
|
|
59
|
+
excludeSearch: true,
|
|
60
|
+
excludeHash: true,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(appendChild).toHaveBeenCalled();
|
|
64
|
+
expect(fakeScript.src).toBe("https://umami.example.com/script.js");
|
|
65
|
+
expect(dataset).toMatchObject({
|
|
66
|
+
websiteId: "w-perf",
|
|
67
|
+
performance: "true",
|
|
68
|
+
tag: "v2",
|
|
69
|
+
beforeSend: "umamiBeforeSend",
|
|
70
|
+
domains: "app.com,www.app.com",
|
|
71
|
+
doNotTrack: "true",
|
|
72
|
+
autoTrack: "false",
|
|
73
|
+
excludeSearch: "true",
|
|
74
|
+
excludeHash: "true",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("skips tracking entirely when disabled is true", () => {
|
|
79
|
+
initAnalytics({
|
|
80
|
+
websiteId: "w1",
|
|
81
|
+
host: "https://u",
|
|
82
|
+
disabled: true,
|
|
83
|
+
});
|
|
84
|
+
track("anything");
|
|
85
|
+
expect(umamiTrack).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("events", () => {
|
|
90
|
+
it("track() sends the event", async () => {
|
|
91
|
+
initAnalytics({
|
|
92
|
+
websiteId: "w1",
|
|
93
|
+
host: "https://u",
|
|
94
|
+
autoInjectScript: false,
|
|
95
|
+
});
|
|
96
|
+
track("hello", { foo: "bar" });
|
|
97
|
+
expect(umamiTrack).toHaveBeenCalledWith("hello", { foo: "bar" });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("trackRevenue() forwards value and currency", async () => {
|
|
101
|
+
initAnalytics({
|
|
102
|
+
websiteId: "w1",
|
|
103
|
+
host: "https://u",
|
|
104
|
+
autoInjectScript: false,
|
|
105
|
+
});
|
|
106
|
+
trackRevenue({
|
|
107
|
+
event: "subscription_started",
|
|
108
|
+
value: 49,
|
|
109
|
+
currency: "USD",
|
|
110
|
+
plan: "pro",
|
|
111
|
+
transactionId: "sub_abc",
|
|
112
|
+
});
|
|
113
|
+
expect(umamiTrack).toHaveBeenCalledWith(
|
|
114
|
+
"subscription_started",
|
|
115
|
+
expect.objectContaining({
|
|
116
|
+
revenue: 49,
|
|
117
|
+
currency: "USD",
|
|
118
|
+
plan: "pro",
|
|
119
|
+
transaction_id: "sub_abc",
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("setProfileId() calls identify", async () => {
|
|
125
|
+
const identify = vi.fn();
|
|
126
|
+
vi.stubGlobal("window", {
|
|
127
|
+
umami: { track: umamiTrack, identify },
|
|
128
|
+
location: { hostname: "example.com" },
|
|
129
|
+
});
|
|
130
|
+
initAnalytics({
|
|
131
|
+
websiteId: "w1",
|
|
132
|
+
host: "https://u",
|
|
133
|
+
autoInjectScript: false,
|
|
134
|
+
});
|
|
135
|
+
setProfileId("user_123");
|
|
136
|
+
expect(identify).toHaveBeenCalledWith({ profileId: "user_123" });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("cleanup function stops tracking", () => {
|
|
140
|
+
const stop = initAnalytics({
|
|
141
|
+
websiteId: "w1",
|
|
142
|
+
host: "https://u",
|
|
143
|
+
autoInjectScript: false,
|
|
144
|
+
});
|
|
145
|
+
stop();
|
|
146
|
+
track("late");
|
|
147
|
+
expect(umamiTrack).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
});
|