mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c31670453 | ||
|
|
16d0ce8b3e | ||
|
|
8c64f54a87 | ||
|
|
7abd7b4417 | ||
|
|
29e0e1e2aa | ||
|
|
4857898af4 | ||
|
|
526a250c95 | ||
|
|
8c18c45e63 | ||
|
|
124f95a5f9 | ||
|
|
ddafc2d04c |
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',
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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({
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user