Compare commits

...
72 changed files with 7776 additions and 282 deletions

No files matched your search

@@ -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.
},
];
}
+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.
+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.
+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(