Compare commits

..
99 changed files with 7875 additions and 1621 deletions

No files matched your search

@@ -2259,62 +2259,6 @@ describe('AuthController grant flows', () => {
).not.toContain(permission);
});
it('revoke-user-app `*`: deletes the worker sessions that app deployed for the user, and only those', async () => {
const app = await server.stores.app.create(
{
name: `tr-worker-${uuidv4()}`,
title: 'TestRevokeWorkerApp',
index_url: `https://tr-worker-${uuidv4()}.example.test/`,
},
{ ownerUserId: issuer.id },
);
const workerName = () => `wk-${uuidv4().slice(0, 8)}`;
const appWorker = await server.stores.session.getOrCreateWorker(
issuer.id,
{ appUid: app.uid, workerName: workerName() },
);
const otherApp = await server.stores.session.getOrCreateWorker(
issuer.id,
{ appUid: `app-${uuidv4()}`, workerName: workerName() },
);
const userScoped = await server.stores.session.getOrCreateWorker(
issuer.id,
{ appUid: null, workerName: workerName() },
);
const exists = async (uuid: string) =>
(
await server.clients.db.read(
'SELECT 1 FROM `sessions` WHERE `uuid` = ?',
[uuid],
)
).length > 0;
// A single permission leaves the app's workers running as before.
await inCtx(issuerActor, () =>
controller.handleRevokeUserApp(
makeReq(
{ app_uid: app.uid, permission: 'service:x:ii:read' },
{ actor: issuerActor },
),
makeRes(),
),
);
expect(await exists(appWorker.uuid)).toBe(true);
await inCtx(issuerActor, () =>
controller.handleRevokeUserApp(
makeReq(
{ app_uid: app.uid, permission: '*' },
{ actor: issuerActor },
),
makeRes(),
),
);
expect(await exists(appWorker.uuid)).toBe(false);
expect(await exists(otherApp.uuid)).toBe(true);
expect(await exists(userScoped.uuid)).toBe(true);
});
it('grant-user-app: 400 on non-string or oversized origin/app_uid/permission', async () => {
const cases = [
{ origin: { host: 'evil' }, permission: 'service:x:ii:read' },
@@ -3265,12 +3265,6 @@ export class AuthController extends PuterController {
app_uid,
meta ?? undefined,
);
// Withdrawing everything also retires the workers the app deployed
// under this user; their tokens would otherwise act as the app.
await this.services.auth.deleteWorkerSessionsForApp(
req.actor!.user!.id as number,
app_uid,
);
} else {
for (const entry of list) {
await this.services.permission.revokeUserAppPermission(
@@ -115,31 +115,6 @@ async function checkMemory(key, limit, windowMs) {
return true;
}
/**
* Spend up to `units` from one bucket at once and report how many fit: the
* many-unit form of `checkMemory`, for a caller that would otherwise loop.
*/
async function claimMemory(key, limit, windowMs, units) {
const now = Date.now();
let entry = memoryWindows.get(key);
if (!entry) {
if (memoryWindows.size >= MEMORY_MAX_KEYS) {
const oldest = memoryWindows.keys().next().value;
memoryWindows.delete(oldest);
}
entry = { ts: [], windowMs };
memoryWindows.set(key, entry);
} else {
entry.windowMs = windowMs;
}
const timestamps = entry.ts;
const cutoff = now - windowMs;
while (timestamps.length > 0 && timestamps[0] < cutoff) timestamps.shift();
const admitted = Math.max(0, Math.min(units, limit - timestamps.length));
for (let i = 0; i < admitted; i++) timestamps.push(now);
return admitted;
}
/**
* Read a bucket's state without spending from it. For gates whose budget is
* consumed by something other than the request being admitted — a failed
@@ -207,32 +182,6 @@ async function checkRedis(
return true;
}
/**
* `checkRedis` for many units in one round trip: add them all, count, and take
* back however many landed over the limit. Same add-then-count shape, so
* concurrent claims can under-admit but never over-admit.
*/
async function claimRedis(redis, key, limit, windowMs, units) {
const redisKey = `rate:${key}`;
const now = Date.now();
const members = Array.from(
{ length: units },
() => `${now}:${crypto.randomUUID()}`,
);
const results = await redis
.multi()
.zremrangebyscore(redisKey, 0, now - windowMs)
.zadd(redisKey, ...members.flatMap((member) => [now, member]))
.zcard(redisKey)
.pexpire(redisKey, windowMs)
.exec();
const count = Number(multiResult(results, 2));
const excess = Math.min(units, Math.max(0, count - limit));
if (excess > 0) await redis.zrem(redisKey, ...members.slice(-excess));
return units - excess;
}
async function peekRedis(redis, key, limit, windowMs) {
const redisKey = `rate:${key}`;
const results = await redis
@@ -269,31 +218,6 @@ async function checkKv(kv, key, limit, windowMs) {
return true;
}
// `checkKv` for many units: one read, then a row per admitted unit. Newest
// first for the same reason `checkKv` reads that way.
async function claimKv(kv, key, limit, windowMs, units) {
const prefix = `rate:${key}:`;
const { res } = await kv.list({
as: 'keys',
pattern: prefix,
limit: limit + 1,
reverse: true,
});
const keys = Array.isArray(res) ? res : (res?.items ?? []);
const admitted = Math.max(0, Math.min(units, limit - keys.length));
const now = Date.now();
await Promise.all(
Array.from({ length: admitted }, () =>
kv.set({
key: `${prefix}${now}:${crypto.randomUUID()}`,
value: 1,
expireAt: Math.ceil((now + windowMs) / 1000),
}),
),
);
return admitted;
}
// `list` filters by TTL, so a non-expired row is in-window — same read
// `checkKv` does, minus the write.
async function peekKv(kv, key, limit) {
@@ -471,10 +395,6 @@ function instrumentBackendPair(name, pair) {
withSpan('rate_limit.peek', attrs, () =>
pair.peek(key, limit, windowMs),
),
claim: (key, limit, windowMs, units) =>
withSpan('rate_limit.claim', attrs, () =>
pair.claim(key, limit, windowMs, units),
),
acquire: (key, limit) =>
withSpan('rate_limit.acquire', attrs, () =>
pair.acquire(key, limit),
@@ -485,7 +405,6 @@ function instrumentBackendPair(name, pair) {
const memoryBackendPair = instrumentBackendPair('memory', {
rate: checkMemory,
peek: peekMemory,
claim: claimMemory,
acquire: acquireMemoryConcurrent,
});
@@ -534,8 +453,6 @@ export function configureRateLimit({
checkRedis(redis, key, limit, windowMs),
peek: (key, limit, windowMs) =>
peekRedis(redis, key, limit, windowMs),
claim: (key, limit, windowMs, units) =>
claimRedis(redis, key, limit, windowMs, units),
acquire: (key, limit) => acquireRedisConcurrent(redis, key, limit),
});
}
@@ -543,8 +460,6 @@ export function configureRateLimit({
backends.kv = instrumentBackendPair('kv', {
rate: (key, limit, windowMs) => checkKv(kv, key, limit, windowMs),
peek: (key, limit) => peekKv(kv, key, limit),
claim: (key, limit, windowMs, units) =>
claimKv(kv, key, limit, windowMs, units),
acquire: (key, limit) => acquireKvConcurrent(kv, key, limit),
});
}
@@ -855,25 +770,6 @@ export async function checkRateLimit(
);
}
/**
* `checkRateLimit` for `units` hits at once, in one backend round trip.
* Resolves to how many were admitted (0..units) and spent; the rest were not
* charged. Fails open, admitting every unit, like the rest of this module.
*/
export async function claimRateLimit(key, limit, windowMs, units, backend) {
if (!(units > 0)) return 0;
const bk = resolveBackend(backend);
try {
return await bk.claim(key, limit, windowMs, units);
} catch (err) {
console.error(
'[rate-limit] imperative claim failed, failing open:',
err,
);
return units;
}
}
/**
* Charge a route-shaped rate-limit spec from inside a handler.
*
@@ -37,7 +37,6 @@ import {
acquireDriverConcurrent,
checkDriverRateLimit,
checkRateLimit,
claimRateLimit,
concurrencyGate,
consumeRouteRateLimit,
configureRateLimit,
@@ -1480,20 +1479,6 @@ describe('rate + concurrent limiting — kv backend', () => {
await Promise.all(held.map((slot) => slot.release()));
});
it('claims many units in one call, admitting only what fits', async () => {
const key = uniqueScope();
expect(await claimRateLimit(key, 5, 60_000, 3)).toBe(3);
expect(await claimRateLimit(key, 5, 60_000, 3)).toBe(2);
expect(await claimRateLimit(key, 5, 60_000, 1)).toBe(0);
});
it('claims against a full bucket as full while expired rows are unswept', async () => {
const key = uniqueScope();
await seedUnswept(`rate:${key}:`, 3);
expect(await claimRateLimit(key, 3, 60_000, 3)).toBe(3);
expect(await claimRateLimit(key, 3, 60_000, 1)).toBe(0);
});
it('holds a concurrent slot until it is released', async () => {
const req = { ip: '9.9.9.9', headers: {}, actor: undefined };
const opts = { limit: 1, backend: 'kv', scope: uniqueScope() };
@@ -2009,59 +1994,3 @@ describe('acquireConcurrent (imperative)', () => {
configureRateLimit();
});
});
describe('claimRateLimit', () => {
const uniqueKey = () => `claim-${Math.random().toString(36).slice(2, 10)}`;
describe.each([
['memory', () => configureRateLimit()],
[
'redis',
() => configureRateLimit({ default: 'redis', redis: new RedisMock() }),
],
])('%s backend', (_name, configure) => {
beforeEach(configure);
it('admits every unit while under the limit', async () => {
expect(await claimRateLimit(uniqueKey(), 5, 60_000, 3)).toBe(3);
});
it('admits exactly up to the limit and spends only that', async () => {
const key = uniqueKey();
expect(await claimRateLimit(key, 5, 60_000, 3)).toBe(3);
expect(await claimRateLimit(key, 5, 60_000, 3)).toBe(2);
// Nothing spent past the limit, so the bucket is exactly full.
expect(await checkRateLimit(key, 5, 60_000)).toBe(false);
expect(await checkRateLimit(key, 6, 60_000)).toBe(true);
});
it('admits nothing once the bucket is full', async () => {
const key = uniqueKey();
expect(await claimRateLimit(key, 2, 60_000, 2)).toBe(2);
expect(await claimRateLimit(key, 2, 60_000, 4)).toBe(0);
});
it('shares its bucket with checkRateLimit', async () => {
const key = uniqueKey();
expect(await checkRateLimit(key, 3, 60_000)).toBe(true);
expect(await claimRateLimit(key, 3, 60_000, 5)).toBe(2);
});
});
it('claims nothing for zero units', async () => {
const key = uniqueKey();
expect(await claimRateLimit(key, 1, 60_000, 0)).toBe(0);
expect(await checkRateLimit(key, 1, 60_000)).toBe(true);
});
it('fails open, admitting every unit, when the backend throws', async () => {
const redis = new RedisMock();
redis.multi = () => {
throw new Error('redis down');
};
configureRateLimit({ default: 'redis', redis });
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(await claimRateLimit(uniqueKey(), 1, 60_000, 4)).toBe(4);
spy.mockRestore();
});
});
@@ -55,6 +55,8 @@ import { NeuralwattProvider } from './providers/neuralwatt/NeuralwattProvider.js
import { OpenAiChatProvider } from './providers/openai/OpenAiChatCompletionsProvider.js';
import { OpenRouterProvider } from './providers/openrouter/OpenRouterProvider.js';
import { TogetherAIProvider } from './providers/together/TogetherAIProvider.js';
import { XAI_CHAT_MODELS } from './providers/xai/catalog.js';
import { XAIProvider } from './providers/xai/XAIProvider.js';
import { handle_completion_output } from './utils/OpenAIUtil.js';
import { clearUnhealthyRoutes, isRouteUnhealthy } from './utils/providerHealth.js';
@@ -127,6 +129,10 @@ beforeAll(async () => {
vi.spyOn(NeuralwattProvider.prototype, 'models').mockResolvedValue(
aggregatorCatalog('neuralwatt-only-model') as never,
);
// xAI reprices its curated catalog over HTTP; serve the curated one.
vi.spyOn(XAIProvider.prototype, 'models').mockResolvedValue(
XAI_CHAT_MODELS,
);
fullDriver = await makeDriver(FULL_PROVIDER_CONFIG);
fakeOnlyDriver = await makeDriver({
providers: { ollama: { enabled: false } },
@@ -969,6 +975,9 @@ describe('ChatCompletionDriver output cap on xAI', () => {
server.services.metering,
'getUsageHeadroom',
).mockResolvedValue({ balance: 360_000, held: 0 });
vi.spyOn(XAIProvider.prototype, 'models').mockResolvedValue(
XAI_CHAT_MODELS,
);
const create = vi
.spyOn(OpenAI.Chat.Completions.prototype, 'create')
.mockResolvedValue(chatCompletion('hi') as never);
@@ -55,6 +55,8 @@ import { COUNT_TOKENS, ChatCompletionDriver } from './ChatCompletionDriver.js';
import { ClaudeProvider } from './providers/claude/ClaudeProvider.js';
import { FakeChatProvider } from './providers/FakeChatProvider.js';
import { OllamaChatProvider } from './providers/ollama/OllamaProvider.js';
import { XAI_CHAT_MODELS } from './providers/xai/catalog.js';
import { XAIProvider } from './providers/xai/XAIProvider.js';
import type {
IChatCompleteResult,
IChatProvider,
@@ -189,6 +191,9 @@ describe('ChatCompletionDriver model map', () => {
);
it('registers a keyed provider under its name, read from its config key', async () => {
vi.spyOn(XAIProvider.prototype, 'models').mockResolvedValue(
XAI_CHAT_MODELS,
);
const d = newDriver({
gemini: { apiKey: 'k' },
// Registered as `moonshotai`, configured as `moonshot`.
@@ -808,18 +813,18 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
expect(passed.max_tokens!).toBeGreaterThan(0);
});
it('caps `max_tokens` at the long-context output rate for a prompt past the threshold', async () => {
it.each(['multipliers', 'catalog overrides'])('caps `max_tokens` at the conditional output rate using %s', async pricingKind => {
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
{
id: 'capme',
aliases: [],
costs_currency: 'usd-cents',
costs: { input_tokens: 1000, output_tokens: 2000 },
long_context_pricing: {
...(pricingKind === 'multipliers' ? { long_context_pricing: {
threshold: 10,
input_multiplier: 2,
output_multiplier: 1.5,
},
} } : { pricingOverrides: [{ minPromptTokens: 10, costs: { input_tokens: 2000, output_tokens: 3000 } }] }),
max_tokens: 8192,
},
]);
@@ -854,6 +859,19 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
expect(passed.max_tokens!).toBeGreaterThan(0);
});
it('reserves a catalog fixed request fee before calling the provider', async () => {
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([{
id: 'request-fee', costs_currency: 'usd-cents', max_tokens: 1000,
costs: { input_tokens: 1, output_tokens: 1, request: 1_000_001 },
}]);
const d = await makeDriver();
vi.spyOn(server.services.metering, 'getUsageHeadroom').mockResolvedValue({ balance: 1_000_000, held: 0 });
const completeSpy = vi.spyOn(FakeChatProvider.prototype, 'complete');
await expect(withTestActor(() => d.complete({ model: 'request-fee', messages: [{ role: 'user', content: 'hi' }] })))
.rejects.toMatchObject({ statusCode: 402 });
expect(completeSpy).not.toHaveBeenCalled();
});
it('throws 402 instead of leaving `max_tokens` unset when credits cannot afford one output token', async () => {
// Regression: previously a sub-1 cap set max_tokens to `undefined`,
// which let the provider run to the model's full output limit and
@@ -98,7 +98,9 @@ import {
} from './utils/normalizeToOpenAI.js';
import { isToolChoice, toolChoiceFromWire } from './utils/openaiParams.js';
import {
catalogPrice,
costKeys,
costsForRequest,
isFreeModel,
longContextMultipliers,
trackedOutputTokens,
@@ -1385,17 +1387,19 @@ export class ChatCompletionDriver extends PuterDriver {
actor,
this.#meteringModelKey(model),
);
const requestCosts = costsForRequest(model, promptTokenEstimate);
// `|| 0` also catches NaN from a malformed cost table.
const inputTokenCost =
(Number(model.costs?.[inputKey] ?? 0) || 0) *
(Number(requestCosts[inputKey] ?? 0) || 0) *
multipliers.input *
costFactor;
const outputTokenCost =
(Number(model.costs?.[outputKey] ?? 0) || 0) *
(Number(requestCosts[outputKey] ?? 0) || 0) *
multipliers.output *
costFactor;
const approximateInputCost = promptTokenEstimate * inputTokenCost;
const extras = (pricing.extraCost ?? 0) * costFactor;
const requestFee = catalogPrice(requestCosts.request) ?? 0;
const extras = ((pricing.extraCost ?? 0) + requestFee) * costFactor;
const minimumCredits = Number(model.minimumCredits || 1);
const needed = Math.max(approximateInputCost + extras, minimumCredits);
@@ -40,6 +40,7 @@ import {
meterChatUsage,
type MeterChatUsageOptions,
} from '../utils/meterChatUsage.js';
import { catalogPrice, costKeys } from '../utils/pricing.js';
import { normalizeModelKey } from '../utils/modelRouting.js';
import * as OpenAIUtil from '../utils/OpenAIUtil.js';
import {
@@ -83,6 +84,7 @@ export interface OpenAICompatOptions {
defaultModel: string;
/** A static catalog; a gateway overrides `models()` instead. */
models?: () => IChatModel[];
requireTokenPrices?: boolean;
/** Usage is recorded under `<prefix>:<id>`; the bare id when omitted. */
meteringPrefix?: string;
/** Stripped from the catalog id to get the model name on the wire. */
@@ -158,6 +160,17 @@ export class OpenAICompatProvider implements IChatProvider {
): Promise<IChatCompleteResult> {
const options = this.#options;
const model = resolved ?? (await this.resolveModel(args.model));
if (options.requireTokenPrices) {
const { inputKey, outputKey } = costKeys(model);
if (
catalogPrice(model.costs[inputKey]) === undefined ||
catalogPrice(model.costs[outputKey]) === undefined
) {
throw new HttpError(503, 'Model pricing is unavailable', {
legacyCode: 'model_pricing_unavailable',
});
}
}
const actor = Context.get('actor');
const signal = Context.get('abortSignal');
@@ -213,6 +226,7 @@ export class OpenAICompatProvider implements IChatProvider {
};
const params = this.vendorParams(shared, args, model, actor);
let pricingTime = new Date();
let completion;
try {
completion = await options.client.chat.completions.create(
@@ -228,6 +242,7 @@ export class OpenAICompatProvider implements IChatProvider {
)
: undefined;
if (!retryParams) throw e;
pricingTime = new Date();
completion = await options.client.chat.completions.create(
retryParams as never,
{ signal },
@@ -254,7 +269,7 @@ export class OpenAICompatProvider implements IChatProvider {
this.meteringModelKey(model.id),
model,
usage,
meterOptions,
{ ...meterOptions, pricingTime },
);
source.setUsageCosts(metered.costs);
return this.reportedUsage(metered.usage);
@@ -20,13 +20,12 @@
/**
* Offline unit tests for AlibabaProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs AlibabaProvider directly against the live
* wired `MeteringService` so the recording side is exercised end-to-
* end. Alibaba is OpenAI-compatible so the OpenAI SDK is mocked at
* the module boundary; that's the real network egress point. The
* companion integration test (AlibabaProvider.integration.test.ts)
* exercises the real DashScope endpoint.
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
* constructs AlibabaProvider directly against the live wired `MeteringService`
* so the recording side is exercised end-to- end. Alibaba is OpenAI-compatible
* so the OpenAI SDK is mocked at the module boundary; that's the real network
* egress point. The companion integration test
* (AlibabaProvider.integration.test.ts) exercises the real DashScope endpoint.
*/
import { Writable } from 'node:stream';
@@ -48,16 +47,20 @@ import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { mapAlibabaCatalogModel, type AlibabaApiModel } from './catalog.js';
import page1 from './fixtures/page1.test.json';
import page2 from './fixtures/page2.test.json';
import { CATALOG_TTL_MS } from '../../utils/cachedRemoteCatalog.js';
import { ALIBABA_MODELS } from './models.js';
import { AlibabaProvider } from './AlibabaProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
const { createMock, openAICtor } = vi.hoisted(() => {
const { createMock, openAICtor, axiosGetMock } = vi.hoisted(() => {
const createMock = vi.fn();
const openAICtor = vi.fn();
return { createMock, openAICtor };
return { createMock, openAICtor, axiosGetMock: vi.fn() };
});
vi.mock('openai', () => {
@@ -71,6 +74,8 @@ vi.mock('openai', () => {
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
});
vi.mock('axios', () => ({ default: { get: axiosGetMock } }));
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
@@ -121,12 +126,15 @@ const makeCapturingChatStream = () => {
};
beforeEach(() => {
axiosGetMock.mockReset();
axiosGetMock.mockRejectedValue(new Error('Catalog unavailable'));
createMock.mockReset();
openAICtor.mockReset();
recordSpy = vi.spyOn(server.services.metering, 'utilRecordUsageObject');
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -138,8 +146,7 @@ describe('AlibabaProvider construction', () => {
expect(openAICtor).toHaveBeenCalledTimes(1);
expect(openAICtor).toHaveBeenCalledWith({
apiKey: 'test-key',
baseURL:
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
baseURL: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
maxRetries: 0,
timeout: 10 * 60 * 1000,
});
@@ -164,9 +171,9 @@ describe('AlibabaProvider model catalog', () => {
expect(provider.getDefaultModel()).toBe('qwen-plus-latest');
});
it('exposes the static ALIBABA_MODELS list verbatim from models()', () => {
it('uses the curated catalog when the initial fetch fails', async () => {
const { provider } = makeProvider();
expect(provider.models()).toBe(ALIBABA_MODELS);
expect(await provider.models()).toBe(ALIBABA_MODELS);
});
it('list() flattens canonical ids and aliases', async () => {
@@ -591,9 +598,7 @@ describe('AlibabaProvider.complete non-stream output', () => {
}),
);
const model = ALIBABA_MODELS.find(
(m) => m.id === 'qwen3.8-max',
)!;
const model = ALIBABA_MODELS.find((m) => m.id === 'qwen3.8-max')!;
const [usage, , prefix, overrides] = recordSpy.mock.calls[0]!;
// Cached reads come out of `prompt_tokens`; they're priced once.
expect(usage).toEqual({
@@ -768,3 +773,229 @@ describe('AlibabaProvider.checkModeration', () => {
);
});
});
describe('Alibaba official catalog', () => {
const seedPages = () => {
axiosGetMock.mockReset();
axiosGetMock
.mockResolvedValueOnce({ data: page1 })
.mockResolvedValueOnce({ data: page2 });
};
it('fetches all official pages, deduplicates ids and preserves curated aliases and prices', async () => {
seedPages();
const { provider } = makeProvider();
const [models, concurrent] = await Promise.all([
provider.models(),
provider.models(),
]);
expect(concurrent).toBe(models);
expect(axiosGetMock).toHaveBeenCalledTimes(2);
expect(axiosGetMock.mock.calls.map((call) => call[0])).toEqual([
'https://dashscope-intl.aliyuncs.com/api/v1/models',
'https://dashscope-intl.aliyuncs.com/api/v1/models',
]);
expect(
axiosGetMock.mock.calls.map((call) => call[1].params.page_no),
).toEqual([1, 2]);
expect(axiosGetMock.mock.calls[0][1]).toMatchObject({
headers: { Authorization: 'Bearer test-key' },
params: { page_size: 100, language: 'en-US' },
signal: expect.any(AbortSignal),
});
expect(
models.filter((model) => model.id === 'qwen-catalog-fixture'),
).toHaveLength(1);
expect(models.some((model) => model.id === 'qwen-image-fixture')).toBe(
false,
);
const plus = models.find((model) => model.id === 'qwen-plus')!;
const original = ALIBABA_MODELS.find(
(model) => model.id === 'qwen-plus',
)!;
expect(plus.costs).toBe(original.costs);
expect(plus.aliases).toEqual(original.aliases);
expect(plus.puterId).toBe(original.puterId);
expect(plus.release_date).toBe(original.release_date);
expect(models.some((model) => model.id === 'qwen-max')).toBe(true);
expect(await provider.models()).toBe(models);
expect(axiosGetMock).toHaveBeenCalledTimes(2);
});
it('normalizes capabilities, limits, release and retirement metadata', () => {
const model = mapAlibabaCatalogModel(
page1.output.models[1] as AlibabaApiModel,
);
expect(model).toMatchObject({
id: 'qwen-catalog-fixture',
context: 131072,
max_tokens: 16384,
maxInputTokens: 130048,
maxReasoningTokens: 8192,
reasoningMaxInputTokens: 64000,
reasoningMaxOutputTokens: 8192,
tool_call: true,
modalities: { input: ['text', 'image'], output: ['text'] },
release_date: '2026-08-01',
equivalentSnapshot: 'qwen-catalog-fixture-0801',
retirementDates: { international: '2027-01-01 00:00:00' },
});
});
it('normalizes unit denominators and preserves unknown currency, cache prices and abbreviated boundaries', () => {
const model = mapAlibabaCatalogModel(
page1.output.models[1] as AlibabaApiModel,
)!;
expect(model.costs).toEqual({ tokens: 1_000_000 });
expect(model.catalogPricing).toMatchObject([
{
minInputTokensExclusive: 0,
maxInputTokensInclusive: 32768,
prices: [
{ amount: 0.002, tokens: 1000, perMillionTokens: 2 },
{ amount: 8, tokens: 1_000_000, perMillionTokens: 8 },
{ amount: undefined, perMillionTokens: undefined },
],
},
{ name: '32k<Input<=128k' },
]);
const tiers =
model.catalogPricing as import('./catalog.js').AlibabaCatalogTier[];
expect(tiers[0].prices[0].currency).toBeUndefined();
expect(tiers[1].minInputTokensExclusive).toBeUndefined();
expect(tiers[1].maxInputTokensInclusive).toBeUndefined();
});
it.each([null, '', -1, 'TBD'])(
'keeps ambiguous price %s unknown',
(value) => {
const model = mapAlibabaCatalogModel({
model: 'fixture',
capabilities: ['TG'],
prices: [
{
range_name: 'Default',
prices: [
{
type: 'input_token',
price: value,
price_unit: 'per million tokens',
},
],
},
],
})!;
expect(model.catalogPricing).toMatchObject([
{
prices: [
{ amount: undefined, perMillionTokens: undefined },
],
},
]);
expect(model.costs).toEqual({ tokens: 1_000_000 });
},
);
it('does not infer limits or release dates from missing, null or zero fields', () => {
const model = mapAlibabaCatalogModel({
model: 'fixture',
capabilities: ['TG'],
published_time: '0',
model_info: { context_window: null, max_output_tokens: 0 },
})!;
expect(model.release_date).toBeUndefined();
expect(model.context).toBeUndefined();
expect(model.max_tokens).toBe(0);
expect(model.costs).toEqual({ tokens: 1_000_000 });
});
it('refuses inference with unverified catalog prices', async () => {
seedPages();
const { provider } = makeProvider();
await expect(
withTestActor(() =>
provider.complete({
model: 'qwen-catalog-fixture',
messages: [],
}),
),
).rejects.toMatchObject({ legacyCode: 'model_pricing_unavailable' });
expect(createMock).not.toHaveBeenCalled();
expect(recordSpy).not.toHaveBeenCalled();
});
it.each(['failed', 'malformed', 'empty', 'repeated'])(
'keeps the full last-good catalog after a %s refresh page',
async (failure) => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.spyOn(console, 'warn').mockImplementation(() => {});
seedPages();
const { provider } = makeProvider();
const good = await provider.models();
vi.advanceTimersByTime(CATALOG_TTL_MS + 1);
axiosGetMock.mockResolvedValueOnce({ data: page1 });
if (failure === 'failed')
axiosGetMock.mockRejectedValueOnce(new Error('unavailable'));
else if (failure === 'malformed')
axiosGetMock.mockResolvedValueOnce({
data: { success: false },
});
else if (failure === 'empty')
axiosGetMock.mockResolvedValueOnce({
data: {
success: true,
output: { total: 4, page_no: 2, models: [] },
},
});
else
axiosGetMock.mockResolvedValueOnce({
data: { ...page1, output: { ...page1.output, page_no: 2 } },
});
expect(await provider.models()).toBe(good);
expect(await provider.models()).toBe(good);
expect(axiosGetMock).toHaveBeenCalledTimes(4);
},
);
});
it('keeps previously discovered Alibaba models when a successful refresh omits them', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
axiosGetMock.mockReset();
axiosGetMock.mockResolvedValueOnce({ data: page1 }).mockResolvedValueOnce({ data: page2 });
const { provider } = makeProvider();
await provider.models();
vi.advanceTimersByTime(CATALOG_TTL_MS + 1);
axiosGetMock.mockResolvedValueOnce({ data: { success: true, output: { total: 0, page_no: 1, models: [] } } });
expect((await provider.models()).some(model => model.id === 'qwen-catalog-fixture')).toBe(true);
});
it.each([
['per token', 1, 2000], ['per thousand tokens', 1000, 2],
['per million tokens', 1_000_000, 0.002], ['per image', undefined, undefined],
])('normalizes Alibaba unit %s without assigning a currency', (unit, tokens, perMillionTokens) => {
const model = mapAlibabaCatalogModel({ model: 'fixture', capabilities: ['TG'], prices: [{
range_name: 'Default', prices: [{ type: 'input_token', price: '0.002', price_unit: unit }],
}] })!;
const [tier] = model.catalogPricing as import('./catalog.js').AlibabaCatalogTier[];
expect(tier.prices[0].tokens).toBe(tokens);
expect(tier.prices[0].perMillionTokens).toBe(perMillionTokens);
expect(tier.prices[0].currency).toBeUndefined();
});
it.each(['100<Input<=10', '9007199254740992<Input<=9007199254740993'])('keeps invalid Alibaba range %s unresolved', range => {
const model = mapAlibabaCatalogModel({ model: 'fixture', capabilities: ['TG'], prices: [{ range_name: range }] })!;
const [tier] = model.catalogPricing as import('./catalog.js').AlibabaCatalogTier[];
expect(tier.minInputTokensExclusive).toBeUndefined();
expect(tier.maxInputTokensInclusive).toBeUndefined();
});
it('uses the official Alibaba catalog path behind a configured proxy prefix', async () => {
axiosGetMock.mockReset();
axiosGetMock.mockResolvedValueOnce({ data: page1 }).mockResolvedValueOnce({ data: page2 });
const { provider } = makeProvider({ apiBaseUrl: 'https://custom.endpoint/proxy/compatible-mode/v1' });
await provider.models();
expect(axiosGetMock.mock.calls[0][0]).toBe('https://custom.endpoint/proxy/api/v1/models');
});
@@ -19,6 +19,9 @@
import { OpenAI } from 'openai';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { IChatModel } from '../../types.js';
import { cachedRemoteCatalog } from '../../utils/cachedRemoteCatalog.js';
import { fetchAlibabaCatalog } from './catalog.js';
import { sdkClientOptions } from '../../utils/sdkClient.js';
import {
type ChatProviderConfig,
@@ -27,6 +30,8 @@ import {
import { ALIBABA_MODELS } from './models.js';
export class AlibabaProvider extends OpenAICompatProvider {
#apiBaseUrl: string;
#apiKey: string;
constructor(config: ChatProviderConfig, meteringService: MeteringService) {
super(meteringService, {
client: new OpenAI({
@@ -36,11 +41,34 @@ export class AlibabaProvider extends OpenAICompatProvider {
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
...sdkClientOptions(),
}),
requireTokenPrices: true,
defaultModel: 'qwen-plus-latest',
models: () => ALIBABA_MODELS,
meteringPrefix: 'alibaba',
defaultMaxTokens: 1000,
passthrough: ['temperature'],
});
this.#apiBaseUrl = (
config.apiBaseUrl ??
'https://dashscope-intl.aliyuncs.com/compatible-mode/v1'
)
.replace(/\/(?:compatible-mode\/)?v1\/?$/, '')
.replace(/\/$/, '');
this.#apiKey = config.apiKey;
}
override async models(): Promise<IChatModel[]> {
return this.#catalog();
}
#catalog = cachedRemoteCatalog({
name: 'Alibaba catalog',
fallback: ALIBABA_MODELS,
fetch: (signal, previous) =>
fetchAlibabaCatalog(
this.#apiBaseUrl,
this.#apiKey,
signal,
previous,
),
});
}
@@ -0,0 +1,255 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import axios from 'axios';
import type { IChatModel } from '../../types.js';
import { catalogPrice } from '../../utils/pricing.js';
import { ALIBABA_MODELS } from './models.js';
export interface AlibabaApiModel {
model: string;
name?: string;
description?: string;
capabilities?: string[];
features?: string[];
published_time?: string | null;
equivalent_snapshot?: string | null;
inference_metadata?: {
request_modality?: string[];
response_modality?: string[];
};
model_info?: {
context_window?: number | null;
max_input_tokens?: number | null;
max_output_tokens?: number | null;
max_reasoning_tokens?: number | null;
reasoning_max_input_tokens?: number | null;
reasoning_max_output_tokens?: number | null;
};
inference_offline_info?: Record<string, { offline_time?: string | null }>;
prices?: Array<{
range_name?: string;
prices?: Array<{
type?: string;
price?: string | number | null;
price_unit?: string;
price_name?: string;
}>;
}>;
}
export interface AlibabaCatalogPrice {
type?: string;
name?: string;
amount?: number;
unit?: string;
tokens?: number;
perMillionTokens?: number;
currency?: string;
}
export interface AlibabaCatalogTier {
name?: string;
minInputTokensExclusive?: number;
maxInputTokensInclusive?: number;
prices: AlibabaCatalogPrice[];
}
const tokenDenominators: Record<string, number> = {
'per token': 1,
'per thousand tokens': 1000,
'per million tokens': 1_000_000,
};
const positiveLimit = (value: unknown): number | undefined =>
typeof value === 'number' && Number.isSafeInteger(value) && value > 0
? value
: undefined;
export const mapAlibabaCatalogModel = (
raw: AlibabaApiModel,
previous?: IChatModel,
): IChatModel | null => {
if (typeof raw.model !== 'string' || !raw.model.trim()) return null;
const id = raw.model.trim();
const existing =
previous ?? ALIBABA_MODELS.find((model) => model.id === id);
const output = raw.inference_metadata?.response_modality;
if (output && !output.includes('Text')) return null;
if (
!output &&
!existing &&
!raw.capabilities?.some((capability) =>
['TG', 'Reasoning', 'VU'].includes(capability),
)
)
return null;
const catalogPricing: AlibabaCatalogTier[] = (raw.prices ?? []).map(
(tier) => {
// Abbreviated ranges do not specify whether k means 1000 or 1024 tokens.
const match = tier.range_name?.match(/^(\d+)<Input<=(\d+)$/);
const range =
match &&
Number.isSafeInteger(Number(match[1])) &&
Number.isSafeInteger(Number(match[2])) &&
Number(match[2]) > Number(match[1])
? match
: undefined;
return {
name: tier.range_name,
...(range
? {
minInputTokensExclusive: Number(range[1]),
maxInputTokensInclusive: Number(range[2]),
}
: {}),
prices: (tier.prices ?? []).map((price) => {
const amount = catalogPrice(price.price);
const tokens = tokenDenominators[price.price_unit ?? ''];
return {
type: price.type,
name: price.price_name,
amount,
unit: price.price_unit,
tokens,
perMillionTokens:
amount !== undefined && tokens
? catalogPrice(amount, 1_000_000 / tokens)
: undefined,
};
}),
};
},
);
const publishedDate = raw.published_time?.match(
/^(\d{4}-\d{2}-\d{2}) \d{2}:\d{2}:\d{2}$/,
)?.[1];
const releaseDate =
publishedDate &&
Number.isFinite(Date.parse(publishedDate)) &&
new Date(publishedDate).toISOString().slice(0, 10) === publishedDate
? publishedDate
: undefined;
return {
...existing,
id,
name: raw.name ?? existing?.name ?? id,
description: raw.description ?? existing?.description,
aliases: existing?.aliases,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
// The official catalog omits currency; curated rates remain the billing source.
costs: existing?.costs ?? { tokens: 1_000_000 },
context:
positiveLimit(raw.model_info?.context_window) ?? existing?.context,
max_tokens:
positiveLimit(raw.model_info?.max_output_tokens) ??
existing?.max_tokens ??
0,
maxInputTokens: positiveLimit(raw.model_info?.max_input_tokens),
maxReasoningTokens: positiveLimit(raw.model_info?.max_reasoning_tokens),
reasoningMaxInputTokens: positiveLimit(
raw.model_info?.reasoning_max_input_tokens,
),
reasoningMaxOutputTokens: positiveLimit(
raw.model_info?.reasoning_max_output_tokens,
),
release_date: existing?.release_date ?? releaseDate,
modalities: raw.inference_metadata
? {
input: (raw.inference_metadata.request_modality ?? []).map(
(modality) => modality.toLowerCase(),
),
output: (output ?? []).map((modality) =>
modality.toLowerCase(),
),
}
: existing?.modalities,
tool_call: raw.features
? raw.features.includes('function-calling')
: existing?.tool_call,
capabilities: raw.capabilities,
features: raw.features,
equivalentSnapshot: raw.equivalent_snapshot ?? undefined,
retirementDates: Object.fromEntries(
Object.entries(raw.inference_offline_info ?? {}).flatMap(
([site, info]) =>
info?.offline_time ? [[site, info.offline_time]] : [],
),
),
catalogPricing,
};
};
export const fetchAlibabaCatalog = async (
apiBaseUrl: string,
apiKey: string,
signal: AbortSignal,
previous = ALIBABA_MODELS,
): Promise<IChatModel[]> => {
const models = new Map(previous.map((model) => [model.id, model]));
const seen = new Set<string>();
let count = 0;
let total: number | undefined;
for (let page = 1; page <= 200; page++) {
const response = await axios.get(`${apiBaseUrl}/api/v1/models`, {
headers: { Authorization: `Bearer ${apiKey}` },
params: { page_no: page, page_size: 100, language: 'en-US' },
signal,
maxContentLength: 8 * 1024 * 1024,
});
const body = response.data;
const output = body?.output;
if (
body?.success !== true ||
!Array.isArray(output?.models) ||
!Number.isSafeInteger(output.total) ||
output.total < 0 ||
output.page_no !== page ||
output.total > 20_000
) {
throw new Error('Invalid Alibaba catalog page');
}
total ??= output.total;
if (total !== output.total)
throw new Error('Alibaba catalog changed during pagination');
const entries = output.models as AlibabaApiModel[];
if (entries.length === 0 && count < output.total)
throw new Error('Incomplete Alibaba catalog');
const previousSize = seen.size;
for (const raw of entries) {
if (typeof raw.model !== 'string' || !raw.model.trim())
throw new Error('Invalid Alibaba model id');
seen.add(raw.model.trim());
const model = mapAlibabaCatalogModel(
raw,
models.get(raw.model.trim()),
);
if (model) models.set(model.id, model);
}
if (models.size > 20_000)
throw new Error('Alibaba catalog size limit exceeded');
if (entries.length && previousSize === seen.size)
throw new Error('Repeated Alibaba catalog page');
count += entries.length;
if (count >= output.total) return [...models.values()];
}
throw new Error('Alibaba catalog pagination limit exceeded');
};
@@ -0,0 +1,107 @@
{
"success": true,
"output": {
"total": 4,
"page_no": 1,
"page_size": 2,
"models": [
{
"model": "qwen-plus",
"published_time": "2026-01-01 00:00:00",
"model_info": {
"context_window": 1000000,
"max_output_tokens": 32768
},
"prices": [
{
"range_name": "Default",
"prices": [
{
"type": "input_token",
"price": "0",
"price_unit": "per million tokens"
}
]
}
]
},
{
"model": "qwen-catalog-fixture",
"name": "Qwen Catalog Fixture",
"capabilities": [
"TG",
"Reasoning",
"VU"
],
"features": [
"function-calling",
"cache",
"structured-outputs"
],
"published_time": "2026-08-01 12:00:00",
"model_info": {
"context_window": 131072,
"max_input_tokens": 130048,
"max_output_tokens": 16384,
"max_reasoning_tokens": 8192,
"reasoning_max_input_tokens": 64000,
"reasoning_max_output_tokens": 8192
},
"inference_metadata": {
"request_modality": [
"Text",
"Image"
],
"response_modality": [
"Text"
]
},
"equivalent_snapshot": "qwen-catalog-fixture-0801",
"inference_offline_info": {
"international": {
"offline_time": "2027-01-01 00:00:00"
}
},
"prices": [
{
"range_name": "0<Input<=32768",
"prices": [
{
"type": "input_token",
"price": "0.002",
"price_unit": "per thousand tokens",
"price_name": "Input"
},
{
"type": "output_token",
"price": "8",
"price_unit": "per million tokens",
"price_name": "Output"
},
{
"type": "input_token_cached",
"price": null,
"price_unit": "per million tokens"
}
]
},
{
"range_name": "32k<Input<=128k",
"prices": [
{
"type": "input_token",
"price": "4",
"price_unit": "per million tokens"
},
{
"type": "output_token",
"price": "16",
"price_unit": "per million tokens"
}
]
}
]
}
]
}
}
@@ -0,0 +1,95 @@
{
"success": true,
"output": {
"total": 4,
"page_no": 2,
"page_size": 2,
"models": [
{
"model": "qwen-catalog-fixture",
"name": "Qwen Catalog Fixture",
"capabilities": [
"TG",
"Reasoning",
"VU"
],
"features": [
"function-calling",
"cache",
"structured-outputs"
],
"published_time": "2026-08-01 12:00:00",
"model_info": {
"context_window": 131072,
"max_input_tokens": 130048,
"max_output_tokens": 16384,
"max_reasoning_tokens": 8192,
"reasoning_max_input_tokens": 64000,
"reasoning_max_output_tokens": 8192
},
"inference_metadata": {
"request_modality": [
"Text",
"Image"
],
"response_modality": [
"Text"
]
},
"equivalent_snapshot": "qwen-catalog-fixture-0801",
"inference_offline_info": {
"international": {
"offline_time": "2027-01-01 00:00:00"
}
},
"prices": [
{
"range_name": "0<Input<=32768",
"prices": [
{
"type": "input_token",
"price": "0.002",
"price_unit": "per thousand tokens",
"price_name": "Input"
},
{
"type": "output_token",
"price": "8",
"price_unit": "per million tokens",
"price_name": "Output"
},
{
"type": "input_token_cached",
"price": null,
"price_unit": "per million tokens"
}
]
},
{
"range_name": "32k<Input<=128k",
"prices": [
{
"type": "input_token",
"price": "4",
"price_unit": "per million tokens"
},
{
"type": "output_token",
"price": "16",
"price_unit": "per million tokens"
}
]
}
]
},
{
"model": "qwen-image-fixture",
"inference_metadata": {
"response_modality": [
"Image"
]
}
}
]
}
}
@@ -278,6 +278,7 @@ beforeEach(() => {
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -716,3 +717,31 @@ describe('InfronProvider.checkModeration', () => {
);
});
});
describe('Infron catalog price validation', () => {
it('keeps missing tier prices unknown and preserves fractional microcents', async () => {
axiosRequestMock.mockResolvedValueOnce({ data: { data: [{ id: 'fixture/model', category_type: 'LLM',
supported_endpoint_types: ['openai'], providers: [{ service_tier: 'standard', prompt_price: 0.014 }],
}] } });
const { provider } = makeProvider();
const [model] = await provider.models();
expect(model.costs.prompt).toBeCloseTo(1.4);
expect(model.costs.completion).toBeUndefined();
expect(model.costs.request).toBeUndefined();
await expect(withTestActor(() => provider.complete({ model: model.id, messages: [] })))
.rejects.toMatchObject({ legacyCode: 'model_pricing_unavailable' });
expect(createMock).not.toHaveBeenCalled();
});
});
it('retains the last-good infron catalog after a malformed refresh', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.spyOn(console, 'warn').mockImplementation(() => {});
const { provider } = makeProvider();
const good = await provider.models();
vi.advanceTimersByTime(15 * 60 * 1000 + 1);
axiosRequestMock.mockResolvedValueOnce({ data: {} });
expect(await provider.models()).toBe(good);
});
@@ -22,6 +22,7 @@ import { OpenAI } from 'openai';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { IChatModel } from '../../types.js';
import { cachedRemoteCatalog } from '../../utils/cachedRemoteCatalog.js';
import { catalogPrice } from '../../utils/pricing.js';
import { sdkClientOptions } from '../../utils/sdkClient.js';
import {
type ChatProviderConfig,
@@ -69,8 +70,8 @@ const DEFAULT_SERVICE_TIER = 'standard';
/** What one tier of a model costs, in USD per million tokens. */
type InfronTierPrices = {
prompt: number;
completion: number;
prompt?: number;
completion?: number;
context?: number;
};
@@ -84,10 +85,14 @@ const pricesByTier = (model: InfronApiModel) => {
for (const offer of model.providers ?? []) {
const tier = offer.service_tier;
if (!tier) continue;
const prompt = offer.prompt_price ?? 0;
const completion = offer.completion_price ?? 0;
const prompt = catalogPrice(offer.prompt_price);
const completion = catalogPrice(offer.completion_price);
const seen = byTier.get(tier);
if (seen && seen.prompt + seen.completion <= prompt + completion) {
if (
seen &&
(seen.prompt ?? Infinity) + (seen.completion ?? Infinity) <=
(prompt ?? Infinity) + (completion ?? Infinity)
) {
continue;
}
byTier.set(tier, { prompt, completion, context: offer.context_length });
@@ -137,7 +142,9 @@ const coerceModel = (
const shortId = model.id.split('/').slice(1).join('/');
// Catalog prices are USD per million tokens; costs are microcents per
// token, so the conversion is ×100.
const promptCost = Math.round(prices.prompt * 100);
const promptCost = catalogPrice(prices.prompt, 100);
const completionCost = catalogPrice(prices.completion, 100);
const requestCost = catalogPrice(model.min_request_price, 100_000_000);
return {
id: `infron:${model.id}${suffix}`,
name: `${model.display_name || model.id} (Infron${tier ? `, ${tier}` : ''})`,
@@ -159,17 +166,13 @@ const coerceModel = (
output_cost_key: 'completion',
costs: {
tokens: 1_000_000,
prompt: promptCost,
completion: Math.round(prices.completion * 100),
// The catalog carries no cache-read price; charge the full
// prompt rate in the fallback path so cached tokens are never
// billed below list. The normal path bills the
// gateway-reported cost instead.
input_cache_read: promptCost,
// USD per request → microcents per request.
request: Math.round(
(model.min_request_price ?? 0) * 1_000_000 * 100,
),
...(promptCost !== undefined
? { prompt: promptCost, input_cache_read: promptCost }
: {}),
...(completionCost !== undefined
? { completion: completionCost }
: {}),
...(requestCost !== undefined ? { request: requestCost } : {}),
},
};
};
@@ -194,6 +197,7 @@ export class InfronProvider extends OpenAICompatProvider {
baseURL: apiBaseUrl,
...sdkClientOptions(),
}),
requireTokenPrices: true,
defaultModel: 'infron:qwen/qwen3.5-flash',
passthrough: ['temperature'],
// Infron reports `cost` at the top level of the response; a
@@ -229,8 +233,11 @@ export class InfronProvider extends OpenAICompatProvider {
Authorization: `Bearer ${this.#apiKey}`,
},
signal,
maxContentLength: 8 * 1024 * 1024,
});
const models: InfronApiModel[] = resp.data.data ?? [];
if (!Array.isArray(resp.data?.data))
throw new Error('Invalid Infron catalog');
const models: InfronApiModel[] = resp.data.data;
const coerced_models: IChatModel[] = [];
for (const model of models) {
// The catalog mixes chat with image/video/embedding/search
@@ -243,8 +250,8 @@ export class InfronProvider extends OpenAICompatProvider {
}
const byTier = pricesByTier(model);
const defaultPrices = byTier.get(DEFAULT_SERVICE_TIER) ?? {
prompt: model.min_prompt_price ?? 0,
completion: model.min_completion_price ?? 0,
prompt: catalogPrice(model.min_prompt_price),
completion: catalogPrice(model.min_completion_price),
};
// The unsuffixed id is the default tier; every other tier the
// model sells gets its own `…:<tier>` id so callers can ask for
@@ -304,7 +311,9 @@ export class InfronProvider extends OpenAICompatProvider {
// The gateway-reported cost when there is one; the catalog's
// per-token prices otherwise.
meterOptions:
typeof cost === 'number' ? { authoritativeUsd: cost } : {},
catalogPrice(cost) !== undefined
? { authoritativeUsd: cost }
: {},
};
}
}
@@ -20,9 +20,9 @@
/**
* Offline unit tests for NeuralwattProvider.
*
* Boots a real PuterServer and constructs NeuralwattProvider against the
* live MeteringService. The OpenAI SDK and axios (catalog + quota) are
* mocked at their module boundaries.
* Boots a real PuterServer and constructs NeuralwattProvider against the live
* MeteringService. The OpenAI SDK and axios (catalog + quota) are mocked at
* their module boundaries.
*/
import { Writable } from 'node:stream';
@@ -94,6 +94,7 @@ const SAMPLE_API_MODELS = [
display_name: 'DeepSeek V4 Flash',
description: 'Fast tool-calling model',
pricing: {
currency: 'USD',
input_per_million: 0.14,
output_per_million: 0.28,
cached_input_per_million: 0.014,
@@ -115,6 +116,7 @@ const SAMPLE_API_MODELS = [
metadata: {
display_name: 'GLM 5.1',
pricing: {
currency: 'USD',
input_per_million: 0.35,
output_per_million: 1.38,
cached_input_per_million: 0.035,
@@ -136,6 +138,7 @@ const SAMPLE_API_MODELS = [
metadata: {
display_name: 'Coming Soon',
pricing: {
currency: 'USD',
input_per_million: 0,
output_per_million: 0,
pricing_tbd: true,
@@ -150,6 +153,7 @@ const SAMPLE_API_MODELS = [
display_name: 'Old',
deprecated: true,
pricing: {
currency: 'USD',
input_per_million: 1,
output_per_million: 2,
pricing_tbd: false,
@@ -228,6 +232,7 @@ beforeEach(() => {
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -269,6 +274,7 @@ describe('Neuralwatt model mapping helpers', () => {
metadata: {
display_name: 'Gemma 4 31B',
pricing: {
currency: 'USD',
input_per_million: 0.1,
output_per_million: 0.2,
pricing_tbd: false,
@@ -638,3 +644,84 @@ describe('NeuralwattProvider.complete streaming', () => {
});
});
});
describe('Neuralwatt catalog price validation', () => {
it.each([null, undefined, -1, NaN])(
'does not fabricate prices from %s',
(value) => {
const mapped = mapNeuralwattApiModel({
id: 'fixture',
created: 0,
metadata: {
pricing: {
currency: 'USD',
input_per_million: value as number,
output_per_million: 1,
},
},
})!;
expect(mapped.costs.prompt_tokens).toBeUndefined();
expect(mapped.costs.completion_tokens).toBe(100);
expect(mapped.costs.cached_tokens).toBeUndefined();
expect(mapped.costs.cached_output_tokens).toBeUndefined();
expect(mapped.release_date).toBeUndefined();
},
);
it.each(['CNY', undefined])(
'keeps unverified currency %s unpriced',
(currency) => {
expect(
mapNeuralwattApiModel({
id: 'fixture',
metadata: {
pricing: {
currency,
input_per_million: 1,
output_per_million: 2,
},
},
})!.costs,
).toEqual({ tokens: 1_000_000 });
},
);
it('preserves explicit zero and numeric cached-output prices separately', () => {
const mapped = mapNeuralwattApiModel({
id: 'fixture',
metadata: {
pricing: {
currency: 'USD',
input_per_million: 0,
output_per_million: 2,
cached_input_per_million: 0,
cached_output_per_million: 0.25,
},
},
})!;
expect(mapped.costs).toEqual({
tokens: 1_000_000,
prompt_tokens: 0,
completion_tokens: 200,
cached_tokens: 0,
cached_output_tokens: 25,
});
});
it('does not use registration timestamps as model release dates', () => {
expect(
mapNeuralwattApiModel(SAMPLE_API_MODELS[0]!)!.release_date,
).toBeUndefined();
});
});
it('retains the last-good neuralwatt catalog after a malformed refresh', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.spyOn(console, 'warn').mockImplementation(() => {});
const { provider } = makeProvider();
const good = await provider.models();
vi.advanceTimersByTime(15 * 60 * 1000 + 1);
axiosRequestMock.mockResolvedValueOnce({ data: {} });
expect(await provider.models()).toBe(good);
});
@@ -28,6 +28,7 @@ import {
modelSupportsVision,
} from '../../utils/mediaParts.js';
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
import { catalogPrice } from '../../utils/pricing.js';
import { sdkClientOptions } from '../../utils/sdkClient.js';
import {
type ChatProviderConfig,
@@ -72,6 +73,7 @@ export class NeuralwattProvider extends OpenAICompatProvider {
baseURL: apiBaseUrl,
...sdkClientOptions(),
}),
requireTokenPrices: true,
defaultModel: NEURALWATT_DEFAULT_MODEL,
idPrefix: NEURALWATT_ID_PREFIX,
passthrough: ['temperature'],
@@ -120,10 +122,12 @@ export class NeuralwattProvider extends OpenAICompatProvider {
Authorization: `Bearer ${this.#apiKey}`,
},
signal,
maxContentLength: 8 * 1024 * 1024,
});
if (!Array.isArray(resp.data?.data))
throw new Error('Invalid Neuralwatt catalog');
const coerced: IChatModel[] = [];
for (const model of (resp.data.data ??
[]) as NeuralwattApiModel[]) {
for (const model of resp.data.data as NeuralwattApiModel[]) {
if (model.metadata?.deprecated) continue;
const mapped = mapNeuralwattApiModel(model);
if (mapped) coerced.push(mapped);
@@ -212,8 +216,7 @@ export class NeuralwattProvider extends OpenAICompatProvider {
// priced.
meterOptions: {
costOverrides: { energy_kwh: 0, energy_joules: 0 },
...(typeof requestCostUsd === 'number' &&
Number.isFinite(requestCostUsd)
...(catalogPrice(requestCostUsd) !== undefined
? { authoritativeUsd: requestCostUsd }
: {}),
},
@@ -18,7 +18,7 @@
*/
import type { IChatModel } from '../../types.js';
import { usdPerMToken } from '../../utils/pricing.js';
import { catalogPrice } from '../../utils/pricing.js';
export const NEURALWATT_ID_PREFIX = 'neuralwatt:';
export const NEURALWATT_DEFAULT_MODEL = 'neuralwatt:deepseek-v4-flash';
@@ -104,15 +104,18 @@ export const mapNeuralwattApiModel = (
const pricing = model.metadata?.pricing;
if (pricing?.pricing_tbd) return null;
const inputUsd = Number(pricing?.input_per_million ?? 0);
const outputUsd = Number(pricing?.output_per_million ?? 0);
const costs = usdPerMToken(
inputUsd,
outputUsd,
Number(pricing?.cached_input_per_million ?? 0),
);
// No published cache price: cached reads bill at the input rate.
if (pricing?.cached_input_per_million == null) delete costs.cached_tokens;
const costs: IChatModel['costs'] = { tokens: 1_000_000 };
if (pricing?.currency === 'USD') {
for (const [key, value] of Object.entries({
prompt_tokens: pricing.input_per_million,
completion_tokens: pricing.output_per_million,
cached_tokens: pricing.cached_input_per_million,
cached_output_tokens: pricing.cached_output_per_million,
})) {
const price = catalogPrice(value, 100);
if (price !== undefined) costs[key] = price;
}
}
const caps = model.metadata?.capabilities;
const limits = model.metadata?.limits;
@@ -153,12 +156,5 @@ export const mapNeuralwattApiModel = (
...(model.metadata?.description
? { description: model.metadata.description }
: {}),
...(model.created
? {
release_date: new Date(model.created * 1000)
.toISOString()
.slice(0, 10),
}
: {}),
};
};
@@ -210,6 +210,7 @@ beforeEach(() => {
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -267,7 +268,7 @@ describe('OpenRouterProvider model catalog', () => {
expect(ids).not.toContain('qwen/qwen3-max');
});
it('coerces string prices and skips the structured overrides entry', async () => {
it('coerces string prices and normalizes conditional tiers', async () => {
const { provider } = makeProvider();
const models = await provider.models();
const sol = models.find((m) => m.id === 'openrouter:openai/gpt-6.1-sol')!;
@@ -275,8 +276,11 @@ describe('OpenRouterProvider model catalog', () => {
tokens: 1_000_000,
prompt: 200,
completion: 1000,
request: 0,
});
expect(sol.pricingOverrides).toEqual([{
minPromptTokens: 272000,
costs: { prompt: 400, completion: 1500 },
}]);
});
it('caches the coerced model list after the first axios round-trip', async () => {
@@ -333,22 +337,11 @@ describe('OpenRouterProvider model catalog', () => {
expect(capped.max_tokens).toBe(16000);
});
it('maps OpenRouter created timestamps to release_date metadata', async () => {
it('does not reinterpret catalog-added timestamps as release dates', async () => {
const { provider } = makeProvider();
const models = await provider.models();
expect(models).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'openrouter:openai/gpt-6-luna',
release_date: '2024-05-01',
}),
expect.objectContaining({
id: 'openrouter:anthropic/claude-haiku-4.5',
release_date: '2025-10-15',
}),
]),
);
expect((await provider.models()).every(model => model.release_date === undefined)).toBe(true);
});
});
// ── Disallowed model gate ───────────────────────────────────────────
@@ -983,3 +976,32 @@ describe('OpenRouterProvider.checkModeration', () => {
);
});
});
describe('OpenRouter catalog refresh validation', () => {
it('keeps missing model fields and prices unknown and blocks unpriced inference', async () => {
axiosRequestMock.mockResolvedValue({ data: { data: [{ id: 'fixture/model', name: 'Fixture' }] } });
const { provider } = makeProvider();
const [model] = await provider.models();
expect(model.costs).toEqual({ tokens: 1_000_000 });
expect(model.context).toBeUndefined();
expect(model.max_tokens).toBe(0);
await expect(withTestActor(() => provider.complete({ model: 'openrouter:fixture/model', messages: [] })))
.rejects.toMatchObject({ legacyCode: 'model_pricing_unavailable' });
expect(createMock).not.toHaveBeenCalled();
});
it.each(['malformed', 'failed', 'ambiguous conditional price'])('retains last-good data after a %s refresh', async failure => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.spyOn(console, 'warn').mockImplementation(() => {});
const { provider } = makeProvider();
const good = await provider.models();
vi.advanceTimersByTime(15 * 60 * 1000 + 1);
if (failure === 'failed') axiosRequestMock.mockRejectedValueOnce(new Error('unavailable'));
else if (failure === 'malformed') axiosRequestMock.mockResolvedValueOnce({ data: {} });
else axiosRequestMock.mockResolvedValueOnce({ data: { data: [{ ...SAMPLE_API_MODELS[0], pricing: {
prompt: '0.000002', overrides: [{ min_prompt_tokens: 10, prompt: '' }],
} }] } });
expect(await provider.models()).toBe(good);
});
});
@@ -23,6 +23,8 @@ import { HttpError } from '../../../../core/http/HttpError.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { IChatModel, ICompleteArguments } from '../../types.js';
import { cachedRemoteCatalog } from '../../utils/cachedRemoteCatalog.js';
import { catalogPrice } from '../../utils/pricing.js';
import { mapOpenRouterPricing } from './pricing.js';
import { sdkClientOptions } from '../../utils/sdkClient.js';
import {
type ChatProviderConfig,
@@ -35,18 +37,6 @@ type OpenrouterUsage = OpenAI.Completions.CompletionUsage & {
cost?: number;
};
const openRouterReleaseDate = (created: unknown): string | undefined => {
const seconds = Number(created);
if (!Number.isFinite(seconds) || seconds <= 0) {
return undefined;
}
const date = new Date(seconds * 1000);
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
export class OpenRouterProvider extends OpenAICompatProvider {
#apiBaseUrl: string;
@@ -58,6 +48,7 @@ export class OpenRouterProvider extends OpenAICompatProvider {
baseURL: apiBaseUrl,
...sdkClientOptions(),
}),
requireTokenPrices: true,
defaultModel: 'openrouter:openai/gpt-6-luna',
idPrefix: 'openrouter:',
passthrough: ['temperature'],
@@ -105,11 +96,17 @@ export class OpenRouterProvider extends OpenAICompatProvider {
method: 'GET',
url: `${this.#apiBaseUrl}/models`,
signal,
maxContentLength: 8 * 1024 * 1024,
});
const models = resp.data.data ?? [];
if (!Array.isArray(resp.data?.data))
throw new Error('Invalid OpenRouter catalog');
const models = resp.data.data;
const coerced_models: IChatModel[] = [];
for (const model of models) {
if ((model.id as string).includes('openrouter/auto')) {
if (
typeof model.id !== 'string' ||
model.id.includes('openrouter/auto')
) {
continue;
}
// OpenRouter sets expiration_date on deprecated models scheduled for removal.
@@ -119,32 +116,13 @@ export class OpenRouterProvider extends OpenAICompatProvider {
const overridenModel = OPEN_ROUTER_MODEL_OVERRIDES.find(
(m) => m.id === `openrouter:${model.id}`,
);
const microcentCosts = Object.fromEntries(
Object.entries(model.pricing)
// Skip structured entries like the long-context `overrides` tier list.
.filter(
([, v]) =>
(typeof v === 'string' || typeof v === 'number') &&
Number.isFinite(Number(v)),
)
.map(([k, v]) => [
k,
Math.round(
((v as number) < 0 ? 1 : (v as number)) *
1_000_000 *
100,
),
]),
);
if (!microcentCosts.request) {
microcentCosts.request = 0;
}
const pricing = mapOpenRouterPricing(model.pricing ?? {});
coerced_models.push({
id: `openrouter:${model.id}`,
name: `${model.name} (OpenRouter)`,
name: `${model.name ?? model.id} (OpenRouter)`,
aliases: [
model.id,
model.name,
...(typeof model.name === 'string' ? [model.name] : []),
`openrouter/${model.id}`,
model.id.split('/').slice(1).join('/'),
],
@@ -152,16 +130,28 @@ export class OpenRouterProvider extends OpenAICompatProvider {
// OpenRouter leaves max_completion_tokens null when a model
// declares no output cap separate from its context window.
max_tokens:
model.top_provider.max_completion_tokens ??
model.context_length,
model.top_provider?.max_completion_tokens ??
model.context_length ??
0,
costs_currency: 'usd-cents',
input_cost_key: 'prompt',
output_cost_key: 'completion',
costs: {
tokens: 1_000_000,
...microcentCosts,
...pricing.costs,
},
release_date: openRouterReleaseDate(model.created),
pricingOverrides: pricing.overrides,
modalities: model.architecture
? {
input: model.architecture.input_modalities ?? [
'text',
],
output: model.architecture.output_modalities ?? [
'text',
],
}
: undefined,
tool_call: model.supported_parameters?.includes('tools'),
...overridenModel,
});
}
@@ -184,12 +174,12 @@ export class OpenRouterProvider extends OpenAICompatProvider {
input_cache_read:
usage.prompt_tokens_details?.cached_tokens ?? 0,
request:
(usage as unknown as Record<string, number>).request || 1,
(usage as unknown as Record<string, number>).request ?? 1,
},
// What OpenRouter billed, when it says; the catalog's prices
// otherwise.
meterOptions:
typeof usage.cost === 'number'
catalogPrice(usage.cost) !== undefined
? { authoritativeUsd: usage.cost }
: {},
};
@@ -0,0 +1,30 @@
{
"prompt": "0.000002",
"completion": "0.00001",
"input_cache_read": "0.0000002",
"request": "0",
"overrides": [
{
"min_prompt_tokens": 100000,
"prompt": "0.000004",
"completion": "0.000015",
"input_cache_read": "0.0000004"
},
{
"min_prompt_tokens": 200000,
"prompt": "0.000006"
},
{
"utc_start": 1630,
"utc_end": 30,
"utc_days": [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday"
],
"completion": "0.00002"
}
]
}
@@ -0,0 +1,158 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import type { IChatModel } from '../../types.js';
import { buildCostsOverride, costsForRequest } from '../../utils/pricing.js';
import { mapOpenRouterPricing } from './pricing.js';
import fixture from './fixtures/pricing.test.json';
const catalogModel = (): IChatModel => {
const pricing = mapOpenRouterPricing(fixture);
return {
id: 'fixture',
max_tokens: 1000,
costs_currency: 'usd-cents',
input_cost_key: 'prompt',
output_cost_key: 'completion',
costs: pricing.costs,
pricingOverrides: pricing.overrides,
};
};
const monday = new Date('2026-10-12T12:00:00Z');
describe('OpenRouter conditional catalog pricing', () => {
it('converts USD per token and per request without rounding fractional microcents away', () => {
expect(
mapOpenRouterPricing({
prompt: '0.0000000014',
request: '0.01',
completion: '0',
}).costs,
).toEqual({ prompt: 0.14, request: 1_000_000, completion: 0 });
});
it.each([100000, 100001, 200000, 200001])(
'applies strict tier boundaries at %s tokens and inherits missing keys',
(tokens) => {
const costs = costsForRequest(catalogModel(), tokens, monday);
expect(costs.prompt).toBe(
tokens > 200000 ? 600 : tokens > 100000 ? 400 : 200,
);
expect(costs.completion).toBe(tokens > 100000 ? 1500 : 1000);
expect(costs.input_cache_read).toBe(tokens > 100000 ? 40 : 20);
},
);
it.each([
['2026-10-12T16:29:00Z', 1000],
['2026-10-12T16:30:00Z', 2000],
['2026-10-13T00:29:00Z', 2000],
['2026-10-13T00:30:00Z', 1000],
['2026-10-11T20:00:00Z', 1000],
])(
'matches wraparound UTC windows and weekdays at %s',
(date, expected) => {
expect(
costsForRequest(catalogModel(), 1000, new Date(date))
.completion,
).toBe(expected);
},
);
it('uses total prompt tokens including cached tokens for billing thresholds', () => {
const model = catalogModel();
model.pricingOverrides = model.pricingOverrides?.slice(0, 2);
expect(
buildCostsOverride(
{ prompt: 80000, input_cache_read: 30000, completion: 10 },
model,
),
).toEqual({
prompt: 80000 * 400,
input_cache_read: 30000 * 40,
completion: 10 * 1500,
});
});
it.each([
{ min_prompt_tokens: 1, unsupported_condition: true, prompt: '0' },
{ utc_start: 1630, prompt: '0' },
{ utc_start: 2460, utc_end: 30, prompt: '0' },
{ utc_days: ['not-a-day'], prompt: '0' },
{ min_prompt_tokens: -1, prompt: '0' },
{ min_prompt_tokens: null, prompt: '0' },
])(
'skips unsupported or malformed conditions without applying their rates',
(override) => {
expect(
mapOpenRouterPricing({ ...fixture, overrides: [override] })
.overrides,
).toEqual([]);
},
);
it('keeps absent, placeholder and invalid base prices unknown', () => {
expect(
mapOpenRouterPricing({
prompt: '',
completion: -1,
input_cache_read: null,
request: 'TBD',
}).costs,
).toEqual({});
});
});
it('does not include fixed request counts in a prompt threshold', () => {
const model = catalogModel();
model.pricingOverrides = model.pricingOverrides?.slice(0, 2);
expect(
buildCostsOverride({ prompt: 100000, request: 1 }, model).prompt,
).toBe(100000 * 200);
});
it('rejects ambiguous conditional rates so a refresh can retain last-good prices', () => {
expect(() =>
mapOpenRouterPricing({
prompt: '0.000002',
overrides: [{ min_prompt_tokens: 10, prompt: '' }],
}),
).toThrow('Invalid OpenRouter conditional price');
});
it('supports full-day equal endpoints and weekday-only predicates', () => {
const pricing = mapOpenRouterPricing({
prompt: '0.000002',
overrides: [
{ utc_start: 0, utc_end: 0, prompt: '0.000004' },
{ utc_days: ['monday'], prompt: '0.000006' },
],
});
const model = {
...catalogModel(),
costs: pricing.costs,
pricingOverrides: pricing.overrides,
};
expect(costsForRequest(model, 1, monday).prompt).toBe(600);
expect(
costsForRequest(model, 1, new Date('2026-10-13T00:00:00Z')).prompt,
).toBe(400);
});
@@ -0,0 +1,126 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { ModelCost, ModelPricingOverride } from '../../types.js';
import { catalogPrice } from '../../utils/pricing.js';
const PRICE_KEYS = new Set([
'prompt',
'completion',
'request',
'image',
'audio',
'web_search',
'internal_reasoning',
'input_cache_read',
'input_cache_write',
'input_cache_write_1h',
'input_audio_cache',
]);
const CONDITION_KEYS = new Set([
'min_prompt_tokens',
'utc_start',
'utc_end',
'utc_days',
]);
const WEEKDAYS = [
'sunday',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
];
const clockTime = (value: unknown): value is number =>
typeof value === 'number' &&
Number.isInteger(value) &&
value >= 0 &&
value <= 2359 &&
value % 100 < 60;
const rates = (pricing: Record<string, unknown>): ModelCost =>
Object.fromEntries(
Object.entries(pricing).flatMap(([key, value]) => {
const price = catalogPrice(value, 100_000_000);
return PRICE_KEYS.has(key) && price !== undefined
? [[key, Number(price.toPrecision(15))]]
: [];
}),
);
export const mapOpenRouterPricing = (
pricing: Record<string, unknown>,
): {
costs: ModelCost;
overrides: ModelPricingOverride[];
} => {
const overrides: ModelPricingOverride[] = [];
for (const raw of Array.isArray(pricing.overrides)
? pricing.overrides
: []) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
if (
Object.keys(raw).some(
(key) => !PRICE_KEYS.has(key) && !CONDITION_KEYS.has(key),
)
)
continue;
if (
raw.min_prompt_tokens !== undefined &&
(typeof raw.min_prompt_tokens !== 'number' ||
!Number.isSafeInteger(raw.min_prompt_tokens) ||
raw.min_prompt_tokens < 0)
)
continue;
const hasWindow =
raw.utc_start !== undefined || raw.utc_end !== undefined;
if (hasWindow && (!clockTime(raw.utc_start) || !clockTime(raw.utc_end)))
continue;
if (
raw.utc_days !== undefined &&
(!Array.isArray(raw.utc_days) ||
!raw.utc_days.length ||
raw.utc_days.some(
(day: unknown) =>
typeof day !== 'string' || !WEEKDAYS.includes(day),
))
)
continue;
if (!Object.keys(raw).some((key) => CONDITION_KEYS.has(key))) continue;
if (
Object.entries(raw).some(
([key, value]) =>
PRICE_KEYS.has(key) &&
catalogPrice(value, 100_000_000) === undefined,
)
) {
throw new Error('Invalid OpenRouter conditional price');
}
overrides.push({
minPromptTokens: raw.min_prompt_tokens,
utcStart: raw.utc_start,
utcEnd: raw.utc_end,
utcDays: raw.utc_days?.map((day: string) => WEEKDAYS.indexOf(day)),
costs: rates(raw),
});
}
return { costs: rates(pricing), overrides };
};
@@ -160,6 +160,7 @@ beforeEach(() => {
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -759,3 +760,30 @@ describe('TogetherAIProvider.checkModeration', () => {
);
});
});
describe('Together catalog price validation', () => {
it('does not fabricate missing prices or round fractional microcents away', async () => {
modelsListMock.mockResolvedValueOnce([{ id: 'fixture/model', type: 'chat', context_length: 1000,
pricing: { input: 0.014, output: null, cached_input: -1 } }]);
const { provider } = makeProvider();
const [model] = await provider.models();
expect(model.costs.input).toBeCloseTo(1.4);
expect(model.costs.output).toBeUndefined();
expect(model.costs.cached_input).toBeUndefined();
await expect(withTestActor(() => provider.complete({ model: model.id, messages: [] })))
.rejects.toMatchObject({ legacyCode: 'model_pricing_unavailable' });
expect(createMock).not.toHaveBeenCalled();
});
});
it('retains the last-good together catalog after a malformed refresh', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.spyOn(console, 'warn').mockImplementation(() => {});
const { provider } = makeProvider();
const good = await provider.models();
vi.advanceTimersByTime(15 * 60 * 1000 + 1);
modelsListMock.mockResolvedValueOnce({});
expect(await provider.models()).toBe(good);
});
@@ -21,6 +21,7 @@ import { Together } from 'together-ai';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { IChatModel } from '../../types.js';
import { cachedRemoteCatalog } from '../../utils/cachedRemoteCatalog.js';
import { catalogPrice } from '../../utils/pricing.js';
import { sdkClientOptions } from '../../utils/sdkClient.js';
import {
type ChatProviderConfig,
@@ -40,6 +41,7 @@ export class TogetherAIProvider extends OpenAICompatProvider {
});
super(meteringService, {
client: together,
requireTokenPrices: true,
defaultModel: 'togetherai:meta-llama/Llama-3.3-70B-Instruct-Turbo',
idPrefix: 'togetherai:',
passthrough: ['temperature'],
@@ -64,6 +66,8 @@ export class TogetherAIProvider extends OpenAICompatProvider {
query: { serverless: 'true' },
signal,
});
if (!Array.isArray(apiModels))
throw new Error('Invalid Together catalog');
const models: IChatModel[] = [];
for (const model of apiModels) {
if (
@@ -88,8 +92,13 @@ export class TogetherAIProvider extends OpenAICompatProvider {
costs: {
tokens: 1_000_000,
...Object.fromEntries(
Object.entries(model.pricing ?? {}).map(
([k, v]) => [k, (v as number) * 100],
Object.entries(model.pricing ?? {}).flatMap(
([key, value]) => {
const price = catalogPrice(value, 100);
return price === undefined
? []
: [[key, price]];
},
),
),
},
@@ -47,7 +47,10 @@ import type { MeteringService } from '../../../../services/metering/MeteringServ
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { costsForRequest, usageCost } from '../../utils/pricing.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { XAI_CHAT_MODELS } from './catalog.js';
import catalogFixture from './fixtures/language-models.json';
import { XAI_MODELS } from './models.js';
import { XAIProvider } from './XAIProvider.js';
@@ -57,10 +60,10 @@ import { XAIProvider } from './XAIProvider.js';
// the test body so each test can stub `chat.completions.create` with
// the response shape it cares about.
const { createMock, openAICtor } = vi.hoisted(() => {
const { createMock, openAICtor, axiosGetMock } = vi.hoisted(() => {
const createMock = vi.fn();
const openAICtor = vi.fn();
return { createMock, openAICtor };
return { createMock, openAICtor, axiosGetMock: vi.fn() };
});
vi.mock('openai', () => {
@@ -78,6 +81,10 @@ vi.mock('openai', () => {
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
});
// The pricing catalog is fetched with axios; tests that don't serve one see
// the upstream down and get the curated catalog.
vi.mock('axios', () => ({ default: { get: axiosGetMock } }));
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
@@ -130,6 +137,8 @@ const makeCapturingChatStream = () => {
beforeEach(() => {
createMock.mockReset();
openAICtor.mockReset();
axiosGetMock.mockReset();
axiosGetMock.mockRejectedValue(new Error('Catalog unavailable'));
// Spy on the live MeteringService — keep the underlying impl so
// recording-side bugs surface here, but capture calls so per-test
// assertions can verify metering shape.
@@ -163,9 +172,15 @@ describe('XAIProvider model catalog', () => {
expect(provider.getDefaultModel()).toBe('grok-4.5');
});
it('exposes the static XAI_MODELS list verbatim from models()', () => {
it('serves the curated catalog from models() while the upstream is down', async () => {
const { provider } = makeProvider();
expect(provider.models()).toBe(XAI_MODELS);
expect(await provider.models()).toBe(XAI_CHAT_MODELS);
expect(
(await provider.models()).map(({ id, aliases }) => ({
id,
aliases,
})),
).toEqual(XAI_MODELS.map(({ id, aliases }) => ({ id, aliases })));
});
it('list() flattens canonical ids and aliases', async () => {
@@ -558,6 +573,324 @@ describe('XAIProvider model resolution', () => {
// ── Non-stream completion + tool-call passthrough ───────────────────
// ── Remote pricing catalog ──────────────────────────────────────────
/** The live catalog with grok-4.5 repriced, so catalog rates are observable. */
const repricedCatalog = () => ({
models: catalogFixture.models.map((m) =>
m.id === 'grok-4.5'
? {
...m,
prompt_text_token_price: 25_000,
prompt_image_token_price: 25_000,
cached_prompt_text_token_price: 2_500,
completion_text_token_price: 70_000,
prompt_text_token_price_long_context: 45_000,
cached_prompt_text_token_price_long_context: 0,
completion_text_token_price_long_context: 150_000,
}
: m,
),
});
const okCompletion = (usage: Record<string, unknown>) => ({
choices: [
{
message: { content: 'ok', role: 'assistant' },
finish_reason: 'stop',
},
],
usage,
});
const promptOf = (total: number) => ({
prompt_tokens: total,
completion_tokens: 100,
prompt_tokens_details: { cached_tokens: 1000 },
});
describe('XAIProvider remote pricing catalog', () => {
afterEach(() => {
vi.useRealTimers();
});
it('fetches the catalog once and serves it from memory', async () => {
axiosGetMock.mockResolvedValue({ data: repricedCatalog() });
const { provider } = makeProvider();
const [a, b, c] = await Promise.all([
provider.models(),
provider.models(),
provider.list(),
]);
await provider.models();
expect(axiosGetMock).toHaveBeenCalledTimes(1);
expect(a).toBe(b);
expect(c).toEqual(await provider.list());
expect(a.find((m) => m.id === 'grok-4.5')!.costs).toEqual({
tokens: 1_000_000,
prompt_tokens: 250,
completion_tokens: 700,
cached_tokens: 25,
});
});
it('keeps the last good catalog when a refresh fails, and remembers the failure', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
axiosGetMock.mockResolvedValueOnce({ data: repricedCatalog() });
const { provider } = makeProvider();
const good = await provider.models();
vi.setSystemTime(Date.now() + 16 * 60 * 1000);
axiosGetMock.mockRejectedValue(new Error('upstream down'));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(await provider.models()).toBe(good);
expect(await provider.models()).toBe(good);
expect(axiosGetMock).toHaveBeenCalledTimes(2);
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0]![0])).not.toContain('test-key');
});
it('serves the curated catalog when the first fetch returns garbage', async () => {
axiosGetMock.mockResolvedValueOnce({ data: { data: [] } });
vi.spyOn(console, 'warn').mockImplementation(() => {});
const { provider } = makeProvider();
expect(await provider.models()).toBe(XAI_CHAT_MODELS);
});
it('bills completions at catalog rates and keeps aliases and the default', async () => {
axiosGetMock.mockResolvedValue({ data: repricedCatalog() });
const { provider } = makeProvider();
for (const model of ['grok-build-latest', 'not-a-model']) {
createMock.mockResolvedValueOnce(okCompletion(promptOf(11_000)));
await withTestActor(() =>
provider.complete({
model,
messages: [{ role: 'user', content: 'hi' }],
}),
);
}
expect(createMock.mock.calls.map((c) => c[0].model)).toEqual([
'grok-4.5',
'grok-4.5',
]);
for (const call of recordSpy.mock.calls) {
expect(call[2]).toBe('xai:grok-4.5');
expect(call[3]).toEqual({
prompt_tokens: 10_000 * 250,
cached_tokens: 1000 * 25,
completion_tokens: 100 * 700,
});
}
});
it.each([
[199_999, { prompt: 250, cached: 25, completion: 700 }],
// The long tier's zero cache amount falls back to the standard rate.
[200_000, { prompt: 450, cached: 25, completion: 1500 }],
[200_001, { prompt: 450, cached: 25, completion: 1500 }],
])(
'a %i-token prompt is metered, reported and estimated at one rate',
async (prompt, rate) => {
axiosGetMock.mockResolvedValue({ data: repricedCatalog() });
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(okCompletion(promptOf(prompt)));
const result = (await withTestActor(() =>
provider.complete({
model: 'grok-4.5',
messages: [{ role: 'user', content: 'hi' }],
}),
)) as {
usage: Record<string, number>;
usageCosts: Record<string, number>;
};
const ledger = {
prompt_tokens: (prompt - 1000) * rate.prompt,
cached_tokens: 1000 * rate.cached,
completion_tokens: 100 * rate.completion,
};
expect(recordSpy.mock.calls[0]![0]).toEqual({
prompt_tokens: prompt - 1000,
cached_tokens: 1000,
completion_tokens: 100,
});
expect(recordSpy.mock.calls[0]![3]).toEqual(ledger);
expect(result.usageCosts).toEqual(ledger);
// The figure the driver reports from the returned usage matches
// the ledger, and the credit gate quotes the same tier.
const model = (await provider.models()).find(
(m) => m.id === 'grok-4.5',
)!;
const cost = usageCost(result.usage, model, result.usageCosts)!;
expect(cost.inputMicroCents + cost.outputMicroCents).toBe(
ledger.prompt_tokens +
ledger.cached_tokens +
ledger.completion_tokens,
);
const estimate = costsForRequest(model, prompt);
expect(estimate.prompt_tokens).toBe(rate.prompt);
expect(estimate.completion_tokens).toBe(rate.completion);
},
);
it('bills explicit zero catalog rates as free and keeps a priced cache rate', async () => {
const catalog = repricedCatalog();
catalog.models = catalog.models.map((m) =>
m.id === 'grok-4.5'
? {
...m,
prompt_text_token_price: 0,
prompt_image_token_price: 0,
completion_text_token_price: 0,
}
: m,
);
axiosGetMock.mockResolvedValue({ data: catalog });
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(okCompletion(promptOf(11_000)));
await withTestActor(() =>
provider.complete({
model: 'grok-4.5',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(recordSpy.mock.calls[0]![3]).toEqual({
prompt_tokens: 0,
cached_tokens: 1000 * 25,
completion_tokens: 0,
});
});
});
/** The live catalog with grok-4.5 priced above the fixture on every rate. */
const higherCatalog = () => ({
models: catalogFixture.models.map((m) =>
m.id === 'grok-4.5'
? {
...m,
prompt_text_token_price: 30_000,
prompt_image_token_price: 30_000,
cached_prompt_text_token_price: 5_000,
completion_text_token_price: 90_000,
prompt_text_token_price_long_context: 60_000,
cached_prompt_text_token_price_long_context: 10_000,
completion_text_token_price_long_context: 180_000,
}
: m,
),
});
const usageChunks = (usage: Record<string, unknown>) =>
asAsyncIterable([
{ choices: [{ delta: { content: 'ok' } }] },
{ choices: [{ delta: {} }], usage },
]);
describe('XAIProvider resolved catalog snapshot', () => {
afterEach(() => {
vi.useRealTimers();
});
it.each([false, true])(
'bills the snapshot the driver held credit against after the catalog refreshes higher (stream: %s)',
async (stream) => {
vi.useFakeTimers({ toFake: ['Date'] });
axiosGetMock.mockResolvedValueOnce({
data: structuredClone(catalogFixture),
});
const { provider } = makeProvider();
const snapshot = (await provider.models()).find(
(m) => m.id === 'grok-4.5',
)!;
vi.setSystemTime(Date.now() + 16 * 60 * 1000);
axiosGetMock.mockResolvedValue({ data: higherCatalog() });
const usage = promptOf(200_000);
if (stream) createMock.mockReturnValueOnce(usageChunks(usage));
else createMock.mockResolvedValueOnce(okCompletion(usage));
const result = await withTestActor(() =>
provider.complete(
{
model: 'grok-4.5',
messages: [{ role: 'user', content: 'hi' }],
stream,
},
snapshot,
),
);
if (stream) {
await (
result as {
init_chat_stream: (p: {
chatStream: unknown;
}) => Promise<void>;
}
).init_chat_stream({
chatStream: makeCapturingChatStream().chatStream,
});
}
// No catalog fetch of its own, and the snapshot's long tier
// applies from the inclusive threshold.
expect(axiosGetMock).toHaveBeenCalledTimes(1);
expect(createMock.mock.calls[0]![0].model).toBe('grok-4.5');
expect(recordSpy).toHaveBeenCalledTimes(1);
expect(recordSpy.mock.calls[0]![2]).toBe('xai:grok-4.5');
expect(recordSpy.mock.calls[0]![3]).toEqual({
prompt_tokens: 199_000 * 400,
cached_tokens: 1000 * 60,
completion_tokens: 100 * 1200,
});
// The catalog the provider would have read is newer and higher.
const fresh = (await provider.models()).find(
(m) => m.id === 'grok-4.5',
)!;
expect(axiosGetMock).toHaveBeenCalledTimes(2);
expect(costsForRequest(fresh, 200_000)).toMatchObject({
prompt_tokens: 600,
cached_tokens: 100,
completion_tokens: 1800,
});
},
);
it('resolves and refreshes the catalog itself when called without a snapshot', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
axiosGetMock.mockResolvedValueOnce({
data: structuredClone(catalogFixture),
});
const { provider } = makeProvider();
await provider.models();
vi.setSystemTime(Date.now() + 16 * 60 * 1000);
axiosGetMock.mockResolvedValue({ data: higherCatalog() });
createMock.mockResolvedValueOnce(okCompletion(promptOf(200_000)));
await withTestActor(() =>
provider.complete({
model: 'grok-4.5-latest',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(axiosGetMock).toHaveBeenCalledTimes(2);
expect(createMock.mock.calls[0]![0].model).toBe('grok-4.5');
expect(recordSpy.mock.calls[0]![3]).toEqual({
prompt_tokens: 199_000 * 600,
cached_tokens: 1000 * 100,
completion_tokens: 100 * 1800,
});
});
});
describe('XAIProvider.complete non-stream output', () => {
it('returns the first choice and runs the metered usage calculator', async () => {
const { provider } = makeProvider();
@@ -23,13 +23,15 @@ import { Context } from '../../../../core/context.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
import type {
IChatModel,
IChatProvider,
ICompleteArguments,
IChatCompleteResult,
} from '../../types.js';
import { XAI_MODELS } from './models.js';
import { XAI_BASE_URL, XAI_CHAT_MODELS, fetchXaiCatalog } from './catalog.js';
import { cachedRemoteCatalog } from '../../utils/cachedRemoteCatalog.js';
import { meterChatUsage } from '../../utils/meterChatUsage.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import { buildCostsOverride } from '../../utils/pricing.js';
import { sdkClientOptions } from '../../utils/sdkClient.js';
export class XAIProvider implements IChatProvider {
@@ -37,25 +39,33 @@ export class XAIProvider implements IChatProvider {
#meteringService: MeteringService;
#catalog: () => Promise<IChatModel[]>;
constructor(config: { apiKey: string }, meteringService: MeteringService) {
this.#openai = new OpenAI({
apiKey: config.apiKey,
baseURL: 'https://api.x.ai/v1',
baseURL: XAI_BASE_URL,
...sdkClientOptions(),
});
this.#meteringService = meteringService;
this.#catalog = cachedRemoteCatalog({
name: 'xAI catalog',
fallback: XAI_CHAT_MODELS,
fetch: (signal, previous) =>
fetchXaiCatalog(config.apiKey, signal, previous),
});
}
getDefaultModel() {
return 'grok-4.5';
}
models() {
return XAI_MODELS;
async models(): Promise<IChatModel[]> {
return this.#catalog();
}
async list() {
return modelLookupNames(this.models());
return modelLookupNames(await this.models());
}
/** The model key this provider records usage under. */
@@ -63,19 +73,14 @@ export class XAIProvider implements IChatProvider {
return `xai:${modelId}`;
}
async complete({
messages,
stream,
model,
tools,
max_tokens,
}: ICompleteArguments): Promise<IChatCompleteResult> {
async complete(
{ messages, stream, model, tools, max_tokens }: ICompleteArguments,
resolved?: IChatModel,
): Promise<IChatCompleteResult> {
const actor = Context.get('actor');
const availableModels = this.models();
const modelUsed =
availableModels.find((m) =>
[m.id, ...(m.aliases || [])].includes(model),
) || availableModels.find((m) => m.id === this.getDefaultModel())!;
// The driver's snapshot is what its credit hold was priced against, so
// a catalog refresh mid-request can't change what this call bills.
const modelUsed = resolved ?? (await this.#resolveModel(model));
messages = await OpenAIUtil.process_input_messages(messages);
let completion;
try {
@@ -102,25 +107,18 @@ export class XAIProvider implements IChatProvider {
}
return OpenAIUtil.handle_completion_output({
usage_calculator: ({ usage }) => {
usage_calculator: ({ usage, setUsageCosts }) => {
// xAI reports cached reads inside `prompt_tokens`; bill them
// only at the cached rate.
const metered = OpenAIUtil.extractMeteredUsage(usage);
const trackedUsage = {
...metered,
prompt_tokens:
metered.prompt_tokens - metered.cached_tokens,
};
const costsOverride = buildCostsOverride(
trackedUsage,
modelUsed,
);
this.#meteringService.utilRecordUsageObject(
trackedUsage,
const { costs } = meterChatUsage(
this.#meteringService,
actor,
this.meteringModelKey(modelUsed.id),
costsOverride,
modelUsed,
OpenAIUtil.splitCachedPrompt(usage),
);
setUsageCosts(costs);
return metered;
},
stream,
@@ -128,6 +126,15 @@ export class XAIProvider implements IChatProvider {
});
}
async #resolveModel(model: string): Promise<IChatModel> {
const availableModels = await this.models();
return (
availableModels.find((m) =>
[m.id, ...(m.aliases || [])].includes(model),
) || availableModels.find((m) => m.id === this.getDefaultModel())!
);
}
checkModeration(
_text: string,
): ReturnType<IChatProvider['checkModeration']> {
@@ -0,0 +1,569 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IChatModel } from '../../types.js';
import {
buildCostsOverride,
costsForRequest,
isFreeModel,
} from '../../utils/pricing.js';
import { fetchXaiCatalog, XAI_CHAT_MODELS } from './catalog.js';
import catalogFixture from './fixtures/language-models.json';
import { XAI_MODELS } from './models.js';
const { axiosGetMock } = vi.hoisted(() => ({ axiosGetMock: vi.fn() }));
vi.mock('axios', () => ({ default: { get: axiosGetMock } }));
type RawModel = Record<string, unknown>;
const fixtureModels = (): RawModel[] =>
structuredClone(catalogFixture.models) as RawModel[];
/** The live catalog with `patch` applied to one entry. */
const withEntry = (id: string, patch: RawModel): RawModel[] =>
fixtureModels().map((m) => (m.id === id ? { ...m, ...patch } : m));
/** A prompt rate with the matching image rate, as xAI publishes them. */
const textRate = (price: unknown): RawModel => ({
prompt_text_token_price: price,
prompt_image_token_price: price,
});
const serve = (models: unknown) =>
axiosGetMock.mockResolvedValueOnce({ data: { models } });
const load = async (models: unknown, previous?: IChatModel[]) => {
serve(models);
return fetchXaiCatalog('test-key', new AbortController().signal, previous);
};
const find = (models: IChatModel[], id: string) =>
models.find((m) => m.id === id)!;
const curated = (id: string) => find(XAI_CHAT_MODELS, id);
const usage = (prompt: number) => ({
prompt_tokens: prompt - 1000,
cached_tokens: 1000,
completion_tokens: 100,
});
beforeEach(() => {
axiosGetMock.mockReset();
});
describe('XAI_CHAT_MODELS (curated fallback)', () => {
it('turns every multiplier tier into an explicit tier starting at the threshold', () => {
for (const source of XAI_MODELS) {
const model = curated(source.id);
expect(model.long_context_pricing).toBeUndefined();
expect(model.pricingOverrides).toEqual([
{
minPromptTokens: 200_000,
minPromptTokensInclusive: true,
costs: {
prompt_tokens: source.costs.prompt_tokens * 2,
completion_tokens: source.costs.completion_tokens * 2,
cached_tokens: source.costs.cached_tokens * 2,
},
},
]);
const { long_context_pricing: _tier, ...rest } = source;
const { pricingOverrides: _overrides, ...converted } = model;
expect(converted).toEqual(rest);
}
});
it('leaves the shared curated list (mirrored by Azure) untouched', () => {
expect(XAI_MODELS.every((m) => m.long_context_pricing)).toBe(true);
expect(XAI_MODELS.some((m) => m.pricingOverrides)).toBe(false);
});
});
describe('fetchXaiCatalog request', () => {
it('asks the global endpoint with the configured key and bounded size', async () => {
const signal = new AbortController().signal;
serve(fixtureModels());
await fetchXaiCatalog('test-key', signal);
expect(axiosGetMock).toHaveBeenCalledWith(
'https://api.x.ai/v1/language-models',
{
headers: { Authorization: 'Bearer test-key' },
signal,
maxContentLength: 8 * 1024 * 1024,
maxRedirects: 0,
},
);
});
it.each([
['a missing models array', { data: [] }],
['a non-array models field', { models: {} }],
['a non-JSON body', '<html>'],
['an oversized model list', { models: new Array(1001).fill({}) }],
])('rejects %s', async (_label, data) => {
axiosGetMock.mockResolvedValueOnce({ data });
await expect(
fetchXaiCatalog('test-key', new AbortController().signal),
).rejects.toThrow('Invalid xAI catalog');
});
it('propagates transport failures so the cache keeps its last good value', async () => {
axiosGetMock.mockRejectedValueOnce(new Error('socket hang up'));
await expect(
fetchXaiCatalog('test-key', new AbortController().signal),
).rejects.toThrow('socket hang up');
});
});
describe('fetchXaiCatalog pricing', () => {
it('converts USD cents per 100M tokens to cents per million, tier by tier', async () => {
const models = await load(fixtureModels());
const grok45 = find(models, 'grok-4.5');
expect(grok45.costs).toEqual({
tokens: 1_000_000,
prompt_tokens: 200,
completion_tokens: 600,
cached_tokens: 30,
});
expect(grok45.pricingOverrides).toEqual([
{
minPromptTokens: 200_000,
minPromptTokensInclusive: true,
costs: {
prompt_tokens: 400,
cached_tokens: 60,
completion_tokens: 1200,
},
},
]);
expect(find(models, 'grok-4.20-0309-reasoning').costs).toEqual({
tokens: 1_000_000,
prompt_tokens: 125,
completion_tokens: 250,
cached_tokens: 20,
});
});
it('preserves curated identity, aliases, limits and modalities', async () => {
const models = await load(fixtureModels());
expect(models.map((m) => m.id)).toEqual(
XAI_CHAT_MODELS.map((m) => m.id),
);
for (const model of models) {
const { costs: _c, pricingOverrides: _o, ...rest } = model;
const {
costs: _cc,
pricingOverrides: _co,
...curatedRest
} = curated(model.id);
expect(rest).toEqual(curatedRest);
}
});
it('keeps fractional cents exactly', async () => {
const models = await load(
withEntry('grok-4.5', {
prompt_text_token_price: 12_345,
prompt_image_token_price: 12_345,
cached_prompt_text_token_price: 1,
completion_text_token_price: 99_999,
}),
);
expect(find(models, 'grok-4.5').costs).toEqual({
tokens: 1_000_000,
prompt_tokens: 123.45,
completion_tokens: 999.99,
cached_tokens: 0.01,
});
});
it('represents independent long-context amounts without a common multiplier', async () => {
const models = await load(
withEntry('grok-4.5', {
prompt_text_token_price_long_context: 30_000,
cached_prompt_text_token_price_long_context: 3_500,
completion_text_token_price_long_context: 100_000,
}),
);
expect(find(models, 'grok-4.5').pricingOverrides![0]!.costs).toEqual({
prompt_tokens: 300,
cached_tokens: 35,
completion_tokens: 1000,
});
});
it('keeps a one-unit long-context amount positive', async () => {
const models = await load(
withEntry('grok-4.5', {
cached_prompt_text_token_price_long_context: 1,
}),
);
expect(
find(models, 'grok-4.5').pricingOverrides![0]!.costs.cached_tokens,
).toBe(0.01);
});
it('bills the standard rate for a zero long-context amount, never zero', async () => {
const models = await load(
withEntry('grok-4.5', {
prompt_text_token_price_long_context: 0,
cached_prompt_text_token_price_long_context: 0,
completion_text_token_price_long_context: 120_000,
}),
);
const model = find(models, 'grok-4.5');
expect(model.pricingOverrides![0]!.costs).toEqual({
completion_tokens: 1200,
});
expect(buildCostsOverride(usage(200_000), model)).toEqual({
prompt_tokens: 199_000 * 200,
cached_tokens: 1000 * 30,
completion_tokens: 100 * 1200,
});
});
it('drops the long-context tier when every long amount is zero', async () => {
const models = await load(
withEntry('grok-4.5', {
prompt_text_token_price_long_context: 0,
cached_prompt_text_token_price_long_context: 0,
completion_text_token_price_long_context: 0,
long_context_threshold: 0,
}),
);
const model = find(models, 'grok-4.5');
expect(model.pricingOverrides).toEqual([]);
expect(costsForRequest(model, 900_000)).toEqual(model.costs);
});
it('bills cached reads free when the cache rate is an explicit zero', async () => {
const models = await load(
withEntry('grok-4.5', {
cached_prompt_text_token_price: 0,
cached_prompt_text_token_price_long_context: 0,
}),
);
const model = find(models, 'grok-4.5');
expect(model.costs.cached_tokens).toBe(0);
expect(isFreeModel(model)).toBe(false);
expect(buildCostsOverride(usage(10_000), model)).toEqual({
prompt_tokens: 9000 * 200,
cached_tokens: 0,
completion_tokens: 100 * 600,
});
// A zero long cache amount falls back to the free standard cache rate.
expect(buildCostsOverride(usage(200_000), model)).toEqual({
prompt_tokens: 199_000 * 400,
cached_tokens: 0,
completion_tokens: 100 * 1200,
});
});
it('applies a long cache amount above a free standard cache rate', async () => {
const models = await load(
withEntry('grok-4.5', { cached_prompt_text_token_price: 0 }),
);
const model = find(models, 'grok-4.5');
expect(buildCostsOverride(usage(199_999), model).cached_tokens).toBe(0);
expect(buildCostsOverride(usage(200_000), model).cached_tokens).toBe(
1000 * 60,
);
});
it.each([
['prompt', textRate(0), { prompt_tokens: 0 }],
[
'completion',
{ completion_text_token_price: 0 },
{ completion_tokens: 0 },
],
])(
'bills an explicit zero standard %s rate as free below the long tier',
async (_label, patch, zeroed) => {
const models = await load(withEntry('grok-4.5', patch));
const model = find(models, 'grok-4.5');
const costs = {
tokens: 1_000_000,
prompt_tokens: 200,
completion_tokens: 600,
cached_tokens: 30,
...zeroed,
};
expect(model.costs).toEqual(costs);
expect(isFreeModel(model)).toBe(false);
expect(buildCostsOverride(usage(10_000), model)).toEqual({
prompt_tokens: 9000 * costs.prompt_tokens,
cached_tokens: 1000 * 30,
completion_tokens: 100 * costs.completion_tokens,
});
// The fixture's positive long amounts still apply past the threshold.
expect(buildCostsOverride(usage(200_000), model)).toEqual({
prompt_tokens: 199_000 * 400,
cached_tokens: 1000 * 60,
completion_tokens: 100 * 1200,
});
},
);
it('admits a catalog entry whose every rate is an explicit zero as free', async () => {
const models = await load(
withEntry('grok-4.5', {
...textRate(0),
cached_prompt_text_token_price: 0,
completion_text_token_price: 0,
prompt_text_token_price_long_context: 0,
cached_prompt_text_token_price_long_context: 0,
completion_text_token_price_long_context: 0,
}),
);
const model = find(models, 'grok-4.5');
expect(model.costs).toEqual({
tokens: 1_000_000,
prompt_tokens: 0,
completion_tokens: 0,
cached_tokens: 0,
});
expect(model.pricingOverrides).toEqual([]);
expect(isFreeModel(model)).toBe(true);
expect(buildCostsOverride(usage(300_000), model)).toEqual({
prompt_tokens: 0,
cached_tokens: 0,
completion_tokens: 0,
});
});
it('ignores the threshold when every long amount is zero', async () => {
const models = await load(
withEntry('grok-4.5', {
prompt_text_token_price_long_context: 0,
cached_prompt_text_token_price_long_context: 0,
completion_text_token_price_long_context: 0,
}),
);
expect(find(models, 'grok-4.5').pricingOverrides).toEqual([]);
});
it.each([
['missing prompt rate', textRate(undefined)],
['null prompt rate', textRate(null)],
['missing completion rate', { completion_text_token_price: undefined }],
['null completion rate', { completion_text_token_price: null }],
['negative prompt rate', textRate(-1)],
['string prompt rate', textRate('20000')],
['fractional prompt rate', textRate(20_000.5)],
['subnormal prompt rate', textRate(5e-324)],
['unsafe-integer prompt rate', textRate(2 ** 53)],
['negative cache rate', { cached_prompt_text_token_price: -3000 }],
['object cache rate', { cached_prompt_text_token_price: { usd: 1 } }],
['missing cache rate', { cached_prompt_text_token_price: undefined }],
['null cache rate', { cached_prompt_text_token_price: null }],
['subnormal cache rate', { cached_prompt_text_token_price: 5e-324 }],
[
'negative long-context rate',
{ completion_text_token_price_long_context: -1 },
],
[
'malformed long-context rate',
{ prompt_text_token_price_long_context: '40000' },
],
[
'missing long-context prompt rate',
{ prompt_text_token_price_long_context: undefined },
],
[
'null long-context cache rate',
{ cached_prompt_text_token_price_long_context: null },
],
[
'missing long-context completion rate',
{ completion_text_token_price_long_context: undefined },
],
[
'subnormal long-context cache rate',
{ cached_prompt_text_token_price_long_context: 5e-324 },
],
['long tier without a threshold', { long_context_threshold: null }],
['missing threshold', { long_context_threshold: undefined }],
[
'missing threshold with no long amounts',
{
prompt_text_token_price_long_context: 0,
cached_prompt_text_token_price_long_context: 0,
completion_text_token_price_long_context: 0,
long_context_threshold: undefined,
},
],
['zero threshold beside long amounts', { long_context_threshold: 0 }],
['negative threshold', { long_context_threshold: -1 }],
['fractional threshold', { long_context_threshold: 199_999.5 }],
['string threshold', { long_context_threshold: '200000' }],
['image rate above text', { prompt_image_token_price: 30_000 }],
['image rate below text', { prompt_image_token_price: 10_000 }],
['missing image rate', { prompt_image_token_price: undefined }],
['no text output', { output_modalities: ['image'] }],
['malformed output modalities', { output_modalities: 'text' }],
])('keeps the previous pricing on a %s', async (_label, patch) => {
const previous = XAI_CHAT_MODELS.map((m) =>
m.id === 'grok-4.5'
? { ...m, costs: { ...m.costs, prompt_tokens: 222 } }
: m,
);
const models = await load(withEntry('grok-4.5', patch), previous);
expect(find(models, 'grok-4.5')).toBe(find(previous, 'grok-4.5'));
expect(isFreeModel(find(models, 'grok-4.5'))).toBe(false);
// Other models still refresh from the same response.
expect(find(models, 'grok-4.6').costs.cached_tokens).toBe(50);
});
it('keeps the last good entry for a model the catalog omits', async () => {
const first = await load(withEntry('grok-4.6', textRate(22_000)));
const second = await load(
fixtureModels().filter((m) => m.id !== 'grok-4.6'),
first,
);
expect(find(second, 'grok-4.6').costs.prompt_tokens).toBe(220);
expect(second.map((m) => m.id)).toEqual(first.map((m) => m.id));
});
it('keeps every model when the catalog is empty', async () => {
expect(await load([])).toEqual(XAI_CHAT_MODELS);
});
it('admits no model outside the curated list', async () => {
const models = await load([
...fixtureModels(),
{
...fixtureModels()[4],
id: 'grok-9-synthetic',
aliases: ['grok-9-synthetic-latest'],
},
]);
expect(models.map((m) => m.id)).toEqual(
XAI_CHAT_MODELS.map((m) => m.id),
);
});
it('skips malformed entries without failing the refresh', async () => {
const models = await load([
null,
'grok-4.5',
[],
{ id: 42 },
...withEntry('grok-4.5', textRate(30_000)),
]);
expect(find(models, 'grok-4.5').costs.prompt_tokens).toBe(300);
});
it('matches a curated id the catalog lists as an alias of a renamed model', async () => {
const models = await load(
withEntry('grok-4.5', {
id: 'grok-4.5-0708',
aliases: ['grok-4.5'],
prompt_text_token_price: 21_000,
prompt_image_token_price: 21_000,
}),
);
const model = find(models, 'grok-4.5');
expect(model.costs.prompt_tokens).toBe(210);
expect(model.aliases).toEqual(curated('grok-4.5').aliases);
});
it('ignores an alias two catalog entries claim', async () => {
const models = await load([
...fixtureModels().filter((m) => m.id !== 'grok-4.5'),
{ ...fixtureModels()[4], id: 'a', aliases: ['grok-4.5'] },
{
...fixtureModels()[4],
id: 'b',
aliases: ['grok-4.5'],
...textRate(1),
},
]);
expect(find(models, 'grok-4.5')).toBe(curated('grok-4.5'));
});
it('keeps the last good entry when two catalog entries claim its id', async () => {
const original = fixtureModels().find((m) => m.id === 'grok-4.5')!;
const models = await load([
...withEntry('grok-4.5', textRate(30_000)),
{ ...original, ...textRate(40_000) },
]);
expect(find(models, 'grok-4.5')).toBe(curated('grok-4.5'));
expect(find(models, 'grok-4.6').costs.cached_tokens).toBe(50);
});
it('does not fall back to an alias when the exact id is ambiguous', async () => {
const original = fixtureModels().find((m) => m.id === 'grok-4.5')!;
const models = await load([
...fixtureModels(),
{ ...original, ...textRate(40_000) },
{
...original,
id: 'grok-4.5-0708',
aliases: ['grok-4.5'],
...textRate(21_000),
},
]);
expect(find(models, 'grok-4.5')).toBe(curated('grok-4.5'));
});
it('ignores an alias whose entry shares its canonical id with another', async () => {
const original = fixtureModels().find((m) => m.id === 'grok-4.5')!;
const models = await load([
...fixtureModels().filter((m) => m.id !== 'grok-4.5'),
{
...original,
id: 'grok-4.5-0708',
aliases: ['grok-4.5'],
...textRate(21_000),
},
{
...original,
id: 'grok-4.5-0708',
aliases: [],
...textRate(40_000),
},
]);
expect(find(models, 'grok-4.5')).toBe(curated('grok-4.5'));
});
});
describe('xAI long-context tier boundary', () => {
it.each([
[199_999, false],
[200_000, true],
[200_001, true],
])('a %i-token prompt pays the long tier: %s', async (prompt, long) => {
const model = find(await load(fixtureModels()), 'grok-4.5');
expect(buildCostsOverride(usage(prompt), model)).toEqual({
prompt_tokens: (prompt - 1000) * (long ? 400 : 200),
cached_tokens: 1000 * (long ? 60 : 30),
completion_tokens: 100 * (long ? 1200 : 600),
});
expect(costsForRequest(model, prompt).prompt_tokens).toBe(
long ? 400 : 200,
);
// The curated fallback starts its tier at the same boundary.
expect(buildCostsOverride(usage(prompt), curated('grok-4.5'))).toEqual(
buildCostsOverride(usage(prompt), model),
);
});
});
@@ -0,0 +1,194 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import axios from 'axios';
import type { IChatModel, ModelCost } from '../../types.js';
import { costKeys, isOutputCostKey } from '../../utils/pricing.js';
import { XAI_MODELS } from './models.js';
export const XAI_BASE_URL = 'https://api.x.ai/v1';
/** One entry of `GET /v1/language-models`; every field is untrusted. */
export type XaiLanguageModel = Record<string, unknown>;
// xAI's long-context tier starts at the threshold, not past it. Azure mirrors
// `XAI_MODELS` with its own semantics, so the conversion happens here.
const inclusiveLongContext = ({
long_context_pricing: tier,
...model
}: IChatModel): IChatModel => {
if (!tier) return model;
const { outputKey } = costKeys(model);
return {
...model,
pricingOverrides: [
{
minPromptTokens: tier.threshold,
minPromptTokensInclusive: true,
costs: Object.fromEntries(
Object.entries(model.costs)
.filter(([key]) => key !== 'tokens')
.map(([key, rate]) => [
key,
rate *
(isOutputCostKey(key, outputKey)
? tier.output_multiplier
: tier.input_multiplier),
]),
),
},
],
};
};
/** The curated catalog as the direct xAI provider bills it. */
export const XAI_CHAT_MODELS: IChatModel[] =
XAI_MODELS.map(inclusiveLongContext);
const isCount = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
/**
* USD cents per 100M tokens as cents per 1M; undefined when absent or not the
* documented non-negative integer.
*/
const centsPerMTok = (value: unknown): number | undefined =>
isCount(value) ? value / 100 : undefined;
const isStringArray = (value: unknown): value is string[] =>
Array.isArray(value) && value.every((item) => typeof item === 'string');
/**
* Reprices a curated model from its catalog entry. Null when the entry can't be
* billed faithfully, so the caller keeps the model's last-good pricing.
*/
export const mapXaiCatalogModel = (
raw: XaiLanguageModel,
previous: IChatModel,
): IChatModel | null => {
if (
!isStringArray(raw.output_modalities) ||
!raw.output_modalities.includes('text')
)
return null;
const prompt = centsPerMTok(raw.prompt_text_token_price);
const completion = centsPerMTok(raw.completion_text_token_price);
const cached = centsPerMTok(raw.cached_prompt_text_token_price);
if (
prompt === undefined ||
completion === undefined ||
cached === undefined
)
return null;
// Image tokens are metered under `prompt_tokens`, so only a text-equal
// image rate can be billed without blending.
const acceptsImages =
previous.modalities?.input.includes('image') ||
(isStringArray(raw.input_modalities) &&
raw.input_modalities.includes('image'));
if (acceptsImages && centsPerMTok(raw.prompt_image_token_price) !== prompt)
return null;
// Unlike the long-context amounts, a zero standard rate is a real free rate.
const costs: ModelCost = {
tokens: 1_000_000,
prompt_tokens: prompt,
completion_tokens: completion,
cached_tokens: cached,
};
// A zero long-context amount means the standard rate applies.
const longCosts: ModelCost = {};
for (const [key, value] of [
['prompt_tokens', raw.prompt_text_token_price_long_context],
['cached_tokens', raw.cached_prompt_text_token_price_long_context],
['completion_tokens', raw.completion_text_token_price_long_context],
] as const) {
const rate = centsPerMTok(value);
if (rate === undefined) return null;
if (rate > 0) longCosts[key] = rate;
}
// A zero threshold means no long tier, which contradicts a long amount.
const threshold = raw.long_context_threshold;
if (!isCount(threshold)) return null;
const hasLongTier = Object.keys(longCosts).length > 0;
if (hasLongTier && threshold === 0) return null;
return {
...previous,
costs,
pricingOverrides: hasLongTier
? [
{
minPromptTokens: threshold,
minPromptTokensInclusive: true,
costs: longCosts,
},
]
: [],
};
};
/**
* Refreshes the curated models' pricing from xAI's catalog. Models the catalog
* omits or prices unusably keep their previous entry, and models outside the
* curated list aren't admitted: the catalog carries no context or output
* limits, nor which endpoints a model supports.
*/
export const fetchXaiCatalog = async (
apiKey: string,
signal: AbortSignal,
previous: IChatModel[] = XAI_CHAT_MODELS,
): Promise<IChatModel[]> => {
const response = await axios.get(`${XAI_BASE_URL}/language-models`, {
headers: { Authorization: `Bearer ${apiKey}` },
signal,
maxContentLength: 8 * 1024 * 1024,
maxRedirects: 0,
});
const entries: unknown = response.data?.models;
if (!Array.isArray(entries) || entries.length > 1000)
throw new Error('Invalid xAI catalog');
// An id or alias two entries claim resolves to neither, an ambiguous id
// doesn't fall back to an alias, and an alias only resolves to an entry
// that uniquely owns its own id.
const byId = new Map<string, XaiLanguageModel | null>();
const byAlias = new Map<string, XaiLanguageModel | null>();
for (const raw of entries) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
const entry = raw as XaiLanguageModel;
if (typeof entry.id !== 'string') continue;
byId.set(entry.id, byId.has(entry.id) ? null : entry);
for (const alias of isStringArray(entry.aliases) ? entry.aliases : []) {
byAlias.set(alias, byAlias.has(alias) ? null : entry);
}
}
return previous.map((model) => {
const aliased = byAlias.get(model.id);
const raw = byId.has(model.id)
? byId.get(model.id)
: aliased && byId.get(aliased.id as string) === aliased
? aliased
: null;
return (raw && mapXaiCatalogModel(raw, model)) || model;
});
};
@@ -0,0 +1,208 @@
{
"models": [
{
"id": "grok-4.20-0309-non-reasoning",
"created": 1773014400,
"object": "model",
"owned_by": "xai",
"version": "1.0",
"input_modalities": ["text", "image"],
"output_modalities": ["text"],
"prompt_text_token_price": 12500,
"cached_prompt_text_token_price": 2000,
"prompt_image_token_price": 12500,
"completion_text_token_price": 25000,
"search_price": 0,
"prompt_text_token_price_long_context": 25000,
"cached_prompt_text_token_price_long_context": 4000,
"completion_text_token_price_long_context": 50000,
"long_context_threshold": 200000,
"aliases": [
"grok-4.20-non-reasoning",
"grok-4.20-non-reasoning-latest",
"grok-4.20-beta-non-reasoning",
"grok-4.20-beta-latest-non-reasoning",
"grok-4.20-experimental-beta-0304-non-reasoning",
"grok-4.20-experimental-beta-non-reasoning-latest",
"grok-4.20-beta-0309-non-reasoning",
"grok-4.20-non-reasoning-gv2"
]
},
{
"id": "grok-4.20-0309-reasoning",
"created": 1773014400,
"object": "model",
"owned_by": "xai",
"version": "1.0",
"input_modalities": ["text", "image"],
"output_modalities": ["text"],
"prompt_text_token_price": 12500,
"cached_prompt_text_token_price": 2000,
"prompt_image_token_price": 12500,
"completion_text_token_price": 25000,
"search_price": 0,
"prompt_text_token_price_long_context": 25000,
"cached_prompt_text_token_price_long_context": 4000,
"completion_text_token_price_long_context": 50000,
"long_context_threshold": 200000,
"aliases": [
"grok-4.20-reasoning-latest",
"grok-4.20",
"grok-4.20-reasoning",
"grok-4.20-0309",
"grok-4.20-beta-0309-reasoning",
"grok-4.20-beta",
"grok-4.20-beta-0309",
"grok-4.20-beta-latest",
"grok-4.20-beta-latest-reasoning",
"grok-4.20-beta-reasoning",
"grok-4.20-experimental-beta-0304-reasoning",
"grok-4.20-experimental-beta-0304",
"grok-4.20-experimental-beta-reasoning-latest",
"grok-4.20-experimental-beta-latest",
"grok-4.20-reasoning-gv2"
]
},
{
"id": "grok-4.20-multi-agent-0309",
"created": 1773014400,
"object": "model",
"owned_by": "xai",
"version": "1.0",
"input_modalities": ["text", "image"],
"output_modalities": ["text"],
"prompt_text_token_price": 12500,
"cached_prompt_text_token_price": 2000,
"prompt_image_token_price": 12500,
"completion_text_token_price": 25000,
"search_price": 0,
"prompt_text_token_price_long_context": 25000,
"cached_prompt_text_token_price_long_context": 4000,
"completion_text_token_price_long_context": 50000,
"long_context_threshold": 200000,
"aliases": [
"grok-4.20-multi-agent",
"grok-4.20-multi-agent-latest",
"grok-4.20-multi-agent-beta-latest",
"grok-4.20-multi-agent-experimental-beta-0304",
"grok-4.20-multi-agent-experimental-beta-latest",
"grok-4.20-multi-agent-beta-0309"
]
},
{
"id": "grok-4.3",
"created": 1776384000,
"object": "model",
"owned_by": "xai",
"version": "1.0",
"input_modalities": ["text", "image"],
"output_modalities": ["text"],
"prompt_text_token_price": 12500,
"cached_prompt_text_token_price": 2000,
"prompt_image_token_price": 12500,
"completion_text_token_price": 25000,
"search_price": 0,
"prompt_text_token_price_long_context": 25000,
"cached_prompt_text_token_price_long_context": 4000,
"completion_text_token_price_long_context": 50000,
"long_context_threshold": 200000,
"aliases": ["grok-4.3-latest"],
"capabilities": {
"reasoning_effort": ["none", "low", "medium", "high", "xhigh"],
"default_reasoning_effort": "low"
}
},
{
"id": "grok-4.5",
"created": 1782691200,
"object": "model",
"owned_by": "xai",
"version": "1.0",
"input_modalities": ["text", "image"],
"output_modalities": ["text"],
"prompt_text_token_price": 20000,
"cached_prompt_text_token_price": 3000,
"prompt_image_token_price": 20000,
"completion_text_token_price": 60000,
"search_price": 0,
"prompt_text_token_price_long_context": 40000,
"cached_prompt_text_token_price_long_context": 6000,
"completion_text_token_price_long_context": 120000,
"long_context_threshold": 200000,
"aliases": ["grok-4.5-latest", "grok-build-latest"],
"capabilities": {
"reasoning_effort": ["low", "medium", "high", "xhigh"],
"default_reasoning_effort": "high"
}
},
{
"id": "grok-4.6",
"created": 1785974400,
"object": "model",
"owned_by": "xai",
"version": "1.0",
"input_modalities": ["text", "image"],
"output_modalities": ["text"],
"prompt_text_token_price": 20000,
"cached_prompt_text_token_price": 5000,
"prompt_image_token_price": 20000,
"completion_text_token_price": 60000,
"search_price": 0,
"prompt_text_token_price_long_context": 40000,
"cached_prompt_text_token_price_long_context": 10000,
"completion_text_token_price_long_context": 120000,
"long_context_threshold": 200000,
"aliases": [],
"capabilities": {
"reasoning_effort": ["low", "medium", "high", "xhigh"],
"default_reasoning_effort": "high"
}
},
{
"id": "grok-4.7",
"created": 1788307200,
"object": "model",
"owned_by": "xai",
"version": "1.0",
"input_modalities": ["text", "image"],
"output_modalities": ["text"],
"prompt_text_token_price": 20000,
"cached_prompt_text_token_price": 5000,
"prompt_image_token_price": 20000,
"completion_text_token_price": 60000,
"search_price": 0,
"prompt_text_token_price_long_context": 40000,
"cached_prompt_text_token_price_long_context": 10000,
"completion_text_token_price_long_context": 120000,
"long_context_threshold": 200000,
"aliases": [],
"capabilities": {
"reasoning_effort": ["low", "medium", "high", "xhigh"],
"default_reasoning_effort": "high"
}
},
{
"id": "grok-build-0.1",
"created": 1776297600,
"object": "model",
"owned_by": "xai",
"version": "1.0",
"input_modalities": ["text", "image"],
"output_modalities": ["text"],
"prompt_text_token_price": 10000,
"cached_prompt_text_token_price": 2000,
"prompt_image_token_price": 10000,
"completion_text_token_price": 20000,
"search_price": 0,
"prompt_text_token_price_long_context": 20000,
"cached_prompt_text_token_price_long_context": 4000,
"completion_text_token_price_long_context": 40000,
"long_context_threshold": 200000,
"aliases": [
"grok-code-fast-1",
"grok-code-fast",
"grok-code-fast-1-0825"
]
}
]
}
@@ -51,6 +51,10 @@ import { AIChatStream } from '../../utils/Streaming.js';
import { ZAI_MODELS } from './models.js';
import { ZAIProvider } from './ZAIProvider.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import {
modelSupportsModality,
requiredInputModalities,
} from '../../utils/mediaParts.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
//
@@ -211,6 +215,47 @@ describe('ZAIProvider model catalog', () => {
cached_tokens: 7.5,
});
});
it('advertises image input, and no video or file input, only on the GLM-5.3 flash models', async () => {
const { provider } = makeProvider();
const models = await provider.models();
const imageIds = models
.filter((m) => modelSupportsModality(m, 'image'))
.map((m) => m.id);
expect(imageIds).toEqual(['glm-5.3-flash', 'glm-5.3-flashx']);
for (const m of models) {
expect(m.modalities?.output).toEqual(['text']);
expect(modelSupportsModality(m, 'video')).toBe(false);
expect(modelSupportsModality(m, 'file')).toBe(false);
}
});
it('passes the driver input gate for a puter.js image message on the flash models only', async () => {
const { provider } = makeProvider();
const models = await provider.models();
// The SDK image overload omits an explicit part type.
const required = requiredInputModalities([
{
content: [
'what is this?',
{ image_url: { url: 'https://cdn.test/cat.png' } },
],
},
]);
expect(required).toEqual(['image']);
const accepts = (id: string) =>
required.every((modality) =>
modelSupportsModality(
models.find((m) => m.id === id)!,
modality,
),
);
expect(accepts('glm-5.3-flash')).toBe(true);
expect(accepts('glm-5.3-flashx')).toBe(true);
expect(accepts('glm-5.3')).toBe(false);
});
});
// ── Request shape (OpenAI-compat quirks specific to GLM) ────────────
@@ -348,6 +393,40 @@ describe('ZAIProvider.complete request shape', () => {
expect(args.response_format).toEqual({ type: 'json_object' });
});
it.each(['glm-5.3-flash', 'zai/glm-5.3-flashx'])(
'forwards image_url parts for %s unchanged, by URL and by data URL',
async (requested) => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
const content = [
{ type: 'text', text: 'compare these' },
{
type: 'image_url',
image_url: { url: 'https://cdn.test/cat.png' },
},
{
type: 'image_url',
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
},
];
await withTestActor(() =>
provider.complete({
model: requested,
messages: [
{ role: 'user', content: structuredClone(content) },
],
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.model).toBe(requested.replace(/^zai\//, ''));
// Z.AI reads http(s) and data URLs itself, so neither is fetched or
// rewritten on the way through.
expect(args.messages).toEqual([{ role: 'user', content }]);
},
);
it('strips Anthropic-style cache_control from messages before sending', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
@@ -22,18 +22,22 @@ import { usdPerMToken } from '../../utils/pricing.js';
const K = 1_000;
// Advertise only media types handled by the chat adapter.
const imageInput = ['text', 'image'];
const textModel = (
id: string,
name: string,
context: number,
maxTokens: number,
costs: IChatModel['costs'],
input: string[] = ['text'],
): IChatModel => ({
puterId: `z-ai:z-ai/${id}`,
id,
name,
aliases: [`z-ai/${id}`, `zai/${id}`],
modalities: { input: ['text'], output: ['text'] },
modalities: { input, output: ['text'] },
open_weights: false,
tool_call: true,
context,
@@ -62,6 +66,7 @@ export const ZAI_MODELS: IChatModel[] = [
1_000 * K,
128 * K,
usdPerMToken(0.15, 0.5, 0.03),
imageInput,
),
textModel(
'glm-5.3-flashx',
@@ -69,6 +74,7 @@ export const ZAI_MODELS: IChatModel[] = [
1_000 * K,
128 * K,
usdPerMToken(0.37, 1.25, 0.075),
imageInput,
),
textModel(
'glm-5.2',
+11
View File
@@ -31,6 +31,16 @@ export interface ModelModalities {
output: string[];
}
export interface ModelPricingOverride {
/** Applies above this many input tokens, or at it too when inclusive. */
minPromptTokens?: number;
minPromptTokensInclusive?: boolean;
utcStart?: number;
utcEnd?: number;
utcDays?: number[];
costs: ModelCost;
}
export interface IChatModel<T extends ModelCost = ModelCost> extends Record<
string,
unknown
@@ -54,6 +64,7 @@ export interface IChatModel<T extends ModelCost = ModelCost> extends Record<
input_multiplier: number;
output_multiplier: number;
};
pricingOverrides?: ModelPricingOverride[];
context?: number;
max_tokens: number;
subscriberOnly?: boolean;
@@ -166,3 +166,13 @@ describe('cachedRemoteCatalog', () => {
expect(fetch).toHaveBeenCalledTimes(2);
});
});
it('passes the last successful snapshot to a refresh without exposing the fallback as fetched data', async () => {
const fetch = vi.fn(async (_signal: AbortSignal, previous?: string[]) => [...(previous ?? []), 'model']);
const get = cachedRemoteCatalog({ name: 'fixture', fetch, fallback: ['fallback'], ttlMs: 0 });
expect(await get()).toEqual(['model']);
expect(fetch.mock.calls[0][1]).toBeUndefined();
expect(await get()).toEqual(['model', 'model']);
expect(fetch.mock.calls[1][1]).toEqual(['model']);
});
@@ -23,7 +23,7 @@ export const CATALOG_TTL_MS = 15 * 60 * 1000;
export interface CachedRemoteCatalogOptions<T> {
/** Names the catalog in the warning a failed fetch logs. */
name: string;
fetch: (signal: AbortSignal) => Promise<T>;
fetch: (signal: AbortSignal, previous?: T) => Promise<T>;
/** What callers get when the first fetch fails. */
fallback: T;
ttlMs?: number;
@@ -56,7 +56,10 @@ export const cachedRemoteCatalog = <T>({
const load = async (): Promise<T> => {
try {
const value = await fetch(AbortSignal.timeout(timeoutMs));
const value = await fetch(
AbortSignal.timeout(timeoutMs),
last?.value,
);
last = { value };
freshUntil = Date.now() + ttlMs;
failing = false;
@@ -252,3 +252,12 @@ describe('cached reads on OpenAI-compatible catalogs', () => {
if (costs.prompt_tokens > 0) expect(cachedRate).toBeGreaterThan(0);
});
});
it('uses the request instant for scheduled prices even when usage is recorded later', () => {
const { metering, calls } = recordingMetering();
const scheduled = { ...gatewayModel, pricingOverrides: [{ utcStart: 1630, utcEnd: 30, costs: { prompt: 400, completion: 1500 } }] };
meterChatUsage(metering, actor, 'scheduled', scheduled, { prompt: 100, completion: 10 },
{ pricingTime: new Date('2026-10-12T16:30:00Z') });
expect(calls[0][2]).toEqual({ prompt: 40000, completion: 15000 });
});
@@ -31,6 +31,7 @@ export interface MeterChatUsageOptions {
* line instead of pricing the tokens, which are still recorded at zero.
*/
authoritativeUsd?: number;
pricingTime?: Date;
/** Per-key µ¢ costs that win over the model's rates. */
costOverrides?: Record<string, number>;
}
@@ -72,9 +73,16 @@ export const meterChatUsage = (
meteringKey: string,
model: IChatModel,
usage: Record<string, number>,
{ authoritativeUsd, costOverrides }: MeterChatUsageOptions = {},
{
authoritativeUsd,
costOverrides,
pricingTime,
}: MeterChatUsageOptions = {},
): { usage: Record<string, number>; costs: Record<string, number> } => {
const priced = { ...buildCostsOverride(usage, model), ...costOverrides };
const priced = {
...buildCostsOverride(usage, model, pricingTime),
...costOverrides,
};
if (authoritativeUsd !== undefined) {
const billed = { ...usage, billedUsage: 1 };
const recorded: Record<string, number> = Object.fromEntries(
@@ -21,6 +21,8 @@ import { describe, expect, it } from 'vitest';
import type { IChatModel } from '../types.js';
import {
buildCostsOverride,
catalogPrice,
costsForRequest,
isFreeModel,
isOutputCostKey,
longContextMultipliers,
@@ -126,13 +128,13 @@ describe('buildCostsOverride', () => {
expect(overrides.cached_tokens).toBe(0);
});
it('falls back to zero only when the model prices nothing at all', () => {
it('leaves missing prices unknown when the model prices nothing at all', () => {
const overrides = buildCostsOverride(
{ prompt_tokens: 10, cached_tokens: 5 },
model({}),
);
expect(overrides).toEqual({ prompt_tokens: 0, cached_tokens: 0 });
expect(overrides).toEqual({});
});
it('skips the tokens scale descriptor', () => {
@@ -199,13 +201,21 @@ describe('trackedInputTokens / trackedOutputTokens skip per-call and advisor key
const m = model({ input_tokens: 1, output_tokens: 1 });
it('excludes a _requests count from both totals', () => {
const usage = { input_tokens: 10, output_tokens: 5, web_search_requests: 2 };
const usage = {
input_tokens: 10,
output_tokens: 5,
web_search_requests: 2,
};
expect(trackedInputTokens(usage, m)).toBe(10);
expect(trackedOutputTokens(usage, m)).toBe(5);
});
it('excludes a _calls count from both totals', () => {
const usage = { input_tokens: 10, output_tokens: 5, web_search_calls: 3 };
const usage = {
input_tokens: 10,
output_tokens: 5,
web_search_calls: 3,
};
expect(trackedInputTokens(usage, m)).toBe(10);
expect(trackedOutputTokens(usage, m)).toBe(5);
});
@@ -242,7 +252,12 @@ describe('usageDetailsFromUsage', () => {
it('ignores the tokens scale descriptor and usd_cents', () => {
const details = usageDetailsFromUsage(
{ prompt_tokens: 10, completion_tokens: 5, tokens: 1_000_000, usd_cents: 3 },
{
prompt_tokens: 10,
completion_tokens: 5,
tokens: 1_000_000,
usd_cents: 3,
},
model({ prompt_tokens: 1, completion_tokens: 1 }),
);
@@ -315,7 +330,11 @@ describe('long-context pricing', () => {
it('keeps per-call tool fees flat on a long prompt', () => {
const overrides = buildCostsOverride(
{ prompt_tokens: 300_000, web_search_requests: 2, web_search_calls: 3 },
{
prompt_tokens: 300_000,
web_search_requests: 2,
web_search_calls: 3,
},
longContext({
...rates,
web_search_requests: 1_000_000,
@@ -362,7 +381,11 @@ describe('usageCost', () => {
it('splits the priced usage into input and output sides', () => {
expect(
usageCost(
{ prompt_tokens: 100, completion_tokens: 10, cached_tokens: 50 },
{
prompt_tokens: 100,
completion_tokens: 10,
cached_tokens: 50,
},
rates,
),
).toEqual({ inputMicroCents: 315, outputMicroCents: 150 });
@@ -430,3 +453,69 @@ describe('usageCost', () => {
).toEqual({ inputMicroCents: 30, outputMicroCents: 0 });
});
});
it('does not reinterpret an absent per-request fee as a token price', () => {
expect(
buildCostsOverride(
{ prompt_tokens: 1, request: 1 },
model({ prompt_tokens: 100 }),
),
).toEqual({ prompt_tokens: 100 });
});
it('does not advertise a partially priced or conditionally paid model as free', () => {
expect(isFreeModel(model({ prompt_tokens: 0 }))).toBe(false);
expect(
isFreeModel({
...model({ prompt_tokens: 0, completion_tokens: 0 }),
pricingOverrides: [
{ minPromptTokens: 10, costs: { prompt_tokens: 1 } },
],
}),
).toBe(false);
});
it('preserves free classification for legacy hyphenated token keys', () => {
expect(isFreeModel(model({ 'input-tokens': 0, 'output-tokens': 0 }))).toBe(true);
});
it('leaves overflowing catalog unit conversions unknown', () => {
expect(catalogPrice(1e308, 100)).toBeUndefined();
expect(catalogPrice(0, 100)).toBe(0);
expect(catalogPrice('0.0000000014', 100_000_000)).toBeCloseTo(0.14);
});
describe('costsForRequest prompt-size overrides', () => {
const model = (minPromptTokensInclusive?: boolean): IChatModel => ({
id: 'tiered',
costs_currency: 'usd-cents',
max_tokens: 1000,
costs: { tokens: 1_000_000, input_tokens: 1, output_tokens: 2 },
pricingOverrides: [
{
minPromptTokens: 200_000,
...(minPromptTokensInclusive === undefined
? {}
: { minPromptTokensInclusive }),
costs: { input_tokens: 3 },
},
],
});
it.each([
[undefined, 199_999, 1],
[undefined, 200_000, 1],
[undefined, 200_001, 3],
[false, 200_000, 1],
[true, 199_999, 1],
[true, 200_000, 3],
[true, 200_001, 3],
])(
'inclusive=%s at %i input tokens charges %i',
(inclusive, inputTokens, rate) => {
const costs = costsForRequest(model(inclusive), inputTokens);
expect(costs.input_tokens).toBe(rate);
expect(costs.output_tokens).toBe(2);
},
);
});
+82 -5
View File
@@ -19,6 +19,29 @@
import type { IChatModel, ModelCost, UsageDetails } from '../types.js';
/**
* Absent, negative and placeholder prices remain unpriced, including empty
* strings.
*/
export const catalogPrice = (
value: unknown,
multiplier = 1,
): number | undefined => {
if (typeof value !== 'number' && typeof value !== 'string')
return undefined;
if (
typeof value === 'string' &&
!/^(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(value.trim())
)
return undefined;
const price = Number(value);
if (!Number.isFinite(price) || price < 0) return undefined;
const converted = price * multiplier;
return Number.isFinite(converted) && !(price > 0 && converted === 0)
? converted
: undefined;
};
const CENTS_PER_USD = 100;
const MTOK = 1_000_000;
@@ -60,6 +83,36 @@ export const costKeys = (
outputKey: (model.output_cost_key as string | undefined) ?? 'output_tokens',
});
/** Applies catalog conditions at the request instant; later matching rates win. */
export const costsForRequest = (
model: IChatModel,
inputTokens: number,
now = new Date(),
): ModelCost => {
const costs = { ...model.costs };
const clock = now.getUTCHours() * 100 + now.getUTCMinutes();
for (const override of model.pricingOverrides ?? []) {
if (
override.minPromptTokens !== undefined &&
(override.minPromptTokensInclusive
? inputTokens < override.minPromptTokens
: inputTokens <= override.minPromptTokens)
)
continue;
if (override.utcDays && !override.utcDays.includes(now.getUTCDay()))
continue;
if (override.utcStart !== undefined && override.utcEnd !== undefined) {
const inWindow =
override.utcStart < override.utcEnd
? clock >= override.utcStart && clock < override.utcEnd
: clock >= override.utcStart || clock < override.utcEnd;
if (!inWindow) continue;
}
Object.assign(costs, override.costs);
}
return costs;
};
/**
* Whether a usage key is priced at the output rate when the model has no rate
* of its own for it.
@@ -77,7 +130,7 @@ export const isOutputCostKey = (key: string, outputKey: string): boolean =>
key.endsWith('_output_tokens');
export const isPerCallCostKey = (key: string): boolean =>
key.endsWith('_requests') || key.endsWith('_calls');
key === 'request' || key.endsWith('_requests') || key.endsWith('_calls');
/**
* The rate multipliers a request pays given how many input tokens it sent —
@@ -160,7 +213,23 @@ export const isFreeModel = (model: IChatModel): boolean => {
const rates = Object.entries(model.costs ?? {}).filter(
([key]) => key !== 'tokens',
);
return rates.length > 0 && rates.every(([, rate]) => Number(rate) === 0);
const { inputKey, outputKey } = costKeys(model);
if (
catalogPrice(
model.costs?.[inputKey] ?? model.costs?.['input-tokens'],
) === undefined ||
catalogPrice(
model.costs?.[outputKey] ?? model.costs?.['output-tokens'],
) === undefined
)
return false;
return (
rates.length > 0 &&
rates.every(([, rate]) => catalogPrice(rate) === 0) &&
(model.pricingOverrides ?? []).every((override) =>
Object.values(override.costs).every((rate) => rate === 0),
)
);
};
/**
@@ -190,10 +259,15 @@ const isBillable = (key: string, amount: unknown): amount is number =>
const priceUsage = (
trackedUsage: Record<string, unknown>,
model: IChatModel,
now?: Date,
): Record<string, number> => {
const { inputKey, outputKey } = costKeys(model);
const costs = model.costs ?? {};
const costs = costsForRequest(
model,
trackedInputTokens(trackedUsage, model),
now,
);
const inputRate = isRate(costs[inputKey]) ? costs[inputKey] : undefined;
const outputRate = isRate(costs[outputKey]) ? costs[outputKey] : undefined;
@@ -206,6 +280,7 @@ const priceUsage = (
for (const [key, amount] of Object.entries(trackedUsage)) {
if (!isBillable(key, amount)) continue;
if (key === 'request' && !isRate(costs.request)) continue;
const isOutput = isOutputCostKey(key, outputKey);
const rate = isRate(costs[key])
? costs[key]
@@ -238,14 +313,16 @@ const priceUsage = (
export const buildCostsOverride = (
trackedUsage: Record<string, number>,
model: IChatModel,
now?: Date,
): Record<string, number> => {
const priced = priceUsage(trackedUsage, model);
const priced = priceUsage(trackedUsage, model, now);
const overrides: Record<string, number> = {};
for (const key of Object.keys(trackedUsage)) {
// `tokens` is a scale descriptor ("costs expressed per N tokens"),
// not a per-unit rate.
if (key === 'tokens') continue;
overrides[key] = priced[key] ?? 0;
if (priced[key] !== undefined) overrides[key] = priced[key];
else if (trackedUsage[key] === 0) overrides[key] = 0;
}
return overrides;
};
@@ -45,12 +45,15 @@ import { SYSTEM_ACTOR } from '../../core/actor.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { CLOUDFLARE_IMAGE_GENERATION_MODELS } from './providers/cloudflare/models.js';
import cloudflareModelSearchFixture from './providers/cloudflare/fixtures/model-search.json';
import { GEMINI_IMAGE_GENERATION_MODELS } from './providers/gemini/models.js';
import { OPEN_AI_IMAGE_GENERATION_MODELS } from './providers/openai/models.js';
import { REPLICATE_IMAGE_GENERATION_MODELS } from './providers/replicate/models.js';
import { TOGETHER_IMAGE_GENERATION_MODELS } from './providers/together/models.js';
import { XAI_IMAGE_GENERATION_MODELS } from './providers/xai/models.js';
import xaiLiveCatalog from './providers/xai/fixtures/image-generation-models.json';
import { ImageGenerationDriver } from './ImageGenerationDriver.js';
import { CATALOG_TTL_MS } from '../ai-chat/utils/cachedRemoteCatalog.js';
import { ReplicateImageGenerationProvider } from './providers/replicate/ReplicateImageGenerationProvider.js';
// ── SDK mocks ──────────────────────────────────────────────────────
@@ -140,6 +143,20 @@ vi.mock('../../util/secureHttp.js', async (importOriginal) => ({
secureFetch: secureFetchMock,
}));
// xAI's image pricing catalog is fetched at boot; serve it a repriced 2.0
// low/1k tier so its effect is visible in listings and billing.
const { axiosGetMock } = vi.hoisted(() => ({ axiosGetMock: vi.fn() }));
vi.mock('axios', () => ({ default: { get: axiosGetMock } }));
const XAI_LIVE_LOW_1K_CENTS = 4.5;
// Cloudflare prices its catalog from model search via fetch; answer it offline.
const cloudflareModelSearch = () =>
new Response(JSON.stringify(cloudflareModelSearchFixture), {
headers: { 'content-type': 'application/json' },
});
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
@@ -147,7 +164,48 @@ let driver: ImageGenerationDriver;
let fetchSpy: MockInstance<typeof fetch>;
let eventEmitSpy: MockInstance<(...args: unknown[]) => unknown>;
/** The live xAI catalog response with every low/1k tier at `cents`. */
const xaiCatalogAt = (cents: number) => ({
data: {
models: xaiLiveCatalog.models.map((model) => ({
...model,
pricing: model.pricing?.map((tier) =>
tier.quality === 'low' && tier.resolution === '1k'
? { ...tier, price_per_image: cents * 100_000_000 }
: tier,
),
})),
},
});
beforeAll(async () => {
axiosGetMock.mockImplementation(async (url: string) => {
if (url !== 'https://api.x.ai/v1/image-generation-models')
throw new Error(`unexpected fetch ${url}`);
return {
data: {
models: xaiLiveCatalog.models.map((model) => ({
...model,
pricing: model.pricing?.map((tier) =>
tier.quality === 'low' && tier.resolution === '1k'
? {
...tier,
price_per_image:
XAI_LIVE_LOW_1K_CENTS * 100_000_000,
}
: tier,
),
})),
},
};
});
const bootFetch = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (url) => {
if (!String(url).includes('/ai/models/search'))
throw new Error(`unexpected fetch ${url}`);
return cloudflareModelSearch();
});
server = await setupTestServer({
providers: {
'openai-image-generation': { apiKey: 'oai-key' },
@@ -161,6 +219,7 @@ beforeAll(async () => {
'replicate-image-generation': { apiKey: 'rp-key' },
},
} as never);
bootFetch.mockRestore();
driver = server.drivers.aiImage as unknown as ImageGenerationDriver;
});
@@ -345,12 +404,21 @@ describe('image model data policy', () => {
});
it('skips an excluded provider when choosing the deployment default', async () => {
const limitedDriver = new ImageGenerationDriver({
providers: {
'together-image-generation': { apiKey: 'tg-key' },
'cloudflare-image-generation': { apiToken: 'cf-token', accountId: 'acct' },
},
} as never, server.clients, server.stores, server.services);
const limitedDriver = new ImageGenerationDriver(
{
providers: {
'together-image-generation': { apiKey: 'tg-key' },
'cloudflare-image-generation': {
apiToken: 'cf-token',
accountId: 'acct',
},
},
} as never,
server.clients,
server.stores,
server.services,
);
fetchSpy.mockResolvedValueOnce(cloudflareModelSearch());
await limitedDriver.onServerStart();
await withActor(() => limitedDriver.generate({ prompt: 'hi', test_mode: true }));
expect(eventEmitSpy).toHaveBeenCalledWith('ai.log.image', expect.objectContaining({
@@ -658,6 +726,187 @@ describe('ImageGenerationDriver.generate provider routing', () => {
}
});
it('advertises, holds and meters the same live xAI price', async () => {
const v2 = (await driver.models()).find(
(m) => m.id === 'grok-imagine-image-2.0',
)!;
expect(v2.costs['output:1k:low']).toBe(XAI_LIVE_LOW_1K_CENTS);
expect(driver.getReportedCosts()).toContainEqual(
expect.objectContaining({
usageType:
'xai-image-generation:grok-imagine-image-2.0:output:1k:low',
costValue: XAI_LIVE_LOW_1K_CENTS,
}),
);
const reserveSpy = vi.spyOn(server.services.metering, 'reserveCredits');
const usageSpy = vi.spyOn(
server.services.metering,
'batchIncrementUsages',
);
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://xai/img.png' }],
});
await withActor(() =>
driver.generate({
model: 'grok-imagine-image-2.0',
prompt: 'hi',
} as never),
);
const microcents = XAI_LIVE_LOW_1K_CENTS * 1_000_000;
expect(reserveSpy.mock.calls[0][1]).toBe(microcents);
expect(usageSpy.mock.calls[0][1]).toEqual([
expect.objectContaining({
usageType: 'xai:grok-imagine-image-2.0:output:1k:low',
costOverride: microcents,
}),
]);
});
it('bills a refreshed price only once every provider catalog is published', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] });
const bootXaiCatalog = axiosGetMock.getMockImplementation()!;
const PHOENIX = '@cf/leonardo/phoenix-1.0';
const OLD_PHOENIX = { tile_512: 583_000, step: 11_000 };
const NEW_PHOENIX = { tile_512: 1_000_000, step: 20_200 };
const NEW_XAI_LOW_1K_CENTS = 6;
const repricedSearch = structuredClone(cloudflareModelSearchFixture);
repricedSearch.result.find(
(model) => model.name === PHOENIX,
)!.properties = [
{
property_id: 'price',
value: [
{
unit: 'per 512 by 512 tile',
price: 0.01,
currency: 'USD',
},
{ unit: 'per step', price: 0.000202, currency: 'USD' },
],
},
];
let search: unknown = cloudflareModelSearchFixture;
fetchSpy.mockImplementation(
async (url) =>
new Response(
JSON.stringify(
String(url).includes('/ai/models/search')
? search
: { result: { image: 'AAAA' } },
),
{ headers: { 'content-type': 'application/json' } },
),
);
const mixedDriver = new ImageGenerationDriver(
{
providers: {
'cloudflare-image-generation': {
apiToken: 'cf-token',
accountId: 'acct',
},
'xai-image-generation': { apiKey: 'xai-key' },
},
} as never,
server.clients,
server.stores,
server.services,
);
const listed = async () => {
const models = await mixedDriver.models();
return {
phoenix: models.find((m) => m.id === PHOENIX)?.costs,
xai: models.find((m) => m.id === 'grok-imagine-image-2.0')
?.costs['output:1k:low'],
};
};
const settle = async () => {
for (let i = 0; i < 10; i++)
await new Promise((resolve) => setImmediate(resolve));
};
try {
await mixedDriver.onServerStart();
expect(await listed()).toEqual({
phoenix: OLD_PHOENIX,
xai: XAI_LIVE_LOW_1K_CENTS,
});
const reserveSpy = vi.spyOn(
server.services.metering,
'reserveCredits',
);
// Cloudflare answers the scheduled refresh with new rates at once;
// xAI's catalog request hangs until released.
search = repricedSearch;
let releaseXai!: () => void;
const xaiReleased = new Promise<void>(
(resolve) => (releaseXai = resolve),
);
const xaiCallsBefore = axiosGetMock.mock.calls.length;
axiosGetMock.mockImplementation(async () => {
await xaiReleased;
return xaiCatalogAt(NEW_XAI_LOW_1K_CENTS);
});
const searchesBefore = fetchSpy.mock.calls.length;
await vi.advanceTimersByTimeAsync(CATALOG_TTL_MS);
await vi.waitFor(() => {
expect(fetchSpy.mock.calls.length).toBeGreaterThan(
searchesBefore,
);
expect(axiosGetMock.mock.calls.length).toBeGreaterThan(
xaiCallsBefore,
);
});
await settle();
// Cloudflare's provider holds its new rates; the driver still lists
// the old ones, so a request waits rather than bill unlisted rates.
expect(await listed()).toEqual({
phoenix: OLD_PHOENIX,
xai: XAI_LIVE_LOW_1K_CENTS,
});
const pending = withActor(() =>
mixedDriver.generate({
model: PHOENIX,
prompt: 'hi',
ratio: { w: 1024, h: 600 },
steps: 10,
} as never),
);
await settle();
expect(reserveSpy).not.toHaveBeenCalled();
releaseXai();
await pending;
expect(await listed()).toEqual({
phoenix: NEW_PHOENIX,
xai: NEW_XAI_LOW_1K_CENTS,
});
// 1024x600 rounds up to 2x2 tiles at 10 steps.
expect(reserveSpy.mock.calls.map(([, cost]) => cost)).toEqual([
4 * NEW_PHOENIX.tile_512 + 10 * NEW_PHOENIX.step,
]);
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://xai/img.png' }],
});
await withActor(() =>
mixedDriver.generate({
model: 'grok-imagine-image-2.0',
prompt: 'hi',
} as never),
);
expect(reserveSpy.mock.calls[1]![1]).toBe(
NEW_XAI_LOW_1K_CENTS * 1_000_000,
);
} finally {
mixedDriver.onServerShutdown();
axiosGetMock.mockImplementation(bootXaiCatalog);
vi.useRealTimers();
}
});
it('routes a known gpt-image-2 model id to the OpenAI image provider', async () => {
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://oai/img.png' }],
@@ -33,6 +33,7 @@ import {
saveGeneratedMediaToFS,
} from '../util/generatedMedia.js';
import { ModelCatalog } from '../util/modelCatalog.js';
import { CATALOG_TTL_MS } from '../ai-chat/utils/cachedRemoteCatalog.js';
import { readProviderKey } from '../util/providerRegistry.js';
import { BytePlusImageProvider } from './providers/byteplus/BytePlusImageProvider.js';
import { CloudflareImageProvider } from './providers/cloudflare/CloudflareImageProvider.js';
@@ -86,6 +87,13 @@ export class ImageGenerationDriver extends PuterDriver {
{ provider: string; reason?: string }
>();
#catalog = new ModelCatalog<IImageModel>([], [], 'aiImage');
#modelMapRefresh: ReturnType<typeof setInterval> | undefined;
/**
* The model map rebuild in flight. Providers adopt their refreshed prices
* as each catalog lands, before the map is swapped, so generate() waits
* this out to bill only prices the driver publishes.
*/
#modelMapBuild: Promise<void> | undefined;
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
get #aiMetering(): AiMeteringService {
@@ -98,12 +106,16 @@ export class ImageGenerationDriver extends PuterDriver {
override async onServerStart() {
this.#registerProviders();
await this.#buildModelMap();
this.#catalog = new ModelCatalog(
this.#listModels(),
Object.values(this.#modelIdMap).flat(),
'aiImage',
);
await this.#refreshModelMap();
// Remote price catalogs expire; rebuild so listings track billing.
this.#modelMapRefresh = setInterval(() => {
void this.#refreshModelMap();
}, CATALOG_TTL_MS);
this.#modelMapRefresh.unref?.();
}
override onServerShutdown() {
clearInterval(this.#modelMapRefresh);
}
async models(): Promise<IImageModel[]> {
@@ -180,6 +192,10 @@ export class ImageGenerationDriver extends PuterDriver {
? await resolveOutputPath(this.services, actor, puterOutputPath)
: undefined;
// Last await before provider.generate() takes its price snapshot:
// from here routing and pricing both read the published map.
while (this.#modelMapBuild) await this.#modelMapBuild.catch(() => {});
let modelId =
typeof args.model === 'string'
? args.model.trim().toLowerCase()
@@ -431,21 +447,34 @@ export class ImageGenerationDriver extends PuterDriver {
}
}
/** Rebuilds the model map; overlapping callers share one rebuild. */
#refreshModelMap(): Promise<void> {
this.#modelMapBuild ??= this.#buildModelMap().finally(() => {
this.#modelMapBuild = undefined;
});
return this.#modelMapBuild;
}
async #buildModelMap() {
const catalogs = await Promise.all(
Object.entries(this.#providers).map(
async ([providerName, provider]) =>
[providerName, provider, await provider.models()] as const,
),
);
// Everything below is synchronous, so requests never see a half-built map.
this.#modelIdMap = Object.create(null);
this.#excludedModelIdMap = Object.create(null);
this.#retiredModelAliases.clear();
const models: IImageModel[] = [];
for (const [providerName, provider] of Object.entries(
this.#providers,
)) {
for (const [providerName, provider, entries] of catalogs) {
for (const alias of provider.retiredModelAliases ?? []) {
this.#retiredModelAliases.set(alias.trim().toLowerCase(), {
provider: providerName,
reason: provider.retiredModelReasons?.[alias],
});
}
for (const entry of await provider.models()) {
for (const entry of entries) {
models.push({ ...entry, provider: providerName });
}
}
@@ -494,6 +523,11 @@ export class ImageGenerationDriver extends PuterDriver {
register(alias, model);
}
}
this.#catalog = new ModelCatalog(
this.#listModels(),
Object.values(this.#modelIdMap).flat(),
'aiImage',
);
}
#resolveModel(modelId: string, provider?: string): IImageModel | null {
@@ -36,6 +36,7 @@ import {
expect,
it,
vi,
type Mock,
type MockInstance,
} from 'vitest';
@@ -45,8 +46,11 @@ import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { withAiCostFactor } from '../../../util/aiCostFactor.js';
import { CATALOG_TTL_MS } from '../../../ai-chat/utils/cachedRemoteCatalog.js';
import { ImageGenerationDriver } from '../../ImageGenerationDriver.js';
import { CLOUDFLARE_IMAGE_GENERATION_MODELS } from './models.js';
import { CloudflareImageProvider } from './CloudflareImageProvider.js';
import modelSearchFixture from './fixtures/model-search.json';
import { translateProviderError } from '../../../../controllers/drivers/DriverController.js';
// Keep URL downloads offline; other input parsing stays real.
@@ -62,7 +66,13 @@ vi.mock('../../inputImage.js', async (orig) => ({
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
let fetchSpy: MockInstance<typeof fetch>;
/**
* Every outbound request; `fetchSpy` and `modelSearchFetch` each see one
* endpoint's share.
*/
let globalFetchSpy: MockInstance<typeof fetch>;
let fetchSpy: Mock<typeof fetch>;
let modelSearchFetch: Mock<typeof fetch>;
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
let batchIncrementUsagesSpy: MockInstance<
MeteringService['batchIncrementUsages']
@@ -102,7 +112,17 @@ const makeProvider = (
beforeEach(() => {
fetchImageBytesMock.mockReset();
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
fetchSpy = vi.fn<typeof fetch>();
modelSearchFetch = vi.fn<typeof fetch>(async () =>
okJsonResponse(modelSearchFixture),
);
globalFetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation((input, init) =>
String(input).includes('/ai/models/search')
? modelSearchFetch(input, init)
: fetchSpy(input, init),
);
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
batchIncrementUsagesSpy = vi.spyOn(
server.services.metering,
@@ -125,7 +145,7 @@ const okJsonResponse = (body: unknown) =>
describe('CloudflareImageProvider construction', () => {
it('does not call out at construction (lazy fetch)', () => {
makeProvider();
expect(fetchSpy).not.toHaveBeenCalled();
expect(globalFetchSpy).not.toHaveBeenCalled();
});
});
@@ -139,9 +159,20 @@ describe('CloudflareImageProvider model catalog', () => {
);
});
it('exposes the static CLOUDFLARE_IMAGE_GENERATION_MODELS list verbatim', () => {
const provider = makeProvider();
expect(provider.models()).toBe(CLOUDFLARE_IMAGE_GENERATION_MODELS);
it('lists the curated models at the live model-search prices', async () => {
// The live prices match the curated ones; flux models stay unpriced upstream.
expect(await makeProvider().models()).toEqual(
CLOUDFLARE_IMAGE_GENERATION_MODELS,
);
expect(modelSearchFetch).toHaveBeenCalledOnce();
});
it('falls back to the curated models when model search fails', async () => {
modelSearchFetch.mockRejectedValueOnce(new Error('offline'));
vi.spyOn(console, 'warn').mockImplementation(() => {});
expect(await makeProvider().models()).toBe(
CLOUDFLARE_IMAGE_GENERATION_MODELS,
);
});
});
@@ -816,3 +847,246 @@ describe('Cloudflare priced legacy models', () => {
expect(hasCreditsSpy).toHaveBeenCalledWith(expect.anything(), 713200);
});
});
describe('Cloudflare model-search pricing', () => {
/** The live fixture with Phoenix's paid rates changed upstream. */
const repricedPhoenix = () => {
const body = structuredClone(modelSearchFixture);
const phoenix = body.result.find(
(model) => model.name === '@cf/leonardo/phoenix-1.0',
)!;
phoenix.properties = [
{
property_id: 'price',
value: [
{
unit: 'per 512 by 512 tile',
price: 0.01,
currency: 'USD',
},
{ unit: 'per step', price: 0.000202, currency: 'USD' },
],
},
];
return body;
};
const runPhoenix = (provider: CloudflareImageProvider) => {
fetchSpy.mockResolvedValueOnce(
okJsonResponse({ result: { image: 'AAAA' } }),
);
return withTestActor(() =>
provider.generate({
model: '@cf/leonardo/phoenix-1.0',
prompt: 'A cup',
// 1024x600 rounds up to 2x2 tiles.
imageSize: { kind: 'pixels', w: 1024, h: 600 },
steps: 10,
}),
);
};
it('quotes, bills, and lists the same changed paid rates over unchanged geometry', async () => {
modelSearchFetch.mockImplementation(async () =>
okJsonResponse(repricedPhoenix()),
);
const provider = makeProvider();
const listed = (await provider.models()).find(
(model) => model.id === '@cf/leonardo/phoenix-1.0',
);
await runPhoenix(provider);
expect(listed?.costs).toEqual({ tile_512: 1_000_000, step: 20_200 });
expect(hasCreditsSpy).toHaveBeenCalledWith(
expect.anything(),
4 * 1_000_000 + 10 * 20_200,
);
expect(batchIncrementUsagesSpy.mock.calls[0]![1]).toEqual([
expect.objectContaining({
usageType:
'cloudflare:workers-ai:leonardo/phoenix-1.0:tile_512',
usageAmount: 4,
costOverride: 4_000_000,
}),
expect.objectContaining({
usageType: 'cloudflare:workers-ai:leonardo/phoenix-1.0:step',
usageAmount: 10,
costOverride: 202_000,
}),
]);
expect(modelSearchFetch).toHaveBeenCalledOnce();
// The shared curated table is never written through.
expect(
CLOUDFLARE_IMAGE_GENERATION_MODELS.find(
(model) => model.id === '@cf/leonardo/phoenix-1.0',
)?.costs,
).toEqual({ tile_512: 583000, step: 11000 });
});
it('bills curated rates without a model search until a catalog is published', async () => {
modelSearchFetch.mockImplementation(async () =>
okJsonResponse(repricedPhoenix()),
);
await runPhoenix(makeProvider());
expect(modelSearchFetch).not.toHaveBeenCalled();
expect(hasCreditsSpy).toHaveBeenCalledWith(
expect.anything(),
4 * 583_000 + 10 * 11_000,
);
});
it('keeps billing the published rates while a refresh is in flight', async () => {
const provider = makeProvider();
await provider.models();
let release!: () => void;
const gate = new Promise<void>((resolve) => (release = resolve));
modelSearchFetch.mockImplementation(async () => {
await gate;
return okJsonResponse(repricedPhoenix());
});
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 60 * 60 * 1000);
const refresh = provider.models();
await vi.waitFor(() =>
expect(modelSearchFetch).toHaveBeenCalledTimes(2),
);
await runPhoenix(provider);
release();
await refresh;
await runPhoenix(provider);
expect(hasCreditsSpy.mock.calls.map(([, cost]) => cost)).toEqual([
2_442_000, 4_202_000,
]);
});
it('keeps the last good rates when a later refresh fails', async () => {
modelSearchFetch.mockImplementationOnce(async () =>
okJsonResponse(repricedPhoenix()),
);
const provider = makeProvider();
await provider.models();
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now + 60 * 60 * 1000);
vi.spyOn(console, 'warn').mockImplementation(() => {});
modelSearchFetch.mockResolvedValueOnce(
new Response('upstream down', { status: 503 }),
);
await provider.models();
await runPhoenix(provider);
expect(modelSearchFetch).toHaveBeenCalledTimes(2);
expect(hasCreditsSpy).toHaveBeenCalledWith(
expect.anything(),
4_202_000,
);
});
it('bills what the driver lists across a failed refresh and its recovery', async () => {
vi.useFakeTimers({ toFake: ['Date', 'setInterval', 'clearInterval'] });
vi.spyOn(console, 'warn').mockImplementation(() => {});
const driver = new ImageGenerationDriver(
{
providers: {
'cloudflare-image-generation': {
apiToken: 'cf-test-token',
accountId: 'acct-test',
},
},
} as never,
server.clients,
server.stores,
server.services,
);
const listedPhoenix = async () =>
(await driver.models()).find(
(model) => model.id === '@cf/leonardo/phoenix-1.0',
)?.costs;
const generate = async () => {
fetchSpy.mockResolvedValueOnce(
okJsonResponse({ result: { image: 'AAAA' } }),
);
await withTestActor(() =>
driver.generate({
model: '@cf/leonardo/phoenix-1.0',
prompt: 'A cup',
ratio: { w: 1024, h: 600 },
steps: 10,
} as never),
);
return hasCreditsSpy.mock.lastCall![1];
};
const settle = () => new Promise((resolve) => setImmediate(resolve));
const OLD = { tile_512: 583_000, step: 11_000 };
const NEW = { tile_512: 1_000_000, step: 20_200 };
try {
await driver.onServerStart();
expect(await listedPhoenix()).toEqual(OLD);
expect(await generate()).toBe(2_442_000);
// The driver's scheduled refresh fails: it keeps listing the old rates.
modelSearchFetch.mockResolvedValueOnce(
new Response('upstream down', { status: 503 }),
);
await vi.advanceTimersByTimeAsync(CATALOG_TTL_MS);
await vi.waitFor(() => expect(console.warn).toHaveBeenCalled());
await settle();
expect(modelSearchFetch).toHaveBeenCalledTimes(2);
expect(await listedPhoenix()).toEqual(OLD);
// Upstream recovers and reprices after the failure backoff, but
// until the driver republishes, requests bill what it lists.
modelSearchFetch.mockImplementation(async () =>
okJsonResponse(repricedPhoenix()),
);
await vi.advanceTimersByTimeAsync(31_000);
expect(await generate()).toBe(2_442_000);
expect(modelSearchFetch).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(CATALOG_TTL_MS - 31_000);
await vi.waitFor(async () =>
expect(await listedPhoenix()).toEqual(NEW),
);
expect(await generate()).toBe(4_202_000);
expect(modelSearchFetch).toHaveBeenCalledTimes(3);
} finally {
driver.onServerShutdown();
vi.useRealTimers();
}
});
it('charges curated rates for models the live response leaves unpriced', async () => {
fetchSpy.mockResolvedValueOnce(
okJsonResponse({ result: { image: 'AAAA' } }),
);
const provider = makeProvider();
await provider.models();
await withTestActor(() =>
provider.generate({
model: '@cf/black-forest-labs/flux-1-schnell',
prompt: 'A cup',
}),
);
expect(modelSearchFetch).toHaveBeenCalledOnce();
expect(hasCreditsSpy).toHaveBeenCalledWith(
expect.anything(),
4 * 5280 + 4 * 10560,
);
});
it('searches models through the configured apiBaseUrl with the provider token', async () => {
await makeProvider({
apiBaseUrl:
'https://gateway.example/v1/acct/gw/workers-ai/client/v4',
}).models();
const [url, init] = modelSearchFetch.mock.calls[0]!;
expect(String(url)).toBe(
'https://gateway.example/v1/acct/gw/workers-ai/client/v4/accounts/acct-test/ai/models/search?per_page=100&page=1',
);
expect((init?.headers as Record<string, string>).Authorization).toBe(
'Bearer cf-test-token',
);
});
});
@@ -23,6 +23,7 @@ import { imageDataUri } from '../../imageOutput.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
import { insufficientCreditsError } from '../../../../services/metering/enforcement.js';
import { Context } from '../../../../core/context.js';
import { cachedRemoteCatalog } from '../../../ai-chat/utils/cachedRemoteCatalog.js';
import type { AiMeteringService } from '../../../util/aiCostFactor.js';
import type {
IGenerateParams,
@@ -33,6 +34,7 @@ import {
CLOUDFLARE_IMAGE_GENERATION_MODELS,
CloudflareImageModel,
} from './models.js';
import { fetchCloudflareImageCatalog } from './catalog.js';
import {
assertInputImageString,
fetchImageBytes,
@@ -66,6 +68,12 @@ export class CloudflareImageProvider implements IImageProvider {
#accountId: string;
#apiBaseUrl: string;
#meteringService: AiMeteringService;
/**
* The catalog last handed to the driver; generate() bills from it so a
* hold, its metering and the advertised price always agree.
*/
#models: CloudflareImageModel[] = CLOUDFLARE_IMAGE_GENERATION_MODELS;
#catalog: () => Promise<CloudflareImageModel[]>;
constructor(
config: {
@@ -80,10 +88,25 @@ export class CloudflareImageProvider implements IImageProvider {
this.#apiBaseUrl =
config.apiBaseUrl || 'https://api.cloudflare.com/client/v4';
this.#meteringService = meteringService;
this.#catalog = cachedRemoteCatalog({
name: 'Cloudflare image pricing',
fetch: (signal, previous) =>
fetchCloudflareImageCatalog(
{
apiToken: this.#apiToken,
accountId: this.#accountId,
apiBaseUrl: this.#apiBaseUrl,
},
signal,
previous,
),
fallback: CLOUDFLARE_IMAGE_GENERATION_MODELS,
});
}
models(): IImageModel[] {
return CLOUDFLARE_IMAGE_GENERATION_MODELS;
async models(): Promise<IImageModel[]> {
this.#models = await this.#catalog();
return this.#models;
}
getDefaultModel(): string {
@@ -185,6 +208,8 @@ export class CloudflareImageProvider implements IImageProvider {
}
const steps = this.#resolveSteps(selectedModel, options);
// selectedModel comes from the published snapshot, so its costs price
// both the credit hold and the recorded usage.
const costComponents = this.#estimateCost(selectedModel, ratio, steps, {
hasInputImage: !!inputImage,
});
@@ -233,7 +258,7 @@ export class CloudflareImageProvider implements IImageProvider {
}
#getModel(model?: string): CloudflareImageModel {
const models = CLOUDFLARE_IMAGE_GENERATION_MODELS;
const models = this.#models;
const found = models.find(
(m) => m.id === model || m.aliases?.includes(model ?? ''),
);
@@ -0,0 +1,575 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
import {
CATALOG_TTL_MS,
cachedRemoteCatalog,
} from '../../../ai-chat/utils/cachedRemoteCatalog.js';
import { fetchCloudflareImageCatalog } from './catalog.js';
import modelSearchFixture from './fixtures/model-search.json';
import {
CLOUDFLARE_IMAGE_GENERATION_MODELS,
type CloudflareImageModel,
} from './models.js';
// The fixture is a live `/ai/models/search?per_page=100` response trimmed to
// the curated image models plus one non-image model, keeping only `price`.
const config = {
apiToken: 'cf-secret-token',
accountId: 'acct-secret',
apiBaseUrl: 'https://api.cloudflare.com/client/v4',
};
const PHOENIX = '@cf/leonardo/phoenix-1.0';
const LUCID = '@cf/leonardo/lucid-origin';
const SDXL = '@cf/stabilityai/stable-diffusion-xl-base-1.0';
const FLUX_2_DEV = '@cf/black-forest-labs/flux-2-dev';
let fetchSpy: MockInstance<typeof fetch>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
vi.restoreAllMocks();
});
const json = (body: unknown, init?: ResponseInit) =>
new Response(JSON.stringify(body), {
headers: { 'content-type': 'application/json' },
...init,
});
type Entry = { name: string; properties?: unknown[] };
/**
* The live fixture with `name`'s price property replaced (or removed when
* `prices` is undefined).
*/
const withPrice = (
name: string,
prices?: unknown,
): typeof modelSearchFixture => {
const body = structuredClone(modelSearchFixture);
const entry = body.result.find((model) => model.name === name)! as Entry;
entry.properties =
prices === undefined ? [] : [{ property_id: 'price', value: prices }];
return body;
};
const page = (result: Entry[], resultInfo?: Record<string, unknown>) => ({
success: true,
result,
errors: [],
messages: [],
...(resultInfo ? { result_info: resultInfo } : {}),
});
const priced = (name: string, prices: unknown[]): Entry => ({
name,
properties: [{ property_id: 'price', value: prices }],
});
const fetchCatalog = (previous?: CloudflareImageModel[]) =>
fetchCloudflareImageCatalog(config, new AbortController().signal, previous);
const costsOf = (models: CloudflareImageModel[], id: string) =>
models.find((model) => model.id === id)?.costs;
/**
* Curated models with Phoenix at a previously fetched rate, standing in for the
* last good catalog.
*/
const lastGood = CLOUDFLARE_IMAGE_GENERATION_MODELS.map((model) =>
model.id === PHOENIX
? { ...model, costs: { tile_512: 600000, step: 12000 } }
: model,
);
describe('fetchCloudflareImageCatalog', () => {
it('reprices the five supported models from the live response and keeps unpriced FLUX entries', async () => {
fetchSpy.mockResolvedValueOnce(json(modelSearchFixture));
const previous = CLOUDFLARE_IMAGE_GENERATION_MODELS.map((model) => ({
...model,
costs: Object.fromEntries(
Object.keys(model.costs).map((key) => [key, 1]),
),
}));
const models = await fetchCatalog(previous);
expect(costsOf(models, PHOENIX)).toEqual({
tile_512: 583000,
step: 11000,
});
// 0.000132 USD is 13200 microcents exactly, not 13200.000000000002.
expect(costsOf(models, LUCID)).toEqual({
tile_512: 700000,
step: 13200,
});
for (const id of [
'@cf/bytedance/stable-diffusion-xl-lightning',
SDXL,
'@cf/runwayml/stable-diffusion-v1-5-inpainting',
])
expect(costsOf(models, id)).toEqual({ step: 0 });
for (const id of [
'@cf/black-forest-labs/flux-1-schnell',
FLUX_2_DEV,
'@cf/black-forest-labs/flux-2-klein-4b',
'@cf/black-forest-labs/flux-2-klein-9b',
])
expect(costsOf(models, id)).toBe(costsOf(previous, id));
// A short page ends the listing even though total_count is larger.
expect(fetchSpy).toHaveBeenCalledOnce();
expect(String(fetchSpy.mock.calls[0]![0])).toBe(
'https://api.cloudflare.com/client/v4/accounts/acct-secret/ai/models/search?per_page=100&page=1',
);
});
it('keeps the last good rates for a model omitted or left unpriced', async () => {
const omitted = structuredClone(modelSearchFixture);
omitted.result = omitted.result.filter(
(model) => model.name !== PHOENIX,
);
fetchSpy
.mockResolvedValueOnce(json(omitted))
.mockResolvedValueOnce(json(withPrice(PHOENIX)));
expect(costsOf(await fetchCatalog(lastGood), PHOENIX)).toEqual({
tile_512: 600000,
step: 12000,
});
expect(costsOf(await fetchCatalog(lastGood), PHOENIX)).toEqual({
tile_512: 600000,
step: 12000,
});
});
it('accepts a published zero and identical duplicate units', async () => {
fetchSpy.mockResolvedValueOnce(
json(
withPrice(PHOENIX, [
{ unit: 'per 512 by 512 tile', price: 0, currency: 'USD' },
{ unit: 'per step', price: 0.0001, currency: 'USD' },
{ unit: 'per step', price: 0.0001, currency: 'USD' },
]),
),
);
expect(costsOf(await fetchCatalog(lastGood), PHOENIX)).toEqual({
tile_512: 0,
step: 10000,
});
});
it.each([
[
'a non-USD currency',
[
{ unit: 'per 512 by 512 tile', price: 0.005, currency: 'EUR' },
{ unit: 'per step', price: 0.0001, currency: 'USD' },
],
],
[
'a missing currency',
[
{ unit: 'per 512 by 512 tile', price: 0.005 },
{ unit: 'per step', price: 0.0001, currency: 'USD' },
],
],
[
'a negative price',
[
{ unit: 'per 512 by 512 tile', price: -0.005, currency: 'USD' },
{ unit: 'per step', price: 0.0001, currency: 'USD' },
],
],
[
'a string price',
[
{
unit: 'per 512 by 512 tile',
price: '0.005',
currency: 'USD',
},
{ unit: 'per step', price: 0.0001, currency: 'USD' },
],
],
[
'a price that overflows microcents',
[
{ unit: 'per 512 by 512 tile', price: 1e308, currency: 'USD' },
{ unit: 'per step', price: 0.0001, currency: 'USD' },
],
],
[
'conflicting prices for one unit',
[
{ unit: 'per 512 by 512 tile', price: 0.005, currency: 'USD' },
{ unit: 'per step', price: 0.0001, currency: 'USD' },
{ unit: 'per step', price: 0.0002, currency: 'USD' },
],
],
[
'a missing unit',
[{ unit: 'per step', price: 0.0001, currency: 'USD' }],
],
[
'an unknown extra unit',
[
{ unit: 'per 512 by 512 tile', price: 0.005, currency: 'USD' },
{ unit: 'per step', price: 0.0001, currency: 'USD' },
{ unit: 'per output image', price: 0.01, currency: 'USD' },
],
],
[
'a non-array value',
{ unit: 'per step', price: 0.0001, currency: 'USD' },
],
['an empty list', []],
])('keeps the last good rates for %s', async (_case, prices) => {
fetchSpy.mockResolvedValueOnce(json(withPrice(PHOENIX, prices)));
expect(costsOf(await fetchCatalog(lastGood), PHOENIX)).toEqual({
tile_512: 600000,
step: 12000,
});
});
it('keeps a step-only model when the response adds a tile price it cannot bill', async () => {
const previous = CLOUDFLARE_IMAGE_GENERATION_MODELS.map((model) =>
model.id === SDXL ? { ...model, costs: { step: 7 } } : model,
);
fetchSpy.mockResolvedValueOnce(
json(
withPrice(SDXL, [
{
unit: 'per 512 by 512 tile',
price: 0.005,
currency: 'USD',
},
{ unit: 'per step', price: 0, currency: 'USD' },
]),
),
);
expect(costsOf(await fetchCatalog(previous), SDXL)).toEqual({
step: 7,
});
});
it('keeps curated rates when a model is listed twice or carries two price properties', async () => {
const twice = structuredClone(modelSearchFixture);
twice.result.push(
priced(PHOENIX, [
{ unit: 'per 512 by 512 tile', price: 0.009, currency: 'USD' },
{ unit: 'per step', price: 0.0002, currency: 'USD' },
]) as never,
);
const twoProps = structuredClone(modelSearchFixture);
(
twoProps.result.find((model) => model.name === PHOENIX) as Entry
).properties!.push({
property_id: 'price',
value: [
{ unit: 'per 512 by 512 tile', price: 0.009, currency: 'USD' },
{ unit: 'per step', price: 0.0002, currency: 'USD' },
],
});
fetchSpy
.mockResolvedValueOnce(json(twice))
.mockResolvedValueOnce(json(twoProps));
expect(costsOf(await fetchCatalog(lastGood), PHOENIX)).toEqual({
tile_512: 600000,
step: 12000,
});
expect(costsOf(await fetchCatalog(lastGood), PHOENIX)).toEqual({
tile_512: 600000,
step: 12000,
});
});
it('ignores prices for billing schemes it does not reprice', async () => {
fetchSpy.mockResolvedValueOnce(
json(
withPrice(FLUX_2_DEV, [
{
unit: 'per 512 by 512 tile',
price: 0.0001,
currency: 'USD',
},
{ unit: 'per step', price: 0.0001, currency: 'USD' },
]),
),
);
expect(costsOf(await fetchCatalog(), FLUX_2_DEV)).toEqual({
input_tile_512_per_step: 21000,
output_tile_512_per_step: 41000,
});
});
it('follows total_pages across pages', async () => {
fetchSpy
.mockResolvedValueOnce(
json(
page(
[
priced(PHOENIX, [
{
unit: 'per 512 by 512 tile',
price: 0.006,
currency: 'USD',
},
{
unit: 'per step',
price: 0.00012,
currency: 'USD',
},
]),
],
{ page: 1, per_page: 100, count: 1, total_pages: 2 },
),
),
)
.mockResolvedValueOnce(
json(
page(
[
priced(LUCID, [
{
unit: 'per 512 by 512 tile',
price: 0.008,
currency: 'USD',
},
{
unit: 'per step',
price: 0.00014,
currency: 'USD',
},
]),
],
{ page: 2, per_page: 100, count: 1, total_pages: 2 },
),
),
);
const models = await fetchCatalog();
expect(fetchSpy).toHaveBeenCalledTimes(2);
expect(String(fetchSpy.mock.calls[1]![0])).toMatch(/[?&]page=2$/);
expect(costsOf(models, PHOENIX)).toEqual({
tile_512: 600000,
step: 12000,
});
expect(costsOf(models, LUCID)).toEqual({
tile_512: 800000,
step: 14000,
});
});
it('requests the next page after a full page without result_info', async () => {
const filler = Array.from({ length: 100 }, (_, i) => ({
name: `@cf/filler/${i}`,
}));
fetchSpy
.mockResolvedValueOnce(json(page(filler)))
.mockResolvedValueOnce(
json(
page([
priced(SDXL, [
{
unit: 'per step',
price: 0.00001,
currency: 'USD',
},
]),
]),
),
);
expect(costsOf(await fetchCatalog(), SDXL)).toEqual({ step: 1000 });
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
it('rejects a listing that still has pages after the bounded number', async () => {
// Page 1 prices Phoenix; an unread page could list it again, which
// would have kept the last good rate, so nothing here is published.
fetchSpy.mockImplementation(async (url) => {
const n = Number(new URL(String(url)).searchParams.get('page'));
return json(
page(
n === 1
? [
priced(PHOENIX, [
{
unit: 'per 512 by 512 tile',
price: 0.01,
currency: 'USD',
},
{
unit: 'per step',
price: 0.0002,
currency: 'USD',
},
]),
]
: [{ name: `@cf/filler/${n}` }],
{ page: n, total_pages: 1000 },
),
);
});
await expect(fetchCatalog(lastGood)).rejects.toThrow(
/exceeded 10 pages/,
);
expect(fetchSpy).toHaveBeenCalledTimes(10);
});
it('keeps serving the last good catalog when a refresh hits the page cap', async () => {
vi.useFakeTimers();
try {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const catalog = cachedRemoteCatalog({
name: 'Cloudflare image pricing',
fallback: CLOUDFLARE_IMAGE_GENERATION_MODELS,
fetch: (signal, previous) =>
fetchCloudflareImageCatalog(config, signal, previous),
});
fetchSpy.mockResolvedValueOnce(
json(
withPrice(PHOENIX, [
{
unit: 'per 512 by 512 tile',
price: 0.006,
currency: 'USD',
},
{ unit: 'per step', price: 0.00012, currency: 'USD' },
]),
),
);
expect(costsOf(await catalog(), PHOENIX)).toEqual({
tile_512: 600000,
step: 12000,
});
fetchSpy.mockImplementation(async (url) => {
const n = Number(new URL(String(url)).searchParams.get('page'));
return json(
page(
[
priced(PHOENIX, [
{
unit: 'per 512 by 512 tile',
price: 0.01,
currency: 'USD',
},
{
unit: 'per step',
price: 0.0002,
currency: 'USD',
},
]),
],
{ page: n, total_pages: 1000 },
),
);
});
vi.advanceTimersByTime(CATALOG_TTL_MS + 1);
expect(costsOf(await catalog(), PHOENIX)).toEqual({
tile_512: 600000,
step: 12000,
});
expect(fetchSpy).toHaveBeenCalledTimes(11);
} finally {
vi.useRealTimers();
}
});
it.each([
['an HTTP error', () => new Response('denied', { status: 403 })],
[
'success:false',
() =>
json({
success: false,
result: [],
errors: [{ message: 'bad' }],
}),
],
['a non-array result', () => json({ success: true, result: {} })],
['an empty listing', () => json(page([]))],
[
'a mismatched page number',
() => json(page([{ name: SDXL }], { page: 2 })),
],
['malformed JSON', () => new Response('{not json')],
[
'a declared oversized body',
() =>
new Response('{}', {
headers: { 'content-length': String(3 * 1024 * 1024) },
}),
],
[
'a streamed oversized body',
() =>
new Response(
JSON.stringify({
success: true,
result: [],
pad: 'x'.repeat(3 * 1024 * 1024),
}),
),
],
])(
'rejects %s without exposing the account or token',
async (_case, respond) => {
fetchSpy.mockResolvedValueOnce(respond());
const error = await fetchCatalog().catch((e: Error) => e);
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).not.toMatch(
/acct-secret|cf-secret-token/,
);
},
);
it('does not mutate the previous catalog it reprices', async () => {
const pristine = structuredClone(CLOUDFLARE_IMAGE_GENERATION_MODELS);
fetchSpy.mockResolvedValueOnce(
json(
withPrice(PHOENIX, [
{
unit: 'per 512 by 512 tile',
price: 0.01,
currency: 'USD',
},
{ unit: 'per step', price: 0.0002, currency: 'USD' },
]),
),
);
await fetchCatalog();
expect(CLOUDFLARE_IMAGE_GENERATION_MODELS).toEqual(pristine);
});
});
@@ -0,0 +1,199 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { catalogPrice } from '../../../ai-chat/utils/pricing.js';
import {
CLOUDFLARE_IMAGE_GENERATION_MODELS,
type CloudflareBillingScheme,
type CloudflareImageModel,
} from './models.js';
const PER_PAGE = 100;
const MAX_PAGES = 10;
const MAX_PAGE_BYTES = 2 * 1024 * 1024;
/** Cloudflare's published `price` units and the cost keys they bill. */
const PRICE_UNIT_KEYS: Record<string, string> = {
'per step': 'step',
'per 512 by 512 tile': 'tile_512',
};
/** Schemes whose every cost key Cloudflare publishes; others stay curated. */
const REMOTE_PRICED_SCHEMES: Partial<
Record<CloudflareBillingScheme, readonly string[]>
> = {
'step-only': ['step'],
'tile-plus-step': ['tile_512', 'step'],
};
export interface CloudflareCatalogConfig {
apiToken: string;
accountId: string;
apiBaseUrl: string;
}
/**
* Shifts the decimal exponent instead of multiplying, so 0.000132 is exactly
* 13200.
*/
const usdToMicroCents = (usd: number) => {
const [digits, exponent = '0'] = String(usd).split('e');
return Number(`${digits}e${Number(exponent) + 8}`);
};
/**
* The model with its cost keys repriced from a Cloudflare `price` property, or
* undefined unless the property is USD, finite, non-negative, conflict-free,
* and prices exactly the keys the model's billing scheme bills.
*/
export const priceCloudflareImageModel = (
model: CloudflareImageModel,
prices: unknown,
): CloudflareImageModel | undefined => {
const keys = REMOTE_PRICED_SCHEMES[model.billingScheme];
if (!keys || !Array.isArray(prices)) return undefined;
const curatedKeys = Object.keys(model.costs);
if (
curatedKeys.length !== keys.length ||
!keys.every((key) => curatedKeys.includes(key))
)
return undefined;
const rates = new Map<string, number>();
for (const entry of prices) {
if (!entry || typeof entry !== 'object') return undefined;
const { unit, price, currency } = entry as Record<string, unknown>;
const key =
typeof unit === 'string' ? PRICE_UNIT_KEYS[unit] : undefined;
if (!key || !keys.includes(key) || currency !== 'USD') return undefined;
const usd = typeof price === 'number' ? catalogPrice(price) : undefined;
if (usd === undefined) return undefined;
const microCents = usdToMicroCents(usd);
if (!Number.isFinite(microCents)) return undefined;
if (rates.has(key) && rates.get(key) !== microCents) return undefined;
rates.set(key, microCents);
}
if (rates.size !== keys.length) return undefined;
return {
...model,
costs: { ...model.costs, ...Object.fromEntries(rates) },
};
};
const readJson = async (response: Response): Promise<unknown> => {
const tooLarge = () =>
new Error('Cloudflare model search response is too large');
if (Number(response.headers.get('content-length')) > MAX_PAGE_BYTES) {
await response.body?.cancel();
throw tooLarge();
}
const reader = response.body?.getReader();
const chunks: Uint8Array[] = [];
let size = 0;
while (reader) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > MAX_PAGE_BYTES) {
await reader.cancel();
throw tooLarge();
}
chunks.push(value);
}
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
};
/** `total_pages` when Cloudflare sends it; otherwise a short page is the last. */
const hasNextPage = (info: unknown, count: number, page: number) => {
const { total_pages: totalPages, per_page: perPage } = (info ??
{}) as Record<string, unknown>;
if (Number.isSafeInteger(totalPages)) return page < (totalPages as number);
const pageSize =
Number.isSafeInteger(perPage) && (perPage as number) > 0
? (perPage as number)
: PER_PAGE;
return count >= pageSize;
};
/**
* Reprices the curated models from `GET /accounts/{id}/ai/models/search`. A
* model the response omits, leaves unpriced, lists twice, or prices unusably
* keeps its previous entry; a failed or malformed page, or a listing longer
* than MAX_PAGES, throws so the last good catalog stays.
*/
export const fetchCloudflareImageCatalog = async (
{ apiToken, accountId, apiBaseUrl }: CloudflareCatalogConfig,
signal: AbortSignal,
previous: CloudflareImageModel[] = CLOUDFLARE_IMAGE_GENERATION_MODELS,
): Promise<CloudflareImageModel[]> => {
// Each listing of a model contributes the values of its `price` properties.
const listings = new Map<string, unknown[][]>();
let complete = false;
for (let page = 1; page <= MAX_PAGES; page++) {
const response = await fetch(
`${apiBaseUrl}/accounts/${accountId}/ai/models/search?per_page=${PER_PAGE}&page=${page}`,
{ headers: { Authorization: `Bearer ${apiToken}` }, signal },
);
if (!response.ok) {
await response.body?.cancel();
throw new Error(
`Cloudflare model search failed with status ${response.status}`,
);
}
const payload = (await readJson(response)) as {
success?: unknown;
result?: unknown;
result_info?: { page?: unknown };
} | null;
const result = payload?.result;
const reportedPage = payload?.result_info?.page;
if (
payload?.success !== true ||
!Array.isArray(result) ||
(reportedPage !== undefined && reportedPage !== page)
)
throw new Error('Invalid Cloudflare model search response');
for (const entry of result) {
if (!entry || typeof entry !== 'object') continue;
const { name, properties } = entry as {
name?: unknown;
properties?: unknown;
};
if (typeof name !== 'string') continue;
const prices = (Array.isArray(properties) ? properties : [])
.filter((property) => property?.property_id === 'price')
.map((property) => property.value);
listings.set(name, [...(listings.get(name) ?? []), prices]);
}
if (!hasNextPage(payload.result_info, result.length, page)) {
complete = true;
break;
}
}
// Unread pages could hold a duplicate listing that should keep the
// previous price, so a listing past the cap is not trusted.
if (!complete)
throw new Error(`Cloudflare model search exceeded ${MAX_PAGES} pages`);
if (listings.size === 0)
throw new Error('Empty Cloudflare model search response');
return previous.map((model) => {
const found = listings.get(model.id);
if (found?.length !== 1 || found[0].length !== 1) return model;
return priceCloudflareImageModel(model, found[0][0]) ?? model;
});
};
@@ -0,0 +1,169 @@
{
"success": true,
"result": [
{
"id": "e580c765-810c-4da3-936c-a2808892f14c",
"name": "@cf/black-forest-labs/flux-2-klein-9b",
"task": {
"name": "Text-to-Image"
},
"properties": []
},
{
"id": "a9abaef0-3031-47ad-8790-d311d8684c6c",
"name": "@cf/runwayml/stable-diffusion-v1-5-inpainting",
"task": {
"name": "Text-to-Image"
},
"properties": [
{
"property_id": "price",
"value": [
{
"unit": "per step",
"price": 0,
"currency": "USD"
}
]
}
]
},
{
"id": "9e087485-23dc-47fa-997d-f5bfafc0c7cc",
"name": "@cf/black-forest-labs/flux-1-schnell",
"task": {
"name": "Text-to-Image"
},
"properties": []
},
{
"id": "7f9a76e1-d120-48dd-a565-101d328bbb02",
"name": "@cf/microsoft/resnet-50",
"task": {
"name": "Image Classification"
},
"properties": [
{
"property_id": "price",
"value": [
{
"unit": "per inference request",
"price": 2.51e-6,
"currency": "USD"
}
]
}
]
},
{
"id": "7f797b20-3eb0-44fd-b571-6cbbaa3c423b",
"name": "@cf/bytedance/stable-diffusion-xl-lightning",
"task": {
"name": "Text-to-Image"
},
"properties": [
{
"property_id": "price",
"value": [
{
"unit": "per step",
"price": 0,
"currency": "USD"
}
]
}
]
},
{
"id": "724608fa-983e-495d-b95c-340d6b7e78be",
"name": "@cf/leonardo/phoenix-1.0",
"task": {
"name": "Text-to-Image"
},
"properties": [
{
"property_id": "price",
"value": [
{
"unit": "per 512 by 512 tile",
"price": 0.00583,
"currency": "USD"
},
{
"unit": "per step",
"price": 0.00011,
"currency": "USD"
}
]
}
]
},
{
"id": "6d52253a-b731-4a03-b203-cde2d4fae871",
"name": "@cf/stabilityai/stable-diffusion-xl-base-1.0",
"task": {
"name": "Text-to-Image"
},
"properties": [
{
"property_id": "price",
"value": [
{
"unit": "per step",
"price": 0,
"currency": "USD"
}
]
}
]
},
{
"id": "5cdffa8e-1b1e-48e8-85f1-ab9b943cdd32",
"name": "@cf/black-forest-labs/flux-2-klein-4b",
"task": {
"name": "Text-to-Image"
},
"properties": []
},
{
"id": "3ae8936e-593e-4fb2-85ee-95dd8a057588",
"name": "@cf/black-forest-labs/flux-2-dev",
"task": {
"name": "Text-to-Image"
},
"properties": []
},
{
"id": "0e372c11-8720-46c9-a02d-666188a22dae",
"name": "@cf/leonardo/lucid-origin",
"task": {
"name": "Text-to-Image"
},
"properties": [
{
"property_id": "price",
"value": [
{
"unit": "per 512 by 512 tile",
"price": 0.007,
"currency": "USD"
},
{
"unit": "per step",
"price": 0.000132,
"currency": "USD"
}
]
}
]
}
],
"errors": [],
"messages": [],
"result_info": {
"count": 10,
"page": 1,
"per_page": 100,
"total_count": 325
}
}
@@ -50,17 +50,23 @@ import {
withTestActor,
} from '../../../integrationTestUtil.js';
import { withAiCostFactor } from '../../../util/aiCostFactor.js';
import { CATALOG_TTL_MS } from '../../../ai-chat/utils/cachedRemoteCatalog.js';
import liveCatalog from './fixtures/image-generation-models.json';
import { XAI_IMAGE_GENERATION_MODELS } from './models.js';
import { XAIImageProvider } from './XAIImageProvider.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
const { generateMock, postMock, openAICtor } = vi.hoisted(() => ({
const { generateMock, postMock, openAICtor, axiosGetMock } = vi.hoisted(() => ({
generateMock: vi.fn(),
postMock: vi.fn(),
openAICtor: vi.fn(),
axiosGetMock: vi.fn(),
}));
// The pricing catalog is the provider's other network egress point.
vi.mock('axios', () => ({ default: { get: axiosGetMock } }));
vi.mock('openai', () => {
const OpenAICtor = vi.fn().mockImplementation(function (
this: Record<string, unknown>,
@@ -107,6 +113,8 @@ beforeEach(() => {
generateMock.mockReset();
postMock.mockReset();
openAICtor.mockReset();
axiosGetMock.mockReset();
axiosGetMock.mockRejectedValue(new Error('Catalog unavailable'));
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
batchIncrementUsagesSpy = vi.spyOn(
server.services.metering,
@@ -115,6 +123,7 @@ beforeEach(() => {
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -145,16 +154,16 @@ describe('XAIImageProvider model catalog', () => {
expect(provider.getDefaultModel()).toBe('grok-imagine-image');
});
it('exposes the static XAI_IMAGE_GENERATION_MODELS list verbatim', () => {
it('serves the curated catalog verbatim when pricing is unavailable', async () => {
const provider = makeProvider();
expect(provider.models()).toBe(XAI_IMAGE_GENERATION_MODELS);
expect(await provider.models()).toBe(XAI_IMAGE_GENERATION_MODELS);
});
it('no longer exposes the deprecated grok-2-image model', () => {
it('no longer exposes the deprecated grok-2-image model', async () => {
const provider = makeProvider();
expect(provider.models().some((m) => m.id === 'grok-2-image')).toBe(
false,
);
expect(
(await provider.models()).some((m) => m.id === 'grok-2-image'),
).toBe(false);
});
});
@@ -663,10 +672,11 @@ describe('XAIImageProvider quality and reference limits', () => {
'grok-imagine-image-quality-20260403',
'grok-imagine-image-quality-latest',
'grok-imagine-image-pro',
])('drops the retired %s instead of redirecting it', (model) => {
const names = makeProvider()
.models()
.flatMap((m) => [m.id, ...(m.aliases ?? [])]);
])('drops the retired %s instead of redirecting it', async (model) => {
const names = (await makeProvider().models()).flatMap((m) => [
m.id,
...(m.aliases ?? []),
]);
expect(names).not.toContain(model);
});
@@ -766,3 +776,389 @@ it.each([
});
},
);
// ── Live pricing catalog ────────────────────────────────────────────
describe('XAIImageProvider live pricing', () => {
type Tier = {
quality: string;
resolution: string;
price_per_image: unknown;
};
type LiveModel = {
id: string;
image_price?: unknown;
pricing?: unknown;
aliases?: string[];
};
/**
* The sanitized live response with the 2.0 matrix (and anything else)
* swapped in.
*/
const catalogWith = (
pricing: unknown,
extra: Partial<LiveModel> = {},
): { models: LiveModel[] } => ({
models: liveCatalog.models.map((model) =>
model.id === 'grok-imagine-image-2.0'
? { ...model, pricing, ...extra }
: model,
),
});
/** A full 2.0 matrix in cents, in xAI's 1e-8 cent units. */
const matrix = (cents: Record<string, number>): Tier[] =>
Object.entries(cents).map(([tier, value]) => {
const [quality, resolution] = tier.split(' ');
return {
quality,
resolution,
price_per_image: value * 100_000_000,
};
});
const repriced = matrix({
'low 1k': 4.5,
'low 1.5k': 5.5,
'low 2k': 6.5,
'medium 1k': 7,
'medium 1.5k': 8,
'medium 2k': 9,
});
const v2Costs = async (provider: XAIImageProvider) =>
(await provider.models()).find(
(m) => m.id === 'grok-imagine-image-2.0',
)!.costs;
const curated = (id: string) =>
XAI_IMAGE_GENERATION_MODELS.find((m) => m.id === id)!;
const serve = (body: unknown) =>
axiosGetMock.mockResolvedValueOnce({ data: body });
const expire = () => vi.advanceTimersByTime(CATALOG_TTL_MS + 1);
const pricedActor = () =>
makeActor({
user: {
id: 52,
uuid: `xai-live-${Math.random().toString(36).slice(2)}`,
username: 'live',
},
});
it('fetches the image model list with the provider key under a size cap', async () => {
serve(liveCatalog);
await makeProvider().models();
expect(axiosGetMock).toHaveBeenCalledWith(
'https://api.x.ai/v1/image-generation-models',
expect.objectContaining({
headers: { Authorization: 'Bearer test-key' },
signal: expect.any(AbortSignal),
maxContentLength: 1024 * 1024,
}),
);
});
it('converts the live matrix from 1e-8 cent units, keeping curated input and v1 rates', async () => {
serve(liveCatalog);
const models = await makeProvider().models();
expect(
models.find((m) => m.id === 'grok-imagine-image-2.0')!.costs,
).toEqual({
'output:1k:low': 4,
'output:2k:low': 6,
'output:1k:medium': 6,
'output:2k:medium': 8,
media_input: 1,
});
// No matrix for v1: its curated rates, ids, aliases and options stay.
expect(models.find((m) => m.id === 'grok-imagine-image')).toBe(
curated('grok-imagine-image'),
);
expect(models.map((m) => m.id)).toEqual(
XAI_IMAGE_GENERATION_MODELS.map((m) => m.id),
);
});
it('reprices only the 2.0 output tiers, preserving everything else', async () => {
serve(catalogWith(repriced));
const v2 = (await makeProvider().models()).find(
(m) => m.id === 'grok-imagine-image-2.0',
)!;
const { costs, ...rest } = curated('grok-imagine-image-2.0');
expect(v2).toEqual({
...rest,
costs: {
'output:1k:low': 4.5,
'output:2k:low': 6.5,
'output:1k:medium': 7,
'output:2k:medium': 9,
media_input: costs.media_input,
},
});
});
it.each([
['1k', 'low', 4.5],
['2k', 'low', 6.5],
['1k', 'medium', 7],
['2k', 'medium', 9],
['1k', 'auto', 4.5],
['2k', undefined, 6.5],
])(
'holds and meters the live %s/%s rate',
async (resolution, quality, cents) => {
serve(catalogWith(repriced));
const provider = makeProvider();
await provider.models();
const actor = pricedActor();
let held = -1;
generateMock.mockImplementationOnce(async () => {
held = await server.stores.creditHold.outstanding(
actor.user!.uuid,
);
return { data: [{ url: 'https://x.ai/image.png' }] };
});
await withTestActor(
() =>
provider.generate({
model: 'grok-imagine-image-2.0',
prompt: 'a landscape',
resolution,
quality,
}),
actor,
);
const expectedQuality =
quality === 'auto' || !quality ? 'low' : quality;
expect(generateMock.mock.calls[0][0]).toMatchObject({
resolution,
quality: expectedQuality,
});
expect(held).toBe(cents * 1_000_000);
expect(batchIncrementUsagesSpy.mock.calls[0][1]).toEqual([
{
usageType: `xai:grok-imagine-image-2.0:output:${resolution}:${expectedQuality}`,
usageAmount: 1,
costOverride: cents * 1_000_000,
},
]);
},
);
it('bills edits at the live output rate plus the curated input rate', async () => {
serve(catalogWith(repriced));
const provider = makeProvider();
await provider.models();
const actor = pricedActor();
let held = -1;
postMock.mockImplementationOnce(async () => {
held = await server.stores.creditHold.outstanding(actor.user!.uuid);
return { data: [{ url: 'https://x.ai/image.png' }] };
});
await withTestActor(
() =>
provider.generate({
model: 'grok-imagine-image-2.0',
prompt: 'add a hat',
input_images: [
'https://example.com/a.png',
'https://example.com/b.png',
],
quality: 'auto',
}),
actor,
);
const media = curated('grok-imagine-image-2.0').costs.media_input;
expect(held).toBe((7 + media * 2) * 1_000_000);
expect(batchIncrementUsagesSpy.mock.calls[0][1]).toEqual([
{
usageType: 'xai:grok-imagine-image-2.0:output:1k:medium',
usageAmount: 1,
costOverride: 7_000_000,
},
{
usageType: 'xai:grok-imagine-image-2.0:media_input',
usageAmount: 2,
costOverride: media * 2 * 1_000_000,
},
]);
});
it('never bills from image_price', async () => {
serve({
models: liveCatalog.models.map(({ pricing: _, ...model }) => ({
...model,
image_price: 999_999_999,
})),
});
expect(await makeProvider().models()).toEqual(
XAI_IMAGE_GENERATION_MODELS,
);
});
it('accepts a zero price and identical duplicate tiers', async () => {
serve(
catalogWith([
...matrix({ 'low 1k': 0, 'low 2k': 6, 'medium 1k': 6 }),
...matrix({ 'medium 2k': 8, 'low 1k': 0 }),
]),
);
expect(await v2Costs(makeProvider())).toEqual({
...curated('grok-imagine-image-2.0').costs,
'output:1k:low': 0,
});
});
const tier = (
price_per_image: unknown,
quality = 'low',
resolution = '1k',
) => ({
quality,
resolution,
price_per_image,
});
const withLow1k = (bad: unknown) => [
bad,
...repriced.filter(
(entry) => !(entry.quality === 'low' && entry.resolution === '1k'),
),
];
it.each([
['a string price', withLow1k(tier('400000000'))],
['a negative price', withLow1k(tier(-1))],
['a fractional price', withLow1k(tier(400000000.5))],
['an unsafe integer price', withLow1k(tier(2 ** 53))],
['a missing price', withLow1k({ quality: 'low', resolution: '1k' })],
['an unknown quality', [...repriced, tier(1, 'ultra')]],
['a malformed resolution', [...repriced, tier(1, 'low', '1024x1024')]],
['a non-object tier', [...repriced, null]],
[
'a missing billed tier',
repriced.filter((t) => t.resolution !== '2k'),
],
['a conflicting duplicate tier', [...repriced, tier(1)]],
['a non-array matrix', { 'low 1k': 400000000 }],
])('keeps the previous 2.0 rates on %s', async (_label, pricing) => {
serve(catalogWith(pricing));
expect(await v2Costs(makeProvider())).toEqual(
curated('grok-imagine-image-2.0').costs,
);
});
it('ignores records that only share an alias or repeat the model id', async () => {
serve({
models: [
...liveCatalog.models.filter(
(m) => m.id !== 'grok-imagine-image-2.0',
),
{
id: 'x-ai/grok-imagine-image-2.0',
aliases: ['grok-imagine-image-2.0'],
pricing: repriced,
},
],
});
expect(await v2Costs(makeProvider())).toEqual(
curated('grok-imagine-image-2.0').costs,
);
serve({
models: [
...liveCatalog.models,
{ id: 'grok-imagine-image-2.0', pricing: repriced },
],
});
expect(await v2Costs(makeProvider())).toEqual(
curated('grok-imagine-image-2.0').costs,
);
});
it('keeps the last good rates through failures, empty lists and omissions', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const provider = makeProvider();
serve(catalogWith(repriced));
const good = await v2Costs(provider);
expect(good['output:1k:low']).toBe(4.5);
for (const next of [
() => axiosGetMock.mockRejectedValueOnce(new Error('down')),
() => serve({ models: [] }),
() => serve({ data: liveCatalog.models }),
() => serve('<html>'),
() =>
serve({
models: liveCatalog.models.filter(
(m) => m.id !== 'grok-imagine-image-2.0',
),
}),
() => serve(catalogWith([tier(-1)])),
]) {
expire();
const before = axiosGetMock.mock.calls.length;
next();
expect(await v2Costs(provider)).toEqual(good);
expect(axiosGetMock.mock.calls.length).toBe(before + 1);
}
// A later valid matrix replaces it.
expire();
serve(liveCatalog);
expect((await v2Costs(provider))['output:1k:low']).toBe(4);
});
it('bills a request at the rates captured when it started', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const provider = makeProvider();
serve(catalogWith(repriced));
await provider.models();
let finish: () => void = () => {};
generateMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
finish = () =>
resolve({ data: [{ url: 'https://x.ai/image.png' }] });
}),
);
const pending = withTestActor(
() =>
provider.generate({
model: 'grok-imagine-image-2.0',
prompt: 'a landscape',
quality: 'medium',
resolution: '2k',
}),
pricedActor(),
);
await vi.waitFor(() => expect(generateMock).toHaveBeenCalled());
expire();
serve(liveCatalog);
expect((await v2Costs(provider))['output:2k:medium']).toBe(8);
finish();
await pending;
expect(batchIncrementUsagesSpy.mock.calls[0][1][0].costOverride).toBe(
9_000_000,
);
});
it('bills from the catalog last handed out, not a fetch of its own', async () => {
serve(catalogWith(repriced));
generateMock.mockResolvedValueOnce({
data: [{ url: 'https://x.ai/image.png' }],
});
await withTestActor(() =>
makeProvider().generate({
model: 'grok-imagine-image-2.0',
prompt: 'a landscape',
}),
);
expect(axiosGetMock).not.toHaveBeenCalled();
expect(batchIncrementUsagesSpy.mock.calls[0][1][0].costOverride).toBe(
curated('grok-imagine-image-2.0').costs['output:1k:low'] *
1_000_000,
);
});
});
@@ -29,6 +29,8 @@ import { HttpError } from '../../../../core/http/HttpError.js';
import { insufficientCreditsError } from '../../../../services/metering/enforcement.js';
import { toUrlOrDataUri } from '../../inputImage.js';
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
import { cachedRemoteCatalog } from '../../../ai-chat/utils/cachedRemoteCatalog.js';
import { fetchXaiImageCatalog } from './catalog.js';
const DEFAULT_MODEL = 'grok-imagine-image';
// xAI's Grok Imagine edit endpoint accepts up to 5 source images per request.
@@ -41,6 +43,12 @@ interface XaiImageResponse {
export class XAIImageProvider implements IImageProvider {
#client: OpenAI;
#meteringService: AiMeteringService;
/**
* The catalog last handed to the driver; generate() bills from it so a
* hold, its metering and the advertised price always agree.
*/
#models: XaiImageModel[] = XAI_IMAGE_GENERATION_MODELS;
#catalog: () => Promise<XaiImageModel[]>;
constructor(
config: { apiKey: string },
@@ -55,10 +63,17 @@ export class XAIImageProvider implements IImageProvider {
apiKey: config.apiKey,
baseURL: 'https://api.x.ai/v1',
});
this.#catalog = cachedRemoteCatalog({
name: 'xAI image pricing',
fallback: XAI_IMAGE_GENERATION_MODELS,
fetch: (signal, previous) =>
fetchXaiImageCatalog(config.apiKey, signal, previous),
});
}
models(): XaiImageModel[] {
return XAI_IMAGE_GENERATION_MODELS;
async models(): Promise<XaiImageModel[]> {
this.#models = await this.#catalog();
return this.#models;
}
getDefaultModel(): string {
@@ -248,7 +263,7 @@ export class XAIImageProvider implements IImageProvider {
}
#getModel(model?: string) {
const models = this.models();
const models = this.#models;
const found = models.find(
(m) => m.id === model || m.aliases?.includes(model ?? ''),
);
@@ -0,0 +1,101 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import axios from 'axios';
import { catalogPrice } from '../../../ai-chat/utils/pricing.js';
import { XAI_IMAGE_GENERATION_MODELS, type XaiImageModel } from './models.js';
/** `pricing[].price_per_image` is documented in 1/100,000,000ths of a USD cent. */
const PRICE_UNITS_PER_CENT = 100_000_000;
const QUALITIES = new Set(['low', 'medium', 'high']);
const RESOLUTIONS = new Set(['1k', '1.5k', '2k']);
const TIERED_COST_KEY = /^output:([^:]+):([^:]+)$/;
/**
* The model's (resolution, quality) tiers repriced from xAI's `pricing` matrix,
* or undefined when the matrix is absent, malformed, conflicting, or misses a
* tier we bill. `image_price` is never read: its documented unit disagrees with
* the live values.
*/
export const priceXaiImageModel = (
model: XaiImageModel,
pricing: unknown,
): XaiImageModel | undefined => {
if (!Array.isArray(pricing)) return undefined;
const tiers = new Map<string, number>();
for (const tier of pricing) {
if (!tier || typeof tier !== 'object') return undefined;
const { quality, resolution, price_per_image: raw } = tier;
if (!QUALITIES.has(quality) || !RESOLUTIONS.has(resolution))
return undefined;
const cents = Number.isSafeInteger(raw)
? catalogPrice(raw / PRICE_UNITS_PER_CENT)
: undefined;
if (cents === undefined) return undefined;
const key = `output:${resolution}:${quality}`;
if (tiers.has(key) && tiers.get(key) !== cents) return undefined;
tiers.set(key, cents);
}
const costs = { ...model.costs };
let repriced = false;
for (const key of Object.keys(costs)) {
if (!TIERED_COST_KEY.test(key)) continue;
const cents = tiers.get(key);
if (cents === undefined) return undefined;
costs[key] = cents;
repriced = true;
}
return repriced ? { ...model, costs } : undefined;
};
/**
* Reprices the curated models from `GET /v1/image-generation-models`. A model
* the response omits or prices unusably keeps its previous entry; a response
* that isn't a non-empty model list throws so the last good catalog stays.
*/
export const fetchXaiImageCatalog = async (
apiKey: string,
signal: AbortSignal,
previous: XaiImageModel[] = XAI_IMAGE_GENERATION_MODELS,
): Promise<XaiImageModel[]> => {
const response = await axios.get(
'https://api.x.ai/v1/image-generation-models',
{
headers: { Authorization: `Bearer ${apiKey}` },
signal,
maxContentLength: 1024 * 1024,
},
);
const entries: unknown = response.data?.models;
if (!Array.isArray(entries) || entries.length === 0)
throw new Error('Invalid xAI image model list');
const byId = new Map<string, unknown[]>();
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const { id, pricing } = entry as { id?: unknown; pricing?: unknown };
if (typeof id !== 'string') continue;
byId.set(id, [...(byId.get(id) ?? []), pricing]);
}
return previous.map((model) => {
// Duplicate records for one id are ambiguous; keep what we had.
const records = byId.get(model.id);
if (records?.length !== 1) return model;
return priceXaiImageModel(model, records[0]) ?? model;
});
};
@@ -0,0 +1,79 @@
{
"models": [
{
"id": "grok-imagine-image",
"fingerprint": "fp_750f5052a8",
"max_prompt_length": 16000,
"created": 1769558400,
"object": "model",
"owned_by": "xai",
"version": "1.0",
"input_modalities": ["text", "image"],
"output_modalities": ["image"],
"image_price": 200000000,
"aliases": ["grok-imagine-image-2026-03-02"]
},
{
"id": "grok-imagine-image-2.0",
"fingerprint": "fp_e15b3a1fcc",
"max_prompt_length": 64000,
"created": 1786147200,
"object": "model",
"owned_by": "xai",
"version": "1.0",
"input_modalities": ["text", "image"],
"output_modalities": ["image"],
"image_price": 600000000,
"pricing": [
{
"quality": "low",
"resolution": "1k",
"price_per_image": 400000000
},
{
"quality": "low",
"resolution": "2k",
"price_per_image": 600000000
},
{
"quality": "low",
"resolution": "1.5k",
"price_per_image": 500000000
},
{
"quality": "medium",
"resolution": "1k",
"price_per_image": 600000000
},
{
"quality": "medium",
"resolution": "2k",
"price_per_image": 800000000
},
{
"quality": "medium",
"resolution": "1.5k",
"price_per_image": 700000000
}
],
"aliases": []
},
{
"id": "grok-imagine-image-quality",
"fingerprint": "fp_cc7863a71d",
"max_prompt_length": 16000,
"created": 1775174400,
"object": "model",
"owned_by": "xai",
"version": "1.0",
"input_modalities": ["text", "image"],
"output_modalities": ["image"],
"image_price": 500000000,
"aliases": [
"grok-imagine-image-quality-20260403",
"grok-imagine-image-quality-latest",
"grok-imagine-image-pro"
]
}
]
}
@@ -24,15 +24,12 @@ export type XaiImageModel = IImageModel & {
defaultEditQuality?: string;
};
// Costs are in usd-cents (1 = $0.01). xAI's "Grok Imagine" image API bills a
// per-image output rate by resolution tier (1k/2k) plus, for edits, a
// per-input-image "media input" rate. Rates per the xAI Imagine pricing table:
// https://docs.x.ai/developers/pricing
// grok-imagine-image media $0.002 | 1k $0.02 | 2k $0.02
// grok-imagine-image-2.0 media $0.01 | low 1k $0.04, 2k $0.06 | medium 1k $0.06, 2k $0.08
// The pricing page only prints a flat $0.04 for 2.0 while the models listing
// reports $0.06; the release notes say images bill at the quality served, so
// the 2.0 tiers above are the best public reading, not a vendor table.
// Costs are in usd-cents (1 = $0.01): a per-image output rate by resolution
// (and, for 2.0, quality) plus a per-input-image `media_input` rate on edits.
// Rates per https://docs.x.ai/developers/pricing. The 2.0 output tiers are
// refreshed from the `pricing` matrix of GET /v1/image-generation-models (see
// catalog.ts); every other rate here is curated, since the endpoint lists no
// media-input price and its `image_price` unit is ambiguous.
export const XAI_IMAGE_GENERATION_MODELS: XaiImageModel[] = [
{
puterId: 'x-ai:x-ai/grok-imagine-image',
+255 -14
View File
@@ -58,6 +58,7 @@ import { setupTestServer } from '../../testUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import type { OCRDriver } from './OCRDriver.js';
import { OCR_COSTS } from './costs.js';
import textractUsWest2 from './fixtures/textract-us-west-2.json';
// ── SDK mocks ───────────────────────────────────────────────────────
//
@@ -81,6 +82,8 @@ vi.mock('@aws-sdk/client-textract', async () => {
opts: unknown,
) {
textractCtor(opts);
// Lets a test read which region's client sent each command.
this.region = (opts as { region?: string }).region;
this.send = textractSendMock;
}),
};
@@ -101,6 +104,65 @@ vi.mock('@mistralai/mistralai', () => ({
}),
}));
// -- Textract price lists --
//
// AWS's public price list is the network boundary for Textract pricing.
// Puter files live in the default bucket region (us-west-2, priced from the
// trimmed public fixture); Textract is configured for us-east-1, served
// here at a deliberately different first-tier rate so the two can't be
// confused.
const S3_REGION = 'us-west-2';
const CONFIGURED_REGION = 'us-east-1';
const S3_RATE = 150000;
const CONFIGURED_RATE = 200000;
const TEXTRACT_USAGE = 'aws-textract:detect-document-text:page';
/** The fixture re-published for another region at another first-tier price. */
const textractPriceList = (region: string, usd: string) => {
const doc = structuredClone(textractUsWest2) as Record<string, any>;
for (const product of Object.values(doc.products) as any[]) {
product.attributes.regionCode = region;
}
const term = Object.values(doc.terms.OnDemand.GDBP4PSVE9T6ZMUT)[0] as any;
term.priceDimensions[
'GDBP4PSVE9T6ZMUT.JRTCKXETXF.SB2VSFF4TP'
].pricePerUnit.USD = usd;
return doc;
};
const priceLists: Record<string, unknown> = {};
const resetPriceLists = () => {
priceLists[S3_REGION] = textractUsWest2;
priceLists[CONFIGURED_REGION] = textractPriceList(
CONFIGURED_REGION,
'0.0020000000',
);
};
resetPriceLists();
const PRICE_LIST_PREFIX =
'https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonTextract/current/';
const priceListRequests: string[] = [];
const realFetch = globalThis.fetch;
const priceListFetch = async (
input: string | URL | Request,
init?: RequestInit,
): Promise<Response> => {
const url = String(input instanceof Request ? input.url : input);
if (!url.startsWith(PRICE_LIST_PREFIX)) return realFetch(input, init);
priceListRequests.push(url);
const region = url.slice(PRICE_LIST_PREFIX.length).split('/')[0]!;
const doc = priceLists[region];
return doc ? Response.json(doc) : new Response('missing', { status: 404 });
};
/** Which region's client sent each Textract command, in order. */
const sentRegions = () =>
textractSendMock.mock.contexts.map(
(client) => (client as { region?: string }).region,
);
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
@@ -109,13 +171,14 @@ let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
beforeAll(async () => {
vi.stubGlobal('fetch', priceListFetch);
server = await setupTestServer({
providers: {
'aws-textract': {
aws: {
access_key: 'AKIA-TEST',
secret_key: 'secret',
region: 'us-west-2',
region: CONFIGURED_REGION,
},
},
'mistral-ocr': { apiKey: 'mistral-key' },
@@ -126,6 +189,7 @@ beforeAll(async () => {
afterAll(async () => {
await server?.shutdown();
vi.unstubAllGlobals();
});
beforeEach(() => {
@@ -182,14 +246,29 @@ const dataUrl = (buffer: Buffer, mime: string) =>
// ── getReportedCosts ────────────────────────────────────────────────
describe('OCRDriver.getReportedCosts', () => {
it('mirrors every entry in costs.ts as a per-page line item', () => {
const reported = driver.getReportedCosts();
it('reports every usage type per page, Textract at the configured region rate', async () => {
// The configured region's price list loads at startup, off the
// report's synchronous path.
await vi.waitFor(() =>
expect(
driver
.getReportedCosts()
.find((c) => c.usageType === TEXTRACT_USAGE)?.ucentsPerUnit,
).toBe(CONFIGURED_RATE),
);
expect(priceListRequests).toContain(
`${PRICE_LIST_PREFIX}${CONFIGURED_REGION}/index.json`,
);
const reported = driver.getReportedCosts();
expect(reported).toHaveLength(Object.keys(OCR_COSTS).length);
for (const [usageType, ucentsPerUnit] of Object.entries(OCR_COSTS)) {
expect(reported).toContainEqual({
usageType,
ucentsPerUnit,
ucentsPerUnit:
usageType === TEXTRACT_USAGE
? CONFIGURED_RATE
: ucentsPerUnit,
unit: 'page',
source: 'driver:aiOcr',
});
@@ -410,7 +489,7 @@ describe('OCRDriver.recognize (aws-textract)', () => {
).toEqual(bytes);
});
it('meters one usage line per detected page at the per-page rate from costs.ts', async () => {
it('meters one usage line per detected page at the configured region rate', async () => {
const { actor } = await makeUser();
textractSendMock.mockResolvedValueOnce(sampleTextractResponse);
@@ -421,8 +500,9 @@ describe('OCRDriver.recognize (aws-textract)', () => {
}),
);
const usageType = 'aws-textract:detect-document-text:page';
const perPage = OCR_COSTS[usageType];
const usageType = TEXTRACT_USAGE;
const perPage = CONFIGURED_RATE;
expect(sentRegions()).toEqual([CONFIGURED_REGION]);
// sampleTextractResponse has 2 PAGE blocks → bill 2 pages.
const ocrCalls = incrementUsageSpy.mock.calls.filter(
([, type]) => type === usageType,
@@ -453,15 +533,179 @@ describe('OCRDriver.recognize (aws-textract)', () => {
}),
);
const usageType = 'aws-textract:detect-document-text:page';
const ocrCalls = incrementUsageSpy.mock.calls.filter(
([, type]) => type === usageType,
([, type]) => type === TEXTRACT_USAGE,
);
expect(ocrCalls).toHaveLength(1);
// pages = pageCount || 1 → bill 1 page when no PAGE block was returned.
const [, , count, cost] = ocrCalls[0]!;
expect(count).toBe(1);
expect(cost).toBe(OCR_COSTS[usageType]);
expect(cost).toBe(CONFIGURED_RATE);
});
describe('regional pricing', () => {
const onePage = { Blocks: [{ BlockType: 'PAGE' }] };
const checkedCosts = () =>
hasCreditsSpy.mock.calls.map(([, cost]) => cost);
const textractCharges = () =>
incrementUsageSpy.mock.calls
.filter(([, type]) => type === TEXTRACT_USAGE)
.map(([, , count, cost]) => [count, cost]);
const recognizeFile = (actor: Actor, path: string) =>
withActor(actor, () =>
driver.recognize({
source: { path },
provider: 'aws-textract',
}),
);
// Cached price lists only refetch once their lifetime passes, so
// tests that change prices move the clock past it.
const realDateNow = Date.now.bind(Date);
let clockOffset = 0;
const outliveCachedPrices = () => {
clockOffset += 60 * 60 * 1000;
vi.spyOn(Date, 'now').mockImplementation(
() => realDateNow() + clockOffset,
);
};
/** Puts both regions back on their published prices for later tests. */
const restorePrices = async () => {
resetPriceLists();
outliveCachedPrices();
const { actor, path } = await writePuterImage();
textractSendMock.mockResolvedValueOnce(onePage);
await recognizeFile(actor, path);
};
it('bills a direct S3 read at the bucket region rate, holding enough for either region', async () => {
const { actor, path } = await writePuterImage();
textractSendMock.mockResolvedValueOnce(onePage);
await recognizeFile(actor, path);
expect(sentRegions()).toEqual([S3_REGION]);
expect(checkedCosts()).toEqual([
Math.max(S3_RATE, CONFIGURED_RATE),
]);
expect(textractCharges()).toEqual([[1, S3_RATE]]);
});
it('bills the configured region rate when Textract falls back to the bytes', async () => {
const { actor, path } = await writePuterImage();
textractSendMock
.mockRejectedValueOnce(
new InvalidS3ObjectException({
message: 'unreachable bucket',
$metadata: {},
}),
)
.mockResolvedValueOnce(onePage);
await recognizeFile(actor, path);
expect(sentRegions()).toEqual([S3_REGION, CONFIGURED_REGION]);
expect(checkedCosts()).toEqual([CONFIGURED_RATE]);
expect(textractCharges()).toEqual([[1, CONFIGURED_RATE]]);
});
it('holds the bucket region rate when it is the higher of the two', async () => {
const { actor, path } = await writePuterImage();
priceLists[S3_REGION] = textractPriceList(
S3_REGION,
'0.0030000000',
);
outliveCachedPrices();
try {
textractSendMock.mockResolvedValueOnce(onePage);
await recognizeFile(actor, path);
expect(checkedCosts()).toEqual([300000]);
expect(textractCharges()).toEqual([[1, 300000]]);
} finally {
await restorePrices();
}
});
it('prices the regional hold at the AI cost factor', async () => {
const { actor, path } = await writePuterImage();
textractSendMock.mockResolvedValueOnce(onePage);
const doubled = (_key: string, event: { factor: number }) => {
event.factor = 2;
};
server.clients.event.on('ai.cost.factor.*', doubled);
try {
await recognizeFile(actor, path);
} finally {
server.clients.event.off('ai.cost.factor.*', doubled);
}
expect(checkedCosts()).toEqual([CONFIGURED_RATE * 2]);
});
it('charges the rates captured before the call even when prices refresh mid-call', async () => {
const { actor, path } = await writePuterImage();
const { actor: other } = await makeUser();
try {
textractSendMock
.mockImplementationOnce(async () => {
// The configured region's price rises and its cache
// expires while this call runs; another call sees it.
priceLists[CONFIGURED_REGION] = textractPriceList(
CONFIGURED_REGION,
'0.0030000000',
);
outliveCachedPrices();
await withActor(other, () =>
driver.recognize({
source: dataUrl(
Buffer.from('img'),
'image/png',
),
provider: 'aws-textract',
}),
);
throw new InvalidS3ObjectException({
message: 'unreachable bucket',
$metadata: {},
});
})
.mockResolvedValueOnce(onePage)
.mockResolvedValueOnce(onePage);
await recognizeFile(actor, path);
expect(checkedCosts()).toEqual([CONFIGURED_RATE, 300000]);
// The concurrent call pays the new rate; this one falls back
// to the configured region at the rate it held for.
expect(textractCharges()).toEqual([
[1, 300000],
[1, CONFIGURED_RATE],
]);
expect(sentRegions()).toEqual([
S3_REGION,
CONFIGURED_REGION,
CONFIGURED_REGION,
]);
} finally {
await restorePrices();
}
});
it('never fetches Textract prices for a Mistral call', async () => {
const { actor } = await makeUser();
const before = priceListRequests.length;
mistralOcrProcessMock.mockResolvedValueOnce({ pages: [] });
await withActor(actor, () =>
driver.recognize({
source: dataUrl(Buffer.from('img'), 'image/png'),
provider: 'mistral',
}),
);
expect(priceListRequests).toHaveLength(before);
});
});
});
@@ -1026,10 +1270,7 @@ describe('OCRDriver credit pre-flight', () => {
}),
);
expect(checkedCosts()).toEqual([
perPage,
OCR_COSTS['aws-textract:detect-document-text:page'],
]);
expect(checkedCosts()).toEqual([perPage, CONFIGURED_RATE]);
});
});
+36 -7
View File
@@ -60,6 +60,7 @@ import {
type OcrProviderId,
} from './models.js';
import { countPdfPages } from './pdfPages.js';
import { textractRates } from './textractPricing.js';
/**
* Driver implementing `puter-ocr` — document OCR. Two providers: •
@@ -149,6 +150,9 @@ const NOT_CONFIGURED: Record<OcrProviderId, string> = {
const TABLE_FORMATS = new Set(['markdown', 'html']);
const DEFAULT_TEXTRACT_REGION = 'us-west-2';
const TEXTRACT_PAGE_USAGE_TYPE = 'aws-textract:detect-document-text:page';
const badRequest = (message: string) =>
new HttpError(400, message, { legacyCode: 'bad_request' });
@@ -262,10 +266,20 @@ export class OCRDriver extends PuterDriver {
);
}
/**
* Textract is reported at the configured region's first-tier rate. A call
* that reads its file from a bucket in another region is billed at that
* region's rate instead, which this per-usage-type report can't show.
*/
override getReportedCosts() {
const textractRegion =
this.#awsConfig?.region ?? DEFAULT_TEXTRACT_REGION;
return Object.entries(OCR_COSTS).map(([usageType, ucentsPerUnit]) => ({
usageType,
ucentsPerUnit,
ucentsPerUnit:
usageType === TEXTRACT_PAGE_USAGE_TYPE
? this.#textractRates.peek(textractRegion)
: ucentsPerUnit,
unit: 'page',
source: 'driver:aiOcr',
}));
@@ -284,6 +298,8 @@ export class OCRDriver extends PuterDriver {
secretAccessKey?: string;
region?: string;
} | null = null;
/** DetectDocumentText rates per region, from AWS's public price list. */
#textractRates = textractRates(OCR_COSTS[TEXTRACT_PAGE_USAGE_TYPE]);
// Mistral state.
#mistral: MistralOcrClient | null = null;
@@ -303,13 +319,15 @@ export class OCRDriver extends PuterDriver {
const textractRegion =
(textractAws?.region as string | undefined) ??
(textract?.region as string | undefined) ??
'us-west-2';
DEFAULT_TEXTRACT_REGION;
if (textractAccessKey && textractSecretKey) {
this.#awsConfig = {
accessKeyId: textractAccessKey,
secretAccessKey: textractSecretKey,
region: textractRegion,
};
// Warm the configured region so the cost report has its rate.
void this.#textractRates.rate(textractRegion);
this.#providers.register(
'aws-textract',
(input, _args, model, actor) =>
@@ -468,11 +486,19 @@ export class OCRDriver extends PuterDriver {
}
: null;
const configuredRegion =
this.#awsConfig!.region ?? DEFAULT_TEXTRACT_REGION;
// Both regions the call may run in are priced up front, so the hold
// covers either and a refresh mid-call can't change the charge.
const [configuredRate, s3Rate] = await Promise.all([
this.#textractRates.rate(configuredRegion),
s3Info ? this.#textractRates.rate(s3Info.bucketRegion) : undefined,
]);
const tryRun = async (useS3: boolean) => {
const region =
s3Info && useS3
? s3Info.bucketRegion
: (this.#awsConfig!.region ?? 'us-west-2');
s3Info && useS3 ? s3Info.bucketRegion : configuredRegion;
const client = this.#textractClientFor(region);
const document =
s3Info && useS3
@@ -483,21 +509,24 @@ export class OCRDriver extends PuterDriver {
);
};
const costPerPage = OCR_COSTS[model.pageUsageType];
const hold = await this.#holdCredits(
actor,
model.pageUsageType,
costPerPage * (await estimateOcrPages(input, model.provider)),
Math.max(configuredRate, s3Rate ?? 0) *
(await estimateOcrPages(input, model.provider)),
);
try {
let response;
let costPerPage: number;
try {
try {
response = await tryRun(Boolean(s3Info));
costPerPage = s3Rate ?? configuredRate;
} catch (err) {
if (!(s3Info && err instanceof InvalidS3ObjectException))
throw err;
response = await tryRun(false);
costPerPage = configuredRate;
}
} catch (err) {
if (err instanceof UnsupportedDocumentException)
@@ -0,0 +1,163 @@
{
"formatVersion": "v1.0",
"disclaimer": "This pricing list is for informational purposes only. All prices are subject to the additional terms included in the pricing pages on http://aws.amazon.com. All Free Tier prices are also subject to the terms included at https://aws.amazon.com/free/",
"offerCode": "AmazonTextract",
"version": "20260911124513",
"publicationDate": "2026-09-11T12:45:13Z",
"products": {
"GDBP4PSVE9T6ZMUT": {
"sku": "GDBP4PSVE9T6ZMUT",
"productFamily": "API Request",
"attributes": {
"servicecode": "AmazonTextract",
"location": "US West (Oregon)",
"locationType": "AWS Region",
"group": "Textract DocumentText API Requests",
"groupDescription": "Number of pages processed using DetectDocumentText",
"usagetype": "USW2-SyncTextPagesProcessed",
"operation": "",
"apiType": "Sync",
"entityType": "Text",
"platopagedatatype": "SyncTextPagesProcessed",
"regionCode": "us-west-2",
"servicename": "Amazon Textract"
}
},
"ASZEG698D47WUZBY": {
"sku": "ASZEG698D47WUZBY",
"productFamily": "API Request",
"attributes": {
"servicecode": "AmazonTextract",
"location": "US West (Oregon)",
"locationType": "AWS Region",
"group": "Textract DocumentText API Requests",
"groupDescription": "Number of pages processed using DocumentTextDetection APIs",
"usagetype": "USW2-AsyncTextPagesProcessed",
"operation": "",
"apiType": "Async",
"entityType": "Text",
"platopagedatatype": "AsyncTextPagesProcessed",
"regionCode": "us-west-2",
"servicename": "Amazon Textract"
}
},
"YXP789HT5ZQGFB9R": {
"sku": "YXP789HT5ZQGFB9R",
"productFamily": "API Request",
"attributes": {
"servicecode": "AmazonTextract",
"location": "US West (Oregon)",
"locationType": "AWS Region",
"group": "Textract Analyze API Requests",
"groupDescription": "Number of pages processed using AnalyzeDocument",
"usagetype": "USW2-SyncTablesPagesProcessed",
"operation": "",
"apiType": "Sync",
"entityType": "Tables",
"platopagedatatype": "SyncTablesPagesProcessed",
"regionCode": "us-west-2",
"servicename": "Amazon Textract"
}
}
},
"terms": {
"OnDemand": {
"GDBP4PSVE9T6ZMUT": {
"GDBP4PSVE9T6ZMUT.JRTCKXETXF": {
"offerTermCode": "JRTCKXETXF",
"sku": "GDBP4PSVE9T6ZMUT",
"effectiveDate": "2024-10-01T00:00:00Z",
"priceDimensions": {
"GDBP4PSVE9T6ZMUT.JRTCKXETXF.SB2VSFF4TP": {
"rateCode": "GDBP4PSVE9T6ZMUT.JRTCKXETXF.SB2VSFF4TP",
"description": "SyncPagesProcessed: 0 - 1M DetectDocumentText pages processed USD per month, $1.50 USD per 1000 pages",
"beginRange": "0",
"endRange": "1000000",
"unit": "Pages",
"pricePerUnit": {
"USD": "0.0015000000"
},
"appliesTo": []
},
"GDBP4PSVE9T6ZMUT.JRTCKXETXF.VNJQCFJPS4": {
"rateCode": "GDBP4PSVE9T6ZMUT.JRTCKXETXF.VNJQCFJPS4",
"description": "SyncPagesProcessed: 1M+ DetectDocumentText pages processed USD per month, $0.60 USD per 1000 pages",
"beginRange": "1000000",
"endRange": "Inf",
"unit": "Pages",
"pricePerUnit": {
"USD": "0.0006000000"
},
"appliesTo": []
}
},
"termAttributes": {}
}
},
"ASZEG698D47WUZBY": {
"ASZEG698D47WUZBY.JRTCKXETXF": {
"offerTermCode": "JRTCKXETXF",
"sku": "ASZEG698D47WUZBY",
"effectiveDate": "2024-10-01T00:00:00Z",
"priceDimensions": {
"ASZEG698D47WUZBY.JRTCKXETXF.SB2VSFF4TP": {
"rateCode": "ASZEG698D47WUZBY.JRTCKXETXF.SB2VSFF4TP",
"description": "AsyncPagesProcessed: 0 - 1M DetectDocumentText pages processed USD per month, $1.50 USD per 1000 pages",
"beginRange": "0",
"endRange": "1000000",
"unit": "Pages",
"pricePerUnit": {
"USD": "0.0015000000"
},
"appliesTo": []
},
"ASZEG698D47WUZBY.JRTCKXETXF.VNJQCFJPS4": {
"rateCode": "ASZEG698D47WUZBY.JRTCKXETXF.VNJQCFJPS4",
"description": "AsyncPagesProcessed: 1M+ DetectDocumentText pages processed USD per month, $0.60 USD per 1000 pages",
"beginRange": "1000000",
"endRange": "Inf",
"unit": "Pages",
"pricePerUnit": {
"USD": "0.0006000000"
},
"appliesTo": []
}
},
"termAttributes": {}
}
},
"YXP789HT5ZQGFB9R": {
"YXP789HT5ZQGFB9R.JRTCKXETXF": {
"offerTermCode": "JRTCKXETXF",
"sku": "YXP789HT5ZQGFB9R",
"effectiveDate": "2024-10-01T00:00:00Z",
"priceDimensions": {
"YXP789HT5ZQGFB9R.JRTCKXETXF.SB2VSFF4TP": {
"rateCode": "YXP789HT5ZQGFB9R.JRTCKXETXF.SB2VSFF4TP",
"description": "SyncPagesProcessed: 0-1M pages of AnalyzeDocument Tables, $15 USD per 1000 pages",
"beginRange": "0",
"endRange": "1000000",
"unit": "Pages",
"pricePerUnit": {
"USD": "0.0150000000"
},
"appliesTo": []
},
"YXP789HT5ZQGFB9R.JRTCKXETXF.VNJQCFJPS4": {
"rateCode": "YXP789HT5ZQGFB9R.JRTCKXETXF.VNJQCFJPS4",
"description": "SyncPagesProcessed: 1M+ pages of AnalyzeDocument Tables, $10 USD per 1000 pages",
"beginRange": "1000000",
"endRange": "Inf",
"unit": "Pages",
"pricePerUnit": {
"USD": "0.0100000000"
},
"appliesTo": []
}
},
"termAttributes": {}
}
}
}
}
}
@@ -0,0 +1,477 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import usWest2 from './fixtures/textract-us-west-2.json';
import {
fetchTextractPriceList,
isTextractPriceRegion,
parseTextractPriceList,
TEXTRACT_PRICE_LIST_MAX_BYTES,
textractRates,
} from './textractPricing.js';
// The fixture is AWS's public us-west-2 Textract price list trimmed to the
// DetectDocumentText SKU and two neighbours (async text, sync tables); all
// its terms took effect on 2024-10-01.
const NOW = Date.parse('2026-10-13T00:00:00Z');
const SYNC_TEXT_SKU = 'GDBP4PSVE9T6ZMUT';
const ASYNC_TEXT_SKU = 'ASZEG698D47WUZBY';
const FIRST_TIER = `${SYNC_TEXT_SKU}.JRTCKXETXF.SB2VSFF4TP`;
const SECOND_TIER = `${SYNC_TEXT_SKU}.JRTCKXETXF.VNJQCFJPS4`;
const PRICE_LIST_URL = (region: string) =>
`https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonTextract/current/${region}/index.json`;
// Tests reshape the fixture freely, so it's handled as untyped JSON.
type Doc = Record<string, any>;
const westCopy = (): Doc => structuredClone(usWest2);
const termOf = (doc: Doc, sku = SYNC_TEXT_SKU) =>
Object.values(doc.terms.OnDemand[sku] as object)[0];
const dimensionOf = (doc: Doc, rateCode = FIRST_TIER) =>
termOf(doc).priceDimensions[rateCode];
/** The fixture re-published for another region at a different first-tier price. */
const regionDoc = (region: string, usd: string): Doc => {
const doc = westCopy();
for (const product of Object.values(doc.products) as Doc[]) {
product.attributes.regionCode = region;
}
dimensionOf(doc).pricePerUnit.USD = usd;
return doc;
};
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('parseTextractPriceList on the published price list', () => {
it('reads the first-tier DetectDocumentText rate as exact microcents per page', () => {
// $1.50 per 1000 pages.
expect(parseTextractPriceList(usWest2, 'us-west-2', NOW)).toBe(150000);
});
it('never prices from a discounted volume tier', () => {
const doc = westCopy();
dimensionOf(doc, SECOND_TIER).pricePerUnit.USD = '0.0000010000';
expect(parseTextractPriceList(doc, 'us-west-2', NOW)).toBe(150000);
});
it('accepts a single unbounded first tier', () => {
const doc = westCopy();
delete termOf(doc).priceDimensions[SECOND_TIER];
dimensionOf(doc).endRange = 'Inf';
expect(parseTextractPriceList(doc, 'us-west-2', NOW)).toBe(150000);
});
it('only reads products for the exact region asked for', () => {
expect(parseTextractPriceList(usWest2, 'us-east-1', NOW)).toBe(
undefined,
);
expect(parseTextractPriceList(usWest2, 'us-west-2 ', NOW)).toBe(
undefined,
);
});
it('ignores the async text and AnalyzeDocument SKUs', () => {
const doc = westCopy();
delete doc.products[SYNC_TEXT_SKU];
expect(parseTextractPriceList(doc, 'us-west-2', NOW)).toBe(undefined);
});
it.each([
['an async api type', (a: any) => (a.apiType = 'Async')],
['another entity type', (a: any) => (a.entityType = 'Tables')],
['another service', (a: any) => (a.servicecode = 'AmazonPolly')],
[
'a promotional usage type',
(a: any) => (a.usagetype = 'USW2-promo-SyncTextPagesProcessed'),
],
[
'an async usage type',
(a: any) => (a.usagetype = 'USW2-AsyncTextPagesProcessed'),
],
[
'a disagreeing page data type',
(a: any) => (a.platopagedatatype = 'SyncTablesPagesProcessed'),
],
['an electronStatus', (a: any) => (a.electronStatus = 'Active')],
])('skips a product with %s', (_name, mutate) => {
const doc = westCopy();
mutate(doc.products[SYNC_TEXT_SKU].attributes);
expect(parseTextractPriceList(doc, 'us-west-2', NOW)).toBe(undefined);
});
it('treats an explicit zero price as unusable rather than free', () => {
const doc = westCopy();
dimensionOf(doc).pricePerUnit.USD = '0.0000000000';
expect(parseTextractPriceList(doc, 'us-west-2', NOW)).toBe(undefined);
});
it.each([
[
'a non-USD currency',
(d: any) => (d.pricePerUnit = { EUR: '0.0015' }),
],
[
'an extra currency beside USD',
(d: any) => (d.pricePerUnit.CNY = '0.01'),
],
['a non-page unit', (d: any) => (d.unit = 'Requests')],
['a zero end range', (d: any) => (d.endRange = '0')],
['a malformed end range', (d: any) => (d.endRange = '1M')],
['a missing end range', (d: any) => delete d.endRange],
[
'a scientific-notation price',
(d: any) => (d.pricePerUnit.USD = '1.5e-3'),
],
['a negative price', (d: any) => (d.pricePerUnit.USD = '-0.0015')],
['a numeric price', (d: any) => (d.pricePerUnit.USD = 0.0015)],
['a mismatched rate code', (d: any) => (d.rateCode = 'OTHER.RATE')],
['a non-empty appliesTo', (d: any) => (d.appliesTo = ['X'])],
])('rejects a first tier with %s', (_name, mutate) => {
const doc = westCopy();
mutate(dimensionOf(doc));
expect(parseTextractPriceList(doc, 'us-west-2', NOW)).toBe(undefined);
});
it.each([
['a non-page unit', (d: any) => (d.unit = 'Requests')],
[
'a non-USD currency',
(d: any) => (d.pricePerUnit = { EUR: '0.0006' }),
],
['a mismatched rate code', (d: any) => (d.rateCode = 'OTHER.RATE')],
])('rejects the term when a later tier has %s', (_name, mutate) => {
const doc = westCopy();
mutate(dimensionOf(doc, SECOND_TIER));
expect(parseTextractPriceList(doc, 'us-west-2', NOW)).toBe(undefined);
});
it('rejects a term with no first tier, or with two', () => {
const missing = westCopy();
delete termOf(missing).priceDimensions[FIRST_TIER];
expect(parseTextractPriceList(missing, 'us-west-2', NOW)).toBe(
undefined,
);
const doubled = westCopy();
dimensionOf(doubled, SECOND_TIER).beginRange = '0';
expect(parseTextractPriceList(doubled, 'us-west-2', NOW)).toBe(
undefined,
);
});
it('skips a term that has not taken effect yet', () => {
const doc = westCopy();
termOf(doc).effectiveDate = '2026-12-01T00:00:00Z';
expect(parseTextractPriceList(doc, 'us-west-2', NOW)).toBe(undefined);
});
it('uses the in-effect term when a future price change is published beside it', () => {
const doc = westCopy();
const terms = doc.terms.OnDemand[SYNC_TEXT_SKU] as Record<string, any>;
const future = structuredClone(termOf(doc));
future.offerTermCode = 'FUTURETERM';
future.effectiveDate = '2027-01-01T00:00:00Z';
future.priceDimensions = Object.fromEntries(
Object.values(future.priceDimensions as Record<string, any>).map(
(dim, i) => {
const rateCode = `${SYNC_TEXT_SKU}.FUTURETERM.TIER${i}`;
return [
rateCode,
{
...dim,
rateCode,
pricePerUnit: { USD: '0.0020000000' },
},
];
},
),
);
terms[`${SYNC_TEXT_SKU}.FUTURETERM`] = future;
expect(parseTextractPriceList(doc, 'us-west-2', NOW)).toBe(150000);
// Once both are in effect the rate is ambiguous.
expect(
parseTextractPriceList(doc, 'us-west-2', Date.parse('2027-02-01')),
).toBe(undefined);
});
it.each([
[
'an unparseable effective date',
(t: any) => (t.effectiveDate = 'soon'),
],
[
'term attributes',
(t: any) => (t.termAttributes = { LeaseContractLength: '1yr' }),
],
['a mismatched SKU', (t: any) => (t.sku = ASYNC_TEXT_SKU)],
['a mismatched offer term code', (t: any) => (t.offerTermCode = 'X')],
])('rejects a term with %s', (_name, mutate) => {
const doc = westCopy();
mutate(termOf(doc));
expect(parseTextractPriceList(doc, 'us-west-2', NOW)).toBe(undefined);
});
it('rejects two SKUs that both claim DetectDocumentText', () => {
const doc = westCopy();
const cloneSku = 'SYNCTEXTCLONE000';
const clone = structuredClone(doc.products[SYNC_TEXT_SKU]);
clone.sku = cloneSku;
doc.products[cloneSku] = clone;
const term = structuredClone(termOf(doc));
term.sku = cloneSku;
term.priceDimensions = Object.fromEntries(
Object.entries(term.priceDimensions as Record<string, any>).map(
([key, dim]) => {
const rateCode = key.replace(SYNC_TEXT_SKU, cloneSku);
return [rateCode, { ...dim, rateCode }];
},
),
);
doc.terms.OnDemand[cloneSku] = { [`${cloneSku}.JRTCKXETXF`]: term };
expect(parseTextractPriceList(doc, 'us-west-2', NOW)).toBe(undefined);
});
it('rejects the rate when the product has no on-demand term', () => {
const doc = westCopy();
delete doc.terms.OnDemand[SYNC_TEXT_SKU];
expect(parseTextractPriceList(doc, 'us-west-2', NOW)).toBe(undefined);
});
it('converts sub-microcent prices exactly', () => {
const doc = westCopy();
dimensionOf(doc).pricePerUnit.USD = '0.000000015';
expect(parseTextractPriceList(doc, 'us-west-2', NOW)).toBe(1.5);
});
it.each([
['null', null],
['another service', { ...usWest2, offerCode: 'AmazonPolly' }],
['an unknown format', { ...usWest2, formatVersion: 'v2.0' }],
['missing on-demand terms', { ...usWest2, terms: {} }],
])('throws on %s', (_name, doc) => {
expect(() => parseTextractPriceList(doc, 'us-west-2', NOW)).toThrow();
});
});
describe('isTextractPriceRegion', () => {
it('accepts plain AWS region codes only', () => {
for (const region of ['us-west-2', 'eu-central-1', 'us-gov-west-1']) {
expect(isTextractPriceRegion(region)).toBe(true);
}
for (const region of ['', 'US-WEST-2', '../us-west-2', 'us-west-2/x']) {
expect(isTextractPriceRegion(region)).toBe(false);
}
});
});
describe('fetchTextractPriceList', () => {
it('reads the region price list from the public AWS endpoint without following redirects', async () => {
const fetchMock = vi.fn(async () => Response.json(usWest2));
vi.stubGlobal('fetch', fetchMock);
const signal = new AbortController().signal;
await expect(fetchTextractPriceList('us-west-2', signal)).resolves.toBe(
150000,
);
expect(fetchMock).toHaveBeenCalledWith(
PRICE_LIST_URL('us-west-2'),
expect.objectContaining({ signal, redirect: 'error' }),
);
});
it('never requests an invalid region', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(
fetchTextractPriceList('../../evil', new AbortController().signal),
).rejects.toThrow();
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects an error status', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('denied', { status: 403 })),
);
await expect(
fetchTextractPriceList('us-west-2', new AbortController().signal),
).rejects.toThrow('403');
});
it('rejects a body over the size cap', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(' '.repeat(TEXTRACT_PRICE_LIST_MAX_BYTES + 1)),
),
);
await expect(
fetchTextractPriceList('us-west-2', new AbortController().signal),
).rejects.toThrow('size cap');
});
it('rejects a body that is not JSON', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('<html>')),
);
await expect(
fetchTextractPriceList('us-west-2', new AbortController().signal),
).rejects.toThrow();
});
it('rejects a price list with no usable rate', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => Response.json(usWest2)),
);
await expect(
fetchTextractPriceList('us-east-1', new AbortController().signal),
).rejects.toThrow('no usable');
});
});
describe('textractRates', () => {
const CURATED = 150000;
/** Serves each region's price list from `docs`, 404 for the rest. */
const stubPriceLists = (docs: Record<string, unknown>) => {
const fetchMock = vi.fn(async (url: string) => {
const region = /\/current\/([^/]+)\/index\.json$/.exec(url)?.[1];
const doc = region ? docs[region] : undefined;
return doc
? Response.json(doc)
: new Response('missing', { status: 404 });
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
};
it('prices each region from its own price list', async () => {
stubPriceLists({
'us-west-2': usWest2,
'eu-west-1': regionDoc('eu-west-1', '0.0020000000'),
});
const rates = textractRates(CURATED);
expect(await rates.rate('us-west-2')).toBe(150000);
expect(await rates.rate('eu-west-1')).toBe(200000);
expect(rates.peek('us-west-2')).toBe(150000);
expect(rates.peek('eu-west-1')).toBe(200000);
});
it('reports the curated rate for a region it has not loaded, without fetching', () => {
const fetchMock = stubPriceLists({});
expect(textractRates(CURATED).peek('us-west-2')).toBe(CURATED);
expect(fetchMock).not.toHaveBeenCalled();
});
it('shares one fetch between concurrent callers and serves it until it expires', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const fetchMock = stubPriceLists({ 'us-west-2': usWest2 });
const rates = textractRates(CURATED);
expect(
await Promise.all([
rates.rate('us-west-2'),
rates.rate('us-west-2'),
rates.rate('us-west-2'),
]),
).toEqual([150000, 150000, 150000]);
await rates.rate('us-west-2');
expect(fetchMock).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(16 * 60 * 1000);
await rates.rate('us-west-2');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('falls back to the curated rate when a region has never loaded', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
stubPriceLists({});
const rates = textractRates(CURATED);
expect(await rates.rate('us-west-2')).toBe(CURATED);
expect(rates.peek('us-west-2')).toBe(CURATED);
});
it('keeps a region on its last good rate when a refresh fails or loses the rate', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.spyOn(console, 'warn').mockImplementation(() => {});
const docs: Record<string, unknown> = {
'us-west-2': regionDoc('us-west-2', '0.0020000000'),
'eu-west-1': regionDoc('eu-west-1', '0.0030000000'),
};
stubPriceLists(docs);
const rates = textractRates(CURATED);
expect(await rates.rate('us-west-2')).toBe(200000);
expect(await rates.rate('eu-west-1')).toBe(300000);
// One region's list drops the rate, the other's starts failing.
const zeroed = regionDoc('us-west-2', '0.0000000000');
docs['us-west-2'] = zeroed;
delete docs['eu-west-1'];
vi.advanceTimersByTime(16 * 60 * 1000);
expect(await rates.rate('us-west-2')).toBe(200000);
expect(await rates.rate('eu-west-1')).toBe(300000);
expect(rates.peek('us-west-2')).toBe(200000);
});
it('returns a fixed number that a later refresh cannot change', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const docs: Record<string, unknown> = { 'us-west-2': usWest2 };
stubPriceLists(docs);
const rates = textractRates(CURATED);
const captured = await rates.rate('us-west-2');
docs['us-west-2'] = regionDoc('us-west-2', '0.0020000000');
vi.advanceTimersByTime(16 * 60 * 1000);
expect(await rates.rate('us-west-2')).toBe(200000);
expect(captured).toBe(150000);
});
it('never fetches an invalid region and bounds how many regions it tracks', async () => {
const fetchMock = stubPriceLists({
'us-west-2': usWest2,
'eu-west-1': regionDoc('eu-west-1', '0.0020000000'),
});
const rates = textractRates(CURATED, 1);
expect(await rates.rate('../evil')).toBe(CURATED);
expect(fetchMock).not.toHaveBeenCalled();
expect(await rates.rate('us-west-2')).toBe(150000);
// Past the cap a region is billed at the curated rate, unfetched.
expect(await rates.rate('eu-west-1')).toBe(CURATED);
expect(rates.peek('eu-west-1')).toBe(CURATED);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
PRICE_LIST_URL('us-west-2'),
expect.anything(),
);
});
});
@@ -0,0 +1,287 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { cachedRemoteCatalog } from '../ai-chat/utils/cachedRemoteCatalog.js';
import { readBoundedBody } from '../util/fileInput.js';
/** A region's Textract prices are tens of KB; anything far past that is not one. */
export const TEXTRACT_PRICE_LIST_MAX_BYTES = 2 * 1024 * 1024;
/**
* Most regions priced from the feed at once. Calls run in the configured region
* and the regions of the deployment's buckets, so a handful is plenty; any
* region past the cap is billed at the curated rate.
*/
export const TEXTRACT_PRICE_MAX_REGIONS = 8;
const PRICE_LIST_BASE_URL =
'https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonTextract/current';
// The region lands in the price list URL, so only plain AWS region codes pass.
const REGION_PATTERN = /^[a-z]{2}(-gov)?-[a-z]+-\d{1,2}$/;
// DetectDocumentText only: `<prefix>-SyncTextPagesProcessed`. Async and
// AnalyzeDocument variants, and prefixed promotional usage, don't match.
const USAGE_TYPE_PATTERN = /^[A-Z0-9]+-SyncTextPagesProcessed$/;
// USD per page as a plain decimal, e.g. "0.0015000000".
const USD_PATTERN = /^(\d+)(?:\.(\d+))?$/;
const RANGE_PATTERN = /^\d+$/;
const UCENTS_PER_USD_DIGITS = 8;
export const isTextractPriceRegion = (region: string): boolean =>
REGION_PATTERN.test(region);
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
/** Exact decimal shift of a USD amount into microcents. */
const usdToUcents = (usd: string): number | undefined => {
const match = USD_PATTERN.exec(usd);
if (!match) return undefined;
const fraction = match[2] ?? '';
const whole = fraction.padEnd(UCENTS_PER_USD_DIGITS, '0');
const ucents = Number(
`${match[1]}${whole.slice(0, UCENTS_PER_USD_DIGITS)}.${whole.slice(UCENTS_PER_USD_DIGITS) || '0'}`,
);
return Number.isFinite(ucents) ? ucents : undefined;
};
/** Whether a product is the regular synchronous DetectDocumentText SKU. */
const isSyncTextProduct = (
sku: string,
product: unknown,
region: string,
): boolean => {
if (!isRecord(product) || product.sku !== sku) return false;
if (product.productFamily !== 'API Request') return false;
const attrs = product.attributes;
if (!isRecord(attrs)) return false;
if (attrs.servicecode !== 'AmazonTextract' || attrs.regionCode !== region)
return false;
if (attrs.apiType !== 'Sync' || attrs.entityType !== 'Text') return false;
if ('electronStatus' in attrs) return false;
if (
typeof attrs.usagetype !== 'string' ||
!USAGE_TYPE_PATTERN.test(attrs.usagetype)
)
return false;
return (
attrs.platopagedatatype === undefined ||
attrs.platopagedatatype === 'SyncTextPagesProcessed'
);
};
/**
* The first-tier USD-per-page rate a term charges now, `null` when the term
* isn't in effect yet, or undefined when its shape isn't one we can bill from.
* Later volume tiers are checked for shape but never priced from: they depend
* on the account's monthly volume, which a single call can't know.
*/
const termFirstTierRate = (
sku: string,
termKey: string,
term: unknown,
now: number,
): number | null | undefined => {
if (!isRecord(term) || term.sku !== sku) return undefined;
if (
typeof term.offerTermCode !== 'string' ||
termKey !== `${sku}.${term.offerTermCode}`
)
return undefined;
if (
term.termAttributes !== undefined &&
(!isRecord(term.termAttributes) ||
Object.keys(term.termAttributes).length > 0)
)
return undefined;
const effective =
typeof term.effectiveDate === 'string'
? Date.parse(term.effectiveDate)
: NaN;
if (!Number.isFinite(effective)) return undefined;
if (effective > now) return null;
if (!isRecord(term.priceDimensions)) return undefined;
const firstTiers: Record<string, unknown>[] = [];
for (const [dimensionKey, dimension] of Object.entries(
term.priceDimensions,
)) {
if (!isRecord(dimension) || dimension.rateCode !== dimensionKey)
return undefined;
if (!dimensionKey.startsWith(`${termKey}.`)) return undefined;
if (dimension.unit !== 'Pages') return undefined;
const price = dimension.pricePerUnit;
if (!isRecord(price) || Object.keys(price).join() !== 'USD')
return undefined;
if (
dimension.appliesTo !== undefined &&
!(
Array.isArray(dimension.appliesTo) &&
dimension.appliesTo.length === 0
)
)
return undefined;
if (dimension.beginRange === '0') firstTiers.push(dimension);
}
if (firstTiers.length !== 1) return undefined;
const tier = firstTiers[0]!;
if (
tier.endRange !== 'Inf' &&
!(
typeof tier.endRange === 'string' &&
RANGE_PATTERN.test(tier.endRange) &&
Number(tier.endRange) > 0
)
)
return undefined;
const usd = (tier.pricePerUnit as Record<string, unknown>).USD;
if (typeof usd !== 'string') return undefined;
const ucents = usdToUcents(usd);
return ucents !== undefined && ucents > 0 ? ucents : undefined;
};
/**
* Microcents per page for DetectDocumentText in one region's AWS price list:
* the first volume tier of the single in-effect on-demand USD term on the
* single regular synchronous text SKU. Undefined when that rate is missing,
* malformed, or ambiguous. Throws when the document isn't a Textract price
* list.
*/
export const parseTextractPriceList = (
doc: unknown,
region: string,
now: number = Date.now(),
): number | undefined => {
if (
!isRecord(doc) ||
doc.offerCode !== 'AmazonTextract' ||
doc.formatVersion !== 'v1.0' ||
!isRecord(doc.products) ||
!isRecord(doc.terms) ||
!isRecord(doc.terms.OnDemand)
) {
throw new Error('Not an AWS Textract price list');
}
const onDemand = doc.terms.OnDemand;
const found: Array<number | undefined> = [];
for (const [sku, product] of Object.entries(doc.products)) {
if (!isSyncTextProduct(sku, product, region)) continue;
const terms = onDemand[sku];
const rates: Array<number | undefined> = [];
if (isRecord(terms)) {
for (const [termKey, term] of Object.entries(terms)) {
const rate = termFirstTierRate(sku, termKey, term, now);
if (rate !== null) rates.push(rate);
}
}
// A matching SKU with no usable term poisons the result rather than
// letting a sibling SKU's rate stand alone.
found.push(...(rates.length ? rates : [undefined]));
}
return found.length === 1 ? found[0] : undefined;
};
/** Fetches one region's public Textract price list and reads its rate. */
export const fetchTextractPriceList = async (
region: string,
signal: AbortSignal,
): Promise<number> => {
if (!isTextractPriceRegion(region)) {
throw new Error('Invalid AWS region for the Textract price list');
}
const response = await fetch(
`${PRICE_LIST_BASE_URL}/${region}/index.json`,
{
signal,
redirect: 'error',
headers: { accept: 'application/json' },
},
);
if (!response.ok) {
await response.body?.cancel();
throw new Error(`AWS Textract price list returned ${response.status}`);
}
const body = await readBoundedBody(
response,
TEXTRACT_PRICE_LIST_MAX_BYTES,
() => new Error('AWS Textract price list exceeds the size cap'),
);
const rate = parseTextractPriceList(
JSON.parse(body.toString('utf8')),
region,
);
if (rate === undefined) {
throw new Error(
`AWS Textract price list has no usable DetectDocumentText rate for ${region}`,
);
}
return rate;
};
export interface TextractRates {
/**
* Microcents per page in a region: its last good feed rate, else the
* curated one.
*/
rate(region: string): Promise<number>;
/** The rate {@link rate} last settled on for a region, without fetching. */
peek(region: string): number;
}
/**
* DetectDocumentText rates per region, each region's price list cached and
* refreshed on its own. A region whose feed fails or omits the rate keeps its
* last good value, or `fallback` until it has one.
*/
export const textractRates = (
fallback: number,
maxRegions: number = TEXTRACT_PRICE_MAX_REGIONS,
): TextractRates => {
const catalogs = new Map<string, () => Promise<number>>();
const latest = new Map<string, number>();
const catalogFor = (region: string) => {
const existing = catalogs.get(region);
if (existing) return existing;
if (!isTextractPriceRegion(region) || catalogs.size >= maxRegions)
return undefined;
const catalog = cachedRemoteCatalog<number>({
name: `AWS Textract ${region} price list`,
fallback,
fetch: (signal) => fetchTextractPriceList(region, signal),
});
catalogs.set(region, catalog);
return catalog;
};
return {
async rate(region) {
const catalog = catalogFor(region);
if (!catalog) return fallback;
const rate = await catalog();
latest.set(region, rate);
return rate;
},
peek: (region) => latest.get(region) ?? fallback,
};
};
@@ -522,6 +522,24 @@ describe('SpeechToTextDriver model selection and payload shape', () => {
expect(sent.timestamp_granularities).toBeUndefined();
});
it('accepts but does not forward chunking or known-speaker options', async () => {
const { actor } = await makeUser();
transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' });
const result = await withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
chunking_strategy: 'auto',
known_speaker_names: ['agent'],
}),
);
expect(result).toEqual({ text: 'x' });
const sent = transcriptionsCreateMock.mock.calls[0]![0];
expect(sent.chunking_strategy).toBeUndefined();
expect(sent.known_speaker_names).toBeUndefined();
});
it('passes extra_body through verbatim', async () => {
const { actor } = await makeUser();
transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' });
@@ -441,6 +441,31 @@ describe('XAISpeechToTextProvider request shape', () => {
expect(form.get('model')).toBeNull();
});
it('accepts but ignores legacy transcription options', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
sttResponse({ text: 'ok', duration: 1 }),
);
const result = await withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
timestamp_granularities: ['word'],
chunking_strategy: 'auto',
known_speaker_names: ['agent'],
}),
);
expect(result).toMatchObject({ text: 'ok' });
const form = (fetchSpy.mock.calls[0]![1] as RequestInit)
.body as FormData;
expect(form.get('timestamp_granularities')).toBeNull();
expect(form.get('timestamp_granularities[]')).toBeNull();
expect(form.get('chunking_strategy')).toBeNull();
expect(form.get('known_speaker_names')).toBeNull();
expect(form.get('known_speaker_names[]')).toBeNull();
});
it('forwards a pinned grok-voice-transcribe version as `model`', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
@@ -53,7 +53,6 @@ export interface ITranscribeArgs {
timestamp_granularities?: string[];
chunking_strategy?: string;
known_speaker_names?: string[];
known_speaker_references?: unknown[];
extra_body?: Record<string, unknown>;
stream?: boolean;
test_mode?: boolean;
+10 -1
View File
@@ -46,6 +46,7 @@ import { SYSTEM_ACTOR } from '../../core/actor.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import type { TTSDriver } from './TTSDriver.js';
import pollyPriceList from './providers/awsPolly/fixtures/us-west-2.json';
// ── SDK mocks ──────────────────────────────────────────────────────
//
@@ -144,7 +145,15 @@ beforeEach(() => {
openaiSpeechCreateMock.mockReset();
geminiGenerateContentMock.mockReset();
pollySendMock.mockReset();
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
// Polly loads its public price list through fetch; serve the recording.
const realFetch = globalThis.fetch;
fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation((input, init) =>
String(input).startsWith('https://pricing.us-east-1.amazonaws.com/')
? Promise.resolve(Response.json(pollyPriceList))
: realFetch(input, init),
) as MockInstance<typeof fetch>;
});
afterEach(() => {
@@ -20,13 +20,14 @@
/**
* Offline unit tests for AWSPollyTTSProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs AWSPollyTTSProvider directly against the live
* wired `MeteringService`. The AWS Polly SDK is mocked at the module
* boundary — that's the real network egress point. Covers voice
* resolution (caller-supplied, language-derived, default-per-engine
* fallback), SSML routing, request shape into SynthesizeSpeechCommand,
* engine validation, and cost reporting.
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
* constructs AWSPollyTTSProvider directly against the live wired
* `MeteringService`. The AWS Polly SDK is mocked at the module boundary —
* that's the real network egress point. Covers voice resolution
* (caller-supplied, language-derived, default-per-engine fallback), SSML
* routing, request shape into SynthesizeSpeechCommand, engine validation, and
* cost reporting. The public AWS price list is served from recorded fixtures
* through a stubbed global `fetch`.
*/
import { Readable } from 'node:stream';
@@ -52,6 +53,8 @@ import {
} from '../../../integrationTestUtil.js';
import { AWSPollyTTSProvider } from './AWSPollyTTSProvider.js';
import { AWS_POLLY_COSTS } from './costs.js';
import usEast1PriceList from './fixtures/us-east-1.json';
import usWest2PriceList from './fixtures/us-west-2.json';
// ── AWS Polly SDK mock ──────────────────────────────────────────────
@@ -80,6 +83,7 @@ vi.mock('@aws-sdk/client-polly', async () => {
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
let priceFetch: MockInstance<typeof fetch>;
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
@@ -145,22 +149,46 @@ const sendDispatch = (
AudioStream: Readable.from([Buffer.from('synthesized-audio')]),
}),
) =>
pollySendMock.mockImplementation((cmd: { constructor: { name: string } }) => {
if (cmd.constructor.name === 'DescribeVoicesCommand') {
return Promise.resolve(onDescribe());
}
return Promise.resolve(onSynthesize());
});
pollySendMock.mockImplementation(
(cmd: { constructor: { name: string } }) => {
if (cmd.constructor.name === 'DescribeVoicesCommand') {
return Promise.resolve(onDescribe());
}
return Promise.resolve(onSynthesize());
},
);
const PRICE_LISTS: Record<string, unknown> = {
'us-west-2': usWest2PriceList,
'us-east-1': usEast1PriceList,
};
/** Serves the recorded price list for whichever region's URL is requested. */
const servePriceLists =
(lists: Record<string, unknown> = PRICE_LISTS) =>
async (url: string | URL | Request) => {
const region = /\/current\/([^/]+)\/index\.json$/.exec(
String(url),
)?.[1];
const list = region && lists[region];
return list
? Response.json(list)
: new Response('not found', { status: 404 });
};
beforeEach(() => {
pollySendMock.mockReset();
pollyCtor.mockReset();
priceFetch = vi.fn(servePriceLists());
vi.stubGlobal('fetch', priceFetch);
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});
// ── Client construction ─────────────────────────────────────────────
@@ -495,3 +523,248 @@ describe('AWSPollyTTSProvider.synthesize error paths', () => {
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
// ── Published price list ────────────────────────────────────────────
const NEURAL_SKU = '9NHBTCP8TY24SED9';
/** The us-west-2 price list with neural repriced to `usd` per character. */
const westWithNeuralAt = (usd: string) => {
const list = structuredClone(usWest2PriceList) as Record<string, any>;
const term = Object.values(list.terms.OnDemand[NEURAL_SKU])[0] as any;
(Object.values(term.priceDimensions)[0] as any).pricePerUnit.USD = usd;
return list;
};
const synthesizeAndDrain = async (
provider: AWSPollyTTSProvider,
args: { text: string; engine: string },
) => {
await readStreamResult(
await withTestActor(() =>
provider.synthesize({ ...args, voice: 'Joanna' }),
),
);
};
describe('AWSPollyTTSProvider published pricing', () => {
it('prices engines from the synthesis region and keeps curated rates the region omits', async () => {
priceFetch.mockImplementation(
servePriceLists({ 'us-west-2': westWithNeuralAt('0.0000170000') }),
);
const provider = makeProvider();
const engines = await provider.listEngines();
const price = (id: string) =>
engines.find((e) => e.id === id)?.pricing_per_million_chars;
expect(price('neural')).toBe(17);
expect(price('standard')).toBe(4);
expect(price('generative')).toBe(30);
// us-west-2 publishes no Long-Form SKU.
expect(price('long-form')).toBe(AWS_POLLY_COSTS['long-form'] / 100);
expect(priceFetch).toHaveBeenCalledTimes(1);
expect(String(priceFetch.mock.calls[0]![0])).toBe(
'https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonPolly/current/us-west-2/index.json',
);
});
it('reads the configured region, which is also where synthesis goes', async () => {
sendDispatch();
const provider = makeProvider('us-east-1');
await synthesizeAndDrain(provider, {
text: 'chapter one',
engine: 'long-form',
});
expect(String(priceFetch.mock.calls[0]![0])).toContain(
'/current/us-east-1/index.json',
);
expect(pollyCtor).toHaveBeenCalledWith(
expect.objectContaining({ region: 'us-east-1' }),
);
const [, usageType, , cost] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('aws-polly:long-form:character');
expect(cost).toBe(10000 * 'chapter one'.length);
});
it('holds and charges each engine at its own published rate', async () => {
priceFetch.mockImplementation(
servePriceLists({ 'us-west-2': westWithNeuralAt('0.0000170000') }),
);
sendDispatch();
const provider = makeProvider();
await synthesizeAndDrain(provider, { text: 'hello', engine: 'neural' });
await synthesizeAndDrain(provider, {
text: 'hello',
engine: 'generative',
});
expect(hasCreditsSpy.mock.calls.map((c) => c[1])).toEqual([
1700 * 5,
3000 * 5,
]);
expect(
incrementUsageSpy.mock.calls.map(([, type, count, cost]) => [
type,
count,
cost,
]),
).toEqual([
['aws-polly:neural:character', 5, 1700 * 5],
['aws-polly:generative:character', 5, 3000 * 5],
]);
});
it('bills a request at the rate it was quoted even if prices refresh mid-synthesis', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
priceFetch.mockImplementation(
servePriceLists({ 'us-west-2': westWithNeuralAt('0.0000170000') }),
);
const provider = makeProvider();
let refreshedNeural: number | undefined;
sendDispatch(undefined, () => {
// The cached price list expires and AWS reprices while audio streams.
vi.setSystemTime(Date.now() + 60 * 60 * 1000);
priceFetch.mockImplementation(
servePriceLists({
'us-west-2': westWithNeuralAt('0.0000190000'),
}),
);
return {
AudioStream: Readable.from([Buffer.from('synthesized-audio')]),
};
});
const result = await withTestActor(() =>
provider.synthesize({
text: 'hello',
engine: 'neural',
voice: 'Joanna',
}),
);
refreshedNeural = (await provider.listEngines()).find(
(e) => e.id === 'neural',
)?.pricing_per_million_chars;
await readStreamResult(result);
expect(refreshedNeural).toBe(19);
expect(hasCreditsSpy.mock.calls[0]![1]).toBe(1700 * 5);
expect(incrementUsageSpy.mock.calls[0]![3]).toBe(1700 * 5);
});
it('shares one price list fetch between concurrent requests', async () => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
priceFetch.mockImplementation(async (url) => {
await gate;
return servePriceLists()(url);
});
sendDispatch();
const provider = makeProvider();
const pending = Promise.all([
provider.listEngines(),
synthesizeAndDrain(provider, { text: 'a', engine: 'neural' }),
synthesizeAndDrain(provider, { text: 'b', engine: 'standard' }),
]);
release();
await pending;
expect(priceFetch).toHaveBeenCalledTimes(1);
});
it('uses curated rates when the price list cannot be fetched', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
priceFetch.mockRejectedValue(new Error('network down'));
sendDispatch();
const provider = makeProvider();
await synthesizeAndDrain(provider, { text: 'hello', engine: 'neural' });
expect(incrementUsageSpy.mock.calls[0]![3]).toBe(
AWS_POLLY_COSTS.neural * 5,
);
expect(provider.getReportedCosts()).toEqual(
Object.entries(AWS_POLLY_COSTS).map(([engine, ucentsPerUnit]) => ({
usageType: `aws-polly:${engine}:character`,
ucentsPerUnit,
unit: 'character',
source: 'driver:aiTts/aws-polly',
})),
);
});
it('keeps the last published rate when a refresh fails or omits an engine', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
vi.spyOn(console, 'warn').mockImplementation(() => {});
priceFetch.mockImplementation(
servePriceLists({ 'us-west-2': westWithNeuralAt('0.0000170000') }),
);
const provider = makeProvider();
const neuralPrice = async () =>
(await provider.listEngines()).find((e) => e.id === 'neural')
?.pricing_per_million_chars;
expect(await neuralPrice()).toBe(17);
vi.setSystemTime(Date.now() + 60 * 60 * 1000);
priceFetch.mockImplementation(
servePriceLists({ 'us-west-2': { offerCode: 'Malformed' } }),
);
expect(await neuralPrice()).toBe(17);
// The neural SKU's term vanishes from an otherwise valid list.
vi.setSystemTime(Date.now() + 60 * 60 * 1000);
const withoutNeural = structuredClone(usWest2PriceList) as any;
delete withoutNeural.terms.OnDemand[NEURAL_SKU];
priceFetch.mockImplementation(
servePriceLists({ 'us-west-2': withoutNeural }),
);
expect(await neuralPrice()).toBe(17);
expect(priceFetch).toHaveBeenCalledTimes(3);
});
it('reports the rates it last loaded as its per-character costs', async () => {
priceFetch.mockImplementation(
servePriceLists({ 'us-west-2': westWithNeuralAt('0.0000170000') }),
);
const provider = makeProvider();
// Nothing has been loaded yet, so the curated table is what bills.
expect(provider.getReportedCosts()).toContainEqual(
expect.objectContaining({
usageType: 'aws-polly:neural:character',
ucentsPerUnit: AWS_POLLY_COSTS.neural,
}),
);
await provider.listEngines();
expect(provider.getReportedCosts()).toContainEqual(
expect.objectContaining({
usageType: 'aws-polly:neural:character',
ucentsPerUnit: 1700,
}),
);
});
it('never fetches prices for a region that is not a plain AWS region code', async () => {
const provider = makeProvider('us-west-2/../../x');
const engines = await provider.listEngines();
expect(priceFetch).not.toHaveBeenCalled();
expect(
engines.find((e) => e.id === 'neural')?.pricing_per_million_chars,
).toBe(AWS_POLLY_COSTS.neural / 100);
});
it('does not load prices for test_mode or rejected requests', async () => {
const provider = makeProvider();
await withTestActor(() =>
provider.synthesize({ text: 'hi', test_mode: true }),
);
await expect(
withTestActor(() =>
provider.synthesize({ text: 'hi', engine: 'fake-engine' }),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(priceFetch).not.toHaveBeenCalled();
});
});
@@ -36,8 +36,14 @@ import type {
ISynthesizeArgs,
ITTSProvider,
} from '../../types.js';
import { cachedRemoteCatalog } from '../../../ai-chat/utils/cachedRemoteCatalog.js';
import { characterCostReport, meterPerCharacter } from '../common.js';
import { AWS_POLLY_COSTS } from './costs.js';
import {
fetchPollyPriceList,
isPollyPriceRegion,
type PollyRates,
} from './pricing.js';
const VALID_ENGINES = ['standard', 'neural', 'long-form', 'generative'];
@@ -66,6 +72,14 @@ export class AWSPollyTTSProvider implements ITTSProvider {
private voicesCache: { data: PollyVoicesResponse; expires: number } | null =
null;
/** The region every synthesis call goes to, and so the one it's priced in. */
private readonly region: string;
/** The rates the latest price-list load produced, for the sync cost report. */
private currentRates: PollyRates = AWS_POLLY_COSTS;
private readonly rates: () => Promise<PollyRates>;
constructor(
private readonly meteringService: AiMeteringService,
private readonly config: {
@@ -73,11 +87,28 @@ export class AWSPollyTTSProvider implements ITTSProvider {
secret_key: string;
region?: string;
},
) {}
) {
this.region = config.region ?? 'us-west-2';
const region = this.region;
// Rates the feed omits or can't vouch for keep their last known value.
this.rates = isPollyPriceRegion(region)
? cachedRemoteCatalog<PollyRates>({
name: `AWS Polly ${region} price list`,
fallback: AWS_POLLY_COSTS,
fetch: async (signal, previous) => {
this.currentRates = {
...(previous ?? AWS_POLLY_COSTS),
...(await fetchPollyPriceList(region, signal)),
};
return this.currentRates;
},
})
: async () => AWS_POLLY_COSTS;
}
private getClient(region?: string): PollyClient {
const cfg = this.config;
const resolvedRegion = region ?? cfg.region ?? 'us-west-2';
const resolvedRegion = region ?? this.region;
if (this.clients[resolvedRegion]) {
return this.clients[resolvedRegion];
@@ -196,16 +227,17 @@ export class AWSPollyTTSProvider implements ITTSProvider {
}
async listEngines(): Promise<ITTSEngine[]> {
const rates = await this.rates();
return VALID_ENGINES.map((engine) => ({
id: engine,
name: engine.charAt(0).toUpperCase() + engine.slice(1),
provider: 'aws-polly',
pricing_per_million_chars: AWS_POLLY_COSTS[engine] / 100, // microcents to dollars
pricing_per_million_chars: rates[engine] / 100, // microcents to dollars
}));
}
getReportedCosts(): Record<string, unknown>[] {
return characterCostReport('aws-polly', AWS_POLLY_COSTS);
return characterCostReport('aws-polly', this.currentRates);
}
async synthesize(
@@ -242,11 +274,15 @@ export class AWSPollyTTSProvider implements ITTSProvider {
});
}
// One rate per request: the hold and the charge both use it even if
// the price list refreshes mid-synthesis.
const ucentsPerChar = (await this.rates())[engine];
return meterPerCharacter(
this.meteringService,
Context.get('actor')!,
`aws-polly:${engine}:character`,
AWS_POLLY_COSTS[engine] ?? 0,
ucentsPerChar,
text,
async () => {
// Resolve voice
@@ -0,0 +1,190 @@
{
"formatVersion": "v1.0",
"disclaimer": "This pricing list is for informational purposes only. All prices are subject to the additional terms included in the pricing pages on http://aws.amazon.com. All Free Tier prices are also subject to the terms included at https://aws.amazon.com/free/",
"offerCode": "AmazonPolly",
"version": "20260911124459",
"publicationDate": "2026-09-11T12:44:59Z",
"products": {
"MRCD29M27PERFRNX": {
"sku": "MRCD29M27PERFRNX",
"productFamily": "AmazonPolly",
"attributes": {
"servicecode": "AmazonPolly",
"location": "US East (N. Virginia)",
"locationType": "AWS Region",
"usagetype": "USE1-SynthesizeSpeechGenerative-Characters",
"operation": "",
"engine": "Generative",
"regionCode": "us-east-1",
"servicename": "Amazon Polly"
}
},
"RCV3WGXXBMMZTDSA": {
"sku": "RCV3WGXXBMMZTDSA",
"productFamily": "AmazonPolly",
"attributes": {
"servicecode": "AmazonPolly",
"location": "US East (N. Virginia)",
"locationType": "AWS Region",
"usagetype": "USE1-SynthesizeSpeechNeural-Characters",
"operation": "",
"engine": "Neural",
"regionCode": "us-east-1",
"servicename": "Amazon Polly"
}
},
"KQ7PUQCHEBQR9339": {
"sku": "KQ7PUQCHEBQR9339",
"productFamily": "Synthesize Speech",
"attributes": {
"servicecode": "AmazonPolly",
"location": "US East (N. Virginia)",
"locationType": "AWS Region",
"usagetype": "USE1-SynthesizeSpeechLongForm-Characters",
"operation": "",
"engine": "Long-Form",
"regionCode": "us-east-1",
"servicename": "Amazon Polly"
}
},
"ER373ARSWJWECU6E": {
"sku": "ER373ARSWJWECU6E",
"productFamily": "Synthesize Speech",
"attributes": {
"servicecode": "AmazonPolly",
"location": "US East (N. Virginia)",
"locationType": "AWS Region",
"usagetype": "USE1-SynthesizeSpeech-Characters",
"operation": "",
"engine": "Standard",
"regionCode": "us-east-1",
"servicename": "Amazon Polly"
}
},
"UY2T3RGQCZBTQBUV": {
"sku": "UY2T3RGQCZBTQBUV",
"productFamily": "AmazonPolly",
"attributes": {
"servicecode": "AmazonPolly",
"location": "US East (N. Virginia)",
"locationType": "AWS Region",
"usagetype": "USE1-ai-SynthesizeSpeechGenerative-Characters",
"operation": "",
"electronStatus": "ai-",
"engine": "Generative",
"regionCode": "us-east-1",
"servicename": "Amazon Polly"
}
}
},
"terms": {
"OnDemand": {
"UY2T3RGQCZBTQBUV": {
"UY2T3RGQCZBTQBUV.JRTCKXETXF": {
"offerTermCode": "JRTCKXETXF",
"sku": "UY2T3RGQCZBTQBUV",
"effectiveDate": "2026-05-01T00:00:00Z",
"priceDimensions": {
"UY2T3RGQCZBTQBUV.JRTCKXETXF.6YS6EN2CT7": {
"rateCode": "UY2T3RGQCZBTQBUV.JRTCKXETXF.6YS6EN2CT7",
"description": "$0.00 per Character for SynthesizeSpeechGenerative in US East (N. Virginia)",
"beginRange": "0",
"endRange": "Inf",
"unit": "Characters",
"pricePerUnit": {
"USD": "0.0000000000"
},
"appliesTo": []
}
},
"termAttributes": {}
}
},
"KQ7PUQCHEBQR9339": {
"KQ7PUQCHEBQR9339.JRTCKXETXF": {
"offerTermCode": "JRTCKXETXF",
"sku": "KQ7PUQCHEBQR9339",
"effectiveDate": "2026-05-01T00:00:00Z",
"priceDimensions": {
"KQ7PUQCHEBQR9339.JRTCKXETXF.6YS6EN2CT7": {
"rateCode": "KQ7PUQCHEBQR9339.JRTCKXETXF.6YS6EN2CT7",
"description": "$100 per million characters for SynthesizeSpeechLongForm-Characters in US East (N. Virginia)",
"beginRange": "0",
"endRange": "Inf",
"unit": "Characters",
"pricePerUnit": {
"USD": "0.0001000000"
},
"appliesTo": []
}
},
"termAttributes": {}
}
},
"RCV3WGXXBMMZTDSA": {
"RCV3WGXXBMMZTDSA.JRTCKXETXF": {
"offerTermCode": "JRTCKXETXF",
"sku": "RCV3WGXXBMMZTDSA",
"effectiveDate": "2026-05-01T00:00:00Z",
"priceDimensions": {
"RCV3WGXXBMMZTDSA.JRTCKXETXF.6YS6EN2CT7": {
"rateCode": "RCV3WGXXBMMZTDSA.JRTCKXETXF.6YS6EN2CT7",
"description": "$16 per million characters for SynthesizeSpeechNeural-Characters in US East (N. Virginia)",
"beginRange": "0",
"endRange": "Inf",
"unit": "Characters",
"pricePerUnit": {
"USD": "0.0000160000"
},
"appliesTo": []
}
},
"termAttributes": {}
}
},
"MRCD29M27PERFRNX": {
"MRCD29M27PERFRNX.JRTCKXETXF": {
"offerTermCode": "JRTCKXETXF",
"sku": "MRCD29M27PERFRNX",
"effectiveDate": "2026-05-01T00:00:00Z",
"priceDimensions": {
"MRCD29M27PERFRNX.JRTCKXETXF.6YS6EN2CT7": {
"rateCode": "MRCD29M27PERFRNX.JRTCKXETXF.6YS6EN2CT7",
"description": "$30 per million characters for SynthesizeSpeechGenerative-Characters in US East (N. Virginia)",
"beginRange": "0",
"endRange": "Inf",
"unit": "Characters",
"pricePerUnit": {
"USD": "0.0000300000"
},
"appliesTo": []
}
},
"termAttributes": {}
}
},
"ER373ARSWJWECU6E": {
"ER373ARSWJWECU6E.JRTCKXETXF": {
"offerTermCode": "JRTCKXETXF",
"sku": "ER373ARSWJWECU6E",
"effectiveDate": "2026-05-01T00:00:00Z",
"priceDimensions": {
"ER373ARSWJWECU6E.JRTCKXETXF.6YS6EN2CT7": {
"rateCode": "ER373ARSWJWECU6E.JRTCKXETXF.6YS6EN2CT7",
"description": "$4 per million characters",
"beginRange": "0",
"endRange": "Inf",
"unit": "Characters",
"pricePerUnit": {
"USD": "0.0000040000"
},
"appliesTo": []
}
},
"termAttributes": {}
}
}
}
},
"attributesList": {}
}
@@ -0,0 +1,155 @@
{
"formatVersion": "v1.0",
"disclaimer": "This pricing list is for informational purposes only. All prices are subject to the additional terms included in the pricing pages on http://aws.amazon.com. All Free Tier prices are also subject to the terms included at https://aws.amazon.com/free/",
"offerCode": "AmazonPolly",
"version": "20260911124459",
"publicationDate": "2026-09-11T12:44:59Z",
"products": {
"VZ4D3PNAPQ5WMXJ8": {
"sku": "VZ4D3PNAPQ5WMXJ8",
"productFamily": "AmazonPolly",
"attributes": {
"servicecode": "AmazonPolly",
"location": "US West (Oregon)",
"locationType": "AWS Region",
"usagetype": "USW2-SynthesizeSpeechGenerative-Characters",
"operation": "",
"engine": "Generative",
"regionCode": "us-west-2",
"servicename": "Amazon Polly"
}
},
"72CUXERTVPEGPMWC": {
"sku": "72CUXERTVPEGPMWC",
"productFamily": "AmazonPolly",
"attributes": {
"servicecode": "AmazonPolly",
"location": "US West (Oregon)",
"locationType": "AWS Region",
"usagetype": "USW2-ai-SynthesizeSpeechGenerative-Characters",
"operation": "",
"electronStatus": "ai-",
"engine": "Generative",
"regionCode": "us-west-2",
"servicename": "Amazon Polly"
}
},
"9NHBTCP8TY24SED9": {
"sku": "9NHBTCP8TY24SED9",
"productFamily": "AmazonPolly",
"attributes": {
"servicecode": "AmazonPolly",
"location": "US West (Oregon)",
"locationType": "AWS Region",
"usagetype": "USW2-SynthesizeSpeechNeural-Characters",
"operation": "",
"engine": "Neural",
"regionCode": "us-west-2",
"servicename": "Amazon Polly"
}
},
"XZARWA3E77CTTW3A": {
"sku": "XZARWA3E77CTTW3A",
"productFamily": "Synthesize Speech",
"attributes": {
"servicecode": "AmazonPolly",
"location": "US West (Oregon)",
"locationType": "AWS Region",
"usagetype": "USW2-SynthesizeSpeech-Characters",
"operation": "",
"engine": "Standard",
"regionCode": "us-west-2",
"servicename": "Amazon Polly"
}
}
},
"terms": {
"OnDemand": {
"9NHBTCP8TY24SED9": {
"9NHBTCP8TY24SED9.JRTCKXETXF": {
"offerTermCode": "JRTCKXETXF",
"sku": "9NHBTCP8TY24SED9",
"effectiveDate": "2026-05-01T00:00:00Z",
"priceDimensions": {
"9NHBTCP8TY24SED9.JRTCKXETXF.6YS6EN2CT7": {
"rateCode": "9NHBTCP8TY24SED9.JRTCKXETXF.6YS6EN2CT7",
"description": "$16 per million characters for SynthesizeSpeechNeural-Characters in US West (Oregon)",
"beginRange": "0",
"endRange": "Inf",
"unit": "Characters",
"pricePerUnit": {
"USD": "0.0000160000"
},
"appliesTo": []
}
},
"termAttributes": {}
}
},
"VZ4D3PNAPQ5WMXJ8": {
"VZ4D3PNAPQ5WMXJ8.JRTCKXETXF": {
"offerTermCode": "JRTCKXETXF",
"sku": "VZ4D3PNAPQ5WMXJ8",
"effectiveDate": "2026-05-01T00:00:00Z",
"priceDimensions": {
"VZ4D3PNAPQ5WMXJ8.JRTCKXETXF.6YS6EN2CT7": {
"rateCode": "VZ4D3PNAPQ5WMXJ8.JRTCKXETXF.6YS6EN2CT7",
"description": "$30.00 per million Characters for Synthesize Speech Generative in US West (Oregon)",
"beginRange": "0",
"endRange": "Inf",
"unit": "Characters",
"pricePerUnit": {
"USD": "0.0000300000"
},
"appliesTo": []
}
},
"termAttributes": {}
}
},
"XZARWA3E77CTTW3A": {
"XZARWA3E77CTTW3A.JRTCKXETXF": {
"offerTermCode": "JRTCKXETXF",
"sku": "XZARWA3E77CTTW3A",
"effectiveDate": "2026-05-01T00:00:00Z",
"priceDimensions": {
"XZARWA3E77CTTW3A.JRTCKXETXF.6YS6EN2CT7": {
"rateCode": "XZARWA3E77CTTW3A.JRTCKXETXF.6YS6EN2CT7",
"description": "$4 per million characters",
"beginRange": "0",
"endRange": "Inf",
"unit": "Characters",
"pricePerUnit": {
"USD": "0.0000040000"
},
"appliesTo": []
}
},
"termAttributes": {}
}
},
"72CUXERTVPEGPMWC": {
"72CUXERTVPEGPMWC.JRTCKXETXF": {
"offerTermCode": "JRTCKXETXF",
"sku": "72CUXERTVPEGPMWC",
"effectiveDate": "2026-05-01T00:00:00Z",
"priceDimensions": {
"72CUXERTVPEGPMWC.JRTCKXETXF.6YS6EN2CT7": {
"rateCode": "72CUXERTVPEGPMWC.JRTCKXETXF.6YS6EN2CT7",
"description": "$0.00 per Character for SynthesizeSpeechGenerative in US West (Oregon)",
"beginRange": "0",
"endRange": "Inf",
"unit": "Characters",
"pricePerUnit": {
"USD": "0.0000000000"
},
"appliesTo": []
}
},
"termAttributes": {}
}
}
}
},
"attributesList": {}
}
@@ -0,0 +1,339 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import usEast1 from './fixtures/us-east-1.json';
import usWest2 from './fixtures/us-west-2.json';
import {
fetchPollyPriceList,
isPollyPriceRegion,
parsePollyPriceList,
POLLY_PRICE_LIST_MAX_BYTES,
} from './pricing.js';
// The fixtures are AWS's public price lists for the region, trimmed only of
// the attribute index; all their terms take effect on 2026-05-01.
const NOW = Date.parse('2026-10-13T00:00:00Z');
const STANDARD_SKU = 'XZARWA3E77CTTW3A';
const NEURAL_SKU = '9NHBTCP8TY24SED9';
const GENERATIVE_SKU = 'VZ4D3PNAPQ5WMXJ8';
const PROMO_GENERATIVE_SKU = '72CUXERTVPEGPMWC';
// Tests reshape the fixture freely, so it's handled as untyped JSON.
type Doc = Record<string, any>;
const westCopy = (): Doc => structuredClone(usWest2);
/** The one price dimension of a SKU's one on-demand term. */
const dimensionOf = (doc: Doc, sku: string) => {
const term = Object.values(doc.terms.OnDemand[sku] as object)[0];
return Object.values(term.priceDimensions as object)[0];
};
const termOf = (doc: Doc, sku: string) =>
Object.values(doc.terms.OnDemand[sku] as object)[0];
afterEach(() => {
vi.unstubAllGlobals();
});
describe('parsePollyPriceList on the published price lists', () => {
it('converts USD per character into exact microcents per engine', () => {
expect(parsePollyPriceList(usWest2, 'us-west-2', NOW)).toEqual({
standard: 400,
neural: 1600,
generative: 3000,
});
expect(parsePollyPriceList(usEast1, 'us-east-1', NOW)).toEqual({
standard: 400,
neural: 1600,
'long-form': 10000,
generative: 3000,
});
});
it('ignores the zero-priced promotional generative offer', () => {
// Without the regular SKU only the `ai-` zero offer is left.
const doc = westCopy();
delete (doc.products as Record<string, unknown>)[GENERATIVE_SKU];
expect(parsePollyPriceList(doc, 'us-west-2', NOW)).not.toHaveProperty(
'generative',
);
});
it('rejects a regular-looking SKU that carries an electronStatus', () => {
const doc = westCopy();
const promo = doc.products[PROMO_GENERATIVE_SKU];
promo.attributes.usagetype =
'USW2-SynthesizeSpeechGenerative-Characters';
dimensionOf(doc, PROMO_GENERATIVE_SKU).pricePerUnit.USD =
'0.0000100000';
// The genuine SKU still prices generative; the flagged one is skipped.
expect(parsePollyPriceList(doc, 'us-west-2', NOW).generative).toBe(
3000,
);
});
it('treats a regular SKU priced at zero as unusable rather than free', () => {
const doc = westCopy();
dimensionOf(doc, NEURAL_SKU).pricePerUnit.USD = '0.0000000000';
expect(parsePollyPriceList(doc, 'us-west-2', NOW)).not.toHaveProperty(
'neural',
);
});
it('only reads products for the exact region asked for', () => {
expect(parsePollyPriceList(usWest2, 'us-east-1', NOW)).toEqual({});
expect(parsePollyPriceList(usWest2, 'us-west-2 ', NOW)).toEqual({});
const doc = westCopy();
doc.products[NEURAL_SKU].attributes.regionCode = 'us-west-1';
expect(parsePollyPriceList(doc, 'us-west-2', NOW)).not.toHaveProperty(
'neural',
);
});
it('requires the usage type and engine attribute to agree', () => {
const doc = westCopy();
doc.products[NEURAL_SKU].attributes.engine = 'Generative';
const rates = parsePollyPriceList(doc, 'us-west-2', NOW);
expect(rates).not.toHaveProperty('neural');
expect(rates.generative).toBe(3000);
});
it('skips other Polly usage families such as speech marks', () => {
const doc = westCopy();
doc.products[NEURAL_SKU].attributes.usagetype =
'USW2-SynthesizeSpeechNeuralMarks-Characters';
expect(parsePollyPriceList(doc, 'us-west-2', NOW)).not.toHaveProperty(
'neural',
);
});
it('skips a term that has not taken effect yet', () => {
const doc = westCopy();
termOf(doc, NEURAL_SKU).effectiveDate = '2026-12-01T00:00:00Z';
expect(parsePollyPriceList(doc, 'us-west-2', NOW)).not.toHaveProperty(
'neural',
);
});
it('uses the in-effect term when a future price change is published beside it', () => {
const doc = westCopy();
const terms = doc.terms.OnDemand[NEURAL_SKU] as Record<string, any>;
const future = structuredClone(Object.values(terms)[0]);
future.offerTermCode = 'FUTURETERM';
future.effectiveDate = '2027-01-01T00:00:00Z';
const [oldKey, dim] = Object.entries(future.priceDimensions)[0] as [
string,
any,
];
delete future.priceDimensions[oldKey];
dim.rateCode = `${NEURAL_SKU}.FUTURETERM.6YS6EN2CT7`;
dim.pricePerUnit.USD = '0.0000200000';
future.priceDimensions[dim.rateCode] = dim;
terms[`${NEURAL_SKU}.FUTURETERM`] = future;
expect(parsePollyPriceList(doc, 'us-west-2', NOW).neural).toBe(1600);
expect(
parsePollyPriceList(doc, 'us-west-2', Date.parse('2027-02-01')),
).not.toHaveProperty('neural');
});
it.each([
[
'a non-USD currency',
(d: any) => (d.pricePerUnit = { EUR: '0.000016' }),
],
[
'an extra currency beside USD',
(d: any) => (d.pricePerUnit.CNY = '0.0001'),
],
['a non-character unit', (d: any) => (d.unit = 'Requests')],
[
'a tier that starts above zero',
(d: any) => (d.beginRange = '1000000'),
],
['a capped tier', (d: any) => (d.endRange = '1000000')],
[
'a scientific-notation price',
(d: any) => (d.pricePerUnit.USD = '1.6e-5'),
],
['a negative price', (d: any) => (d.pricePerUnit.USD = '-0.000016')],
['a numeric price', (d: any) => (d.pricePerUnit.USD = 0.000016)],
['a mismatched rate code', (d: any) => (d.rateCode = 'OTHER.RATE')],
['a non-empty appliesTo', (d: any) => (d.appliesTo = ['X'])],
])('falls back on %s', (_name, mutate) => {
const doc = westCopy();
mutate(dimensionOf(doc, NEURAL_SKU));
const rates = parsePollyPriceList(doc, 'us-west-2', NOW);
expect(rates).not.toHaveProperty('neural');
expect(rates.standard).toBe(400);
});
it.each([
[
'an unparseable effective date',
(t: any) => (t.effectiveDate = 'soon'),
],
[
'term attributes',
(t: any) => (t.termAttributes = { LeaseContractLength: '1yr' }),
],
['a mismatched SKU', (t: any) => (t.sku = STANDARD_SKU)],
])('falls back on a term with %s', (_name, mutate) => {
const doc = westCopy();
mutate(termOf(doc, NEURAL_SKU));
expect(parsePollyPriceList(doc, 'us-west-2', NOW)).not.toHaveProperty(
'neural',
);
});
it('falls back when a term prices more than one tier', () => {
const doc = westCopy();
const term = termOf(doc, NEURAL_SKU);
const first = dimensionOf(doc, NEURAL_SKU);
first.endRange = '1000000';
const second = { ...first, beginRange: '1000000', endRange: 'Inf' };
second.rateCode = `${NEURAL_SKU}.JRTCKXETXF.SECONDTIER`;
second.pricePerUnit = { USD: '0.0000080000' };
term.priceDimensions[second.rateCode] = second;
expect(parsePollyPriceList(doc, 'us-west-2', NOW)).not.toHaveProperty(
'neural',
);
});
it('falls back when two SKUs price the same engine', () => {
const doc = westCopy();
const clone = structuredClone(doc.products[NEURAL_SKU]);
clone.sku = 'NEURALCLONE00000';
doc.products[clone.sku] = clone;
const term = structuredClone(termOf(doc, NEURAL_SKU));
term.sku = clone.sku;
const dim = Object.values(term.priceDimensions)[0] as any;
term.priceDimensions = {
[`${clone.sku}.JRTCKXETXF.6YS6EN2CT7`]: {
...dim,
rateCode: `${clone.sku}.JRTCKXETXF.6YS6EN2CT7`,
pricePerUnit: { USD: '0.0000170000' },
},
};
doc.terms.OnDemand[clone.sku] = {
[`${clone.sku}.JRTCKXETXF`]: term,
};
expect(parsePollyPriceList(doc, 'us-west-2', NOW)).not.toHaveProperty(
'neural',
);
});
it('falls back when a product has no on-demand term', () => {
const doc = westCopy();
delete doc.terms.OnDemand[NEURAL_SKU];
expect(parsePollyPriceList(doc, 'us-west-2', NOW)).not.toHaveProperty(
'neural',
);
});
it('converts sub-microcent prices exactly', () => {
const doc = westCopy();
dimensionOf(doc, NEURAL_SKU).pricePerUnit.USD = '0.000016125';
expect(parsePollyPriceList(doc, 'us-west-2', NOW).neural).toBe(1612.5);
});
it.each([
['null', null],
['another service', { ...usWest2, offerCode: 'AmazonTextract' }],
['an unknown format', { ...usWest2, formatVersion: 'v2.0' }],
['missing on-demand terms', { ...usWest2, terms: {} }],
])('throws on %s', (_name, doc) => {
expect(() => parsePollyPriceList(doc, 'us-west-2', NOW)).toThrow();
});
});
describe('isPollyPriceRegion', () => {
it('accepts plain AWS region codes only', () => {
for (const region of ['us-west-2', 'ap-southeast-5', 'us-gov-west-1']) {
expect(isPollyPriceRegion(region)).toBe(true);
}
for (const region of ['', 'US-WEST-2', '../us-west-2', 'us-west-2/x']) {
expect(isPollyPriceRegion(region)).toBe(false);
}
});
});
describe('fetchPollyPriceList', () => {
it('reads the region price list from the public AWS endpoint', async () => {
const fetchMock = vi.fn(async () => Response.json(usWest2));
vi.stubGlobal('fetch', fetchMock);
const signal = new AbortController().signal;
await expect(fetchPollyPriceList('us-west-2', signal)).resolves.toEqual(
{
standard: 400,
neural: 1600,
generative: 3000,
},
);
expect(fetchMock).toHaveBeenCalledWith(
'https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonPolly/current/us-west-2/index.json',
expect.objectContaining({ signal }),
);
});
it('never requests an invalid region', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
await expect(
fetchPollyPriceList('../../evil', new AbortController().signal),
).rejects.toThrow();
expect(fetchMock).not.toHaveBeenCalled();
});
it('rejects an error status', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('denied', { status: 403 })),
);
await expect(
fetchPollyPriceList('us-west-2', new AbortController().signal),
).rejects.toThrow('403');
});
it('rejects a body over the size cap', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
async () =>
new Response(' '.repeat(POLLY_PRICE_LIST_MAX_BYTES + 1)),
),
);
await expect(
fetchPollyPriceList('us-west-2', new AbortController().signal),
).rejects.toThrow('size cap');
});
it('rejects a body that is not JSON', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('<html>')),
);
await expect(
fetchPollyPriceList('us-west-2', new AbortController().signal),
).rejects.toThrow();
});
});
@@ -0,0 +1,238 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { readBoundedBody } from '../../../util/fileInput.js';
/** A region's Polly prices are a few KB; anything far past that is not one. */
export const POLLY_PRICE_LIST_MAX_BYTES = 1024 * 1024;
const PRICE_LIST_BASE_URL =
'https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/AmazonPolly/current';
// The region lands in the price list URL, so only plain AWS region codes pass.
const REGION_PATTERN = /^[a-z]{2}(-gov)?-[a-z]+-\d{1,2}$/;
// Regular synthesis only: `<prefix>-SynthesizeSpeech[Engine]-Characters`.
// Promotional variants (`USW2-ai-SynthesizeSpeech…`) don't match.
const USAGE_TYPE_PATTERN =
/^[A-Z0-9]+-SynthesizeSpeech(Neural|LongForm|Generative)?-Characters$/;
const ENGINE_BY_USAGE_SUFFIX: Record<string, string> = {
'': 'standard',
Neural: 'neural',
LongForm: 'long-form',
Generative: 'generative',
};
const ENGINE_BY_ATTRIBUTE: Record<string, string> = {
Standard: 'standard',
Neural: 'neural',
'Long-Form': 'long-form',
Generative: 'generative',
};
const PRODUCT_FAMILIES = new Set(['AmazonPolly', 'Synthesize Speech']);
// USD per character as a plain decimal, e.g. "0.0000160000".
const USD_PATTERN = /^(\d+)(?:\.(\d+))?$/;
const UCENTS_PER_USD_DIGITS = 8;
/** Microcents per character, keyed by Polly engine id. */
export type PollyRates = Record<string, number>;
export const isPollyPriceRegion = (region: string): boolean =>
REGION_PATTERN.test(region);
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
/** Exact decimal shift of a USD amount into microcents. */
const usdToUcents = (usd: string): number | undefined => {
const match = USD_PATTERN.exec(usd);
if (!match) return undefined;
const fraction = match[2] ?? '';
const whole = fraction.padEnd(UCENTS_PER_USD_DIGITS, '0');
const ucents = Number(
`${match[1]}${whole.slice(0, UCENTS_PER_USD_DIGITS)}.${whole.slice(UCENTS_PER_USD_DIGITS) || '0'}`,
);
return Number.isFinite(ucents) ? ucents : undefined;
};
/** The engine a product prices, or undefined when it isn't regular synthesis. */
const productEngine = (
sku: string,
product: unknown,
region: string,
): string | undefined => {
if (!isRecord(product) || product.sku !== sku) return undefined;
if (
typeof product.productFamily !== 'string' ||
!PRODUCT_FAMILIES.has(product.productFamily)
)
return undefined;
const attrs = product.attributes;
if (!isRecord(attrs)) return undefined;
if (attrs.servicecode !== 'AmazonPolly' || attrs.regionCode !== region)
return undefined;
if ('electronStatus' in attrs) return undefined;
if (typeof attrs.usagetype !== 'string') return undefined;
const usage = USAGE_TYPE_PATTERN.exec(attrs.usagetype);
if (!usage) return undefined;
const engine = ENGINE_BY_USAGE_SUFFIX[usage[1] ?? ''];
if (
typeof attrs.engine !== 'string' ||
ENGINE_BY_ATTRIBUTE[attrs.engine] !== engine
)
return undefined;
return engine;
};
/**
* The single flat on-demand rate a term charges now, `null` when the term isn't
* in effect yet, or undefined when its shape isn't one we can bill from.
*/
const termRate = (
sku: string,
termKey: string,
term: unknown,
now: number,
): number | null | undefined => {
if (!isRecord(term) || term.sku !== sku) return undefined;
if (
typeof term.offerTermCode !== 'string' ||
termKey !== `${sku}.${term.offerTermCode}`
)
return undefined;
if (
term.termAttributes !== undefined &&
(!isRecord(term.termAttributes) ||
Object.keys(term.termAttributes).length > 0)
)
return undefined;
const effective =
typeof term.effectiveDate === 'string'
? Date.parse(term.effectiveDate)
: NaN;
if (!Number.isFinite(effective)) return undefined;
if (effective > now) return null;
if (!isRecord(term.priceDimensions)) return undefined;
const dimensions = Object.entries(term.priceDimensions);
if (dimensions.length !== 1) return undefined;
const [dimensionKey, dimension] = dimensions[0]!;
if (!isRecord(dimension) || dimension.rateCode !== dimensionKey)
return undefined;
if (!dimensionKey.startsWith(`${termKey}.`)) return undefined;
if (dimension.unit !== 'Characters') return undefined;
if (dimension.beginRange !== '0' || dimension.endRange !== 'Inf')
return undefined;
if (
dimension.appliesTo !== undefined &&
!(
Array.isArray(dimension.appliesTo) &&
dimension.appliesTo.length === 0
)
)
return undefined;
const price = dimension.pricePerUnit;
if (!isRecord(price) || Object.keys(price).join() !== 'USD')
return undefined;
if (typeof price.USD !== 'string') return undefined;
const ucents = usdToUcents(price.USD);
return ucents !== undefined && ucents > 0 ? ucents : undefined;
};
/**
* Per-engine rates from one region's AWS price list. An engine appears only
* when exactly one well-formed, in-effect, flat USD-per-character rate prices
* it; anything missing, malformed, or ambiguous is left out for the caller to
* fill from its curated rates. Throws when the document isn't a Polly price
* list.
*/
export const parsePollyPriceList = (
doc: unknown,
region: string,
now: number = Date.now(),
): PollyRates => {
if (
!isRecord(doc) ||
doc.offerCode !== 'AmazonPolly' ||
doc.formatVersion !== 'v1.0' ||
!isRecord(doc.products) ||
!isRecord(doc.terms) ||
!isRecord(doc.terms.OnDemand)
) {
throw new Error('Not an AWS Polly price list');
}
const onDemand = doc.terms.OnDemand;
const candidates: Record<string, Array<number | undefined>> = {};
for (const [sku, product] of Object.entries(doc.products)) {
const engine = productEngine(sku, product, region);
if (!engine) continue;
const terms = onDemand[sku];
const rates: Array<number | undefined> = [];
if (isRecord(terms)) {
for (const [termKey, term] of Object.entries(terms)) {
const rate = termRate(sku, termKey, term, now);
if (rate !== null) rates.push(rate);
}
}
// A billable SKU with no usable term poisons its engine rather than
// letting a sibling SKU's rate stand alone.
(candidates[engine] ??= []).push(
...(rates.length ? rates : [undefined]),
);
}
const rates: PollyRates = {};
for (const [engine, found] of Object.entries(candidates)) {
if (found.length === 1 && found[0] !== undefined) {
rates[engine] = found[0];
}
}
return rates;
};
/** Fetches and parses one region's public Polly price list. */
export const fetchPollyPriceList = async (
region: string,
signal: AbortSignal,
): Promise<PollyRates> => {
if (!isPollyPriceRegion(region)) {
throw new Error('Invalid AWS region for the Polly price list');
}
const response = await fetch(
`${PRICE_LIST_BASE_URL}/${region}/index.json`,
{
signal,
headers: { accept: 'application/json' },
},
);
if (!response.ok) {
await response.body?.cancel();
throw new Error(`AWS Polly price list returned ${response.status}`);
}
const body = await readBoundedBody(
response,
POLLY_PRICE_LIST_MAX_BYTES,
() => new Error('AWS Polly price list exceeds the size cap'),
);
return parsePollyPriceList(JSON.parse(body.toString('utf8')), region);
};
@@ -218,6 +218,28 @@ describe('ElevenLabsTTSProvider catalog', () => {
);
});
it('prices each engine from the cost table in dollars per 1M chars', async () => {
const provider = makeProvider();
const engines = await provider.listEngines();
// toBe considers NaN equal to itself, so assert finiteness separately.
for (const cost of Object.values(ELEVENLABS_TTS_COSTS)) {
expect(Number.isFinite(cost)).toBe(true);
}
for (const engine of engines) {
expect(Number.isFinite(engine.pricing_per_million_chars)).toBe(
true,
);
expect(engine.pricing_per_million_chars).toBe(
ELEVENLABS_TTS_COSTS[engine.id] / 100,
);
}
const byId = Object.fromEntries(
engines.map((e) => [e.id, e.pricing_per_million_chars]),
);
expect(byId.eleven_multilingual_v2).toBe(162);
expect(byId.eleven_flash_v2_5).toBe(81);
});
it.each(['eleven_turbo_v2_5', 'eleven_turbo_v2'])(
'drops the deprecated %s from the listing',
async (model) => {
@@ -271,7 +271,7 @@ export class ElevenLabsTTSProvider implements ITTSProvider {
id: model.id,
name: model.name,
provider: 'elevenlabs',
pricing_per_million_chars: 0,
pricing_per_million_chars: ELEVENLABS_TTS_COSTS[model.id] / 100, // Convert microcents per character to dollars per million characters.
}));
}
@@ -115,15 +115,18 @@ describe('XAITTSProvider catalog', () => {
}
});
it('listEngines reports a single xai-tts engine with pricing_per_million_chars', async () => {
it('listEngines reports a single xai-tts engine priced in dollars per 1M chars', async () => {
const provider = makeProvider();
const engines = await provider.listEngines();
expect(engines).toHaveLength(1);
expect(engines[0]).toMatchObject({
id: 'xai-tts',
provider: 'xai',
pricing_per_million_chars: 1500,
pricing_per_million_chars: 15,
});
expect(engines[0].pricing_per_million_chars).toBe(
XAI_TTS_COSTS['xai-tts'] / 100,
);
});
});
@@ -93,7 +93,7 @@ export class XAITTSProvider implements ITTSProvider {
id: 'xai-tts',
name: 'xAI TTS',
provider: 'xai',
pricing_per_million_chars: 1500,
pricing_per_million_chars: XAI_TTS_COSTS['xai-tts'] / 100, // Convert microcents per character to dollars per million characters.
},
];
}
@@ -926,108 +926,3 @@ describe('WorkerDriver hot reload', () => {
expect(sessionMint).not.toHaveBeenCalled();
});
});
// -- worker sessions --------------------------------------------------
describe('WorkerDriver keeps a worker`s token tied to the worker', () => {
const deployedToken = () => {
const [, init] = putCalls().at(-1)!;
const form = (init as RequestInit).body as FormData;
const metadata = JSON.parse(form.get('metadata') as string);
return metadata.bindings.find(
(b: { name: string }) => b.name === 'puter_auth',
).text as string;
};
const deployWorker = async (label: string) => {
const { user, actor } = await makeUser();
const path = `/${user.username}/${label}.js`;
const entry = await writeSource(actor, user.id, path, 'src');
const name = `${label}-${user.username}`;
await inCtx(actor, () =>
target.create({ appId: '', workerName: name, filePath: path }),
);
return { user, actor, entry, name, token: deployedToken() };
};
const workerSession = (userId: number, workerName: string) =>
server.stores.session.getWorker(userId, { appUid: null, workerName });
it('binds the deployed token to the worker row, and a redeploy keeps it', async () => {
const { user, actor, name, token } = await deployWorker('bind');
const row = await server.stores.subdomain.getBySubdomain(
`workers.puter.${name}`,
);
const decoded = server.services.token.verify('auth', token) as {
worker_uid?: string;
};
expect(decoded.worker_uid).toBe(row!.uuid);
expect(
(await server.services.auth.authenticate(token)).actor,
).toBeTruthy();
await inCtx(actor, () =>
target.create({
appId: '',
workerName: name,
filePath: `/${user.username}/bind.js`,
}),
);
const redeployed = server.services.token.verify(
'auth',
deployedToken(),
) as { worker_uid?: string };
expect(redeployed.worker_uid).toBe(row!.uuid);
});
it('destroy deletes the worker`s session and leaves other workers alone', async () => {
const kept = await deployWorker('kept');
const { user, actor, name, token } = await deployWorker('gone');
await inCtx(actor, () => target.destroy({ workerName: name }));
expect(await workerSession(user.id, name)).toBeNull();
expect(await server.services.auth.authenticate(token)).toEqual({
invalid: true,
});
expect(await workerSession(kept.user.id, kept.name)).toBeTruthy();
expect(
(await server.services.auth.authenticate(kept.token)).actor,
).toBeTruthy();
});
it('deleting the source file deletes the worker`s session', async () => {
const { user, actor, entry, name } = await deployWorker('srcdel');
// Resolved at delete time, as every caller does: the entry names the
// workers bound to it.
const current = await server.stores.fsEntry.getEntryByUuid(entry.uuid);
await inCtx(actor, () =>
server.services.fs.remove(user.id, { entry: current! }),
);
await waitFor(
async () => !(await workerSession(user.id, name)),
'worker session removal after source delete',
);
});
it('a worker row deleted elsewhere takes its session with it', async () => {
const { user, name } = await deployWorker('elsewhere');
const row = await server.stores.subdomain.getBySubdomain(
`workers.puter.${name}`,
);
await server.stores.subdomain.deleteByUuid(String(row!.uuid));
server.clients.event.emit(
'subdomain.delete',
{ subdomain: `workers.puter.${name}`, uid: String(row!.uuid) },
{},
);
await waitFor(
async () => !(await workerSession(user.id, name)),
'worker session removal after subdomain.delete',
);
});
});
@@ -20,7 +20,6 @@
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { v4 as uuidv4 } from 'uuid';
import type { EventMetadata } from '../../clients/event/types.js';
import {
assertActorEmailVerified,
@@ -415,19 +414,11 @@ export class WorkerDriver extends PuterDriver {
let authorization: string | undefined = undefined;
const appOwnerId = boundApp?.id;
const omitOwnerToken = deployTarget?.omitOwnerToken === true;
// The token is bound to the row it deploys under, which a new worker
// only gets further down, so its uuid is picked here.
const workerUid = deployTarget
? undefined
: existingSub
? String(existingSub.uuid)
: uuidv4();
if (boundApp && !omitOwnerToken) {
authorization = await this.services.auth.createWorkerAppToken(
actor,
boundApp.uid,
workerName,
{ workerUid },
);
}
if (!authorization && !omitOwnerToken) {
@@ -443,8 +434,6 @@ export class WorkerDriver extends PuterDriver {
actor,
userRow,
workerName,
{},
{ workerUid },
);
authorization = session.token;
}
@@ -501,7 +490,6 @@ export class WorkerDriver extends PuterDriver {
// than letting the driver error escape as a 500.
try {
await this.stores.subdomain.create({
uuid: workerUid,
userId: actor.user.id!,
subdomain: subdomainName,
rootDirId: loaded.fsEntry.sqlId,
@@ -584,7 +572,6 @@ export class WorkerDriver extends PuterDriver {
await this.stores.subdomain.deleteByUuid(row.uuid, {
userId: actor.user.id,
});
await this.services.auth.deleteWorkerSessionsByName(workerName);
return cfResult;
}
@@ -1089,14 +1076,6 @@ export class WorkerDriver extends PuterDriver {
err,
);
});
void this.services.auth
.deleteWorkerSessionsByName(workerName)
.catch((err) => {
console.warn(
`[workers] session cleanup failed for ${workerName}`,
err,
);
});
},
);
}
@@ -1166,11 +1145,6 @@ export class WorkerDriver extends PuterDriver {
ownerActor,
app.uid,
workerName,
{
workerUid: row.uuid
? String(row.uuid)
: undefined,
},
);
} else {
const session =
@@ -1178,12 +1152,6 @@ export class WorkerDriver extends PuterDriver {
ownerActor,
ownerUser,
workerName,
{},
{
workerUid: row.uuid
? String(row.uuid)
: undefined,
},
);
authorization = session.token;
@@ -1357,7 +1325,6 @@ export class WorkerDriver extends PuterDriver {
userId,
});
}
await this.services.auth.deleteWorkerSessionsByName(workerName);
} catch (err) {
console.warn(
`[workers] source cleanup failed for ${workerName}`,
@@ -26,7 +26,6 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { makeActor } from '../../core/actor.js';
import { WORKER_SUBDOMAIN_PREFIX } from '../../stores/subdomain/SubdomainStore.js';
import {
createTestUser,
setupPuterTestEnv,
@@ -328,16 +327,10 @@ describe('an app uid that returns after its app was deleted', () => {
it('replaces a worker session older than its app', async () => {
const { app, actor } = await createApp();
const workerName = `w-${uuidv4().slice(0, 8)}`;
const { uuid: workerUid } = await env.server.stores.subdomain.create({
userId: actor.user.id as number,
subdomain: `${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
appOwner: app.id,
});
const old = await env.server.services.auth.createWorkerAppToken(
actor,
app.uid,
workerName,
{ workerUid },
);
await backdateSession(sessionUid(old), 3600);
expect((await authenticate(old)).reauth?.reason).toBe(
@@ -348,7 +341,6 @@ describe('an app uid that returns after its app was deleted', () => {
actor,
app.uid,
workerName,
{ workerUid },
);
expect(sessionUid(fresh)).not.toBe(sessionUid(old));
expect(await isRevoked(sessionUid(old))).toBe(true);
@@ -22,10 +22,8 @@ import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { makeActor, type Actor } from '../../core/actor.js';
import { PuterServer } from '../../server.js';
import { WORKER_SUBDOMAIN_PREFIX } from '../../stores/subdomain/SubdomainStore.js';
import { setupTestServer } from '../../testUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import { EVENTS_WORKER_SESSION_NAME } from '../events/workerRuntime.js';
import { FULL_API_ACCESS } from '../permission/consts.js';
import { AuthService, HOSTED_ASSET_COOKIES } from './AuthService.js';
@@ -1649,16 +1647,10 @@ describe('AuthService (integration)', () => {
// revoke so the re-create produces a fresh row.
const user = await makeUser();
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const { uuid: workerUid } = await server.stores.subdomain.create({
userId: user.id,
subdomain: `${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
});
const first = await authService.createWorkerSessionToken(
makeActor({ user }),
user,
workerName,
{},
{ workerUid },
);
const firstUuid = (first.session as { uuid: string }).uuid;
await authService.revokeSession(firstUuid);
@@ -1667,8 +1659,6 @@ describe('AuthService (integration)', () => {
makeActor({ user }),
user,
workerName,
{},
{ workerUid },
);
const secondUuid = (second.session as { uuid: string }).uuid;
expect(secondUuid).not.toBe(firstUuid);
@@ -1741,178 +1731,6 @@ describe('AuthService (integration)', () => {
});
});
describe('a worker token lives only as long as its worker', () => {
const deployRow = async (
userId: number,
workerName: string,
appOwner?: number,
) =>
await server.stores.subdomain.create({
userId,
subdomain: `${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
appOwner: appOwner ?? null,
});
const newName = () => `wk-${Math.random().toString(36).slice(2, 8)}`;
it('resolves while the worker row exists and is invalid once it is deleted', async () => {
const user = await makeUser();
const workerName = newName();
const row = await deployRow(user.id, workerName);
const { token, gui_token } =
await authService.createWorkerSessionToken(
makeActor({ user }),
user,
workerName,
{},
{ workerUid: row.uuid },
);
expect((await authService.authenticate(token)).actor).toBeTruthy();
// Straight through the store: no hook revokes the session.
await server.stores.subdomain.deleteByUuid(row.uuid);
expect(await authService.authenticate(token)).toEqual({
invalid: true,
});
expect(await authService.authenticate(gui_token)).toEqual({
invalid: true,
});
});
it('does not come back when the same name is deployed again', async () => {
const user = await makeUser();
const workerName = newName();
const first = await deployRow(user.id, workerName);
const { token } = await authService.createWorkerSessionToken(
makeActor({ user }),
user,
workerName,
{},
{ workerUid: first.uuid },
);
await server.stores.subdomain.deleteByUuid(first.uuid);
await deployRow(user.id, workerName);
expect(await authService.authenticate(token)).toEqual({
invalid: true,
});
});
it('applies to app-scoped worker tokens', async () => {
const user = await makeUser();
const app = await server.stores.app.create(
{
name: `wk-app-${uuidv4()}`,
title: 'Worker app',
index_url: `https://${uuidv4()}.example.com/`,
},
{ ownerUserId: user.id },
);
const workerName = newName();
const row = await deployRow(user.id, workerName, app.id);
const token = await authService.createWorkerAppToken(
makeActor({ user }),
app.uid,
workerName,
{ workerUid: row.uuid },
);
expect(
(await authService.authenticate(token)).actor?.app?.uid,
).toBe(app.uid);
await server.stores.subdomain.deleteByUuid(row.uuid);
expect(await authService.authenticate(token)).toEqual({
invalid: true,
});
});
it('holds a token minted without a worker uid to the name and owner', async () => {
const user = await makeUser();
const other = await makeUser();
const workerName = newName();
const { token } = await authService.createWorkerSessionToken(
makeActor({ user }),
user,
workerName,
);
// No row at all.
expect(await authService.authenticate(token)).toEqual({
invalid: true,
});
// Someone else's worker under that name.
const foreign = await deployRow(other.id, workerName);
expect(await authService.authenticate(token)).toEqual({
invalid: true,
});
await server.stores.subdomain.deleteByUuid(foreign.uuid);
await deployRow(user.id, workerName);
expect((await authService.authenticate(token)).actor).toBeTruthy();
});
it('leaves events handler sessions alone; they have no worker row', async () => {
const user = await makeUser();
const app = await server.stores.app.create(
{
name: `ev-app-${uuidv4()}`,
title: 'Events app',
index_url: `https://${uuidv4()}.example.com/`,
},
{ ownerUserId: user.id },
);
const token = await authService.createWorkerAppToken(
makeActor({ user }),
app.uid,
EVENTS_WORKER_SESSION_NAME,
);
expect((await authService.authenticate(token)).actor).toBeTruthy();
});
});
describe('deleting worker sessions', () => {
it('deleteWorkerSessionsByName ends the token and drops a derived token`s grants', async () => {
const user = await makeUser();
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
const row = await server.stores.subdomain.create({
userId: user.id,
subdomain: `${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
});
const { session, token } =
await authService.createWorkerSessionToken(
makeActor({ user }),
user,
workerName,
{},
{ workerUid: row.uuid },
);
const sessionUuid = (session as { uuid: string }).uuid;
const childUid = uuidv4();
await server.stores.session.create(user.id, {
kind: 'access_token',
parent_session_id: sessionUuid,
access_token_uid: childUid,
});
await server.clients.db.write(
'INSERT INTO `access_token_permissions` (`token_uid`, `authorizer_user_id`, `permission`) VALUES (?, ?, ?)',
[childUid, user.id, 'fs:x:read'],
);
await authService.deleteWorkerSessionsByName(workerName);
// The worker row is still there: only the session went.
expect(await authService.authenticate(token)).toEqual({
invalid: true,
});
const grants = await server.clients.db.read(
'SELECT * FROM `access_token_permissions` WHERE `token_uid` = ?',
[childUid],
);
expect(grants).toHaveLength(0);
});
});
describe('appUidFromOrigin', () => {
it('throws 400 for an unparseable origin string', async () => {
await expect(
+6 -95
View File
@@ -33,14 +33,12 @@ import {
GODMODE_TOKEN_WINDOW_SECONDS,
WEB_WINDOW_SECONDS,
} from '../../stores/session/SessionStore.js';
import { WORKER_SUBDOMAIN_PREFIX } from '../../stores/subdomain/SubdomainStore.js';
import type { UserRow } from '../../stores/user/UserStore';
import type { LayerInstances } from '../../types';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { isGodmodeApp } from '../../util/godmodeApps.js';
import { Span } from '../../util/span.js';
import type { puterServices } from '../index';
import { EVENTS_WORKER_SESSION_NAME } from '../events/workerRuntime.js';
import { FULL_API_ACCESS, PERMISSION_MAX_LEN } from '../permission/consts';
import { PuterService } from '../types';
import type {
@@ -403,11 +401,7 @@ export class AuthService extends PuterService {
user: UserRow,
sessionUuid: string,
authId: string,
opts: {
worker?: boolean;
workerName?: string;
workerUid?: string;
} = {},
opts: { worker?: boolean; workerName?: string } = {},
): string {
const claims: Record<string, unknown> = {
type,
@@ -422,7 +416,6 @@ export class AuthService extends PuterService {
};
if (opts.worker) claims.worker = true;
if (opts.workerName) claims.worker_name = opts.workerName;
if (opts.workerUid) claims.worker_uid = opts.workerUid;
return this.services.token.sign('auth', claims);
}
@@ -433,15 +426,13 @@ export class AuthService extends PuterService {
* the row and returns the same stable token. Expires after
* `WORKER_WINDOW_SECONDS` (effectively infinite). The emitted JWT carries
* `worker: true` and `worker_name` so downstream code can tell a worker
* session from a user-driven one without a DB round-trip. `workerUid` binds
* the token to the worker's subdomain row, so it stops working with it.
* session from a user-driven one without a DB round-trip.
*/
async createWorkerSessionToken(
actor: Actor,
user: UserRow,
workerName: string,
meta: Record<string, unknown> = {},
{ workerUid }: { workerUid?: string } = {},
): Promise<{
session: Record<string, unknown>;
token: string;
@@ -473,14 +464,14 @@ export class AuthService extends PuterService {
user,
session.uuid as string,
auth_id,
{ worker: true, workerName, workerUid },
{ worker: true, workerName },
);
const gui_token = this.#signSessionTypeToken(
'gui',
user,
session.uuid as string,
auth_id,
{ worker: true, workerName, workerUid },
{ worker: true, workerName },
);
return { session, token, gui_token };
@@ -498,18 +489,13 @@ export class AuthService extends PuterService {
* handler runs deep; `expiresInSeconds` bounds a token that carries one.
* Refuses an actor that is itself running behind a handler — the minted
* token would outlive any one run, so stamping it with the caller's depth
* would only delay the escape, not close it. `workerUid` binds the token to
* the worker's subdomain row, so it stops working with it.
* would only delay the escape, not close it.
*/
async createWorkerAppToken(
actor: Actor,
appUid: string,
workerName: string,
options: {
handlerDepth?: number;
expiresInSeconds?: number;
workerUid?: string;
} = {},
options: { handlerDepth?: number; expiresInSeconds?: number } = {},
): Promise<string> {
if (!actor.user) {
throw new HttpError(403, 'Actor must be a user', {
@@ -560,7 +546,6 @@ export class AuthService extends PuterService {
auth_id,
worker: true,
worker_name: workerName,
...(options.workerUid ? { worker_uid: options.workerUid } : {}),
...(options.handlerDepth
? { handler_depth: options.handlerDepth }
: {}),
@@ -906,50 +891,6 @@ export class AuthService extends PuterService {
}
}
/**
* Delete every session of a worker that no longer exists, whoever minted
* it: worker names are unique, so nothing else can be using them.
*/
async deleteWorkerSessionsByName(workerName: string): Promise<void> {
if (!workerName) return;
await this.#deleteWorkerSessions({ workerName });
}
/**
* Delete the sessions of the workers an app deployed for this user, e.g.
* when the user uninstalls it. The workers themselves keep running.
*/
async deleteWorkerSessionsForApp(
userId: number,
appUid: string,
): Promise<void> {
if (!userId || !appUid) return;
await this.#deleteWorkerSessions({ userId, appUid });
}
async #deleteWorkerSessions(filter: {
workerName?: string;
userId?: number;
appUid?: string;
}): Promise<void> {
const deleted = (await this.stores.session.deleteWorkerSessions(
filter,
)) as { uuid: string; userId: number; accessTokenUid: string | null }[];
const byUser = new Map<number, string[]>();
for (const row of deleted) {
byUser.set(row.userId, [
...(byUser.get(row.userId) ?? []),
row.uuid,
]);
if (row.accessTokenUid) {
await this.#dropAccessTokenGrants(row.accessTokenUid);
}
}
for (const [userId, uuids] of byUser) {
this.#announceRevocation({ userId, uuids });
}
}
/**
* Password-reset cascade: revoke every interactive (web/app) session for
* the user, so a hijacked session doesn't outlive a password reset. No
@@ -2331,30 +2272,6 @@ export class AuthService extends PuterService {
}
}
/**
* A worker credential lives only as long as the worker it was minted for.
* Tokens minted before `worker_uid` existed are held to the worker's name
* and owner instead.
*/
async #workerStillDeployed(
decoded: {
worker?: boolean;
worker_name?: string;
worker_uid?: string;
},
userId: number,
): Promise<boolean> {
if (!decoded.worker) return true;
// Events handler sessions have no worker row; EventsService retires them.
if (decoded.worker_name === EVENTS_WORKER_SESSION_NAME) return true;
if (!decoded.worker_name) return false;
const row = await this.stores.subdomain.getBySubdomain(
`${WORKER_SUBDOMAIN_PREFIX}${decoded.worker_name}`,
);
if (!row || Number(row.user_id) !== Number(userId)) return false;
return !decoded.worker_uid || row.uuid === decoded.worker_uid;
}
async #actorFromSessionToken(
decoded: SessionTokenPayload,
ctx: AuthenticateContext = {},
@@ -2385,9 +2302,6 @@ export class AuthService extends PuterService {
const session: SessionRow | null = rawRow;
if (!session) return { invalid: true };
if (!(await this.#workerStillDeployed(decoded, user.id as number))) {
return { invalid: true };
}
void this.#touchSession(session.uuid, user.id, ctx);
@@ -2432,9 +2346,6 @@ export class AuthService extends PuterService {
if (notBefore !== null && createdAt > 0 && createdAt < notBefore) {
return { reauth: { reason: 'session_revoked' } };
}
if (!(await this.#workerStillDeployed(decoded, user.id as number))) {
return { invalid: true };
}
void this.#touchSession(session.uuid, user.id, ctx);
+1 -10
View File
@@ -53,9 +53,6 @@ export interface SessionTokenPayload extends TokenPayloadBase {
user_uid: string;
/** Set on a worker credential riding this token type; not a browser session. */
worker?: boolean;
worker_name?: string;
/** Uuid of the worker's subdomain row; the token dies with that row. */
worker_uid?: string;
}
/**
@@ -70,10 +67,6 @@ export interface AppUnderUserTokenPayload extends TokenPayloadBase {
session?: string;
/** Events handler runs behind this token's writes; see `Actor.handlerDepth`. */
handler_depth?: number;
worker?: boolean;
worker_name?: string;
/** Uuid of the worker's subdomain row; the token dies with that row. */
worker_uid?: string;
}
/**
@@ -101,9 +94,7 @@ export interface AccessTokenPayload extends TokenPayloadBase {
}
export type AnyTokenPayload =
| SessionTokenPayload
| AppUnderUserTokenPayload
| AccessTokenPayload;
SessionTokenPayload | AppUnderUserTokenPayload | AccessTokenPayload;
// -- Session row (from `sessions` table) ----------------------------
@@ -70,13 +70,6 @@ describe('addresses', () => {
'[email protected]',
]);
});
test('a username with capitals yields lowercase addresses', () => {
expect(puterEmailAddressesOf('Dan')).toEqual([
'[email protected]',
'[email protected]',
]);
});
});
describe('account eligibility', () => {
+2 -6
View File
@@ -59,13 +59,9 @@ export const isPuterEmailAddress = (address: string): boolean =>
export const puterEmailUsername = (address: string): string =>
address.split('@')[0];
/**
* Every address that resolves to `username`, lowercased: callers compare these
* against addresses parsed out of headers, which are lowercased too, and a
* username stored with capitals would otherwise match nothing.
*/
/** Every address that resolves to `username`. */
export const puterEmailAddressesOf = (username: string): string[] =>
PUTER_EMAIL_DOMAINS.map((domain) => `${username.toLowerCase()}@${domain}`);
PUTER_EMAIL_DOMAINS.map((domain) => `${username}@${domain}`);
/**
* A temp account has neither a password nor an email - the same test
@@ -21,10 +21,7 @@ import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import { Readable } from 'node:stream';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
WORKER_SUBDOMAIN_PREFIX,
type SubdomainRow,
} from '../../stores/subdomain/SubdomainStore.js';
import type { SubdomainRow } from '../../stores/subdomain/SubdomainStore.js';
import type { PuterServer } from '../../server.js';
import { createTestUser, setupTestServer } from '../../testUtil.js';
import { getWorkerPreamble } from '../../drivers/workers/WorkerDriver.js';
@@ -85,18 +82,6 @@ const subdomainRow = (over: Partial<SubdomainRow>): SubdomainRow =>
...over,
}) as SubdomainRow;
/** A stored worker row: the minted token is only valid while it exists. */
const seedWorkerRow = async (
workerName: string,
over: { root_dir_id: number; app_owner?: number },
): Promise<SubdomainRow> =>
(await server.stores.subdomain.create({
userId: ownerUserId,
subdomain: `${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
rootDirId: over.root_dir_id,
appOwner: over.app_owner ?? null,
})) as unknown as SubdomainRow;
describe('LocalWorkerService.reconstructDeployArgs', () => {
it('mints a user-scoped worker session token and prepends the preamble', async () => {
const src = await writeSource(
@@ -106,7 +91,7 @@ describe('LocalWorkerService.reconstructDeployArgs', () => {
const [name, authorization, code] =
await localWorkers.reconstructDeployArgs(
'worker-a',
await seedWorkerRow('worker-a', { root_dir_id: src.id }),
subdomainRow({ root_dir_id: src.id }),
);
const source = "export default { fetch: () => new Response('a') };";
@@ -145,10 +130,7 @@ describe('LocalWorkerService.reconstructDeployArgs', () => {
const [, authorization] = await localWorkers.reconstructDeployArgs(
'worker-b',
await seedWorkerRow('worker-b', {
root_dir_id: src.id,
app_owner: app.id,
}),
subdomainRow({ root_dir_id: src.id, app_owner: app.id }),
);
const auth = await server.services.auth.authenticate(authorization);
@@ -268,15 +268,12 @@ export class LocalWorkerService extends PuterService {
ownerActor,
app.uid,
workerName,
{ workerUid: row.uuid ? String(row.uuid) : undefined },
);
} else {
const session = await this.services.auth.createWorkerSessionToken(
ownerActor,
ownerUser,
workerName,
{},
{ workerUid: row.uuid ? String(row.uuid) : undefined },
);
authorization = session.token;
@@ -456,77 +456,6 @@ export class SessionStore extends PuterStore {
};
}
/**
* Hard-delete the worker sessions matching every given filter, plus the
* sessions derived from them via `parent_session_id`. A deleted worker
* never comes back, so there is no revoked row worth keeping.
*
* Returns each deleted row as `{ uuid, userId, accessTokenUid }` so the
* caller can announce the revocation and drop access-token grants.
*
* @param {{
* workerName?: string;
* userId?: number;
* appUid?: string;
* }} filter
*/
async deleteWorkerSessions({ workerName, userId, appUid } = {}) {
const where = ["`kind` = 'worker'"];
const params = [];
if (workerName) {
const workerNameExpr = this.clients.db.jsonTextExtract('`meta`', [
'worker_name',
]);
where.push(`${workerNameExpr} = ?`);
params.push(workerName);
}
if (userId) {
where.push('`user_id` = ?');
params.push(userId);
}
if (appUid) {
where.push('`app_uid` = ?');
params.push(appUid);
}
// No filter would mean every worker session on the server.
if (params.length === 0) return [];
// Primary: a session minted moments ago must not survive a replica miss.
const roots = await this.clients.db.pread(
`SELECT \`uuid\` FROM \`sessions\` WHERE ${where.join(' AND ')}`,
params,
);
if (roots.length === 0) return [];
const rootUuids = roots.map((r) => String(r.uuid));
const placeholders = rootUuids.map(() => '?').join(', ');
const scope = `\`uuid\` IN (${placeholders}) OR \`parent_session_id\` IN (${placeholders})`;
const scopeParams = [...rootUuids, ...rootUuids];
const rows = await this.clients.db.pread(
'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid`, `meta`, `created_via`, `last_ip`, `last_user_agent`, `access_token_uid` ' +
`FROM \`sessions\` WHERE ${scope}`,
scopeParams,
);
// Double-delete: see `removeByUuid` for rationale.
const keys = [];
for (const r of rows) keys.push(...this.#allCacheKeysForRow(r));
await this.publishCacheKeys({ keys, broadcast: true });
await this.clients.db.write(
`DELETE FROM \`sessions\` WHERE ${scope}`,
scopeParams,
);
await this.publishCacheKeys({ keys, broadcast: true });
return rows.map((r) => ({
uuid: String(r.uuid),
userId: Number(r.user_id),
accessTokenUid: r.access_token_uid
? String(r.access_token_uid)
: null,
}));
}
/**
* Access-token identities among the rows `revokeCascade(rootUuid)` would
* affect — the root itself when it is a token row, plus any token row
@@ -805,123 +805,6 @@ describe('SessionStore', () => {
});
});
describe('deleteWorkerSessions', () => {
const name = () => `wk-${Math.random().toString(36).slice(2, 8)}`;
it('deletes every row of a worker name across users and apps, and nothing else', async () => {
const alice = await makeUser();
const bob = await makeUser();
const workerName = name();
const userScoped = await target.getOrCreateWorker(alice.id, {
appUid: null,
workerName,
});
const appScoped = await target.getOrCreateWorker(bob.id, {
appUid: `app-${uuidv4()}`,
workerName,
});
const otherWorker = await target.getOrCreateWorker(alice.id, {
appUid: null,
workerName: name(),
});
const deleted = await target.deleteWorkerSessions({ workerName });
expect(deleted.map((r: { uuid: string }) => r.uuid).sort()).toEqual(
[userScoped.uuid, appScoped.uuid].sort(),
);
expect(await rawRow(userScoped.uuid)).toBeNull();
expect(await rawRow(appScoped.uuid)).toBeNull();
expect(await rawRow(otherWorker.uuid)).not.toBeNull();
});
it('filters by user and app together', async () => {
const user = await makeUser();
const other = await makeUser();
const appUid = `app-${uuidv4()}`;
const deployed = await target.getOrCreateWorker(user.id, {
appUid,
workerName: name(),
});
const otherApp = await target.getOrCreateWorker(user.id, {
appUid: `app-${uuidv4()}`,
workerName: name(),
});
const userScoped = await target.getOrCreateWorker(user.id, {
appUid: null,
workerName: name(),
});
const otherUser = await target.getOrCreateWorker(other.id, {
appUid,
workerName: name(),
});
await target.deleteWorkerSessions({ userId: user.id, appUid });
expect(await rawRow(deployed.uuid)).toBeNull();
expect(await rawRow(otherApp.uuid)).not.toBeNull();
expect(await rawRow(userScoped.uuid)).not.toBeNull();
expect(await rawRow(otherUser.uuid)).not.toBeNull();
});
it('takes derived sessions with it', async () => {
const user = await makeUser();
const worker = await target.getOrCreateWorker(user.id, {
appUid: `app-${uuidv4()}`,
workerName: name(),
});
const child = await target.create(user.id, {
kind: 'access_token',
parent_session_id: worker.uuid,
access_token_uid: uuidv4(),
});
const deleted = await target.deleteWorkerSessions({
userId: user.id,
appUid: worker.app_uid,
});
expect(await rawRow(child.uuid)).toBeNull();
expect(
deleted.find((r: { uuid: string }) => r.uuid === child.uuid)
?.accessTokenUid,
).toBe(child.access_token_uid);
});
it('drops the cached rows, so a lookup misses and a redeploy mints fresh', async () => {
const user = await makeUser();
const workerName = name();
const first = await target.getOrCreateWorker(user.id, {
appUid: null,
workerName,
});
// Warm both the uuid and the composite worker cache.
expect(await target.getByUuidAny(first.uuid)).toBeTruthy();
expect(
await target.getWorker(user.id, { appUid: null, workerName }),
).toBeTruthy();
await target.deleteWorkerSessions({ workerName });
expect(await target.getByUuidAny(first.uuid)).toBeNull();
const second = await target.getOrCreateWorker(user.id, {
appUid: null,
workerName,
});
expect(second.uuid).not.toBe(first.uuid);
});
it('refuses to run without a filter', async () => {
const user = await makeUser();
const row = await target.getOrCreateWorker(user.id, {
appUid: null,
workerName: name(),
});
expect(await target.deleteWorkerSessions({})).toEqual([]);
expect(await rawRow(row.uuid)).not.toBeNull();
});
});
describe('error propagation (no silent swallow)', () => {
// INSERT-IGNORE used to mask every constraint violation, not just
// the partial-unique-index conflict the `getOrCreate*` paths rely
@@ -510,7 +510,6 @@ export class SubdomainStore extends PuterStore {
* }} opts
*/
async create({
uuid = uuidv4(),
userId,
subdomain,
rootDirId = null,
@@ -519,8 +518,6 @@ export class SubdomainStore extends PuterStore {
preambleVersion = null,
isProtected = false,
}: {
/** Pre-generated by a caller that has to hand the uuid out first. */
uuid?: string;
userId: number;
subdomain: string;
rootDirId?: number | null;
@@ -533,6 +530,7 @@ export class SubdomainStore extends PuterStore {
if (!userId || !subdomain) {
throw new Error('create: userId and subdomain are required');
}
const uuid = uuidv4();
await this.clients.db.write(
`INSERT INTO \`subdomains\`
(\`uuid\`, \`subdomain\`, \`user_id\`, \`root_dir_id\`, \`associated_app_id\`, \`app_owner\`, \`preamble_version\`, \`protected\`)
+2 -11
View File
@@ -17,7 +17,6 @@ import {
import type { PoolConfig } from 'pg';
import { ADMIN_GROUP_UID } from './services/selfhosted/DefaultUserService';
import { FULL_API_ACCESS } from './services/permission/consts';
import { WORKER_SUBDOMAIN_PREFIX } from './stores/subdomain/SubdomainStore.js';
import { generateDefaultFsentries } from './util/userProvisioning';
export const POSTGRES_TEST_MIGRATIONS_PATH =
@@ -310,20 +309,12 @@ export const createTestUser = async (
);
// Mint a user-scoped worker token the same way deploying an app-less
// worker does (WorkerDriver falls back to createWorkerSessionToken). The
// token only lives as long as its worker row, so seed one.
const workerName = `test-env-worker-${user.id}`;
const workerRow = await server.stores.subdomain.create({
userId: user.id,
subdomain: `${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
});
// worker does (WorkerDriver falls back to createWorkerSessionToken).
const { token: workerToken } =
await server.services.auth.createWorkerSessionToken(
makeActor({ user }),
user,
workerName,
{},
{ workerUid: workerRow.uuid },
'puter-test-env-worker',
);
return {
+2 -2
View File
@@ -20,7 +20,7 @@ puter.ai.txt2speech.listEngines(options)
A provider name to query. When passed as a string, this is shorthand for `{ provider }`. Defaults to `'aws-polly'`.
Accepted values: `'aws-polly'`, `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, or `'all'` to list every provider at once.
Accepted values are `'aws-polly'`, `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, `'speechify'`, or `'all'` to list every provider at once.
Common aliases are also accepted (e.g. `'eleven'`, `'google'`, `'grok'`). An unrecognized provider is rejected with a `bad_request` error.
@@ -28,7 +28,7 @@ Common aliases are also accepted (e.g. `'eleven'`, `'google'`, `'grok'`). An unr
| Option | Type | Description |
|--------|------|-------------|
| `provider` | `String` | TTS provider to query. Defaults to `'aws-polly'`; `'all'` returns every provider's engines |
| `provider` | `String` | TTS provider to query. Defaults to `'aws-polly'`. The value `'all'` returns every provider's engines. |
## Return value
+1 -1
View File
@@ -19,7 +19,7 @@ puter.ai.txt2speech.listVoices(options)
| Option | Type | Description |
|--------|------|-------------|
| `provider` | `String` | TTS provider to query. Defaults to `'aws-polly'`. Accepted: `'aws-polly'`, `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, or `'all'` to list every provider at once. Common aliases are also accepted (e.g. `'eleven'`, `'google'`, `'grok'`). |
| `provider` | `String` | TTS provider to query. Defaults to `'aws-polly'`. Accepted values are `'aws-polly'`, `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, `'speechify'`, or `'all'` to list every provider at once. Common aliases are also accepted (e.g. `'eleven'`, `'google'`, `'grok'`). |
| `engine` | `String` | Engine/model filter (provider-specific, ignored by some providers) |
When `options` is a plain string it is treated as an `engine` filter for the default (AWS Polly) provider.
+8 -12
View File
@@ -42,8 +42,8 @@ A plain-text body. Shorthand for `text`; use the options form for anything else.
- `attachments` (Array) - Up to **10** attachments, **25 MB** in total. Each is an object:
- `filename` (String) - Required with `content`; defaults to the file's name for a `path`/`uid` attachment.
- `content` (String) - The file body as base64. Mutually exclusive with `path`/`uid`.
- `path` (String) - A Puter path (`~/` allowed, meaning the caller's home). The file is read on the server with the caller's file permissions only, and streamed rather than uploaded — prefer this over `content` for anything larger than a few hundred kilobytes.
- `uid` (String) - A Puter file uid, as an alternative to `path`. Read with the caller's file permissions, falling back to the worker owner's — use a uid to attach a file from the worker owner's storage.
- `path` (String) - A Puter path (`~/` allowed). The file is read on the server with the caller's file permissions, falling back to the worker owner's, and streamed rather than uploaded — prefer this over `content` for anything larger than a few hundred kilobytes.
- `uid` (String) - A Puter file uid, as an alternative to `path`.
- `contentType` (String) - MIME type. Detected from the file for `path`/`uid` attachments.
- `cid` (String) - Content-ID that makes the attachment an inline part of the `html` body: with `cid: 'logo'`, `<img src="cid:logo">` shows it. Printable ASCII with no whitespace or angle brackets. Inline parts are billed like any other attachment.
@@ -52,7 +52,7 @@ A plain-text body. Shorthand for `text`; use the options form for anything else.
A `Promise` that resolves to an object:
- `messageId` (String | null) - The `Message-ID` of the first delivery: the transport's for a relayed copy, the stored message's for a Puter mailbox.
- `cost` (Number) - What the send was charged, in microcents: one delivery's price for each recipient in neither `failed` nor `suppressed`.
- `cost` (Number) - What the send was charged, in microcents.
- `suppressed` (Array) - Recipients (lowercased) dropped because they unsubscribed from your app's mail. The message went to the others.
- `failed` (Array) - Recipients (lowercased) whose delivery failed. Everyone else got their copy — retry with just these addresses.
@@ -65,7 +65,7 @@ In case of an error, the `Promise` rejects with `{ message, code }`. Codes you m
- `subscription_required` (402) - The calling account is not on a paid plan.
- `insufficient_funds` (402) - The calling account has no usage credit left for this send.
- `bad_request` (400) - Invalid arguments: a bad address, a missing body, too many recipients, an invalid attachment.
- `not_found` (404) - An attachment's `path`/`uid` could not be read; or every recipient was a Puter address with no mailbox to receive in.
- `not_found` (404) - Every recipient was a Puter address with no mailbox to receive in.
- `too_many_requests` (429) - Over the rate limit; see [Rate Limits and Quotas](/rate-limits-and-quotas/#email).
## Recipients on Puter
@@ -84,7 +84,7 @@ Every message carries an unsubscribe link and a report-abuse link. Opting out is
## Limits and cost
Sends are rate-limited per calling account; the numbers are on [Rate Limits and Quotas](/rate-limits-and-quotas/#email). Each recipient's delivery costs a flat charge plus a per-byte charge for the attachments it carries, so a send to three recipients costs three deliveries. Only deliveries that succeed are charged — retrying the `failed` addresses never pays twice — and a send that reaches nobody is not charged. The calling account pays.
Sends are rate-limited per calling account; the numbers are on [Rate Limits and Quotas](/rate-limits-and-quotas/#email). A send costs a flat charge per message plus a per-byte charge for attachments, and is charged to the calling account. A send that reaches nobody is not charged.
## Examples
@@ -123,10 +123,6 @@ router.post('/api/receipt', async ({ request, user }) => {
<strong class="example-title">Attach a file and show an inline image</strong>
```js
// The uid of a file in the worker owner's storage, e.g. from
// `(await me.puter.fs.stat('~/Public/logo.png')).uid`.
const LOGO_UID = '...';
router.post('/api/invoice', async ({ request, user }) => {
const { to } = await request.json();
return await user.puter.email.sendTransactional({
@@ -134,9 +130,9 @@ router.post('/api/invoice', async ({ request, user }) => {
subject: 'Your invoice',
html: '<img src="cid:logo" alt="Acme"><p>Your invoice is attached.</p>',
attachments: [
// A uid falls back to the worker owner's storage; shown inline via the cid.
{ uid: LOGO_UID, cid: 'logo' },
// A path is always the caller's: streamed from the user's own storage.
// Streamed from the worker owner's storage; shown inline via the cid.
{ path: '~/Public/logo.png', cid: 'logo' },
// Streamed from the user's own storage as a regular attachment.
{ path: '~/Documents/invoice.pdf' },
],
emailAccessToken: me.puter.authToken,
+1 -1
View File
@@ -21,4 +21,4 @@ The provider this engine belongs to, e.g. `'aws-polly'`, `'openai'`, `'elevenlab
#### `pricing_per_million_chars` (Number)
An optional cost per million characters. May be absent when the provider does not expose pricing.
An optional cost in US dollars per million characters. May be absent when the provider does not expose pricing.
+3 -4
View File
@@ -24,10 +24,9 @@ import { forgetGodmodeTokens } from './godmodeTokens.js';
* response.
*
* `app` rows, plus the `access_token` row a godmode app runs on (the only
* access tokens that carry an `app_uid`). `worker` rows also carry one, but the
* server deletes them itself when the app's grants are revoked with `*`, and
* access tokens the app issued follow on their own through the server-side
* cascade.
* access tokens that carry an `app_uid`). `worker` rows also carry one but are
* deployment credentials rather than this user's grant to the app, and access
* tokens the app issued follow on their own through the server-side cascade.
*
* @param {{ uuid?: string; kind?: string; app_uid?: string | null }[]} sessions
* @param {string} appUid
+30 -116
View File
@@ -2,23 +2,18 @@
// Multipart email files are handled as String'd FormData to save on Multipart parsing logic. The primary goal of this library
// is not necessarily high performance but high maintanability.
import { PuterJSError } from '../../lib/PuterJSError.js';
/** Every refusal here is the caller's input; the SDK error contract wants a code. */
const invalid = (message) => new PuterJSError(message, 'invalid_request');
/**
* One attachment: either inline base64 `content`, or a Puter FS reference
* (`path`/`uid`) read server-side with the caller's file permissions; a `uid`
* falls back to the authorizing worker's. FS references are streamed from
* (`path`/`uid`) read server-side with the caller's — falling back to the
* authorizing worker's — file permissions. FS references are streamed from
* storage and never travel through the request, so prefer them for anything
* larger than a few hundred kilobytes.
*
* @typedef {Object} EmailAttachment
* @property {string} [filename] Required with `content`; defaults to the file's name for FS refs.
* @property {string} [content] Base64 file body. Mutually exclusive with `path`/`uid`.
* @property {string} [path] Puter FS path (supports `~/`, the caller's home). Mutually exclusive with `content`.
* @property {string} [uid] Puter FS entry uid; the only way to attach a file of the worker owner's. Mutually exclusive with `content`.
* @property {string} [path] Puter FS path (supports `~/`). Mutually exclusive with `content`.
* @property {string} [uid] Puter FS entry uid. Mutually exclusive with `content`.
* @property {string|undefined} [cid] Email content ID for inline images
* @property {string} [contentType] MIME type of the attachment.
*/
@@ -45,103 +40,25 @@ export function getRFC822DateUTC(date = new Date()) {
export function emlHeader(key, value) {
if (!isASCII(key)) {
throw invalid("eml header key " + key + " is not valid ASCII");
throw new Error("eml header key " + key + " is not valid ASCII");
}
if (Array.isArray(value)) {
value.forEach((val) => {
if (!isASCII(val)) {
throw invalid("eml header value " + val + " is not valid ASCII");
throw new Error("eml header value " + val + " is not valid ASCII");
}
})
return `${key}: ${value.join(', ')}\r\n`;
} else {
if (!isASCII(value)) {
throw invalid("eml header value " + value + " is not valid ASCII");
throw new Error("eml header value " + value + " is not valid ASCII");
}
return `${key}: ${value}\r\n`;
}
}
// UTF-8 bytes per encoded word: 45 bytes is 60 base64 characters, which with
// the `=?UTF-8?B?` and `?=` wrapping stays inside RFC 2047's 75.
const ENCODED_WORD_BYTES = 45;
const bytesToBase64 = (bytes) => {
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
};
/**
* Header text as RFC 2047 encoded words: printable ASCII comes back as is,
* anything else as `=?UTF-8?B?...?=` words split on character boundaries and
* folded onto continuation lines. Control characters are refused either way:
* they have no business in a header, encoded or not.
*
* @param {string} text
* @returns {string}
*/
export function encodeWord(text) {
if (typeof text !== 'string' || /[\x00-\x1f\x7f]/.test(text)) {
throw invalid("eml header value " + text + " is not valid");
}
if (isASCII(text)) return text;
const encoder = new TextEncoder();
const words = [];
let pending = [];
for (const char of text) {
const bytes = encoder.encode(char);
if (pending.length + bytes.length > ENCODED_WORD_BYTES) {
words.push(pending);
pending = [];
}
pending.push(...bytes);
}
if (pending.length > 0) words.push(pending);
return words.map(word => `=?UTF-8?B?${bytesToBase64(word)}?=`).join('\r\n ');
}
/**
* One address-list entry, its display name encoded when it needs to be. The
* address itself has to stay ASCII: mail without SMTPUTF8 has no encoding
* for it.
*
* @param {string} entry `addr@host` or `Name <addr@host>`
* @returns {string}
*/
function encodeAddress(entry) {
if (isASCII(entry)) return entry;
const match = /^\s*(.*?)\s*<([^<>]*)>\s*$/.exec(String(entry));
if (!match || !isASCII(match[2])) {
throw invalid("eml address " + entry + " is not valid ASCII");
}
const name = match[1].replace(/^"(.*)"$/, '$1').replace(/\\(.)/g, '$1');
return `${encodeWord(name)} <${match[2]}>`;
}
/** A free-text header (Subject), RFC 2047 encoded when not plain ASCII. */
export function textHeader(key, value) {
if (!isASCII(key)) {
throw invalid("eml header key " + key + " is not valid ASCII");
}
return `${key}: ${encodeWord(value)}\r\n`;
}
/**
* An address-list header (To, Cc, Bcc, Reply-To, From) with non-ASCII display
* names encoded. A string is one entry, or an ASCII list passed through as is.
*/
export function addressHeader(key, value) {
if (!isASCII(key)) {
throw invalid("eml header key " + key + " is not valid ASCII");
}
const entries = Array.isArray(value) ? value : [value];
return `${key}: ${entries.map(encodeAddress).join(', ')}\r\n`;
}
export function determineTopLevelMimeType({ attachments = [], text, html }) {
const inlineAttachments = attachments.filter(a => a.cid);
const regularAttachments = attachments.filter(a => !a.cid);
@@ -161,7 +78,7 @@ export function determineTopLevelMimeType({ attachments = [], text, html }) {
if (html) {
return { isMultiPart: false, mimeType: 'text/html; charset=UTF-8' };
}
throw invalid('Email must have text, html, or an attachment');
throw new Error('Email must have text, html, or an attachment');
}
@@ -183,22 +100,21 @@ export const isASCII = (str) => /^[\x20-\x7E]*$/.test(str);
/**
* Composes a multipart email attachment
*
* @param {EmailAttachment} attachment
* @param {import('../../index.js').Puter} [puter] Reads `path` attachments; the module's own instance.
*
* @param {EmailAttachment} attachment
*/
export async function composeAttachment({ cid, content, contentType, filename, path, uid }, puter = globalThis.puter) {
export async function composeAttachment({ cid, content, contentType, filename, path, uid }) {
if (path && content) {
throw invalid("Path and Content are mutually exclusive");
throw new Error("Path and Content are mutually exclusive");
}
if (!path && !content && !uid) {
throw invalid("Attachment requires content, path, or uid");
throw new Error("Attachment requires content, path, or uid");
}
if (uid) {
throw invalid("uid attachments are not supported yet");
throw new Error("uid attachments are not supported yet");
}
if (contentType && !filename) {
throw invalid("filename parameter required for contentType");
throw new Error("filename parameter required for contentType");
}
let b64emailContent = content;
@@ -213,12 +129,12 @@ export async function composeAttachment({ cid, content, contentType, filename, p
}
if (!filename) {
throw invalid("Attachment's filename cannot be determined automatically. Please provide filename!");
throw new Error("Attachment's filename cannot be determined automatically. Please provide filename!");
}
filename = filename.replaceAll('\\', '\\\\');
filename = filename.replaceAll('"', '\\"');
if (!isASCII(filename)) {
throw invalid("filename must be ascii");
throw new Error("filename must be ascii");
}
if (contentType) {
@@ -226,7 +142,7 @@ export async function composeAttachment({ cid, content, contentType, filename, p
}
if (!mimeType) {
throw invalid("Attachment's mimetype cannot be determined automatically. Please provide contentType!");
throw new Error("Attachment's mimetype cannot be determined automatically. Please provide contentType!");
}
mimeType += ';name="' + filename + '"';
@@ -252,36 +168,34 @@ export function combineParts(parts, boundary) {
}
/**
*
* @param {EmailComposeOptions} param0
* @param {import('../../index.js').Puter} [puter] The module's own instance: who the mail is from, and
* whose storage `path` attachments are read from.
*
* @param {EmailComposeOptions} param0
*/
export async function compose({ from, to, cc, bcc, subject, replyTo, attachments = [], text, html }, puter = globalThis.puter) {
export async function compose({ from, to, cc, bcc, subject, replyTo, attachments = [], text, html }) {
// --- Header constructing logic ---
let headerLines = "MIME-Version: 1.0\r\n";
headerLines += emlHeader('Date', getRFC822DateUTC());
if (subject) {
headerLines += textHeader('Subject', subject);
headerLines += emlHeader('Subject', subject);
}
if (to) {
headerLines += addressHeader('To', to);
headerLines += emlHeader('To', to);
} else {
throw invalid("Cannot compose email without recipient!");
throw new Error("Cannot compose email without recipient!");
}
if (cc) {
headerLines += addressHeader('cc', cc);
headerLines += emlHeader('cc', cc);
}
if (bcc) {
headerLines += addressHeader('bcc', bcc);
headerLines += emlHeader('bcc', bcc);
}
if (replyTo) {
headerLines += addressHeader('Reply-To', replyTo);
headerLines += emlHeader('Reply-To', replyTo);
}
if (from) {
headerLines += addressHeader('From', from);
headerLines += emlHeader('From', from);
} else {
// Add From Block (hardcoded to puter.email for now)
const userinfo = await puter.getUser();
@@ -315,8 +229,8 @@ export async function compose({ from, to, cc, bcc, subject, replyTo, attachments
const inlineAttachments = attachments.filter(a => a.cid);
const regularAttachments = attachments.filter(a => !a.cid);
const inlineParts = await Promise.all(inlineAttachments.map(a => composeAttachment(a, puter)));
const regularParts = await Promise.all(regularAttachments.map(a => composeAttachment(a, puter)));
const inlineParts = await Promise.all(inlineAttachments.map(composeAttachment));
const regularParts = await Promise.all(regularAttachments.map(composeAttachment));
// Build the text/html content as one part. Only wrap it in its own nested
// multipart/alternative (fresh boundary) when both text and html are present.
@@ -502,63 +502,8 @@ describe('compose', () => {
await expect(compose({ ...baseOptions, attachments: [] })).rejects.toThrow(/text, html, or an attachment/);
});
it('encodes a non-ASCII subject as RFC 2047 words instead of refusing it', async () => {
const eml = await compose({ ...baseOptions, subject: 'héllo', text: 'hi' });
expect(eml).toContain('Subject: =?UTF-8?B?aMOpbGxv?=\r\n');
expect(isASCII(eml.split('\r\n\r\n')[0].replace(/\r\n/g, ''))).toBe(true);
});
it('folds a long non-ASCII subject into words of at most 75 characters', async () => {
const subject = 'Réunion — 東京 🎉 '.repeat(12);
const eml = await compose({ ...baseOptions, subject, text: 'hi' });
const head = eml.split('\r\n\r\n')[0];
const subjectLines = head.slice(head.indexOf('Subject: ')).split('\r\n');
const words = [subjectLines[0].slice('Subject: '.length)];
for ( const line of subjectLines.slice(1) ) {
if ( ! line.startsWith(' ') ) break;
words.push(line.slice(1));
}
expect(words.length).toBeGreaterThan(1);
for ( const word of words ) {
expect(word).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/);
expect(word.length).toBeLessThanOrEqual(75);
}
});
it('refuses control characters in a subject, encoded or not', async () => {
await expect(compose({ ...baseOptions, subject: 'hi\r\nBcc: [email protected]', text: 'hi' }))
.rejects.toMatchObject({ code: 'invalid_request' });
await expect(compose({ ...baseOptions, subject: 'héllo\r\nBcc: [email protected]', text: 'hi' }))
.rejects.toMatchObject({ code: 'invalid_request' });
});
it('refuses a non-ASCII address while allowing a non-ASCII display name', async () => {
await expect(compose({ ...baseOptions, to: 'ü@example.com', text: 'hi' }))
.rejects.toMatchObject({ code: 'invalid_request' });
await expect(compose({ ...baseOptions, to: 'Jürgen <ü@example.com>', text: 'hi' }))
.rejects.toMatchObject({ code: 'invalid_request' });
await expect(compose({ ...baseOptions, to: 'Jürgen <[email protected]>', text: 'hi' })).resolves.toBeTypeOf('string');
});
it('rejects with a coded PuterJSError, per the SDK error contract', async () => {
await expect(compose({ subject: 'hi', text: 'hi' }))
.rejects.toMatchObject({ name: 'PuterJSError', code: 'invalid_request' });
await expect(composeAttachment({ uid: 'uid-1' }))
.rejects.toMatchObject({ code: 'invalid_request' });
expect(() => emlHeader('Subject', 'héllo')).toThrow(expect.objectContaining({ code: 'invalid_request' }));
});
it('uses the instance it is given rather than the global one', async () => {
const own = {
fs: { read: vi.fn(async () => new Blob([PNG_BYTES], { type: 'image/png' })) },
getUser: vi.fn(async () => ({ username: 'carol' })),
};
const eml = await compose({ to: '[email protected]', subject: 'hi', html: '<p/>', attachments: [{ path: '~/x.png', cid: 'c' }] }, own);
expect(eml).toContain('From: [email protected]\r\n');
expect(own.fs.read).toHaveBeenCalledWith('~/x.png');
expect(own.getUser).toHaveBeenCalled();
expect(fsRead).not.toHaveBeenCalled();
expect(getUser).not.toHaveBeenCalled();
it('rejects a non-ASCII subject', async () => {
await expect(compose({ ...baseOptions, subject: 'héllo', text: 'hi' })).rejects.toThrow(/not valid ASCII/);
});
});
@@ -568,24 +513,6 @@ describe('compose round-trips through a MIME parser', () => {
// The parser terminates every text body with a newline of its own.
const bodyOf = (str) => str?.replace(/\n$/, '');
it('recovers a UTF-8 subject and display names', async () => {
const subject = 'Grüße ✓ — 東京 🎉 '.repeat(6).trim();
const parsed = await parse({
subject,
to: ['"José Pérez" <[email protected]>', '[email protected]'],
cc: 'Zoë <[email protected]>',
from: 'Ålice <[email protected]>',
text: 'hi',
});
expect(parsed.subject).toBe(subject);
expect(parsed.to).toEqual([
{ address: '[email protected]', name: 'José Pérez' },
{ address: '[email protected]', name: '' },
]);
expect(parsed.cc).toEqual([{ address: '[email protected]', name: 'Zoë' }]);
expect(parsed.from).toEqual({ address: '[email protected]', name: 'Ålice' });
});
it('recovers a plain text body', async () => {
const parsed = await parse({ text: 'hello there' });
expect(bodyOf(parsed.text)).toBe('hello there');
+3 -16
View File
@@ -32,8 +32,7 @@ import { list } from './list.js';
*
* @typedef {Object} EmailSendResult
* @property {string | null} messageId First transport message id reported for this send, when available.
* @property {number} cost Total charge for this send, in microcents: one delivery's price for each
* recipient that got a copy.
* @property {number} cost Total charge for this send, in microcents.
* @property {string[]} suppressed Recipients omitted because they opted out of this app's mail.
* @property {string[]} failed Recipients whose delivery attempt failed. Everyone else got their copy —
* retry with just these addresses. A send where every delivery fails rejects instead.
@@ -83,8 +82,7 @@ const preprocessSendArgs = (args) => {
* // Inline or Puter-FS attachments:
* attachments: [
* { filename, content, contentType }, // content = base64
* { path: '~/Documents/report.pdf' }, // the caller's file, streamed server-side
* { uid: LOGO_UID }, // a uid may also name a file of the worker owner's
* { path: '~/Documents/report.pdf' }, // streamed server-side
* ],
* });
* });
@@ -143,9 +141,6 @@ export class EmailModule extends PuterModule {
iface: 'puter-transactional-email',
method: 'sendTransactional',
argNames: ['to', 'subject', 'body'],
// This module's instance, not `globalThis.puter`: in a worker the two
// differ (`user.puter` vs `me.puter`), and the caller is the one billed.
puter: this.puter,
preprocess: preprocessSendArgs,
upgradePrompt: {
method: 'puter.email.sendTransactional',
@@ -162,15 +157,7 @@ export class EmailModule extends PuterModule {
* @returns {Promise<unknown>}
*/
send = async (options) => {
// `includePuterAuth` reads the global instance's token, so any other
// instance (a worker's `user.puter`) sends its own.
const isGlobal = this.puter === globalThis.puter;
const req = await fetchUrl(`${this.APIOrigin}/email/send`, {
method: "POST",
includePuterAuth: isGlobal,
...(isGlobal ? {} : { authToken: this.authToken }),
body: new Blob([await compose(options, this.puter)], { type: 'message/rfc822' }),
});
const req = await fetchUrl(`${this.APIOrigin}/email/send`, { method: "POST", includePuterAuth: true, body: new Blob([await compose(options)], { type: 'message/rfc822' }) });
const result = await req.json();
if ( ! req.ok ) {
promptIfUpgradeRequired(
@@ -1,77 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
/**
* The email methods have to act for the instance they hang off, not for
* `globalThis.puter`: in a worker, `user.puter` and `me.puter` are different
* identities, and the one making the call is the one billed and rate-limited.
*/
const captured = vi.hoisted(() => ({ specs: [], fetches: [] }));
vi.mock('../../lib/utils.js', async (importOriginal) => ({
...(await importOriginal()),
makeDriverMethod: (spec) => {
captured.specs.push(spec);
return async () => ({});
},
}));
vi.mock('../../lib/networkUtils.js', async (importOriginal) => ({
...(await importOriginal()),
fetchUrl: async (url, opts) => {
captured.fetches.push({ url, opts });
return { ok: true, status: 200, json: async () => ({ delivered: [] }) };
},
}));
const { EmailModule } = await import('./Email.js');
const origPuter = globalThis.puter;
const makePuter = (username, authToken) => ({
APIOrigin: 'https://api.example.test',
authToken,
getUser: vi.fn(async () => ({ username })),
fs: { read: vi.fn() },
});
beforeEach(() => {
captured.specs.length = 0;
captured.fetches.length = 0;
globalThis.puter = makePuter('global', 'global-token');
});
afterEach(() => {
globalThis.puter = origPuter;
});
describe('EmailModule binds to its own instance', () => {
it('hands sendTransactional its own puter, not the global one', () => {
const own = makePuter('caller', 'caller-token');
new EmailModule(own);
const spec = captured.specs.find(s => s.method === 'sendTransactional');
expect(spec.puter).toBe(own);
});
it('sends from, and authenticates as, a non-global instance', async () => {
const own = makePuter('caller', 'caller-token');
const email = new EmailModule(own);
await email.send({ to: '[email protected]', subject: 'hi', text: 'hi' });
expect(own.getUser).toHaveBeenCalled();
expect(globalThis.puter.getUser).not.toHaveBeenCalled();
const [{ url, opts }] = captured.fetches;
expect(url).toBe('https://api.example.test/email/send');
expect(opts.includePuterAuth).toBe(false);
expect(opts.authToken).toBe('caller-token');
expect(await opts.body.text()).toContain('From: [email protected]\r\n');
});
it('keeps the live-token path for the global instance', async () => {
const email = new EmailModule(globalThis.puter);
await email.send({ to: '[email protected]', subject: 'hi', text: 'hi' });
const [{ opts }] = captured.fetches;
expect(opts.includePuterAuth).toBe(true);
expect(opts.authToken).toBeUndefined();
});
});
+6 -20
View File
@@ -122,24 +122,15 @@ export const toSummary = (entry, folder) => {
};
/**
* Where a listing resumes: the day folder being walked; while it still has
* pages, the readdir cursor within it; and the readdir cursor of the page of
* day folders that day was read from, so resuming does not re-list every day
* newer than it. A cursor without that last part (issued before it existed)
* walks the days from the top, which is only slower.
* Where a listing resumes: the day folder being walked and, while it still has
* pages, the readdir cursor within it.
*
* @typedef {{ folder: EmailFolder, day: string, fsCursor?: string, daysCursor?: string }} ListPosition
* @typedef {{ folder: EmailFolder, day: string, fsCursor?: string }} ListPosition
*/
/** @param {ListPosition} position @returns {string} */
export const encodeCursor = ({ folder, day, fsCursor, daysCursor }) => {
const payload = {
v: 1,
f: folder,
d: day,
...(fsCursor ? { c: fsCursor } : {}),
...(daysCursor ? { dc: daysCursor } : {}),
};
export const encodeCursor = ({ folder, day, fsCursor }) => {
const payload = { v: 1, f: folder, d: day, ...(fsCursor ? { c: fsCursor } : {}) };
return btoa(JSON.stringify(payload));
};
@@ -162,12 +153,7 @@ export const decodeCursor = (cursor, folder) => {
if ( payload.f !== folder ) {
throw new PuterJSError('Cursor does not match the requested folder', 'invalid_request');
}
return {
folder,
day: payload.d,
...(typeof payload.c === 'string' ? { fsCursor: payload.c } : {}),
...(typeof payload.dc === 'string' ? { daysCursor: payload.dc } : {}),
};
return { folder, day: payload.d, ...(typeof payload.c === 'string' ? { fsCursor: payload.c } : {}) };
};
/** A readdir on a folder that was never created. @param {unknown} e */
+32 -48
View File
@@ -12,49 +12,36 @@ import {
/** @typedef {import('../../lib/types.js').ListPage<EmailSummary>} EmailListPage */
/** @typedef {import('./lib/mailbox.js').ListPosition} ListPosition */
// Day folders read per directory page. Small on purpose: a listing resumes
// from the page its cursor's day came from, so this bounds what every later
// page re-reads before reaching its own day. About a month of a mailbox that
// gets mail daily.
const DAY_PAGE_LIMIT = 30;
// Page size for the SDK's own walks over the mailbox's day folders.
const DIRECTORY_PAGE_LIMIT = 1000;
/**
* The day folders in `folder`, newest first, read a directory page at a time
* starting at `daysCursor`. Each day comes with the cursor of the page it was
* read from, which is where a later listing can pick the walk back up. A
* mailbox that was never written to has no folder at all, which walks as
* empty.
* Every day folder in `folder`, newest first. A mailbox that was never
* written to has no folder at all, which lists as empty.
*
* @param {import('../../index.js').Puter} puter
* @this {import('./Email.js').EmailModule}
* @param {EmailFolder} folder
* @param {string | undefined} daysCursor
* @returns {AsyncGenerator<{ day: string, daysCursor?: string }>}
* @returns {Promise<string[]>}
*/
async function* walkDays (puter, folder, daysCursor) {
let cursor = daysCursor ?? null;
while ( true ) {
/** @type {{ items: Array<Record<string, any>>, cursor?: string }} */
let page;
try {
page = await puter.fs.readdir({
path: folderRoot(folder),
limit: DAY_PAGE_LIMIT,
sortBy: 'name',
sortOrder: 'desc',
cursor,
});
} catch (e) {
if ( ! isMissingDirectory(e) ) throw e;
return;
}
for ( const entry of page.items ) {
if ( entry.is_dir && isDayName(entry.name) ) {
yield { day: entry.name, ...(cursor ? { daysCursor: cursor } : {}) };
async function listDays (folder) {
const fetchPage = pageParams => this.puter.fs.readdir({
path: folderRoot(folder),
limit: DIRECTORY_PAGE_LIMIT,
sortBy: 'name',
sortOrder: 'desc',
...pageParams,
});
const days = [];
try {
for await ( const page of iteratePages(fetchPage) ) {
for ( const entry of page.items ) {
if ( entry.is_dir && isDayName(entry.name) ) days.push(entry.name);
}
}
if ( ! page.cursor ) return;
cursor = page.cursor;
} catch (e) {
if ( ! isMissingDirectory(e) ) throw e;
}
return days.sort().reverse();
}
/**
@@ -69,20 +56,18 @@ async function* walkDays (puter, folder, daysCursor) {
* @returns {Promise<EmailListPage>}
*/
async function fetchPage (folder, limit, position) {
const days = walkDays(this.puter, folder, position?.daysCursor);
const days = await listDays.call(this, folder);
/** @type {EmailSummary[]} */
const items = [];
// Resume at the cursor's day; a day folder deleted since the cursor was
// issued means continuing from the next older one.
let next = await days.next();
while ( position && ! next.done && next.value.day > position.day ) {
next = await days.next();
}
let fsCursor = position && ! next.done && next.value.day === position.day ? position.fsCursor : undefined;
let dayIndex = position ? days.findIndex(day => day <= position.day) : 0;
if ( dayIndex === -1 ) return { items };
let fsCursor = position && days[dayIndex] === position.day ? position.fsCursor : undefined;
while ( ! next.done && items.length < limit ) {
const { day, daysCursor } = next.value;
while ( dayIndex < days.length && items.length < limit ) {
const day = days[dayIndex];
// Keep reading this day until the page is full or the day is spent:
// entries that are not mail objects are skipped, and skipping must
// not shorten the page while the same day still has messages.
@@ -109,14 +94,13 @@ async function fetchPage (folder, limit, position) {
if ( ! fsCursor ) break;
}
if ( fsCursor ) {
return { items, cursor: encodeCursor({ folder, day, fsCursor, daysCursor }) };
return { items, cursor: encodeCursor({ folder, day, fsCursor }) };
}
next = await days.next();
dayIndex += 1;
}
if ( ! next.done ) {
const { day, daysCursor } = next.value;
return { items, cursor: encodeCursor({ folder, day, daysCursor }) };
if ( dayIndex < days.length ) {
return { items, cursor: encodeCursor({ folder, day: days[dayIndex] }) };
}
return { items };
}
@@ -79,13 +79,6 @@ describe('cursors', () => {
expect(decodeCursor(encodeCursor({ folder: 'sent', day: '2024-03-15' }), 'sent')).toEqual({ folder: 'sent', day: '2024-03-15' });
});
it('carries the day-listing cursor, and reads one issued without it', () => {
const position = { folder: 'inbox', day: '2024-03-15', fsCursor: 'abc', daysCursor: 'dc1' };
expect(decodeCursor(encodeCursor(position), 'inbox')).toEqual(position);
const legacy = btoa(JSON.stringify({ v: 1, f: 'inbox', d: '2024-03-15', c: 'abc' }));
expect(decodeCursor(legacy, 'inbox')).toEqual({ folder: 'inbox', day: '2024-03-15', fsCursor: 'abc' });
});
it('treats null and undefined as the first page', () => {
expect(decodeCursor(null, 'inbox')).toBeUndefined();
expect(decodeCursor(undefined, 'inbox')).toBeUndefined();
@@ -215,61 +208,6 @@ describe('list()', () => {
expect(calls.every(c => c.path.startsWith('~/.mail/sent'))).toBe(true);
});
it('resumes the day walk where the cursor left it instead of re-listing from the top', async () => {
// 100 days, one message each: far more day folders than one page of them.
const tree = { '~/.mail/objects': [] };
const dayIds = [];
for ( let i = 0; i < 100; i++ ) {
const ms = Date.UTC(2024, 0, 1) + i * 24 * 60 * 60 * 1000;
const day = new Date(ms).toISOString().slice(0, 10);
const id = uuidAt(ms + 1000);
tree['~/.mail/objects'].push(day);
tree[`~/.mail/objects/${ day }`] = [`${ id }--${ toB64Url(day) }`];
dayIds.unshift(id);
}
const { fs, calls } = fakeFs(tree);
const email = moduleOver(fs);
const seen = [];
let cursor = null;
do {
const before = calls.length;
const page = await email.list.call(email, { limit: 7, cursor });
seen.push(...page.items.map(m => m.id));
const dayListings = calls.slice(before).filter(c => c.path === '~/.mail/objects');
// Never more than the page holding the cursor's day plus the one
// after it, however deep into the mailbox the cursor points.
expect(dayListings.length).toBeLessThanOrEqual(2);
if ( cursor ) {
const resumedFrom = decodeCursor(cursor, 'inbox').daysCursor ?? null;
expect(dayListings[0].cursor).toBe(resumedFrom);
}
cursor = page.cursor;
} while ( cursor );
expect(seen).toEqual(dayIds);
// Only pages whose day sits in the first page of day folders start
// there; the deeper ones resumed mid-folder.
const fromTop = calls.filter(c => c.path === '~/.mail/objects' && ! c.cursor);
expect(fromTop.length).toBeLessThanOrEqual(Math.ceil(30 / 7) + 1);
expect(calls.some(c => c.path === '~/.mail/objects' && c.cursor)).toBe(true);
});
it('resumes from a cursor issued before day-listing cursors existed', async () => {
const { fs } = fakeFs(inboxTree);
const email = moduleOver(fs);
const legacy = btoa(JSON.stringify({ v: 1, f: 'inbox', d: '2024-03-15' }));
const page = await email.list.call(email, { limit: 10, cursor: legacy });
expect(page.items.map(m => m.id)).toEqual([ids.d15a, ids.d14b, ids.d14a]);
});
it('continues from the next older day when the cursor day was deleted', async () => {
const { fs } = fakeFs({ ...inboxTree, '~/.mail/objects': ['2024-03-14', '2024-03-16'] });
const email = moduleOver(fs);
const gone = encodeCursor({ folder: 'inbox', day: '2024-03-15', fsCursor: '5' });
const page = await email.list.call(email, { limit: 10, cursor: gone });
expect(page.items.map(m => m.id)).toEqual([ids.d14b, ids.d14a]);
});
it('rejects offset, includeTotal, and a foreign cursor before any request', async () => {
const { fs, calls } = fakeFs(inboxTree);
const email = moduleOver(fs);
+14
View File
@@ -561,6 +561,20 @@ describe('ai.speech2txt driver payloads', () => {
});
});
it('speech2txt still sends options the backend accepts but ignores', async () => {
FakeXHR.respondWith = () => ({ success: true, result: { text: 'hi' } });
const legacy = {
timestamp_granularities: ['word'],
chunking_strategy: 'auto',
known_speaker_names: ['agent'],
};
await ai.speech2txt({ file: 'data:audio/mpeg;base64,QUJD', ...legacy });
expect(lastBody().args).toEqual({
file: 'data:audio/mpeg;base64,QUJD',
...legacy,
});
});
it('speech2txt returns bare text for response_format text', async () => {
FakeXHR.respondWith = () => ({ success: true, result: { text: 'transcribed' } });
const out = await ai.speech2txt({
+3 -4
View File
@@ -480,10 +480,9 @@
* @property {boolean} [translate]
* @property {number} [temperature]
* @property {boolean} [logprobs]
* @property {string[]} [timestamp_granularities]
* @property {string} [chunking_strategy]
* @property {string[]} [known_speaker_names]
* @property {string[]} [known_speaker_references]
* @property {string[]} [timestamp_granularities] Accepted and ignored by all providers.
* @property {string} [chunking_strategy] Accepted and ignored by all providers.
* @property {string[]} [known_speaker_names] Accepted and ignored by all providers.
* @property {Record<string, unknown>} [extra_body]
* @property {boolean} [format]
* @property {boolean} [diarize]
+12
View File
@@ -1300,6 +1300,18 @@ export default suite('ai', {
t.assert.equal(error.message, 'Audio input is required');
},
'speech2txt accepts ignored options in test mode': async (t) => {
useApiToken(t);
const result = (await t.puter.ai.speech2txt({
file: TINY_AUDIO,
timestamp_granularities: ['word'],
chunking_strategy: 'auto',
known_speaker_names: ['speaker'],
test_mode: true,
})) as { text?: string };
t.assert.ok((result.text ?? '').includes('sample transcription'));
},
'speech2txt honors the positional test-mode flag': async (t) => {
useApiToken(t);
const result = (await t.puter.ai.speech2txt(