mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cca509f821 | ||
|
|
4401b4c4dc | ||
|
|
62270ff9aa |
No files matched your search
@@ -2259,6 +2259,62 @@ describe('AuthController grant flows', () => {
|
||||
).not.toContain(permission);
|
||||
});
|
||||
|
||||
it('revoke-user-app `*`: deletes the worker sessions that app deployed for the user, and only those', async () => {
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `tr-worker-${uuidv4()}`,
|
||||
title: 'TestRevokeWorkerApp',
|
||||
index_url: `https://tr-worker-${uuidv4()}.example.test/`,
|
||||
},
|
||||
{ ownerUserId: issuer.id },
|
||||
);
|
||||
const workerName = () => `wk-${uuidv4().slice(0, 8)}`;
|
||||
const appWorker = await server.stores.session.getOrCreateWorker(
|
||||
issuer.id,
|
||||
{ appUid: app.uid, workerName: workerName() },
|
||||
);
|
||||
const otherApp = await server.stores.session.getOrCreateWorker(
|
||||
issuer.id,
|
||||
{ appUid: `app-${uuidv4()}`, workerName: workerName() },
|
||||
);
|
||||
const userScoped = await server.stores.session.getOrCreateWorker(
|
||||
issuer.id,
|
||||
{ appUid: null, workerName: workerName() },
|
||||
);
|
||||
const exists = async (uuid: string) =>
|
||||
(
|
||||
await server.clients.db.read(
|
||||
'SELECT 1 FROM `sessions` WHERE `uuid` = ?',
|
||||
[uuid],
|
||||
)
|
||||
).length > 0;
|
||||
|
||||
// A single permission leaves the app's workers running as before.
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleRevokeUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission: 'service:x:ii:read' },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
);
|
||||
expect(await exists(appWorker.uuid)).toBe(true);
|
||||
|
||||
await inCtx(issuerActor, () =>
|
||||
controller.handleRevokeUserApp(
|
||||
makeReq(
|
||||
{ app_uid: app.uid, permission: '*' },
|
||||
{ actor: issuerActor },
|
||||
),
|
||||
makeRes(),
|
||||
),
|
||||
);
|
||||
expect(await exists(appWorker.uuid)).toBe(false);
|
||||
expect(await exists(otherApp.uuid)).toBe(true);
|
||||
expect(await exists(userScoped.uuid)).toBe(true);
|
||||
});
|
||||
|
||||
it('grant-user-app: 400 on non-string or oversized origin/app_uid/permission', async () => {
|
||||
const cases = [
|
||||
{ origin: { host: 'evil' }, permission: 'service:x:ii:read' },
|
||||
|
||||
@@ -3265,6 +3265,12 @@ export class AuthController extends PuterController {
|
||||
app_uid,
|
||||
meta ?? undefined,
|
||||
);
|
||||
// Withdrawing everything also retires the workers the app deployed
|
||||
// under this user; their tokens would otherwise act as the app.
|
||||
await this.services.auth.deleteWorkerSessionsForApp(
|
||||
req.actor!.user!.id as number,
|
||||
app_uid,
|
||||
);
|
||||
} else {
|
||||
for (const entry of list) {
|
||||
await this.services.permission.revokeUserAppPermission(
|
||||
|
||||
@@ -115,6 +115,31 @@ async function checkMemory(key, limit, windowMs) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend up to `units` from one bucket at once and report how many fit: the
|
||||
* many-unit form of `checkMemory`, for a caller that would otherwise loop.
|
||||
*/
|
||||
async function claimMemory(key, limit, windowMs, units) {
|
||||
const now = Date.now();
|
||||
let entry = memoryWindows.get(key);
|
||||
if (!entry) {
|
||||
if (memoryWindows.size >= MEMORY_MAX_KEYS) {
|
||||
const oldest = memoryWindows.keys().next().value;
|
||||
memoryWindows.delete(oldest);
|
||||
}
|
||||
entry = { ts: [], windowMs };
|
||||
memoryWindows.set(key, entry);
|
||||
} else {
|
||||
entry.windowMs = windowMs;
|
||||
}
|
||||
const timestamps = entry.ts;
|
||||
const cutoff = now - windowMs;
|
||||
while (timestamps.length > 0 && timestamps[0] < cutoff) timestamps.shift();
|
||||
const admitted = Math.max(0, Math.min(units, limit - timestamps.length));
|
||||
for (let i = 0; i < admitted; i++) timestamps.push(now);
|
||||
return admitted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a bucket's state without spending from it. For gates whose budget is
|
||||
* consumed by something other than the request being admitted — a failed
|
||||
@@ -182,6 +207,32 @@ async function checkRedis(
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* `checkRedis` for many units in one round trip: add them all, count, and take
|
||||
* back however many landed over the limit. Same add-then-count shape, so
|
||||
* concurrent claims can under-admit but never over-admit.
|
||||
*/
|
||||
async function claimRedis(redis, key, limit, windowMs, units) {
|
||||
const redisKey = `rate:${key}`;
|
||||
const now = Date.now();
|
||||
const members = Array.from(
|
||||
{ length: units },
|
||||
() => `${now}:${crypto.randomUUID()}`,
|
||||
);
|
||||
const results = await redis
|
||||
.multi()
|
||||
.zremrangebyscore(redisKey, 0, now - windowMs)
|
||||
.zadd(redisKey, ...members.flatMap((member) => [now, member]))
|
||||
.zcard(redisKey)
|
||||
.pexpire(redisKey, windowMs)
|
||||
.exec();
|
||||
|
||||
const count = Number(multiResult(results, 2));
|
||||
const excess = Math.min(units, Math.max(0, count - limit));
|
||||
if (excess > 0) await redis.zrem(redisKey, ...members.slice(-excess));
|
||||
return units - excess;
|
||||
}
|
||||
|
||||
async function peekRedis(redis, key, limit, windowMs) {
|
||||
const redisKey = `rate:${key}`;
|
||||
const results = await redis
|
||||
@@ -218,6 +269,31 @@ async function checkKv(kv, key, limit, windowMs) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// `checkKv` for many units: one read, then a row per admitted unit. Newest
|
||||
// first for the same reason `checkKv` reads that way.
|
||||
async function claimKv(kv, key, limit, windowMs, units) {
|
||||
const prefix = `rate:${key}:`;
|
||||
const { res } = await kv.list({
|
||||
as: 'keys',
|
||||
pattern: prefix,
|
||||
limit: limit + 1,
|
||||
reverse: true,
|
||||
});
|
||||
const keys = Array.isArray(res) ? res : (res?.items ?? []);
|
||||
const admitted = Math.max(0, Math.min(units, limit - keys.length));
|
||||
const now = Date.now();
|
||||
await Promise.all(
|
||||
Array.from({ length: admitted }, () =>
|
||||
kv.set({
|
||||
key: `${prefix}${now}:${crypto.randomUUID()}`,
|
||||
value: 1,
|
||||
expireAt: Math.ceil((now + windowMs) / 1000),
|
||||
}),
|
||||
),
|
||||
);
|
||||
return admitted;
|
||||
}
|
||||
|
||||
// `list` filters by TTL, so a non-expired row is in-window — same read
|
||||
// `checkKv` does, minus the write.
|
||||
async function peekKv(kv, key, limit) {
|
||||
@@ -395,6 +471,10 @@ function instrumentBackendPair(name, pair) {
|
||||
withSpan('rate_limit.peek', attrs, () =>
|
||||
pair.peek(key, limit, windowMs),
|
||||
),
|
||||
claim: (key, limit, windowMs, units) =>
|
||||
withSpan('rate_limit.claim', attrs, () =>
|
||||
pair.claim(key, limit, windowMs, units),
|
||||
),
|
||||
acquire: (key, limit) =>
|
||||
withSpan('rate_limit.acquire', attrs, () =>
|
||||
pair.acquire(key, limit),
|
||||
@@ -405,6 +485,7 @@ function instrumentBackendPair(name, pair) {
|
||||
const memoryBackendPair = instrumentBackendPair('memory', {
|
||||
rate: checkMemory,
|
||||
peek: peekMemory,
|
||||
claim: claimMemory,
|
||||
acquire: acquireMemoryConcurrent,
|
||||
});
|
||||
|
||||
@@ -453,6 +534,8 @@ export function configureRateLimit({
|
||||
checkRedis(redis, key, limit, windowMs),
|
||||
peek: (key, limit, windowMs) =>
|
||||
peekRedis(redis, key, limit, windowMs),
|
||||
claim: (key, limit, windowMs, units) =>
|
||||
claimRedis(redis, key, limit, windowMs, units),
|
||||
acquire: (key, limit) => acquireRedisConcurrent(redis, key, limit),
|
||||
});
|
||||
}
|
||||
@@ -460,6 +543,8 @@ export function configureRateLimit({
|
||||
backends.kv = instrumentBackendPair('kv', {
|
||||
rate: (key, limit, windowMs) => checkKv(kv, key, limit, windowMs),
|
||||
peek: (key, limit) => peekKv(kv, key, limit),
|
||||
claim: (key, limit, windowMs, units) =>
|
||||
claimKv(kv, key, limit, windowMs, units),
|
||||
acquire: (key, limit) => acquireKvConcurrent(kv, key, limit),
|
||||
});
|
||||
}
|
||||
@@ -770,6 +855,25 @@ export async function checkRateLimit(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `checkRateLimit` for `units` hits at once, in one backend round trip.
|
||||
* Resolves to how many were admitted (0..units) and spent; the rest were not
|
||||
* charged. Fails open, admitting every unit, like the rest of this module.
|
||||
*/
|
||||
export async function claimRateLimit(key, limit, windowMs, units, backend) {
|
||||
if (!(units > 0)) return 0;
|
||||
const bk = resolveBackend(backend);
|
||||
try {
|
||||
return await bk.claim(key, limit, windowMs, units);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
'[rate-limit] imperative claim failed, failing open:',
|
||||
err,
|
||||
);
|
||||
return units;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge a route-shaped rate-limit spec from inside a handler.
|
||||
*
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
acquireDriverConcurrent,
|
||||
checkDriverRateLimit,
|
||||
checkRateLimit,
|
||||
claimRateLimit,
|
||||
concurrencyGate,
|
||||
consumeRouteRateLimit,
|
||||
configureRateLimit,
|
||||
@@ -1479,6 +1480,20 @@ describe('rate + concurrent limiting — kv backend', () => {
|
||||
await Promise.all(held.map((slot) => slot.release()));
|
||||
});
|
||||
|
||||
it('claims many units in one call, admitting only what fits', async () => {
|
||||
const key = uniqueScope();
|
||||
expect(await claimRateLimit(key, 5, 60_000, 3)).toBe(3);
|
||||
expect(await claimRateLimit(key, 5, 60_000, 3)).toBe(2);
|
||||
expect(await claimRateLimit(key, 5, 60_000, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('claims against a full bucket as full while expired rows are unswept', async () => {
|
||||
const key = uniqueScope();
|
||||
await seedUnswept(`rate:${key}:`, 3);
|
||||
expect(await claimRateLimit(key, 3, 60_000, 3)).toBe(3);
|
||||
expect(await claimRateLimit(key, 3, 60_000, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('holds a concurrent slot until it is released', async () => {
|
||||
const req = { ip: '9.9.9.9', headers: {}, actor: undefined };
|
||||
const opts = { limit: 1, backend: 'kv', scope: uniqueScope() };
|
||||
@@ -1994,3 +2009,59 @@ describe('acquireConcurrent (imperative)', () => {
|
||||
configureRateLimit();
|
||||
});
|
||||
});
|
||||
|
||||
describe('claimRateLimit', () => {
|
||||
const uniqueKey = () => `claim-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
describe.each([
|
||||
['memory', () => configureRateLimit()],
|
||||
[
|
||||
'redis',
|
||||
() => configureRateLimit({ default: 'redis', redis: new RedisMock() }),
|
||||
],
|
||||
])('%s backend', (_name, configure) => {
|
||||
beforeEach(configure);
|
||||
|
||||
it('admits every unit while under the limit', async () => {
|
||||
expect(await claimRateLimit(uniqueKey(), 5, 60_000, 3)).toBe(3);
|
||||
});
|
||||
|
||||
it('admits exactly up to the limit and spends only that', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await claimRateLimit(key, 5, 60_000, 3)).toBe(3);
|
||||
expect(await claimRateLimit(key, 5, 60_000, 3)).toBe(2);
|
||||
// Nothing spent past the limit, so the bucket is exactly full.
|
||||
expect(await checkRateLimit(key, 5, 60_000)).toBe(false);
|
||||
expect(await checkRateLimit(key, 6, 60_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('admits nothing once the bucket is full', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await claimRateLimit(key, 2, 60_000, 2)).toBe(2);
|
||||
expect(await claimRateLimit(key, 2, 60_000, 4)).toBe(0);
|
||||
});
|
||||
|
||||
it('shares its bucket with checkRateLimit', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await checkRateLimit(key, 3, 60_000)).toBe(true);
|
||||
expect(await claimRateLimit(key, 3, 60_000, 5)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('claims nothing for zero units', async () => {
|
||||
const key = uniqueKey();
|
||||
expect(await claimRateLimit(key, 1, 60_000, 0)).toBe(0);
|
||||
expect(await checkRateLimit(key, 1, 60_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('fails open, admitting every unit, when the backend throws', async () => {
|
||||
const redis = new RedisMock();
|
||||
redis.multi = () => {
|
||||
throw new Error('redis down');
|
||||
};
|
||||
configureRateLimit({ default: 'redis', redis });
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(await claimRateLimit(uniqueKey(), 1, 60_000, 4)).toBe(4);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -55,8 +55,6 @@ 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';
|
||||
|
||||
@@ -129,10 +127,6 @@ 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 } },
|
||||
@@ -975,9 +969,6 @@ 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,8 +55,6 @@ 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,
|
||||
@@ -191,9 +189,6 @@ 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`.
|
||||
@@ -813,18 +808,18 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
|
||||
expect(passed.max_tokens!).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(['multipliers', 'catalog overrides'])('caps `max_tokens` at the conditional output rate using %s', async pricingKind => {
|
||||
it('caps `max_tokens` at the long-context output rate for a prompt past the threshold', async () => {
|
||||
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
|
||||
{
|
||||
id: 'capme',
|
||||
aliases: [],
|
||||
costs_currency: 'usd-cents',
|
||||
costs: { input_tokens: 1000, output_tokens: 2000 },
|
||||
...(pricingKind === 'multipliers' ? { long_context_pricing: {
|
||||
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,
|
||||
},
|
||||
]);
|
||||
@@ -859,19 +854,6 @@ 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,9 +98,7 @@ import {
|
||||
} from './utils/normalizeToOpenAI.js';
|
||||
import { isToolChoice, toolChoiceFromWire } from './utils/openaiParams.js';
|
||||
import {
|
||||
catalogPrice,
|
||||
costKeys,
|
||||
costsForRequest,
|
||||
isFreeModel,
|
||||
longContextMultipliers,
|
||||
trackedOutputTokens,
|
||||
@@ -1387,19 +1385,17 @@ 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(requestCosts[inputKey] ?? 0) || 0) *
|
||||
(Number(model.costs?.[inputKey] ?? 0) || 0) *
|
||||
multipliers.input *
|
||||
costFactor;
|
||||
const outputTokenCost =
|
||||
(Number(requestCosts[outputKey] ?? 0) || 0) *
|
||||
(Number(model.costs?.[outputKey] ?? 0) || 0) *
|
||||
multipliers.output *
|
||||
costFactor;
|
||||
const approximateInputCost = promptTokenEstimate * inputTokenCost;
|
||||
const requestFee = catalogPrice(requestCosts.request) ?? 0;
|
||||
const extras = ((pricing.extraCost ?? 0) + requestFee) * costFactor;
|
||||
const extras = (pricing.extraCost ?? 0) * costFactor;
|
||||
const minimumCredits = Number(model.minimumCredits || 1);
|
||||
const needed = Math.max(approximateInputCost + extras, minimumCredits);
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ 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 {
|
||||
@@ -84,7 +83,6 @@ 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. */
|
||||
@@ -160,17 +158,6 @@ 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');
|
||||
|
||||
@@ -226,7 +213,6 @@ 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(
|
||||
@@ -242,7 +228,6 @@ export class OpenAICompatProvider implements IChatProvider {
|
||||
)
|
||||
: undefined;
|
||||
if (!retryParams) throw e;
|
||||
pricingTime = new Date();
|
||||
completion = await options.client.chat.completions.create(
|
||||
retryParams as never,
|
||||
{ signal },
|
||||
@@ -269,7 +254,7 @@ export class OpenAICompatProvider implements IChatProvider {
|
||||
this.meteringModelKey(model.id),
|
||||
model,
|
||||
usage,
|
||||
{ ...meterOptions, pricingTime },
|
||||
meterOptions,
|
||||
);
|
||||
source.setUsageCosts(metered.costs);
|
||||
return this.reportedUsage(metered.usage);
|
||||
|
||||
@@ -20,12 +20,13 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -47,20 +48,16 @@ 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, axiosGetMock } = vi.hoisted(() => {
|
||||
const { createMock, openAICtor } = vi.hoisted(() => {
|
||||
const createMock = vi.fn();
|
||||
const openAICtor = vi.fn();
|
||||
return { createMock, openAICtor, axiosGetMock: vi.fn() };
|
||||
return { createMock, openAICtor };
|
||||
});
|
||||
|
||||
vi.mock('openai', () => {
|
||||
@@ -74,8 +71,6 @@ vi.mock('openai', () => {
|
||||
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
|
||||
});
|
||||
|
||||
vi.mock('axios', () => ({ default: { get: axiosGetMock } }));
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
@@ -126,15 +121,12 @@ 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();
|
||||
});
|
||||
|
||||
@@ -146,7 +138,8 @@ 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,
|
||||
});
|
||||
@@ -171,9 +164,9 @@ describe('AlibabaProvider model catalog', () => {
|
||||
expect(provider.getDefaultModel()).toBe('qwen-plus-latest');
|
||||
});
|
||||
|
||||
it('uses the curated catalog when the initial fetch fails', async () => {
|
||||
it('exposes the static ALIBABA_MODELS list verbatim from models()', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(await provider.models()).toBe(ALIBABA_MODELS);
|
||||
expect(provider.models()).toBe(ALIBABA_MODELS);
|
||||
});
|
||||
|
||||
it('list() flattens canonical ids and aliases', async () => {
|
||||
@@ -598,7 +591,9 @@ 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({
|
||||
@@ -773,229 +768,3 @@ 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,9 +19,6 @@
|
||||
|
||||
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,
|
||||
@@ -30,8 +27,6 @@ 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({
|
||||
@@ -41,34 +36,11 @@ 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,
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
/*
|
||||
* 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');
|
||||
};
|
||||
@@ -1,107 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
{
|
||||
"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,7 +278,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -717,31 +716,3 @@ 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,7 +22,6 @@ 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,
|
||||
@@ -70,8 +69,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;
|
||||
};
|
||||
|
||||
@@ -85,14 +84,10 @@ const pricesByTier = (model: InfronApiModel) => {
|
||||
for (const offer of model.providers ?? []) {
|
||||
const tier = offer.service_tier;
|
||||
if (!tier) continue;
|
||||
const prompt = catalogPrice(offer.prompt_price);
|
||||
const completion = catalogPrice(offer.completion_price);
|
||||
const prompt = offer.prompt_price ?? 0;
|
||||
const completion = offer.completion_price ?? 0;
|
||||
const seen = byTier.get(tier);
|
||||
if (
|
||||
seen &&
|
||||
(seen.prompt ?? Infinity) + (seen.completion ?? Infinity) <=
|
||||
(prompt ?? Infinity) + (completion ?? Infinity)
|
||||
) {
|
||||
if (seen && seen.prompt + seen.completion <= prompt + completion) {
|
||||
continue;
|
||||
}
|
||||
byTier.set(tier, { prompt, completion, context: offer.context_length });
|
||||
@@ -142,9 +137,7 @@ 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 = catalogPrice(prices.prompt, 100);
|
||||
const completionCost = catalogPrice(prices.completion, 100);
|
||||
const requestCost = catalogPrice(model.min_request_price, 100_000_000);
|
||||
const promptCost = Math.round(prices.prompt * 100);
|
||||
return {
|
||||
id: `infron:${model.id}${suffix}`,
|
||||
name: `${model.display_name || model.id} (Infron${tier ? `, ${tier}` : ''})`,
|
||||
@@ -166,13 +159,17 @@ const coerceModel = (
|
||||
output_cost_key: 'completion',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
...(promptCost !== undefined
|
||||
? { prompt: promptCost, input_cache_read: promptCost }
|
||||
: {}),
|
||||
...(completionCost !== undefined
|
||||
? { completion: completionCost }
|
||||
: {}),
|
||||
...(requestCost !== undefined ? { request: requestCost } : {}),
|
||||
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,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -197,7 +194,6 @@ 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
|
||||
@@ -233,11 +229,8 @@ export class InfronProvider extends OpenAICompatProvider {
|
||||
Authorization: `Bearer ${this.#apiKey}`,
|
||||
},
|
||||
signal,
|
||||
maxContentLength: 8 * 1024 * 1024,
|
||||
});
|
||||
if (!Array.isArray(resp.data?.data))
|
||||
throw new Error('Invalid Infron catalog');
|
||||
const models: InfronApiModel[] = resp.data.data;
|
||||
const models: InfronApiModel[] = resp.data.data ?? [];
|
||||
const coerced_models: IChatModel[] = [];
|
||||
for (const model of models) {
|
||||
// The catalog mixes chat with image/video/embedding/search
|
||||
@@ -250,8 +243,8 @@ export class InfronProvider extends OpenAICompatProvider {
|
||||
}
|
||||
const byTier = pricesByTier(model);
|
||||
const defaultPrices = byTier.get(DEFAULT_SERVICE_TIER) ?? {
|
||||
prompt: catalogPrice(model.min_prompt_price),
|
||||
completion: catalogPrice(model.min_completion_price),
|
||||
prompt: model.min_prompt_price ?? 0,
|
||||
completion: model.min_completion_price ?? 0,
|
||||
};
|
||||
// The unsuffixed id is the default tier; every other tier the
|
||||
// model sells gets its own `…:<tier>` id so callers can ask for
|
||||
@@ -311,9 +304,7 @@ export class InfronProvider extends OpenAICompatProvider {
|
||||
// The gateway-reported cost when there is one; the catalog's
|
||||
// per-token prices otherwise.
|
||||
meterOptions:
|
||||
catalogPrice(cost) !== undefined
|
||||
? { authoritativeUsd: cost }
|
||||
: {},
|
||||
typeof cost === 'number' ? { 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,7 +94,6 @@ 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,
|
||||
@@ -116,7 +115,6 @@ 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,
|
||||
@@ -138,7 +136,6 @@ const SAMPLE_API_MODELS = [
|
||||
metadata: {
|
||||
display_name: 'Coming Soon',
|
||||
pricing: {
|
||||
currency: 'USD',
|
||||
input_per_million: 0,
|
||||
output_per_million: 0,
|
||||
pricing_tbd: true,
|
||||
@@ -153,7 +150,6 @@ const SAMPLE_API_MODELS = [
|
||||
display_name: 'Old',
|
||||
deprecated: true,
|
||||
pricing: {
|
||||
currency: 'USD',
|
||||
input_per_million: 1,
|
||||
output_per_million: 2,
|
||||
pricing_tbd: false,
|
||||
@@ -232,7 +228,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -274,7 +269,6 @@ 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,
|
||||
@@ -644,84 +638,3 @@ 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,7 +28,6 @@ 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,
|
||||
@@ -73,7 +72,6 @@ export class NeuralwattProvider extends OpenAICompatProvider {
|
||||
baseURL: apiBaseUrl,
|
||||
...sdkClientOptions(),
|
||||
}),
|
||||
requireTokenPrices: true,
|
||||
defaultModel: NEURALWATT_DEFAULT_MODEL,
|
||||
idPrefix: NEURALWATT_ID_PREFIX,
|
||||
passthrough: ['temperature'],
|
||||
@@ -122,12 +120,10 @@ 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);
|
||||
@@ -216,7 +212,8 @@ export class NeuralwattProvider extends OpenAICompatProvider {
|
||||
// priced.
|
||||
meterOptions: {
|
||||
costOverrides: { energy_kwh: 0, energy_joules: 0 },
|
||||
...(catalogPrice(requestCostUsd) !== undefined
|
||||
...(typeof requestCostUsd === 'number' &&
|
||||
Number.isFinite(requestCostUsd)
|
||||
? { authoritativeUsd: requestCostUsd }
|
||||
: {}),
|
||||
},
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import type { IChatModel } from '../../types.js';
|
||||
import { catalogPrice } from '../../utils/pricing.js';
|
||||
import { usdPerMToken } from '../../utils/pricing.js';
|
||||
|
||||
export const NEURALWATT_ID_PREFIX = 'neuralwatt:';
|
||||
export const NEURALWATT_DEFAULT_MODEL = 'neuralwatt:deepseek-v4-flash';
|
||||
@@ -104,18 +104,15 @@ export const mapNeuralwattApiModel = (
|
||||
const pricing = model.metadata?.pricing;
|
||||
if (pricing?.pricing_tbd) return null;
|
||||
|
||||
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 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 caps = model.metadata?.capabilities;
|
||||
const limits = model.metadata?.limits;
|
||||
@@ -156,5 +153,12 @@ export const mapNeuralwattApiModel = (
|
||||
...(model.metadata?.description
|
||||
? { description: model.metadata.description }
|
||||
: {}),
|
||||
...(model.created
|
||||
? {
|
||||
release_date: new Date(model.created * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
@@ -210,7 +210,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -268,7 +267,7 @@ describe('OpenRouterProvider model catalog', () => {
|
||||
expect(ids).not.toContain('qwen/qwen3-max');
|
||||
});
|
||||
|
||||
it('coerces string prices and normalizes conditional tiers', async () => {
|
||||
it('coerces string prices and skips the structured overrides entry', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const models = await provider.models();
|
||||
const sol = models.find((m) => m.id === 'openrouter:openai/gpt-6.1-sol')!;
|
||||
@@ -276,11 +275,8 @@ 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 () => {
|
||||
@@ -337,11 +333,22 @@ describe('OpenRouterProvider model catalog', () => {
|
||||
expect(capped.max_tokens).toBe(16000);
|
||||
});
|
||||
|
||||
it('does not reinterpret catalog-added timestamps as release dates', async () => {
|
||||
it('maps OpenRouter created timestamps to release_date metadata', async () => {
|
||||
const { provider } = makeProvider();
|
||||
expect((await provider.models()).every(model => model.release_date === undefined)).toBe(true);
|
||||
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',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── Disallowed model gate ───────────────────────────────────────────
|
||||
@@ -976,32 +983,3 @@ 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,8 +23,6 @@ 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,
|
||||
@@ -37,6 +35,18 @@ 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;
|
||||
|
||||
@@ -48,7 +58,6 @@ export class OpenRouterProvider extends OpenAICompatProvider {
|
||||
baseURL: apiBaseUrl,
|
||||
...sdkClientOptions(),
|
||||
}),
|
||||
requireTokenPrices: true,
|
||||
defaultModel: 'openrouter:openai/gpt-6-luna',
|
||||
idPrefix: 'openrouter:',
|
||||
passthrough: ['temperature'],
|
||||
@@ -96,17 +105,11 @@ export class OpenRouterProvider extends OpenAICompatProvider {
|
||||
method: 'GET',
|
||||
url: `${this.#apiBaseUrl}/models`,
|
||||
signal,
|
||||
maxContentLength: 8 * 1024 * 1024,
|
||||
});
|
||||
if (!Array.isArray(resp.data?.data))
|
||||
throw new Error('Invalid OpenRouter catalog');
|
||||
const models = resp.data.data;
|
||||
const models = resp.data.data ?? [];
|
||||
const coerced_models: IChatModel[] = [];
|
||||
for (const model of models) {
|
||||
if (
|
||||
typeof model.id !== 'string' ||
|
||||
model.id.includes('openrouter/auto')
|
||||
) {
|
||||
if ((model.id as string).includes('openrouter/auto')) {
|
||||
continue;
|
||||
}
|
||||
// OpenRouter sets expiration_date on deprecated models scheduled for removal.
|
||||
@@ -116,13 +119,32 @@ export class OpenRouterProvider extends OpenAICompatProvider {
|
||||
const overridenModel = OPEN_ROUTER_MODEL_OVERRIDES.find(
|
||||
(m) => m.id === `openrouter:${model.id}`,
|
||||
);
|
||||
const pricing = mapOpenRouterPricing(model.pricing ?? {});
|
||||
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;
|
||||
}
|
||||
coerced_models.push({
|
||||
id: `openrouter:${model.id}`,
|
||||
name: `${model.name ?? model.id} (OpenRouter)`,
|
||||
name: `${model.name} (OpenRouter)`,
|
||||
aliases: [
|
||||
model.id,
|
||||
...(typeof model.name === 'string' ? [model.name] : []),
|
||||
model.name,
|
||||
`openrouter/${model.id}`,
|
||||
model.id.split('/').slice(1).join('/'),
|
||||
],
|
||||
@@ -130,28 +152,16 @@ 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 ??
|
||||
0,
|
||||
model.top_provider.max_completion_tokens ??
|
||||
model.context_length,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt',
|
||||
output_cost_key: 'completion',
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
...pricing.costs,
|
||||
...microcentCosts,
|
||||
},
|
||||
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'),
|
||||
release_date: openRouterReleaseDate(model.created),
|
||||
...overridenModel,
|
||||
});
|
||||
}
|
||||
@@ -174,12 +184,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:
|
||||
catalogPrice(usage.cost) !== undefined
|
||||
typeof usage.cost === 'number'
|
||||
? { authoritativeUsd: usage.cost }
|
||||
: {},
|
||||
};
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
});
|
||||
@@ -1,126 +0,0 @@
|
||||
/*
|
||||
* 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,7 +160,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -760,30 +759,3 @@ 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,7 +21,6 @@ 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,
|
||||
@@ -41,7 +40,6 @@ 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'],
|
||||
@@ -66,8 +64,6 @@ 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 (
|
||||
@@ -92,13 +88,8 @@ export class TogetherAIProvider extends OpenAICompatProvider {
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
...Object.fromEntries(
|
||||
Object.entries(model.pricing ?? {}).flatMap(
|
||||
([key, value]) => {
|
||||
const price = catalogPrice(value, 100);
|
||||
return price === undefined
|
||||
? []
|
||||
: [[key, price]];
|
||||
},
|
||||
Object.entries(model.pricing ?? {}).map(
|
||||
([k, v]) => [k, (v as number) * 100],
|
||||
),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -47,10 +47,7 @@ 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';
|
||||
|
||||
@@ -60,10 +57,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, axiosGetMock } = vi.hoisted(() => {
|
||||
const { createMock, openAICtor } = vi.hoisted(() => {
|
||||
const createMock = vi.fn();
|
||||
const openAICtor = vi.fn();
|
||||
return { createMock, openAICtor, axiosGetMock: vi.fn() };
|
||||
return { createMock, openAICtor };
|
||||
});
|
||||
|
||||
vi.mock('openai', () => {
|
||||
@@ -81,10 +78,6 @@ 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;
|
||||
@@ -137,8 +130,6 @@ 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.
|
||||
@@ -172,15 +163,9 @@ describe('XAIProvider model catalog', () => {
|
||||
expect(provider.getDefaultModel()).toBe('grok-4.5');
|
||||
});
|
||||
|
||||
it('serves the curated catalog from models() while the upstream is down', async () => {
|
||||
it('exposes the static XAI_MODELS list verbatim from models()', () => {
|
||||
const { provider } = makeProvider();
|
||||
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 })));
|
||||
expect(provider.models()).toBe(XAI_MODELS);
|
||||
});
|
||||
|
||||
it('list() flattens canonical ids and aliases', async () => {
|
||||
@@ -573,324 +558,6 @@ 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,15 +23,13 @@ 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_BASE_URL, XAI_CHAT_MODELS, fetchXaiCatalog } from './catalog.js';
|
||||
import { cachedRemoteCatalog } from '../../utils/cachedRemoteCatalog.js';
|
||||
import { meterChatUsage } from '../../utils/meterChatUsage.js';
|
||||
import { XAI_MODELS } from './models.js';
|
||||
import { modelLookupNames } from '../../utils/modelRouting.js';
|
||||
import { buildCostsOverride } from '../../utils/pricing.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
|
||||
export class XAIProvider implements IChatProvider {
|
||||
@@ -39,33 +37,25 @@ export class XAIProvider implements IChatProvider {
|
||||
|
||||
#meteringService: MeteringService;
|
||||
|
||||
#catalog: () => Promise<IChatModel[]>;
|
||||
|
||||
constructor(config: { apiKey: string }, meteringService: MeteringService) {
|
||||
this.#openai = new OpenAI({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: XAI_BASE_URL,
|
||||
baseURL: 'https://api.x.ai/v1',
|
||||
...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';
|
||||
}
|
||||
|
||||
async models(): Promise<IChatModel[]> {
|
||||
return this.#catalog();
|
||||
models() {
|
||||
return XAI_MODELS;
|
||||
}
|
||||
|
||||
async list() {
|
||||
return modelLookupNames(await this.models());
|
||||
return modelLookupNames(this.models());
|
||||
}
|
||||
|
||||
/** The model key this provider records usage under. */
|
||||
@@ -73,14 +63,19 @@ export class XAIProvider implements IChatProvider {
|
||||
return `xai:${modelId}`;
|
||||
}
|
||||
|
||||
async complete(
|
||||
{ messages, stream, model, tools, max_tokens }: ICompleteArguments,
|
||||
resolved?: IChatModel,
|
||||
): Promise<IChatCompleteResult> {
|
||||
async complete({
|
||||
messages,
|
||||
stream,
|
||||
model,
|
||||
tools,
|
||||
max_tokens,
|
||||
}: ICompleteArguments): Promise<IChatCompleteResult> {
|
||||
const actor = Context.get('actor');
|
||||
// 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));
|
||||
const availableModels = this.models();
|
||||
const modelUsed =
|
||||
availableModels.find((m) =>
|
||||
[m.id, ...(m.aliases || [])].includes(model),
|
||||
) || availableModels.find((m) => m.id === this.getDefaultModel())!;
|
||||
messages = await OpenAIUtil.process_input_messages(messages);
|
||||
let completion;
|
||||
try {
|
||||
@@ -107,18 +102,25 @@ export class XAIProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
usage_calculator: ({ usage }) => {
|
||||
// xAI reports cached reads inside `prompt_tokens`; bill them
|
||||
// only at the cached rate.
|
||||
const metered = OpenAIUtil.extractMeteredUsage(usage);
|
||||
const { costs } = meterChatUsage(
|
||||
this.#meteringService,
|
||||
const trackedUsage = {
|
||||
...metered,
|
||||
prompt_tokens:
|
||||
metered.prompt_tokens - metered.cached_tokens,
|
||||
};
|
||||
const costsOverride = buildCostsOverride(
|
||||
trackedUsage,
|
||||
modelUsed,
|
||||
);
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
modelUsed,
|
||||
OpenAIUtil.splitCachedPrompt(usage),
|
||||
costsOverride,
|
||||
);
|
||||
setUsageCosts(costs);
|
||||
return metered;
|
||||
},
|
||||
stream,
|
||||
@@ -126,15 +128,6 @@ 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']> {
|
||||
|
||||
@@ -1,569 +0,0 @@
|
||||
/*
|
||||
* 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,194 +0,0 @@
|
||||
/*
|
||||
* 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;
|
||||
});
|
||||
};
|
||||
@@ -1,208 +0,0 @@
|
||||
{
|
||||
"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,10 +51,6 @@ 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 ─────────────────────────────────────────────────
|
||||
//
|
||||
@@ -215,47 +211,6 @@ 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) ────────────
|
||||
@@ -393,40 +348,6 @@ 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,22 +22,18 @@ 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, output: ['text'] },
|
||||
modalities: { input: ['text'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
context,
|
||||
@@ -66,7 +62,6 @@ export const ZAI_MODELS: IChatModel[] = [
|
||||
1_000 * K,
|
||||
128 * K,
|
||||
usdPerMToken(0.15, 0.5, 0.03),
|
||||
imageInput,
|
||||
),
|
||||
textModel(
|
||||
'glm-5.3-flashx',
|
||||
@@ -74,7 +69,6 @@ export const ZAI_MODELS: IChatModel[] = [
|
||||
1_000 * K,
|
||||
128 * K,
|
||||
usdPerMToken(0.37, 1.25, 0.075),
|
||||
imageInput,
|
||||
),
|
||||
textModel(
|
||||
'glm-5.2',
|
||||
|
||||
@@ -31,16 +31,6 @@ 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
|
||||
@@ -64,7 +54,6 @@ 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,13 +166,3 @@ 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, previous?: T) => Promise<T>;
|
||||
fetch: (signal: AbortSignal) => Promise<T>;
|
||||
/** What callers get when the first fetch fails. */
|
||||
fallback: T;
|
||||
ttlMs?: number;
|
||||
@@ -56,10 +56,7 @@ export const cachedRemoteCatalog = <T>({
|
||||
|
||||
const load = async (): Promise<T> => {
|
||||
try {
|
||||
const value = await fetch(
|
||||
AbortSignal.timeout(timeoutMs),
|
||||
last?.value,
|
||||
);
|
||||
const value = await fetch(AbortSignal.timeout(timeoutMs));
|
||||
last = { value };
|
||||
freshUntil = Date.now() + ttlMs;
|
||||
failing = false;
|
||||
|
||||
@@ -252,12 +252,3 @@ 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,7 +31,6 @@ 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>;
|
||||
}
|
||||
@@ -73,16 +72,9 @@ export const meterChatUsage = (
|
||||
meteringKey: string,
|
||||
model: IChatModel,
|
||||
usage: Record<string, number>,
|
||||
{
|
||||
authoritativeUsd,
|
||||
costOverrides,
|
||||
pricingTime,
|
||||
}: MeterChatUsageOptions = {},
|
||||
{ authoritativeUsd, costOverrides }: MeterChatUsageOptions = {},
|
||||
): { usage: Record<string, number>; costs: Record<string, number> } => {
|
||||
const priced = {
|
||||
...buildCostsOverride(usage, model, pricingTime),
|
||||
...costOverrides,
|
||||
};
|
||||
const priced = { ...buildCostsOverride(usage, model), ...costOverrides };
|
||||
if (authoritativeUsd !== undefined) {
|
||||
const billed = { ...usage, billedUsage: 1 };
|
||||
const recorded: Record<string, number> = Object.fromEntries(
|
||||
|
||||
@@ -21,8 +21,6 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { IChatModel } from '../types.js';
|
||||
import {
|
||||
buildCostsOverride,
|
||||
catalogPrice,
|
||||
costsForRequest,
|
||||
isFreeModel,
|
||||
isOutputCostKey,
|
||||
longContextMultipliers,
|
||||
@@ -128,13 +126,13 @@ describe('buildCostsOverride', () => {
|
||||
expect(overrides.cached_tokens).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves missing prices unknown when the model prices nothing at all', () => {
|
||||
it('falls back to zero only when the model prices nothing at all', () => {
|
||||
const overrides = buildCostsOverride(
|
||||
{ prompt_tokens: 10, cached_tokens: 5 },
|
||||
model({}),
|
||||
);
|
||||
|
||||
expect(overrides).toEqual({});
|
||||
expect(overrides).toEqual({ prompt_tokens: 0, cached_tokens: 0 });
|
||||
});
|
||||
|
||||
it('skips the tokens scale descriptor', () => {
|
||||
@@ -201,21 +199,13 @@ 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);
|
||||
});
|
||||
@@ -252,12 +242,7 @@ 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 }),
|
||||
);
|
||||
|
||||
@@ -330,11 +315,7 @@ 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,
|
||||
@@ -381,11 +362,7 @@ 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 });
|
||||
@@ -453,69 +430,3 @@ 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,29 +19,6 @@
|
||||
|
||||
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;
|
||||
|
||||
@@ -83,36 +60,6 @@ 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.
|
||||
@@ -130,7 +77,7 @@ export const isOutputCostKey = (key: string, outputKey: string): boolean =>
|
||||
key.endsWith('_output_tokens');
|
||||
|
||||
export const isPerCallCostKey = (key: string): boolean =>
|
||||
key === 'request' || key.endsWith('_requests') || key.endsWith('_calls');
|
||||
key.endsWith('_requests') || key.endsWith('_calls');
|
||||
|
||||
/**
|
||||
* The rate multipliers a request pays given how many input tokens it sent —
|
||||
@@ -213,23 +160,7 @@ export const isFreeModel = (model: IChatModel): boolean => {
|
||||
const rates = Object.entries(model.costs ?? {}).filter(
|
||||
([key]) => key !== 'tokens',
|
||||
);
|
||||
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),
|
||||
)
|
||||
);
|
||||
return rates.length > 0 && rates.every(([, rate]) => Number(rate) === 0);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -259,15 +190,10 @@ 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 = costsForRequest(
|
||||
model,
|
||||
trackedInputTokens(trackedUsage, model),
|
||||
now,
|
||||
);
|
||||
const costs = model.costs ?? {};
|
||||
const inputRate = isRate(costs[inputKey]) ? costs[inputKey] : undefined;
|
||||
const outputRate = isRate(costs[outputKey]) ? costs[outputKey] : undefined;
|
||||
|
||||
@@ -280,7 +206,6 @@ 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]
|
||||
@@ -313,16 +238,14 @@ const priceUsage = (
|
||||
export const buildCostsOverride = (
|
||||
trackedUsage: Record<string, number>,
|
||||
model: IChatModel,
|
||||
now?: Date,
|
||||
): Record<string, number> => {
|
||||
const priced = priceUsage(trackedUsage, model, now);
|
||||
const priced = priceUsage(trackedUsage, model);
|
||||
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;
|
||||
if (priced[key] !== undefined) overrides[key] = priced[key];
|
||||
else if (trackedUsage[key] === 0) overrides[key] = 0;
|
||||
overrides[key] = priced[key] ?? 0;
|
||||
}
|
||||
return overrides;
|
||||
};
|
||||
|
||||
@@ -45,15 +45,12 @@ 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 ──────────────────────────────────────────────────────
|
||||
@@ -143,20 +140,6 @@ 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;
|
||||
@@ -164,48 +147,7 @@ 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' },
|
||||
@@ -219,7 +161,6 @@ beforeAll(async () => {
|
||||
'replicate-image-generation': { apiKey: 'rp-key' },
|
||||
},
|
||||
} as never);
|
||||
bootFetch.mockRestore();
|
||||
driver = server.drivers.aiImage as unknown as ImageGenerationDriver;
|
||||
});
|
||||
|
||||
@@ -404,21 +345,12 @@ 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,
|
||||
);
|
||||
fetchSpy.mockResolvedValueOnce(cloudflareModelSearch());
|
||||
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);
|
||||
await limitedDriver.onServerStart();
|
||||
await withActor(() => limitedDriver.generate({ prompt: 'hi', test_mode: true }));
|
||||
expect(eventEmitSpy).toHaveBeenCalledWith('ai.log.image', expect.objectContaining({
|
||||
@@ -726,187 +658,6 @@ 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,7 +33,6 @@ 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';
|
||||
@@ -87,13 +86,6 @@ 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 {
|
||||
@@ -106,16 +98,12 @@ export class ImageGenerationDriver extends PuterDriver {
|
||||
|
||||
override async onServerStart() {
|
||||
this.#registerProviders();
|
||||
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);
|
||||
await this.#buildModelMap();
|
||||
this.#catalog = new ModelCatalog(
|
||||
this.#listModels(),
|
||||
Object.values(this.#modelIdMap).flat(),
|
||||
'aiImage',
|
||||
);
|
||||
}
|
||||
|
||||
async models(): Promise<IImageModel[]> {
|
||||
@@ -192,10 +180,6 @@ 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()
|
||||
@@ -447,34 +431,21 @@ 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, entries] of catalogs) {
|
||||
for (const [providerName, provider] of Object.entries(
|
||||
this.#providers,
|
||||
)) {
|
||||
for (const alias of provider.retiredModelAliases ?? []) {
|
||||
this.#retiredModelAliases.set(alias.trim().toLowerCase(), {
|
||||
provider: providerName,
|
||||
reason: provider.retiredModelReasons?.[alias],
|
||||
});
|
||||
}
|
||||
for (const entry of entries) {
|
||||
for (const entry of await provider.models()) {
|
||||
models.push({ ...entry, provider: providerName });
|
||||
}
|
||||
}
|
||||
@@ -523,11 +494,6 @@ 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,7 +36,6 @@ import {
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
type Mock,
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
@@ -46,11 +45,8 @@ 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.
|
||||
@@ -66,13 +62,7 @@ vi.mock('../../inputImage.js', async (orig) => ({
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
|
||||
let server: PuterServer;
|
||||
/**
|
||||
* 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 fetchSpy: MockInstance<typeof fetch>;
|
||||
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
|
||||
let batchIncrementUsagesSpy: MockInstance<
|
||||
MeteringService['batchIncrementUsages']
|
||||
@@ -112,17 +102,7 @@ const makeProvider = (
|
||||
|
||||
beforeEach(() => {
|
||||
fetchImageBytesMock.mockReset();
|
||||
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),
|
||||
);
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
|
||||
batchIncrementUsagesSpy = vi.spyOn(
|
||||
server.services.metering,
|
||||
@@ -145,7 +125,7 @@ const okJsonResponse = (body: unknown) =>
|
||||
describe('CloudflareImageProvider construction', () => {
|
||||
it('does not call out at construction (lazy fetch)', () => {
|
||||
makeProvider();
|
||||
expect(globalFetchSpy).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,20 +139,9 @@ describe('CloudflareImageProvider model catalog', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
it('exposes the static CLOUDFLARE_IMAGE_GENERATION_MODELS list verbatim', () => {
|
||||
const provider = makeProvider();
|
||||
expect(provider.models()).toBe(CLOUDFLARE_IMAGE_GENERATION_MODELS);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -847,246 +816,3 @@ 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,7 +23,6 @@ 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,
|
||||
@@ -34,7 +33,6 @@ import {
|
||||
CLOUDFLARE_IMAGE_GENERATION_MODELS,
|
||||
CloudflareImageModel,
|
||||
} from './models.js';
|
||||
import { fetchCloudflareImageCatalog } from './catalog.js';
|
||||
import {
|
||||
assertInputImageString,
|
||||
fetchImageBytes,
|
||||
@@ -68,12 +66,6 @@ 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: {
|
||||
@@ -88,25 +80,10 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
async models(): Promise<IImageModel[]> {
|
||||
this.#models = await this.#catalog();
|
||||
return this.#models;
|
||||
models(): IImageModel[] {
|
||||
return CLOUDFLARE_IMAGE_GENERATION_MODELS;
|
||||
}
|
||||
|
||||
getDefaultModel(): string {
|
||||
@@ -208,8 +185,6 @@ 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,
|
||||
});
|
||||
@@ -258,7 +233,7 @@ export class CloudflareImageProvider implements IImageProvider {
|
||||
}
|
||||
|
||||
#getModel(model?: string): CloudflareImageModel {
|
||||
const models = this.#models;
|
||||
const models = CLOUDFLARE_IMAGE_GENERATION_MODELS;
|
||||
const found = models.find(
|
||||
(m) => m.id === model || m.aliases?.includes(model ?? ''),
|
||||
);
|
||||
|
||||
@@ -1,575 +0,0 @@
|
||||
/*
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
@@ -1,199 +0,0 @@
|
||||
/*
|
||||
* 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;
|
||||
});
|
||||
};
|
||||
@@ -1,169 +0,0 @@
|
||||
{
|
||||
"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,23 +50,17 @@ 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, axiosGetMock } = vi.hoisted(() => ({
|
||||
const { generateMock, postMock, openAICtor } = 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>,
|
||||
@@ -113,8 +107,6 @@ 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,
|
||||
@@ -123,7 +115,6 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -154,16 +145,16 @@ describe('XAIImageProvider model catalog', () => {
|
||||
expect(provider.getDefaultModel()).toBe('grok-imagine-image');
|
||||
});
|
||||
|
||||
it('serves the curated catalog verbatim when pricing is unavailable', async () => {
|
||||
it('exposes the static XAI_IMAGE_GENERATION_MODELS list verbatim', () => {
|
||||
const provider = makeProvider();
|
||||
expect(await provider.models()).toBe(XAI_IMAGE_GENERATION_MODELS);
|
||||
expect(provider.models()).toBe(XAI_IMAGE_GENERATION_MODELS);
|
||||
});
|
||||
|
||||
it('no longer exposes the deprecated grok-2-image model', async () => {
|
||||
it('no longer exposes the deprecated grok-2-image model', () => {
|
||||
const provider = makeProvider();
|
||||
expect(
|
||||
(await provider.models()).some((m) => m.id === 'grok-2-image'),
|
||||
).toBe(false);
|
||||
expect(provider.models().some((m) => m.id === 'grok-2-image')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -672,11 +663,10 @@ 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', async (model) => {
|
||||
const names = (await makeProvider().models()).flatMap((m) => [
|
||||
m.id,
|
||||
...(m.aliases ?? []),
|
||||
]);
|
||||
])('drops the retired %s instead of redirecting it', (model) => {
|
||||
const names = makeProvider()
|
||||
.models()
|
||||
.flatMap((m) => [m.id, ...(m.aliases ?? [])]);
|
||||
expect(names).not.toContain(model);
|
||||
});
|
||||
|
||||
@@ -776,389 +766,3 @@ 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,8 +29,6 @@ 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.
|
||||
@@ -43,12 +41,6 @@ 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 },
|
||||
@@ -63,17 +55,10 @@ 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),
|
||||
});
|
||||
}
|
||||
|
||||
async models(): Promise<XaiImageModel[]> {
|
||||
this.#models = await this.#catalog();
|
||||
return this.#models;
|
||||
models(): XaiImageModel[] {
|
||||
return XAI_IMAGE_GENERATION_MODELS;
|
||||
}
|
||||
|
||||
getDefaultModel(): string {
|
||||
@@ -263,7 +248,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 ?? ''),
|
||||
);
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
/*
|
||||
* 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;
|
||||
});
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
{
|
||||
"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,12 +24,15 @@ export type XaiImageModel = IImageModel & {
|
||||
defaultEditQuality?: string;
|
||||
};
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
export const XAI_IMAGE_GENERATION_MODELS: XaiImageModel[] = [
|
||||
{
|
||||
puterId: 'x-ai:x-ai/grok-imagine-image',
|
||||
|
||||
@@ -58,7 +58,6 @@ 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 ───────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -82,8 +81,6 @@ 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;
|
||||
}),
|
||||
};
|
||||
@@ -104,65 +101,6 @@ 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;
|
||||
@@ -171,14 +109,13 @@ 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: CONFIGURED_REGION,
|
||||
region: 'us-west-2',
|
||||
},
|
||||
},
|
||||
'mistral-ocr': { apiKey: 'mistral-key' },
|
||||
@@ -189,7 +126,6 @@ beforeAll(async () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.shutdown();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -246,29 +182,14 @@ const dataUrl = (buffer: Buffer, mime: string) =>
|
||||
// ── getReportedCosts ────────────────────────────────────────────────
|
||||
|
||||
describe('OCRDriver.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`,
|
||||
);
|
||||
|
||||
it('mirrors every entry in costs.ts as a per-page line item', () => {
|
||||
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:
|
||||
usageType === TEXTRACT_USAGE
|
||||
? CONFIGURED_RATE
|
||||
: ucentsPerUnit,
|
||||
ucentsPerUnit,
|
||||
unit: 'page',
|
||||
source: 'driver:aiOcr',
|
||||
});
|
||||
@@ -489,7 +410,7 @@ describe('OCRDriver.recognize (aws-textract)', () => {
|
||||
).toEqual(bytes);
|
||||
});
|
||||
|
||||
it('meters one usage line per detected page at the configured region rate', async () => {
|
||||
it('meters one usage line per detected page at the per-page rate from costs.ts', async () => {
|
||||
const { actor } = await makeUser();
|
||||
textractSendMock.mockResolvedValueOnce(sampleTextractResponse);
|
||||
|
||||
@@ -500,9 +421,8 @@ describe('OCRDriver.recognize (aws-textract)', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const usageType = TEXTRACT_USAGE;
|
||||
const perPage = CONFIGURED_RATE;
|
||||
expect(sentRegions()).toEqual([CONFIGURED_REGION]);
|
||||
const usageType = 'aws-textract:detect-document-text:page';
|
||||
const perPage = OCR_COSTS[usageType];
|
||||
// sampleTextractResponse has 2 PAGE blocks → bill 2 pages.
|
||||
const ocrCalls = incrementUsageSpy.mock.calls.filter(
|
||||
([, type]) => type === usageType,
|
||||
@@ -533,179 +453,15 @@ describe('OCRDriver.recognize (aws-textract)', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const usageType = 'aws-textract:detect-document-text:page';
|
||||
const ocrCalls = incrementUsageSpy.mock.calls.filter(
|
||||
([, type]) => type === TEXTRACT_USAGE,
|
||||
([, type]) => type === usageType,
|
||||
);
|
||||
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(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);
|
||||
});
|
||||
expect(cost).toBe(OCR_COSTS[usageType]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1270,7 +1026,10 @@ describe('OCRDriver credit pre-flight', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(checkedCosts()).toEqual([perPage, CONFIGURED_RATE]);
|
||||
expect(checkedCosts()).toEqual([
|
||||
perPage,
|
||||
OCR_COSTS['aws-textract:detect-document-text:page'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ import {
|
||||
type OcrProviderId,
|
||||
} from './models.js';
|
||||
import { countPdfPages } from './pdfPages.js';
|
||||
import { textractRates } from './textractPricing.js';
|
||||
|
||||
/**
|
||||
* Driver implementing `puter-ocr` — document OCR. Two providers: •
|
||||
@@ -150,9 +149,6 @@ 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' });
|
||||
|
||||
@@ -266,20 +262,10 @@ 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:
|
||||
usageType === TEXTRACT_PAGE_USAGE_TYPE
|
||||
? this.#textractRates.peek(textractRegion)
|
||||
: ucentsPerUnit,
|
||||
ucentsPerUnit,
|
||||
unit: 'page',
|
||||
source: 'driver:aiOcr',
|
||||
}));
|
||||
@@ -298,8 +284,6 @@ 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;
|
||||
@@ -319,15 +303,13 @@ export class OCRDriver extends PuterDriver {
|
||||
const textractRegion =
|
||||
(textractAws?.region as string | undefined) ??
|
||||
(textract?.region as string | undefined) ??
|
||||
DEFAULT_TEXTRACT_REGION;
|
||||
'us-west-2';
|
||||
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) =>
|
||||
@@ -486,19 +468,11 @@ 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 : configuredRegion;
|
||||
s3Info && useS3
|
||||
? s3Info.bucketRegion
|
||||
: (this.#awsConfig!.region ?? 'us-west-2');
|
||||
const client = this.#textractClientFor(region);
|
||||
const document =
|
||||
s3Info && useS3
|
||||
@@ -509,24 +483,21 @@ export class OCRDriver extends PuterDriver {
|
||||
);
|
||||
};
|
||||
|
||||
const costPerPage = OCR_COSTS[model.pageUsageType];
|
||||
const hold = await this.#holdCredits(
|
||||
actor,
|
||||
model.pageUsageType,
|
||||
Math.max(configuredRate, s3Rate ?? 0) *
|
||||
(await estimateOcrPages(input, model.provider)),
|
||||
costPerPage * (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)
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,477 +0,0 @@
|
||||
/*
|
||||
* 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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,287 +0,0 @@
|
||||
/*
|
||||
* 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,24 +522,6 @@ 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,31 +441,6 @@ 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,6 +53,7 @@ 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,7 +46,6 @@ 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 ──────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -145,15 +144,7 @@ beforeEach(() => {
|
||||
openaiSpeechCreateMock.mockReset();
|
||||
geminiGenerateContentMock.mockReset();
|
||||
pollySendMock.mockReset();
|
||||
// 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>;
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -20,14 +20,13 @@
|
||||
/**
|
||||
* 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. The public AWS price list is served from recorded fixtures
|
||||
* through a stubbed global `fetch`.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { Readable } from 'node:stream';
|
||||
@@ -53,8 +52,6 @@ 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 ──────────────────────────────────────────────
|
||||
|
||||
@@ -83,7 +80,6 @@ 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']>;
|
||||
|
||||
@@ -149,46 +145,22 @@ 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());
|
||||
},
|
||||
);
|
||||
|
||||
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 });
|
||||
};
|
||||
pollySendMock.mockImplementation((cmd: { constructor: { name: string } }) => {
|
||||
if (cmd.constructor.name === 'DescribeVoicesCommand') {
|
||||
return Promise.resolve(onDescribe());
|
||||
}
|
||||
return Promise.resolve(onSynthesize());
|
||||
});
|
||||
|
||||
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 ─────────────────────────────────────────────
|
||||
@@ -523,248 +495,3 @@ 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,14 +36,8 @@ 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'];
|
||||
|
||||
@@ -72,14 +66,6 @@ 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: {
|
||||
@@ -87,28 +73,11 @@ 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 ?? this.region;
|
||||
const resolvedRegion = region ?? cfg.region ?? 'us-west-2';
|
||||
|
||||
if (this.clients[resolvedRegion]) {
|
||||
return this.clients[resolvedRegion];
|
||||
@@ -227,17 +196,16 @@ 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: rates[engine] / 100, // microcents to dollars
|
||||
pricing_per_million_chars: AWS_POLLY_COSTS[engine] / 100, // microcents to dollars
|
||||
}));
|
||||
}
|
||||
|
||||
getReportedCosts(): Record<string, unknown>[] {
|
||||
return characterCostReport('aws-polly', this.currentRates);
|
||||
return characterCostReport('aws-polly', AWS_POLLY_COSTS);
|
||||
}
|
||||
|
||||
async synthesize(
|
||||
@@ -274,15 +242,11 @@ 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`,
|
||||
ucentsPerChar,
|
||||
AWS_POLLY_COSTS[engine] ?? 0,
|
||||
text,
|
||||
async () => {
|
||||
// Resolve voice
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
{
|
||||
"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": {}
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
/*
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -1,238 +0,0 @@
|
||||
/*
|
||||
* 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,28 +218,6 @@ 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: ELEVENLABS_TTS_COSTS[model.id] / 100, // Convert microcents per character to dollars per million characters.
|
||||
pricing_per_million_chars: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -115,18 +115,15 @@ describe('XAITTSProvider catalog', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('listEngines reports a single xai-tts engine priced in dollars per 1M chars', async () => {
|
||||
it('listEngines reports a single xai-tts engine with pricing_per_million_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: 15,
|
||||
pricing_per_million_chars: 1500,
|
||||
});
|
||||
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: XAI_TTS_COSTS['xai-tts'] / 100, // Convert microcents per character to dollars per million characters.
|
||||
pricing_per_million_chars: 1500,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -926,3 +926,108 @@ describe('WorkerDriver hot reload', () => {
|
||||
expect(sessionMint).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// -- worker sessions --------------------------------------------------
|
||||
|
||||
describe('WorkerDriver keeps a worker`s token tied to the worker', () => {
|
||||
const deployedToken = () => {
|
||||
const [, init] = putCalls().at(-1)!;
|
||||
const form = (init as RequestInit).body as FormData;
|
||||
const metadata = JSON.parse(form.get('metadata') as string);
|
||||
return metadata.bindings.find(
|
||||
(b: { name: string }) => b.name === 'puter_auth',
|
||||
).text as string;
|
||||
};
|
||||
|
||||
const deployWorker = async (label: string) => {
|
||||
const { user, actor } = await makeUser();
|
||||
const path = `/${user.username}/${label}.js`;
|
||||
const entry = await writeSource(actor, user.id, path, 'src');
|
||||
const name = `${label}-${user.username}`;
|
||||
await inCtx(actor, () =>
|
||||
target.create({ appId: '', workerName: name, filePath: path }),
|
||||
);
|
||||
return { user, actor, entry, name, token: deployedToken() };
|
||||
};
|
||||
|
||||
const workerSession = (userId: number, workerName: string) =>
|
||||
server.stores.session.getWorker(userId, { appUid: null, workerName });
|
||||
|
||||
it('binds the deployed token to the worker row, and a redeploy keeps it', async () => {
|
||||
const { user, actor, name, token } = await deployWorker('bind');
|
||||
const row = await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
);
|
||||
const decoded = server.services.token.verify('auth', token) as {
|
||||
worker_uid?: string;
|
||||
};
|
||||
expect(decoded.worker_uid).toBe(row!.uuid);
|
||||
expect(
|
||||
(await server.services.auth.authenticate(token)).actor,
|
||||
).toBeTruthy();
|
||||
|
||||
await inCtx(actor, () =>
|
||||
target.create({
|
||||
appId: '',
|
||||
workerName: name,
|
||||
filePath: `/${user.username}/bind.js`,
|
||||
}),
|
||||
);
|
||||
const redeployed = server.services.token.verify(
|
||||
'auth',
|
||||
deployedToken(),
|
||||
) as { worker_uid?: string };
|
||||
expect(redeployed.worker_uid).toBe(row!.uuid);
|
||||
});
|
||||
|
||||
it('destroy deletes the worker`s session and leaves other workers alone', async () => {
|
||||
const kept = await deployWorker('kept');
|
||||
const { user, actor, name, token } = await deployWorker('gone');
|
||||
|
||||
await inCtx(actor, () => target.destroy({ workerName: name }));
|
||||
|
||||
expect(await workerSession(user.id, name)).toBeNull();
|
||||
expect(await server.services.auth.authenticate(token)).toEqual({
|
||||
invalid: true,
|
||||
});
|
||||
expect(await workerSession(kept.user.id, kept.name)).toBeTruthy();
|
||||
expect(
|
||||
(await server.services.auth.authenticate(kept.token)).actor,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('deleting the source file deletes the worker`s session', async () => {
|
||||
const { user, actor, entry, name } = await deployWorker('srcdel');
|
||||
// Resolved at delete time, as every caller does: the entry names the
|
||||
// workers bound to it.
|
||||
const current = await server.stores.fsEntry.getEntryByUuid(entry.uuid);
|
||||
|
||||
await inCtx(actor, () =>
|
||||
server.services.fs.remove(user.id, { entry: current! }),
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
async () => !(await workerSession(user.id, name)),
|
||||
'worker session removal after source delete',
|
||||
);
|
||||
});
|
||||
|
||||
it('a worker row deleted elsewhere takes its session with it', async () => {
|
||||
const { user, name } = await deployWorker('elsewhere');
|
||||
const row = await server.stores.subdomain.getBySubdomain(
|
||||
`workers.puter.${name}`,
|
||||
);
|
||||
|
||||
await server.stores.subdomain.deleteByUuid(String(row!.uuid));
|
||||
server.clients.event.emit(
|
||||
'subdomain.delete',
|
||||
{ subdomain: `workers.puter.${name}`, uid: String(row!.uuid) },
|
||||
{},
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
async () => !(await workerSession(user.id, name)),
|
||||
'worker session removal after subdomain.delete',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { EventMetadata } from '../../clients/event/types.js';
|
||||
import {
|
||||
assertActorEmailVerified,
|
||||
@@ -414,11 +415,19 @@ export class WorkerDriver extends PuterDriver {
|
||||
let authorization: string | undefined = undefined;
|
||||
const appOwnerId = boundApp?.id;
|
||||
const omitOwnerToken = deployTarget?.omitOwnerToken === true;
|
||||
// The token is bound to the row it deploys under, which a new worker
|
||||
// only gets further down, so its uuid is picked here.
|
||||
const workerUid = deployTarget
|
||||
? undefined
|
||||
: existingSub
|
||||
? String(existingSub.uuid)
|
||||
: uuidv4();
|
||||
if (boundApp && !omitOwnerToken) {
|
||||
authorization = await this.services.auth.createWorkerAppToken(
|
||||
actor,
|
||||
boundApp.uid,
|
||||
workerName,
|
||||
{ workerUid },
|
||||
);
|
||||
}
|
||||
if (!authorization && !omitOwnerToken) {
|
||||
@@ -434,6 +443,8 @@ export class WorkerDriver extends PuterDriver {
|
||||
actor,
|
||||
userRow,
|
||||
workerName,
|
||||
{},
|
||||
{ workerUid },
|
||||
);
|
||||
authorization = session.token;
|
||||
}
|
||||
@@ -490,6 +501,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
// than letting the driver error escape as a 500.
|
||||
try {
|
||||
await this.stores.subdomain.create({
|
||||
uuid: workerUid,
|
||||
userId: actor.user.id!,
|
||||
subdomain: subdomainName,
|
||||
rootDirId: loaded.fsEntry.sqlId,
|
||||
@@ -572,6 +584,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
await this.stores.subdomain.deleteByUuid(row.uuid, {
|
||||
userId: actor.user.id,
|
||||
});
|
||||
await this.services.auth.deleteWorkerSessionsByName(workerName);
|
||||
return cfResult;
|
||||
}
|
||||
|
||||
@@ -1076,6 +1089,14 @@ export class WorkerDriver extends PuterDriver {
|
||||
err,
|
||||
);
|
||||
});
|
||||
void this.services.auth
|
||||
.deleteWorkerSessionsByName(workerName)
|
||||
.catch((err) => {
|
||||
console.warn(
|
||||
`[workers] session cleanup failed for ${workerName}`,
|
||||
err,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1145,6 +1166,11 @@ export class WorkerDriver extends PuterDriver {
|
||||
ownerActor,
|
||||
app.uid,
|
||||
workerName,
|
||||
{
|
||||
workerUid: row.uuid
|
||||
? String(row.uuid)
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
const session =
|
||||
@@ -1152,6 +1178,12 @@ export class WorkerDriver extends PuterDriver {
|
||||
ownerActor,
|
||||
ownerUser,
|
||||
workerName,
|
||||
{},
|
||||
{
|
||||
workerUid: row.uuid
|
||||
? String(row.uuid)
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
authorization = session.token;
|
||||
@@ -1325,6 +1357,7 @@ export class WorkerDriver extends PuterDriver {
|
||||
userId,
|
||||
});
|
||||
}
|
||||
await this.services.auth.deleteWorkerSessionsByName(workerName);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[workers] source cleanup failed for ${workerName}`,
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { makeActor } from '../../core/actor.js';
|
||||
import { WORKER_SUBDOMAIN_PREFIX } from '../../stores/subdomain/SubdomainStore.js';
|
||||
import {
|
||||
createTestUser,
|
||||
setupPuterTestEnv,
|
||||
@@ -327,10 +328,16 @@ describe('an app uid that returns after its app was deleted', () => {
|
||||
it('replaces a worker session older than its app', async () => {
|
||||
const { app, actor } = await createApp();
|
||||
const workerName = `w-${uuidv4().slice(0, 8)}`;
|
||||
const { uuid: workerUid } = await env.server.stores.subdomain.create({
|
||||
userId: actor.user.id as number,
|
||||
subdomain: `${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
|
||||
appOwner: app.id,
|
||||
});
|
||||
const old = await env.server.services.auth.createWorkerAppToken(
|
||||
actor,
|
||||
app.uid,
|
||||
workerName,
|
||||
{ workerUid },
|
||||
);
|
||||
await backdateSession(sessionUid(old), 3600);
|
||||
expect((await authenticate(old)).reauth?.reason).toBe(
|
||||
@@ -341,6 +348,7 @@ describe('an app uid that returns after its app was deleted', () => {
|
||||
actor,
|
||||
app.uid,
|
||||
workerName,
|
||||
{ workerUid },
|
||||
);
|
||||
expect(sessionUid(fresh)).not.toBe(sessionUid(old));
|
||||
expect(await isRevoked(sessionUid(old))).toBe(true);
|
||||
|
||||
@@ -22,8 +22,10 @@ import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { makeActor, type Actor } from '../../core/actor.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { WORKER_SUBDOMAIN_PREFIX } from '../../stores/subdomain/SubdomainStore.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
|
||||
import { EVENTS_WORKER_SESSION_NAME } from '../events/workerRuntime.js';
|
||||
import { FULL_API_ACCESS } from '../permission/consts.js';
|
||||
import { AuthService, HOSTED_ASSET_COOKIES } from './AuthService.js';
|
||||
|
||||
@@ -1647,10 +1649,16 @@ describe('AuthService (integration)', () => {
|
||||
// revoke so the re-create produces a fresh row.
|
||||
const user = await makeUser();
|
||||
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const { uuid: workerUid } = await server.stores.subdomain.create({
|
||||
userId: user.id,
|
||||
subdomain: `${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
|
||||
});
|
||||
const first = await authService.createWorkerSessionToken(
|
||||
makeActor({ user }),
|
||||
user,
|
||||
workerName,
|
||||
{},
|
||||
{ workerUid },
|
||||
);
|
||||
const firstUuid = (first.session as { uuid: string }).uuid;
|
||||
await authService.revokeSession(firstUuid);
|
||||
@@ -1659,6 +1667,8 @@ describe('AuthService (integration)', () => {
|
||||
makeActor({ user }),
|
||||
user,
|
||||
workerName,
|
||||
{},
|
||||
{ workerUid },
|
||||
);
|
||||
const secondUuid = (second.session as { uuid: string }).uuid;
|
||||
expect(secondUuid).not.toBe(firstUuid);
|
||||
@@ -1731,6 +1741,178 @@ describe('AuthService (integration)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a worker token lives only as long as its worker', () => {
|
||||
const deployRow = async (
|
||||
userId: number,
|
||||
workerName: string,
|
||||
appOwner?: number,
|
||||
) =>
|
||||
await server.stores.subdomain.create({
|
||||
userId,
|
||||
subdomain: `${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
|
||||
appOwner: appOwner ?? null,
|
||||
});
|
||||
const newName = () => `wk-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
it('resolves while the worker row exists and is invalid once it is deleted', async () => {
|
||||
const user = await makeUser();
|
||||
const workerName = newName();
|
||||
const row = await deployRow(user.id, workerName);
|
||||
const { token, gui_token } =
|
||||
await authService.createWorkerSessionToken(
|
||||
makeActor({ user }),
|
||||
user,
|
||||
workerName,
|
||||
{},
|
||||
{ workerUid: row.uuid },
|
||||
);
|
||||
expect((await authService.authenticate(token)).actor).toBeTruthy();
|
||||
|
||||
// Straight through the store: no hook revokes the session.
|
||||
await server.stores.subdomain.deleteByUuid(row.uuid);
|
||||
|
||||
expect(await authService.authenticate(token)).toEqual({
|
||||
invalid: true,
|
||||
});
|
||||
expect(await authService.authenticate(gui_token)).toEqual({
|
||||
invalid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not come back when the same name is deployed again', async () => {
|
||||
const user = await makeUser();
|
||||
const workerName = newName();
|
||||
const first = await deployRow(user.id, workerName);
|
||||
const { token } = await authService.createWorkerSessionToken(
|
||||
makeActor({ user }),
|
||||
user,
|
||||
workerName,
|
||||
{},
|
||||
{ workerUid: first.uuid },
|
||||
);
|
||||
await server.stores.subdomain.deleteByUuid(first.uuid);
|
||||
await deployRow(user.id, workerName);
|
||||
|
||||
expect(await authService.authenticate(token)).toEqual({
|
||||
invalid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies to app-scoped worker tokens', async () => {
|
||||
const user = await makeUser();
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `wk-app-${uuidv4()}`,
|
||||
title: 'Worker app',
|
||||
index_url: `https://${uuidv4()}.example.com/`,
|
||||
},
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
const workerName = newName();
|
||||
const row = await deployRow(user.id, workerName, app.id);
|
||||
const token = await authService.createWorkerAppToken(
|
||||
makeActor({ user }),
|
||||
app.uid,
|
||||
workerName,
|
||||
{ workerUid: row.uuid },
|
||||
);
|
||||
expect(
|
||||
(await authService.authenticate(token)).actor?.app?.uid,
|
||||
).toBe(app.uid);
|
||||
|
||||
await server.stores.subdomain.deleteByUuid(row.uuid);
|
||||
|
||||
expect(await authService.authenticate(token)).toEqual({
|
||||
invalid: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('holds a token minted without a worker uid to the name and owner', async () => {
|
||||
const user = await makeUser();
|
||||
const other = await makeUser();
|
||||
const workerName = newName();
|
||||
const { token } = await authService.createWorkerSessionToken(
|
||||
makeActor({ user }),
|
||||
user,
|
||||
workerName,
|
||||
);
|
||||
// No row at all.
|
||||
expect(await authService.authenticate(token)).toEqual({
|
||||
invalid: true,
|
||||
});
|
||||
|
||||
// Someone else's worker under that name.
|
||||
const foreign = await deployRow(other.id, workerName);
|
||||
expect(await authService.authenticate(token)).toEqual({
|
||||
invalid: true,
|
||||
});
|
||||
await server.stores.subdomain.deleteByUuid(foreign.uuid);
|
||||
|
||||
await deployRow(user.id, workerName);
|
||||
expect((await authService.authenticate(token)).actor).toBeTruthy();
|
||||
});
|
||||
|
||||
it('leaves events handler sessions alone; they have no worker row', async () => {
|
||||
const user = await makeUser();
|
||||
const app = await server.stores.app.create(
|
||||
{
|
||||
name: `ev-app-${uuidv4()}`,
|
||||
title: 'Events app',
|
||||
index_url: `https://${uuidv4()}.example.com/`,
|
||||
},
|
||||
{ ownerUserId: user.id },
|
||||
);
|
||||
const token = await authService.createWorkerAppToken(
|
||||
makeActor({ user }),
|
||||
app.uid,
|
||||
EVENTS_WORKER_SESSION_NAME,
|
||||
);
|
||||
expect((await authService.authenticate(token)).actor).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleting worker sessions', () => {
|
||||
it('deleteWorkerSessionsByName ends the token and drops a derived token`s grants', async () => {
|
||||
const user = await makeUser();
|
||||
const workerName = `wk-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const row = await server.stores.subdomain.create({
|
||||
userId: user.id,
|
||||
subdomain: `${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
|
||||
});
|
||||
const { session, token } =
|
||||
await authService.createWorkerSessionToken(
|
||||
makeActor({ user }),
|
||||
user,
|
||||
workerName,
|
||||
{},
|
||||
{ workerUid: row.uuid },
|
||||
);
|
||||
const sessionUuid = (session as { uuid: string }).uuid;
|
||||
const childUid = uuidv4();
|
||||
await server.stores.session.create(user.id, {
|
||||
kind: 'access_token',
|
||||
parent_session_id: sessionUuid,
|
||||
access_token_uid: childUid,
|
||||
});
|
||||
await server.clients.db.write(
|
||||
'INSERT INTO `access_token_permissions` (`token_uid`, `authorizer_user_id`, `permission`) VALUES (?, ?, ?)',
|
||||
[childUid, user.id, 'fs:x:read'],
|
||||
);
|
||||
|
||||
await authService.deleteWorkerSessionsByName(workerName);
|
||||
|
||||
// The worker row is still there: only the session went.
|
||||
expect(await authService.authenticate(token)).toEqual({
|
||||
invalid: true,
|
||||
});
|
||||
const grants = await server.clients.db.read(
|
||||
'SELECT * FROM `access_token_permissions` WHERE `token_uid` = ?',
|
||||
[childUid],
|
||||
);
|
||||
expect(grants).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appUidFromOrigin', () => {
|
||||
it('throws 400 for an unparseable origin string', async () => {
|
||||
await expect(
|
||||
|
||||
@@ -33,12 +33,14 @@ import {
|
||||
GODMODE_TOKEN_WINDOW_SECONDS,
|
||||
WEB_WINDOW_SECONDS,
|
||||
} from '../../stores/session/SessionStore.js';
|
||||
import { WORKER_SUBDOMAIN_PREFIX } from '../../stores/subdomain/SubdomainStore.js';
|
||||
import type { UserRow } from '../../stores/user/UserStore';
|
||||
import type { LayerInstances } from '../../types';
|
||||
import { sessionCookieFlags } from '../../util/cookieFlags.js';
|
||||
import { isGodmodeApp } from '../../util/godmodeApps.js';
|
||||
import { Span } from '../../util/span.js';
|
||||
import type { puterServices } from '../index';
|
||||
import { EVENTS_WORKER_SESSION_NAME } from '../events/workerRuntime.js';
|
||||
import { FULL_API_ACCESS, PERMISSION_MAX_LEN } from '../permission/consts';
|
||||
import { PuterService } from '../types';
|
||||
import type {
|
||||
@@ -401,7 +403,11 @@ export class AuthService extends PuterService {
|
||||
user: UserRow,
|
||||
sessionUuid: string,
|
||||
authId: string,
|
||||
opts: { worker?: boolean; workerName?: string } = {},
|
||||
opts: {
|
||||
worker?: boolean;
|
||||
workerName?: string;
|
||||
workerUid?: string;
|
||||
} = {},
|
||||
): string {
|
||||
const claims: Record<string, unknown> = {
|
||||
type,
|
||||
@@ -416,6 +422,7 @@ export class AuthService extends PuterService {
|
||||
};
|
||||
if (opts.worker) claims.worker = true;
|
||||
if (opts.workerName) claims.worker_name = opts.workerName;
|
||||
if (opts.workerUid) claims.worker_uid = opts.workerUid;
|
||||
return this.services.token.sign('auth', claims);
|
||||
}
|
||||
|
||||
@@ -426,13 +433,15 @@ export class AuthService extends PuterService {
|
||||
* the row and returns the same stable token. Expires after
|
||||
* `WORKER_WINDOW_SECONDS` (effectively infinite). The emitted JWT carries
|
||||
* `worker: true` and `worker_name` so downstream code can tell a worker
|
||||
* session from a user-driven one without a DB round-trip.
|
||||
* session from a user-driven one without a DB round-trip. `workerUid` binds
|
||||
* the token to the worker's subdomain row, so it stops working with it.
|
||||
*/
|
||||
async createWorkerSessionToken(
|
||||
actor: Actor,
|
||||
user: UserRow,
|
||||
workerName: string,
|
||||
meta: Record<string, unknown> = {},
|
||||
{ workerUid }: { workerUid?: string } = {},
|
||||
): Promise<{
|
||||
session: Record<string, unknown>;
|
||||
token: string;
|
||||
@@ -464,14 +473,14 @@ export class AuthService extends PuterService {
|
||||
user,
|
||||
session.uuid as string,
|
||||
auth_id,
|
||||
{ worker: true, workerName },
|
||||
{ worker: true, workerName, workerUid },
|
||||
);
|
||||
const gui_token = this.#signSessionTypeToken(
|
||||
'gui',
|
||||
user,
|
||||
session.uuid as string,
|
||||
auth_id,
|
||||
{ worker: true, workerName },
|
||||
{ worker: true, workerName, workerUid },
|
||||
);
|
||||
|
||||
return { session, token, gui_token };
|
||||
@@ -489,13 +498,18 @@ export class AuthService extends PuterService {
|
||||
* handler runs deep; `expiresInSeconds` bounds a token that carries one.
|
||||
* Refuses an actor that is itself running behind a handler — the minted
|
||||
* token would outlive any one run, so stamping it with the caller's depth
|
||||
* would only delay the escape, not close it.
|
||||
* would only delay the escape, not close it. `workerUid` binds the token to
|
||||
* the worker's subdomain row, so it stops working with it.
|
||||
*/
|
||||
async createWorkerAppToken(
|
||||
actor: Actor,
|
||||
appUid: string,
|
||||
workerName: string,
|
||||
options: { handlerDepth?: number; expiresInSeconds?: number } = {},
|
||||
options: {
|
||||
handlerDepth?: number;
|
||||
expiresInSeconds?: number;
|
||||
workerUid?: string;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
if (!actor.user) {
|
||||
throw new HttpError(403, 'Actor must be a user', {
|
||||
@@ -546,6 +560,7 @@ export class AuthService extends PuterService {
|
||||
auth_id,
|
||||
worker: true,
|
||||
worker_name: workerName,
|
||||
...(options.workerUid ? { worker_uid: options.workerUid } : {}),
|
||||
...(options.handlerDepth
|
||||
? { handler_depth: options.handlerDepth }
|
||||
: {}),
|
||||
@@ -891,6 +906,50 @@ export class AuthService extends PuterService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every session of a worker that no longer exists, whoever minted
|
||||
* it: worker names are unique, so nothing else can be using them.
|
||||
*/
|
||||
async deleteWorkerSessionsByName(workerName: string): Promise<void> {
|
||||
if (!workerName) return;
|
||||
await this.#deleteWorkerSessions({ workerName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the sessions of the workers an app deployed for this user, e.g.
|
||||
* when the user uninstalls it. The workers themselves keep running.
|
||||
*/
|
||||
async deleteWorkerSessionsForApp(
|
||||
userId: number,
|
||||
appUid: string,
|
||||
): Promise<void> {
|
||||
if (!userId || !appUid) return;
|
||||
await this.#deleteWorkerSessions({ userId, appUid });
|
||||
}
|
||||
|
||||
async #deleteWorkerSessions(filter: {
|
||||
workerName?: string;
|
||||
userId?: number;
|
||||
appUid?: string;
|
||||
}): Promise<void> {
|
||||
const deleted = (await this.stores.session.deleteWorkerSessions(
|
||||
filter,
|
||||
)) as { uuid: string; userId: number; accessTokenUid: string | null }[];
|
||||
const byUser = new Map<number, string[]>();
|
||||
for (const row of deleted) {
|
||||
byUser.set(row.userId, [
|
||||
...(byUser.get(row.userId) ?? []),
|
||||
row.uuid,
|
||||
]);
|
||||
if (row.accessTokenUid) {
|
||||
await this.#dropAccessTokenGrants(row.accessTokenUid);
|
||||
}
|
||||
}
|
||||
for (const [userId, uuids] of byUser) {
|
||||
this.#announceRevocation({ userId, uuids });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Password-reset cascade: revoke every interactive (web/app) session for
|
||||
* the user, so a hijacked session doesn't outlive a password reset. No
|
||||
@@ -2272,6 +2331,30 @@ export class AuthService extends PuterService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A worker credential lives only as long as the worker it was minted for.
|
||||
* Tokens minted before `worker_uid` existed are held to the worker's name
|
||||
* and owner instead.
|
||||
*/
|
||||
async #workerStillDeployed(
|
||||
decoded: {
|
||||
worker?: boolean;
|
||||
worker_name?: string;
|
||||
worker_uid?: string;
|
||||
},
|
||||
userId: number,
|
||||
): Promise<boolean> {
|
||||
if (!decoded.worker) return true;
|
||||
// Events handler sessions have no worker row; EventsService retires them.
|
||||
if (decoded.worker_name === EVENTS_WORKER_SESSION_NAME) return true;
|
||||
if (!decoded.worker_name) return false;
|
||||
const row = await this.stores.subdomain.getBySubdomain(
|
||||
`${WORKER_SUBDOMAIN_PREFIX}${decoded.worker_name}`,
|
||||
);
|
||||
if (!row || Number(row.user_id) !== Number(userId)) return false;
|
||||
return !decoded.worker_uid || row.uuid === decoded.worker_uid;
|
||||
}
|
||||
|
||||
async #actorFromSessionToken(
|
||||
decoded: SessionTokenPayload,
|
||||
ctx: AuthenticateContext = {},
|
||||
@@ -2302,6 +2385,9 @@ export class AuthService extends PuterService {
|
||||
const session: SessionRow | null = rawRow;
|
||||
|
||||
if (!session) return { invalid: true };
|
||||
if (!(await this.#workerStillDeployed(decoded, user.id as number))) {
|
||||
return { invalid: true };
|
||||
}
|
||||
|
||||
void this.#touchSession(session.uuid, user.id, ctx);
|
||||
|
||||
@@ -2346,6 +2432,9 @@ export class AuthService extends PuterService {
|
||||
if (notBefore !== null && createdAt > 0 && createdAt < notBefore) {
|
||||
return { reauth: { reason: 'session_revoked' } };
|
||||
}
|
||||
if (!(await this.#workerStillDeployed(decoded, user.id as number))) {
|
||||
return { invalid: true };
|
||||
}
|
||||
|
||||
void this.#touchSession(session.uuid, user.id, ctx);
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ export interface SessionTokenPayload extends TokenPayloadBase {
|
||||
user_uid: string;
|
||||
/** Set on a worker credential riding this token type; not a browser session. */
|
||||
worker?: boolean;
|
||||
worker_name?: string;
|
||||
/** Uuid of the worker's subdomain row; the token dies with that row. */
|
||||
worker_uid?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,6 +70,10 @@ export interface AppUnderUserTokenPayload extends TokenPayloadBase {
|
||||
session?: string;
|
||||
/** Events handler runs behind this token's writes; see `Actor.handlerDepth`. */
|
||||
handler_depth?: number;
|
||||
worker?: boolean;
|
||||
worker_name?: string;
|
||||
/** Uuid of the worker's subdomain row; the token dies with that row. */
|
||||
worker_uid?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,7 +101,9 @@ export interface AccessTokenPayload extends TokenPayloadBase {
|
||||
}
|
||||
|
||||
export type AnyTokenPayload =
|
||||
SessionTokenPayload | AppUnderUserTokenPayload | AccessTokenPayload;
|
||||
| SessionTokenPayload
|
||||
| AppUnderUserTokenPayload
|
||||
| AccessTokenPayload;
|
||||
|
||||
// -- Session row (from `sessions` table) ----------------------------
|
||||
|
||||
|
||||
@@ -70,6 +70,13 @@ describe('addresses', () => {
|
||||
'[email protected]',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a username with capitals yields lowercase addresses', () => {
|
||||
expect(puterEmailAddressesOf('Dan')).toEqual([
|
||||
'[email protected]',
|
||||
'[email protected]',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('account eligibility', () => {
|
||||
|
||||
@@ -59,9 +59,13 @@ export const isPuterEmailAddress = (address: string): boolean =>
|
||||
export const puterEmailUsername = (address: string): string =>
|
||||
address.split('@')[0];
|
||||
|
||||
/** Every address that resolves to `username`. */
|
||||
/**
|
||||
* Every address that resolves to `username`, lowercased: callers compare these
|
||||
* against addresses parsed out of headers, which are lowercased too, and a
|
||||
* username stored with capitals would otherwise match nothing.
|
||||
*/
|
||||
export const puterEmailAddressesOf = (username: string): string[] =>
|
||||
PUTER_EMAIL_DOMAINS.map((domain) => `${username}@${domain}`);
|
||||
PUTER_EMAIL_DOMAINS.map((domain) => `${username.toLowerCase()}@${domain}`);
|
||||
|
||||
/**
|
||||
* A temp account has neither a password nor an email - the same test
|
||||
|
||||
@@ -21,7 +21,10 @@ import { createServer } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { Readable } from 'node:stream';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import type { SubdomainRow } from '../../stores/subdomain/SubdomainStore.js';
|
||||
import {
|
||||
WORKER_SUBDOMAIN_PREFIX,
|
||||
type SubdomainRow,
|
||||
} from '../../stores/subdomain/SubdomainStore.js';
|
||||
import type { PuterServer } from '../../server.js';
|
||||
import { createTestUser, setupTestServer } from '../../testUtil.js';
|
||||
import { getWorkerPreamble } from '../../drivers/workers/WorkerDriver.js';
|
||||
@@ -82,6 +85,18 @@ const subdomainRow = (over: Partial<SubdomainRow>): SubdomainRow =>
|
||||
...over,
|
||||
}) as SubdomainRow;
|
||||
|
||||
/** A stored worker row: the minted token is only valid while it exists. */
|
||||
const seedWorkerRow = async (
|
||||
workerName: string,
|
||||
over: { root_dir_id: number; app_owner?: number },
|
||||
): Promise<SubdomainRow> =>
|
||||
(await server.stores.subdomain.create({
|
||||
userId: ownerUserId,
|
||||
subdomain: `${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
|
||||
rootDirId: over.root_dir_id,
|
||||
appOwner: over.app_owner ?? null,
|
||||
})) as unknown as SubdomainRow;
|
||||
|
||||
describe('LocalWorkerService.reconstructDeployArgs', () => {
|
||||
it('mints a user-scoped worker session token and prepends the preamble', async () => {
|
||||
const src = await writeSource(
|
||||
@@ -91,7 +106,7 @@ describe('LocalWorkerService.reconstructDeployArgs', () => {
|
||||
const [name, authorization, code] =
|
||||
await localWorkers.reconstructDeployArgs(
|
||||
'worker-a',
|
||||
subdomainRow({ root_dir_id: src.id }),
|
||||
await seedWorkerRow('worker-a', { root_dir_id: src.id }),
|
||||
);
|
||||
|
||||
const source = "export default { fetch: () => new Response('a') };";
|
||||
@@ -130,7 +145,10 @@ describe('LocalWorkerService.reconstructDeployArgs', () => {
|
||||
|
||||
const [, authorization] = await localWorkers.reconstructDeployArgs(
|
||||
'worker-b',
|
||||
subdomainRow({ root_dir_id: src.id, app_owner: app.id }),
|
||||
await seedWorkerRow('worker-b', {
|
||||
root_dir_id: src.id,
|
||||
app_owner: app.id,
|
||||
}),
|
||||
);
|
||||
|
||||
const auth = await server.services.auth.authenticate(authorization);
|
||||
|
||||
@@ -268,12 +268,15 @@ export class LocalWorkerService extends PuterService {
|
||||
ownerActor,
|
||||
app.uid,
|
||||
workerName,
|
||||
{ workerUid: row.uuid ? String(row.uuid) : undefined },
|
||||
);
|
||||
} else {
|
||||
const session = await this.services.auth.createWorkerSessionToken(
|
||||
ownerActor,
|
||||
ownerUser,
|
||||
workerName,
|
||||
{},
|
||||
{ workerUid: row.uuid ? String(row.uuid) : undefined },
|
||||
);
|
||||
|
||||
authorization = session.token;
|
||||
|
||||
@@ -456,6 +456,77 @@ export class SessionStore extends PuterStore {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-delete the worker sessions matching every given filter, plus the
|
||||
* sessions derived from them via `parent_session_id`. A deleted worker
|
||||
* never comes back, so there is no revoked row worth keeping.
|
||||
*
|
||||
* Returns each deleted row as `{ uuid, userId, accessTokenUid }` so the
|
||||
* caller can announce the revocation and drop access-token grants.
|
||||
*
|
||||
* @param {{
|
||||
* workerName?: string;
|
||||
* userId?: number;
|
||||
* appUid?: string;
|
||||
* }} filter
|
||||
*/
|
||||
async deleteWorkerSessions({ workerName, userId, appUid } = {}) {
|
||||
const where = ["`kind` = 'worker'"];
|
||||
const params = [];
|
||||
if (workerName) {
|
||||
const workerNameExpr = this.clients.db.jsonTextExtract('`meta`', [
|
||||
'worker_name',
|
||||
]);
|
||||
where.push(`${workerNameExpr} = ?`);
|
||||
params.push(workerName);
|
||||
}
|
||||
if (userId) {
|
||||
where.push('`user_id` = ?');
|
||||
params.push(userId);
|
||||
}
|
||||
if (appUid) {
|
||||
where.push('`app_uid` = ?');
|
||||
params.push(appUid);
|
||||
}
|
||||
// No filter would mean every worker session on the server.
|
||||
if (params.length === 0) return [];
|
||||
|
||||
// Primary: a session minted moments ago must not survive a replica miss.
|
||||
const roots = await this.clients.db.pread(
|
||||
`SELECT \`uuid\` FROM \`sessions\` WHERE ${where.join(' AND ')}`,
|
||||
params,
|
||||
);
|
||||
if (roots.length === 0) return [];
|
||||
const rootUuids = roots.map((r) => String(r.uuid));
|
||||
const placeholders = rootUuids.map(() => '?').join(', ');
|
||||
const scope = `\`uuid\` IN (${placeholders}) OR \`parent_session_id\` IN (${placeholders})`;
|
||||
const scopeParams = [...rootUuids, ...rootUuids];
|
||||
|
||||
const rows = await this.clients.db.pread(
|
||||
'SELECT `uuid`, `user_id`, `kind`, `app_uid`, `legacy_token_uid`, `meta`, `created_via`, `last_ip`, `last_user_agent`, `access_token_uid` ' +
|
||||
`FROM \`sessions\` WHERE ${scope}`,
|
||||
scopeParams,
|
||||
);
|
||||
|
||||
// Double-delete: see `removeByUuid` for rationale.
|
||||
const keys = [];
|
||||
for (const r of rows) keys.push(...this.#allCacheKeysForRow(r));
|
||||
await this.publishCacheKeys({ keys, broadcast: true });
|
||||
await this.clients.db.write(
|
||||
`DELETE FROM \`sessions\` WHERE ${scope}`,
|
||||
scopeParams,
|
||||
);
|
||||
await this.publishCacheKeys({ keys, broadcast: true });
|
||||
|
||||
return rows.map((r) => ({
|
||||
uuid: String(r.uuid),
|
||||
userId: Number(r.user_id),
|
||||
accessTokenUid: r.access_token_uid
|
||||
? String(r.access_token_uid)
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Access-token identities among the rows `revokeCascade(rootUuid)` would
|
||||
* affect — the root itself when it is a token row, plus any token row
|
||||
|
||||
@@ -805,6 +805,123 @@ describe('SessionStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteWorkerSessions', () => {
|
||||
const name = () => `wk-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
it('deletes every row of a worker name across users and apps, and nothing else', async () => {
|
||||
const alice = await makeUser();
|
||||
const bob = await makeUser();
|
||||
const workerName = name();
|
||||
const userScoped = await target.getOrCreateWorker(alice.id, {
|
||||
appUid: null,
|
||||
workerName,
|
||||
});
|
||||
const appScoped = await target.getOrCreateWorker(bob.id, {
|
||||
appUid: `app-${uuidv4()}`,
|
||||
workerName,
|
||||
});
|
||||
const otherWorker = await target.getOrCreateWorker(alice.id, {
|
||||
appUid: null,
|
||||
workerName: name(),
|
||||
});
|
||||
|
||||
const deleted = await target.deleteWorkerSessions({ workerName });
|
||||
|
||||
expect(deleted.map((r: { uuid: string }) => r.uuid).sort()).toEqual(
|
||||
[userScoped.uuid, appScoped.uuid].sort(),
|
||||
);
|
||||
expect(await rawRow(userScoped.uuid)).toBeNull();
|
||||
expect(await rawRow(appScoped.uuid)).toBeNull();
|
||||
expect(await rawRow(otherWorker.uuid)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('filters by user and app together', async () => {
|
||||
const user = await makeUser();
|
||||
const other = await makeUser();
|
||||
const appUid = `app-${uuidv4()}`;
|
||||
const deployed = await target.getOrCreateWorker(user.id, {
|
||||
appUid,
|
||||
workerName: name(),
|
||||
});
|
||||
const otherApp = await target.getOrCreateWorker(user.id, {
|
||||
appUid: `app-${uuidv4()}`,
|
||||
workerName: name(),
|
||||
});
|
||||
const userScoped = await target.getOrCreateWorker(user.id, {
|
||||
appUid: null,
|
||||
workerName: name(),
|
||||
});
|
||||
const otherUser = await target.getOrCreateWorker(other.id, {
|
||||
appUid,
|
||||
workerName: name(),
|
||||
});
|
||||
|
||||
await target.deleteWorkerSessions({ userId: user.id, appUid });
|
||||
|
||||
expect(await rawRow(deployed.uuid)).toBeNull();
|
||||
expect(await rawRow(otherApp.uuid)).not.toBeNull();
|
||||
expect(await rawRow(userScoped.uuid)).not.toBeNull();
|
||||
expect(await rawRow(otherUser.uuid)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('takes derived sessions with it', async () => {
|
||||
const user = await makeUser();
|
||||
const worker = await target.getOrCreateWorker(user.id, {
|
||||
appUid: `app-${uuidv4()}`,
|
||||
workerName: name(),
|
||||
});
|
||||
const child = await target.create(user.id, {
|
||||
kind: 'access_token',
|
||||
parent_session_id: worker.uuid,
|
||||
access_token_uid: uuidv4(),
|
||||
});
|
||||
|
||||
const deleted = await target.deleteWorkerSessions({
|
||||
userId: user.id,
|
||||
appUid: worker.app_uid,
|
||||
});
|
||||
|
||||
expect(await rawRow(child.uuid)).toBeNull();
|
||||
expect(
|
||||
deleted.find((r: { uuid: string }) => r.uuid === child.uuid)
|
||||
?.accessTokenUid,
|
||||
).toBe(child.access_token_uid);
|
||||
});
|
||||
|
||||
it('drops the cached rows, so a lookup misses and a redeploy mints fresh', async () => {
|
||||
const user = await makeUser();
|
||||
const workerName = name();
|
||||
const first = await target.getOrCreateWorker(user.id, {
|
||||
appUid: null,
|
||||
workerName,
|
||||
});
|
||||
// Warm both the uuid and the composite worker cache.
|
||||
expect(await target.getByUuidAny(first.uuid)).toBeTruthy();
|
||||
expect(
|
||||
await target.getWorker(user.id, { appUid: null, workerName }),
|
||||
).toBeTruthy();
|
||||
|
||||
await target.deleteWorkerSessions({ workerName });
|
||||
|
||||
expect(await target.getByUuidAny(first.uuid)).toBeNull();
|
||||
const second = await target.getOrCreateWorker(user.id, {
|
||||
appUid: null,
|
||||
workerName,
|
||||
});
|
||||
expect(second.uuid).not.toBe(first.uuid);
|
||||
});
|
||||
|
||||
it('refuses to run without a filter', async () => {
|
||||
const user = await makeUser();
|
||||
const row = await target.getOrCreateWorker(user.id, {
|
||||
appUid: null,
|
||||
workerName: name(),
|
||||
});
|
||||
expect(await target.deleteWorkerSessions({})).toEqual([]);
|
||||
expect(await rawRow(row.uuid)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error propagation (no silent swallow)', () => {
|
||||
// INSERT-IGNORE used to mask every constraint violation, not just
|
||||
// the partial-unique-index conflict the `getOrCreate*` paths rely
|
||||
|
||||
@@ -510,6 +510,7 @@ export class SubdomainStore extends PuterStore {
|
||||
* }} opts
|
||||
*/
|
||||
async create({
|
||||
uuid = uuidv4(),
|
||||
userId,
|
||||
subdomain,
|
||||
rootDirId = null,
|
||||
@@ -518,6 +519,8 @@ export class SubdomainStore extends PuterStore {
|
||||
preambleVersion = null,
|
||||
isProtected = false,
|
||||
}: {
|
||||
/** Pre-generated by a caller that has to hand the uuid out first. */
|
||||
uuid?: string;
|
||||
userId: number;
|
||||
subdomain: string;
|
||||
rootDirId?: number | null;
|
||||
@@ -530,7 +533,6 @@ export class SubdomainStore extends PuterStore {
|
||||
if (!userId || !subdomain) {
|
||||
throw new Error('create: userId and subdomain are required');
|
||||
}
|
||||
const uuid = uuidv4();
|
||||
await this.clients.db.write(
|
||||
`INSERT INTO \`subdomains\`
|
||||
(\`uuid\`, \`subdomain\`, \`user_id\`, \`root_dir_id\`, \`associated_app_id\`, \`app_owner\`, \`preamble_version\`, \`protected\`)
|
||||
|
||||
+11
-2
@@ -17,6 +17,7 @@ import {
|
||||
import type { PoolConfig } from 'pg';
|
||||
import { ADMIN_GROUP_UID } from './services/selfhosted/DefaultUserService';
|
||||
import { FULL_API_ACCESS } from './services/permission/consts';
|
||||
import { WORKER_SUBDOMAIN_PREFIX } from './stores/subdomain/SubdomainStore.js';
|
||||
import { generateDefaultFsentries } from './util/userProvisioning';
|
||||
|
||||
export const POSTGRES_TEST_MIGRATIONS_PATH =
|
||||
@@ -309,12 +310,20 @@ export const createTestUser = async (
|
||||
);
|
||||
|
||||
// Mint a user-scoped worker token the same way deploying an app-less
|
||||
// worker does (WorkerDriver falls back to createWorkerSessionToken).
|
||||
// worker does (WorkerDriver falls back to createWorkerSessionToken). The
|
||||
// token only lives as long as its worker row, so seed one.
|
||||
const workerName = `test-env-worker-${user.id}`;
|
||||
const workerRow = await server.stores.subdomain.create({
|
||||
userId: user.id,
|
||||
subdomain: `${WORKER_SUBDOMAIN_PREFIX}${workerName}`,
|
||||
});
|
||||
const { token: workerToken } =
|
||||
await server.services.auth.createWorkerSessionToken(
|
||||
makeActor({ user }),
|
||||
user,
|
||||
'puter-test-env-worker',
|
||||
workerName,
|
||||
{},
|
||||
{ workerUid: workerRow.uuid },
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -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 are `'aws-polly'`, `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, `'speechify'`, or `'all'` to list every provider at once.
|
||||
Accepted values: `'aws-polly'`, `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, 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'`. The value `'all'` returns every provider's engines. |
|
||||
| `provider` | `String` | TTS provider to query. Defaults to `'aws-polly'`; `'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 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'`). |
|
||||
| `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'`). |
|
||||
| `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.
|
||||
|
||||
@@ -42,8 +42,8 @@ A plain-text body. Shorthand for `text`; use the options form for anything else.
|
||||
- `attachments` (Array) - Up to **10** attachments, **25 MB** in total. Each is an object:
|
||||
- `filename` (String) - Required with `content`; defaults to the file's name for a `path`/`uid` attachment.
|
||||
- `content` (String) - The file body as base64. Mutually exclusive with `path`/`uid`.
|
||||
- `path` (String) - A Puter path (`~/` allowed). The file is read on the server with the caller's file permissions, falling back to the worker owner's, and streamed rather than uploaded — prefer this over `content` for anything larger than a few hundred kilobytes.
|
||||
- `uid` (String) - A Puter file uid, as an alternative to `path`.
|
||||
- `path` (String) - A Puter path (`~/` allowed, meaning the caller's home). The file is read on the server with the caller's file permissions only, and streamed rather than uploaded — prefer this over `content` for anything larger than a few hundred kilobytes.
|
||||
- `uid` (String) - A Puter file uid, as an alternative to `path`. Read with the caller's file permissions, falling back to the worker owner's — use a uid to attach a file from the worker owner's storage.
|
||||
- `contentType` (String) - MIME type. Detected from the file for `path`/`uid` attachments.
|
||||
- `cid` (String) - Content-ID that makes the attachment an inline part of the `html` body: with `cid: 'logo'`, `<img src="cid:logo">` shows it. Printable ASCII with no whitespace or angle brackets. Inline parts are billed like any other attachment.
|
||||
|
||||
@@ -52,7 +52,7 @@ A plain-text body. Shorthand for `text`; use the options form for anything else.
|
||||
A `Promise` that resolves to an object:
|
||||
|
||||
- `messageId` (String | null) - The `Message-ID` of the first delivery: the transport's for a relayed copy, the stored message's for a Puter mailbox.
|
||||
- `cost` (Number) - What the send was charged, in microcents.
|
||||
- `cost` (Number) - What the send was charged, in microcents: one delivery's price for each recipient in neither `failed` nor `suppressed`.
|
||||
- `suppressed` (Array) - Recipients (lowercased) dropped because they unsubscribed from your app's mail. The message went to the others.
|
||||
- `failed` (Array) - Recipients (lowercased) whose delivery failed. Everyone else got their copy — retry with just these addresses.
|
||||
|
||||
@@ -65,7 +65,7 @@ In case of an error, the `Promise` rejects with `{ message, code }`. Codes you m
|
||||
- `subscription_required` (402) - The calling account is not on a paid plan.
|
||||
- `insufficient_funds` (402) - The calling account has no usage credit left for this send.
|
||||
- `bad_request` (400) - Invalid arguments: a bad address, a missing body, too many recipients, an invalid attachment.
|
||||
- `not_found` (404) - Every recipient was a Puter address with no mailbox to receive in.
|
||||
- `not_found` (404) - An attachment's `path`/`uid` could not be read; or every recipient was a Puter address with no mailbox to receive in.
|
||||
- `too_many_requests` (429) - Over the rate limit; see [Rate Limits and Quotas](/rate-limits-and-quotas/#email).
|
||||
|
||||
## Recipients on Puter
|
||||
@@ -84,7 +84,7 @@ Every message carries an unsubscribe link and a report-abuse link. Opting out is
|
||||
|
||||
## Limits and cost
|
||||
|
||||
Sends are rate-limited per calling account; the numbers are on [Rate Limits and Quotas](/rate-limits-and-quotas/#email). A send costs a flat charge per message plus a per-byte charge for attachments, and is charged to the calling account. A send that reaches nobody is not charged.
|
||||
Sends are rate-limited per calling account; the numbers are on [Rate Limits and Quotas](/rate-limits-and-quotas/#email). Each recipient's delivery costs a flat charge plus a per-byte charge for the attachments it carries, so a send to three recipients costs three deliveries. Only deliveries that succeed are charged — retrying the `failed` addresses never pays twice — and a send that reaches nobody is not charged. The calling account pays.
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -123,6 +123,10 @@ router.post('/api/receipt', async ({ request, user }) => {
|
||||
<strong class="example-title">Attach a file and show an inline image</strong>
|
||||
|
||||
```js
|
||||
// The uid of a file in the worker owner's storage, e.g. from
|
||||
// `(await me.puter.fs.stat('~/Public/logo.png')).uid`.
|
||||
const LOGO_UID = '...';
|
||||
|
||||
router.post('/api/invoice', async ({ request, user }) => {
|
||||
const { to } = await request.json();
|
||||
return await user.puter.email.sendTransactional({
|
||||
@@ -130,9 +134,9 @@ router.post('/api/invoice', async ({ request, user }) => {
|
||||
subject: 'Your invoice',
|
||||
html: '<img src="cid:logo" alt="Acme"><p>Your invoice is attached.</p>',
|
||||
attachments: [
|
||||
// Streamed from the worker owner's storage; shown inline via the cid.
|
||||
{ path: '~/Public/logo.png', cid: 'logo' },
|
||||
// Streamed from the user's own storage as a regular attachment.
|
||||
// A uid falls back to the worker owner's storage; shown inline via the cid.
|
||||
{ uid: LOGO_UID, cid: 'logo' },
|
||||
// A path is always the caller's: streamed from the user's own storage.
|
||||
{ path: '~/Documents/invoice.pdf' },
|
||||
],
|
||||
emailAccessToken: me.puter.authToken,
|
||||
|
||||
@@ -21,4 +21,4 @@ The provider this engine belongs to, e.g. `'aws-polly'`, `'openai'`, `'elevenlab
|
||||
|
||||
#### `pricing_per_million_chars` (Number)
|
||||
|
||||
An optional cost in US dollars per million characters. May be absent when the provider does not expose pricing.
|
||||
An optional cost per million characters. May be absent when the provider does not expose pricing.
|
||||
@@ -24,9 +24,10 @@ import { forgetGodmodeTokens } from './godmodeTokens.js';
|
||||
* response.
|
||||
*
|
||||
* `app` rows, plus the `access_token` row a godmode app runs on (the only
|
||||
* access tokens that carry an `app_uid`). `worker` rows also carry one but are
|
||||
* deployment credentials rather than this user's grant to the app, and access
|
||||
* tokens the app issued follow on their own through the server-side cascade.
|
||||
* access tokens that carry an `app_uid`). `worker` rows also carry one, but the
|
||||
* server deletes them itself when the app's grants are revoked with `*`, and
|
||||
* access tokens the app issued follow on their own through the server-side
|
||||
* cascade.
|
||||
*
|
||||
* @param {{ uuid?: string; kind?: string; app_uid?: string | null }[]} sessions
|
||||
* @param {string} appUid
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
// Multipart email files are handled as String'd FormData to save on Multipart parsing logic. The primary goal of this library
|
||||
// is not necessarily high performance but high maintanability.
|
||||
|
||||
import { PuterJSError } from '../../lib/PuterJSError.js';
|
||||
|
||||
/** Every refusal here is the caller's input; the SDK error contract wants a code. */
|
||||
const invalid = (message) => new PuterJSError(message, 'invalid_request');
|
||||
|
||||
/**
|
||||
* One attachment: either inline base64 `content`, or a Puter FS reference
|
||||
* (`path`/`uid`) read server-side with the caller's — falling back to the
|
||||
* authorizing worker's — file permissions. FS references are streamed from
|
||||
* (`path`/`uid`) read server-side with the caller's file permissions; a `uid`
|
||||
* falls back to the authorizing worker's. FS references are streamed from
|
||||
* storage and never travel through the request, so prefer them for anything
|
||||
* larger than a few hundred kilobytes.
|
||||
*
|
||||
* @typedef {Object} EmailAttachment
|
||||
* @property {string} [filename] Required with `content`; defaults to the file's name for FS refs.
|
||||
* @property {string} [content] Base64 file body. Mutually exclusive with `path`/`uid`.
|
||||
* @property {string} [path] Puter FS path (supports `~/`). Mutually exclusive with `content`.
|
||||
* @property {string} [uid] Puter FS entry uid. Mutually exclusive with `content`.
|
||||
* @property {string} [path] Puter FS path (supports `~/`, the caller's home). Mutually exclusive with `content`.
|
||||
* @property {string} [uid] Puter FS entry uid; the only way to attach a file of the worker owner's. Mutually exclusive with `content`.
|
||||
* @property {string|undefined} [cid] Email content ID for inline images
|
||||
* @property {string} [contentType] MIME type of the attachment.
|
||||
*/
|
||||
@@ -40,25 +45,103 @@ export function getRFC822DateUTC(date = new Date()) {
|
||||
|
||||
export function emlHeader(key, value) {
|
||||
if (!isASCII(key)) {
|
||||
throw new Error("eml header key " + key + " is not valid ASCII");
|
||||
throw invalid("eml header key " + key + " is not valid ASCII");
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => {
|
||||
if (!isASCII(val)) {
|
||||
throw new Error("eml header value " + val + " is not valid ASCII");
|
||||
throw invalid("eml header value " + val + " is not valid ASCII");
|
||||
}
|
||||
})
|
||||
|
||||
return `${key}: ${value.join(', ')}\r\n`;
|
||||
} else {
|
||||
if (!isASCII(value)) {
|
||||
throw new Error("eml header value " + value + " is not valid ASCII");
|
||||
throw invalid("eml header value " + value + " is not valid ASCII");
|
||||
}
|
||||
return `${key}: ${value}\r\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// UTF-8 bytes per encoded word: 45 bytes is 60 base64 characters, which with
|
||||
// the `=?UTF-8?B?` and `?=` wrapping stays inside RFC 2047's 75.
|
||||
const ENCODED_WORD_BYTES = 45;
|
||||
|
||||
const bytesToBase64 = (bytes) => {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
/**
|
||||
* Header text as RFC 2047 encoded words: printable ASCII comes back as is,
|
||||
* anything else as `=?UTF-8?B?...?=` words split on character boundaries and
|
||||
* folded onto continuation lines. Control characters are refused either way:
|
||||
* they have no business in a header, encoded or not.
|
||||
*
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function encodeWord(text) {
|
||||
if (typeof text !== 'string' || /[\x00-\x1f\x7f]/.test(text)) {
|
||||
throw invalid("eml header value " + text + " is not valid");
|
||||
}
|
||||
if (isASCII(text)) return text;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const words = [];
|
||||
let pending = [];
|
||||
for (const char of text) {
|
||||
const bytes = encoder.encode(char);
|
||||
if (pending.length + bytes.length > ENCODED_WORD_BYTES) {
|
||||
words.push(pending);
|
||||
pending = [];
|
||||
}
|
||||
pending.push(...bytes);
|
||||
}
|
||||
if (pending.length > 0) words.push(pending);
|
||||
return words.map(word => `=?UTF-8?B?${bytesToBase64(word)}?=`).join('\r\n ');
|
||||
}
|
||||
|
||||
/**
|
||||
* One address-list entry, its display name encoded when it needs to be. The
|
||||
* address itself has to stay ASCII: mail without SMTPUTF8 has no encoding
|
||||
* for it.
|
||||
*
|
||||
* @param {string} entry `addr@host` or `Name <addr@host>`
|
||||
* @returns {string}
|
||||
*/
|
||||
function encodeAddress(entry) {
|
||||
if (isASCII(entry)) return entry;
|
||||
const match = /^\s*(.*?)\s*<([^<>]*)>\s*$/.exec(String(entry));
|
||||
if (!match || !isASCII(match[2])) {
|
||||
throw invalid("eml address " + entry + " is not valid ASCII");
|
||||
}
|
||||
const name = match[1].replace(/^"(.*)"$/, '$1').replace(/\\(.)/g, '$1');
|
||||
return `${encodeWord(name)} <${match[2]}>`;
|
||||
}
|
||||
|
||||
/** A free-text header (Subject), RFC 2047 encoded when not plain ASCII. */
|
||||
export function textHeader(key, value) {
|
||||
if (!isASCII(key)) {
|
||||
throw invalid("eml header key " + key + " is not valid ASCII");
|
||||
}
|
||||
return `${key}: ${encodeWord(value)}\r\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* An address-list header (To, Cc, Bcc, Reply-To, From) with non-ASCII display
|
||||
* names encoded. A string is one entry, or an ASCII list passed through as is.
|
||||
*/
|
||||
export function addressHeader(key, value) {
|
||||
if (!isASCII(key)) {
|
||||
throw invalid("eml header key " + key + " is not valid ASCII");
|
||||
}
|
||||
const entries = Array.isArray(value) ? value : [value];
|
||||
return `${key}: ${entries.map(encodeAddress).join(', ')}\r\n`;
|
||||
}
|
||||
|
||||
export function determineTopLevelMimeType({ attachments = [], text, html }) {
|
||||
const inlineAttachments = attachments.filter(a => a.cid);
|
||||
const regularAttachments = attachments.filter(a => !a.cid);
|
||||
@@ -78,7 +161,7 @@ export function determineTopLevelMimeType({ attachments = [], text, html }) {
|
||||
if (html) {
|
||||
return { isMultiPart: false, mimeType: 'text/html; charset=UTF-8' };
|
||||
}
|
||||
throw new Error('Email must have text, html, or an attachment');
|
||||
throw invalid('Email must have text, html, or an attachment');
|
||||
}
|
||||
|
||||
|
||||
@@ -100,21 +183,22 @@ export const isASCII = (str) => /^[\x20-\x7E]*$/.test(str);
|
||||
|
||||
/**
|
||||
* Composes a multipart email attachment
|
||||
*
|
||||
* @param {EmailAttachment} attachment
|
||||
*
|
||||
* @param {EmailAttachment} attachment
|
||||
* @param {import('../../index.js').Puter} [puter] Reads `path` attachments; the module's own instance.
|
||||
*/
|
||||
export async function composeAttachment({ cid, content, contentType, filename, path, uid }) {
|
||||
export async function composeAttachment({ cid, content, contentType, filename, path, uid }, puter = globalThis.puter) {
|
||||
if (path && content) {
|
||||
throw new Error("Path and Content are mutually exclusive");
|
||||
throw invalid("Path and Content are mutually exclusive");
|
||||
}
|
||||
if (!path && !content && !uid) {
|
||||
throw new Error("Attachment requires content, path, or uid");
|
||||
throw invalid("Attachment requires content, path, or uid");
|
||||
}
|
||||
if (uid) {
|
||||
throw new Error("uid attachments are not supported yet");
|
||||
throw invalid("uid attachments are not supported yet");
|
||||
}
|
||||
if (contentType && !filename) {
|
||||
throw new Error("filename parameter required for contentType");
|
||||
throw invalid("filename parameter required for contentType");
|
||||
}
|
||||
|
||||
let b64emailContent = content;
|
||||
@@ -129,12 +213,12 @@ export async function composeAttachment({ cid, content, contentType, filename, p
|
||||
}
|
||||
|
||||
if (!filename) {
|
||||
throw new Error("Attachment's filename cannot be determined automatically. Please provide filename!");
|
||||
throw invalid("Attachment's filename cannot be determined automatically. Please provide filename!");
|
||||
}
|
||||
filename = filename.replaceAll('\\', '\\\\');
|
||||
filename = filename.replaceAll('"', '\\"');
|
||||
if (!isASCII(filename)) {
|
||||
throw new Error("filename must be ascii");
|
||||
throw invalid("filename must be ascii");
|
||||
}
|
||||
|
||||
if (contentType) {
|
||||
@@ -142,7 +226,7 @@ export async function composeAttachment({ cid, content, contentType, filename, p
|
||||
}
|
||||
|
||||
if (!mimeType) {
|
||||
throw new Error("Attachment's mimetype cannot be determined automatically. Please provide contentType!");
|
||||
throw invalid("Attachment's mimetype cannot be determined automatically. Please provide contentType!");
|
||||
}
|
||||
|
||||
mimeType += ';name="' + filename + '"';
|
||||
@@ -168,34 +252,36 @@ export function combineParts(parts, boundary) {
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {EmailComposeOptions} param0
|
||||
*
|
||||
* @param {EmailComposeOptions} param0
|
||||
* @param {import('../../index.js').Puter} [puter] The module's own instance: who the mail is from, and
|
||||
* whose storage `path` attachments are read from.
|
||||
*/
|
||||
export async function compose({ from, to, cc, bcc, subject, replyTo, attachments = [], text, html }) {
|
||||
export async function compose({ from, to, cc, bcc, subject, replyTo, attachments = [], text, html }, puter = globalThis.puter) {
|
||||
// --- Header constructing logic ---
|
||||
let headerLines = "MIME-Version: 1.0\r\n";
|
||||
|
||||
headerLines += emlHeader('Date', getRFC822DateUTC());
|
||||
|
||||
if (subject) {
|
||||
headerLines += emlHeader('Subject', subject);
|
||||
headerLines += textHeader('Subject', subject);
|
||||
}
|
||||
if (to) {
|
||||
headerLines += emlHeader('To', to);
|
||||
headerLines += addressHeader('To', to);
|
||||
} else {
|
||||
throw new Error("Cannot compose email without recipient!");
|
||||
throw invalid("Cannot compose email without recipient!");
|
||||
}
|
||||
if (cc) {
|
||||
headerLines += emlHeader('cc', cc);
|
||||
headerLines += addressHeader('cc', cc);
|
||||
}
|
||||
if (bcc) {
|
||||
headerLines += emlHeader('bcc', bcc);
|
||||
headerLines += addressHeader('bcc', bcc);
|
||||
}
|
||||
if (replyTo) {
|
||||
headerLines += emlHeader('Reply-To', replyTo);
|
||||
headerLines += addressHeader('Reply-To', replyTo);
|
||||
}
|
||||
if (from) {
|
||||
headerLines += emlHeader('From', from);
|
||||
headerLines += addressHeader('From', from);
|
||||
} else {
|
||||
// Add From Block (hardcoded to puter.email for now)
|
||||
const userinfo = await puter.getUser();
|
||||
@@ -229,8 +315,8 @@ export async function compose({ from, to, cc, bcc, subject, replyTo, attachments
|
||||
const inlineAttachments = attachments.filter(a => a.cid);
|
||||
const regularAttachments = attachments.filter(a => !a.cid);
|
||||
|
||||
const inlineParts = await Promise.all(inlineAttachments.map(composeAttachment));
|
||||
const regularParts = await Promise.all(regularAttachments.map(composeAttachment));
|
||||
const inlineParts = await Promise.all(inlineAttachments.map(a => composeAttachment(a, puter)));
|
||||
const regularParts = await Promise.all(regularAttachments.map(a => composeAttachment(a, puter)));
|
||||
|
||||
// Build the text/html content as one part. Only wrap it in its own nested
|
||||
// multipart/alternative (fresh boundary) when both text and html are present.
|
||||
|
||||
@@ -502,8 +502,63 @@ describe('compose', () => {
|
||||
await expect(compose({ ...baseOptions, attachments: [] })).rejects.toThrow(/text, html, or an attachment/);
|
||||
});
|
||||
|
||||
it('rejects a non-ASCII subject', async () => {
|
||||
await expect(compose({ ...baseOptions, subject: 'héllo', text: 'hi' })).rejects.toThrow(/not valid ASCII/);
|
||||
it('encodes a non-ASCII subject as RFC 2047 words instead of refusing it', async () => {
|
||||
const eml = await compose({ ...baseOptions, subject: 'héllo', text: 'hi' });
|
||||
expect(eml).toContain('Subject: =?UTF-8?B?aMOpbGxv?=\r\n');
|
||||
expect(isASCII(eml.split('\r\n\r\n')[0].replace(/\r\n/g, ''))).toBe(true);
|
||||
});
|
||||
|
||||
it('folds a long non-ASCII subject into words of at most 75 characters', async () => {
|
||||
const subject = 'Réunion — 東京 🎉 '.repeat(12);
|
||||
const eml = await compose({ ...baseOptions, subject, text: 'hi' });
|
||||
const head = eml.split('\r\n\r\n')[0];
|
||||
const subjectLines = head.slice(head.indexOf('Subject: ')).split('\r\n');
|
||||
const words = [subjectLines[0].slice('Subject: '.length)];
|
||||
for ( const line of subjectLines.slice(1) ) {
|
||||
if ( ! line.startsWith(' ') ) break;
|
||||
words.push(line.slice(1));
|
||||
}
|
||||
expect(words.length).toBeGreaterThan(1);
|
||||
for ( const word of words ) {
|
||||
expect(word).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/);
|
||||
expect(word.length).toBeLessThanOrEqual(75);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses control characters in a subject, encoded or not', async () => {
|
||||
await expect(compose({ ...baseOptions, subject: 'hi\r\nBcc: [email protected]', text: 'hi' }))
|
||||
.rejects.toMatchObject({ code: 'invalid_request' });
|
||||
await expect(compose({ ...baseOptions, subject: 'héllo\r\nBcc: [email protected]', text: 'hi' }))
|
||||
.rejects.toMatchObject({ code: 'invalid_request' });
|
||||
});
|
||||
|
||||
it('refuses a non-ASCII address while allowing a non-ASCII display name', async () => {
|
||||
await expect(compose({ ...baseOptions, to: 'ü@example.com', text: 'hi' }))
|
||||
.rejects.toMatchObject({ code: 'invalid_request' });
|
||||
await expect(compose({ ...baseOptions, to: 'Jürgen <ü@example.com>', text: 'hi' }))
|
||||
.rejects.toMatchObject({ code: 'invalid_request' });
|
||||
await expect(compose({ ...baseOptions, to: 'Jürgen <[email protected]>', text: 'hi' })).resolves.toBeTypeOf('string');
|
||||
});
|
||||
|
||||
it('rejects with a coded PuterJSError, per the SDK error contract', async () => {
|
||||
await expect(compose({ subject: 'hi', text: 'hi' }))
|
||||
.rejects.toMatchObject({ name: 'PuterJSError', code: 'invalid_request' });
|
||||
await expect(composeAttachment({ uid: 'uid-1' }))
|
||||
.rejects.toMatchObject({ code: 'invalid_request' });
|
||||
expect(() => emlHeader('Subject', 'héllo')).toThrow(expect.objectContaining({ code: 'invalid_request' }));
|
||||
});
|
||||
|
||||
it('uses the instance it is given rather than the global one', async () => {
|
||||
const own = {
|
||||
fs: { read: vi.fn(async () => new Blob([PNG_BYTES], { type: 'image/png' })) },
|
||||
getUser: vi.fn(async () => ({ username: 'carol' })),
|
||||
};
|
||||
const eml = await compose({ to: '[email protected]', subject: 'hi', html: '<p/>', attachments: [{ path: '~/x.png', cid: 'c' }] }, own);
|
||||
expect(eml).toContain('From: [email protected]\r\n');
|
||||
expect(own.fs.read).toHaveBeenCalledWith('~/x.png');
|
||||
expect(own.getUser).toHaveBeenCalled();
|
||||
expect(fsRead).not.toHaveBeenCalled();
|
||||
expect(getUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -513,6 +568,24 @@ describe('compose round-trips through a MIME parser', () => {
|
||||
// The parser terminates every text body with a newline of its own.
|
||||
const bodyOf = (str) => str?.replace(/\n$/, '');
|
||||
|
||||
it('recovers a UTF-8 subject and display names', async () => {
|
||||
const subject = 'Grüße ✓ — 東京 🎉 '.repeat(6).trim();
|
||||
const parsed = await parse({
|
||||
subject,
|
||||
to: ['"José Pérez" <[email protected]>', '[email protected]'],
|
||||
cc: 'Zoë <[email protected]>',
|
||||
from: 'Ålice <[email protected]>',
|
||||
text: 'hi',
|
||||
});
|
||||
expect(parsed.subject).toBe(subject);
|
||||
expect(parsed.to).toEqual([
|
||||
{ address: '[email protected]', name: 'José Pérez' },
|
||||
{ address: '[email protected]', name: '' },
|
||||
]);
|
||||
expect(parsed.cc).toEqual([{ address: '[email protected]', name: 'Zoë' }]);
|
||||
expect(parsed.from).toEqual({ address: '[email protected]', name: 'Ålice' });
|
||||
});
|
||||
|
||||
it('recovers a plain text body', async () => {
|
||||
const parsed = await parse({ text: 'hello there' });
|
||||
expect(bodyOf(parsed.text)).toBe('hello there');
|
||||
|
||||
@@ -32,7 +32,8 @@ import { list } from './list.js';
|
||||
*
|
||||
* @typedef {Object} EmailSendResult
|
||||
* @property {string | null} messageId First transport message id reported for this send, when available.
|
||||
* @property {number} cost Total charge for this send, in microcents.
|
||||
* @property {number} cost Total charge for this send, in microcents: one delivery's price for each
|
||||
* recipient that got a copy.
|
||||
* @property {string[]} suppressed Recipients omitted because they opted out of this app's mail.
|
||||
* @property {string[]} failed Recipients whose delivery attempt failed. Everyone else got their copy —
|
||||
* retry with just these addresses. A send where every delivery fails rejects instead.
|
||||
@@ -82,7 +83,8 @@ const preprocessSendArgs = (args) => {
|
||||
* // Inline or Puter-FS attachments:
|
||||
* attachments: [
|
||||
* { filename, content, contentType }, // content = base64
|
||||
* { path: '~/Documents/report.pdf' }, // streamed server-side
|
||||
* { path: '~/Documents/report.pdf' }, // the caller's file, streamed server-side
|
||||
* { uid: LOGO_UID }, // a uid may also name a file of the worker owner's
|
||||
* ],
|
||||
* });
|
||||
* });
|
||||
@@ -141,6 +143,9 @@ export class EmailModule extends PuterModule {
|
||||
iface: 'puter-transactional-email',
|
||||
method: 'sendTransactional',
|
||||
argNames: ['to', 'subject', 'body'],
|
||||
// This module's instance, not `globalThis.puter`: in a worker the two
|
||||
// differ (`user.puter` vs `me.puter`), and the caller is the one billed.
|
||||
puter: this.puter,
|
||||
preprocess: preprocessSendArgs,
|
||||
upgradePrompt: {
|
||||
method: 'puter.email.sendTransactional',
|
||||
@@ -157,7 +162,15 @@ export class EmailModule extends PuterModule {
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
send = async (options) => {
|
||||
const req = await fetchUrl(`${this.APIOrigin}/email/send`, { method: "POST", includePuterAuth: true, body: new Blob([await compose(options)], { type: 'message/rfc822' }) });
|
||||
// `includePuterAuth` reads the global instance's token, so any other
|
||||
// instance (a worker's `user.puter`) sends its own.
|
||||
const isGlobal = this.puter === globalThis.puter;
|
||||
const req = await fetchUrl(`${this.APIOrigin}/email/send`, {
|
||||
method: "POST",
|
||||
includePuterAuth: isGlobal,
|
||||
...(isGlobal ? {} : { authToken: this.authToken }),
|
||||
body: new Blob([await compose(options, this.puter)], { type: 'message/rfc822' }),
|
||||
});
|
||||
const result = await req.json();
|
||||
if ( ! req.ok ) {
|
||||
promptIfUpgradeRequired(
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* The email methods have to act for the instance they hang off, not for
|
||||
* `globalThis.puter`: in a worker, `user.puter` and `me.puter` are different
|
||||
* identities, and the one making the call is the one billed and rate-limited.
|
||||
*/
|
||||
|
||||
const captured = vi.hoisted(() => ({ specs: [], fetches: [] }));
|
||||
|
||||
vi.mock('../../lib/utils.js', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
makeDriverMethod: (spec) => {
|
||||
captured.specs.push(spec);
|
||||
return async () => ({});
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/networkUtils.js', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
fetchUrl: async (url, opts) => {
|
||||
captured.fetches.push({ url, opts });
|
||||
return { ok: true, status: 200, json: async () => ({ delivered: [] }) };
|
||||
},
|
||||
}));
|
||||
|
||||
const { EmailModule } = await import('./Email.js');
|
||||
|
||||
const origPuter = globalThis.puter;
|
||||
|
||||
const makePuter = (username, authToken) => ({
|
||||
APIOrigin: 'https://api.example.test',
|
||||
authToken,
|
||||
getUser: vi.fn(async () => ({ username })),
|
||||
fs: { read: vi.fn() },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
captured.specs.length = 0;
|
||||
captured.fetches.length = 0;
|
||||
globalThis.puter = makePuter('global', 'global-token');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.puter = origPuter;
|
||||
});
|
||||
|
||||
describe('EmailModule binds to its own instance', () => {
|
||||
it('hands sendTransactional its own puter, not the global one', () => {
|
||||
const own = makePuter('caller', 'caller-token');
|
||||
new EmailModule(own);
|
||||
const spec = captured.specs.find(s => s.method === 'sendTransactional');
|
||||
expect(spec.puter).toBe(own);
|
||||
});
|
||||
|
||||
it('sends from, and authenticates as, a non-global instance', async () => {
|
||||
const own = makePuter('caller', 'caller-token');
|
||||
const email = new EmailModule(own);
|
||||
await email.send({ to: '[email protected]', subject: 'hi', text: 'hi' });
|
||||
|
||||
expect(own.getUser).toHaveBeenCalled();
|
||||
expect(globalThis.puter.getUser).not.toHaveBeenCalled();
|
||||
const [{ url, opts }] = captured.fetches;
|
||||
expect(url).toBe('https://api.example.test/email/send');
|
||||
expect(opts.includePuterAuth).toBe(false);
|
||||
expect(opts.authToken).toBe('caller-token');
|
||||
expect(await opts.body.text()).toContain('From: [email protected]\r\n');
|
||||
});
|
||||
|
||||
it('keeps the live-token path for the global instance', async () => {
|
||||
const email = new EmailModule(globalThis.puter);
|
||||
await email.send({ to: '[email protected]', subject: 'hi', text: 'hi' });
|
||||
const [{ opts }] = captured.fetches;
|
||||
expect(opts.includePuterAuth).toBe(true);
|
||||
expect(opts.authToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -122,15 +122,24 @@ export const toSummary = (entry, folder) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a listing resumes: the day folder being walked and, while it still has
|
||||
* pages, the readdir cursor within it.
|
||||
* Where a listing resumes: the day folder being walked; while it still has
|
||||
* pages, the readdir cursor within it; and the readdir cursor of the page of
|
||||
* day folders that day was read from, so resuming does not re-list every day
|
||||
* newer than it. A cursor without that last part (issued before it existed)
|
||||
* walks the days from the top, which is only slower.
|
||||
*
|
||||
* @typedef {{ folder: EmailFolder, day: string, fsCursor?: string }} ListPosition
|
||||
* @typedef {{ folder: EmailFolder, day: string, fsCursor?: string, daysCursor?: string }} ListPosition
|
||||
*/
|
||||
|
||||
/** @param {ListPosition} position @returns {string} */
|
||||
export const encodeCursor = ({ folder, day, fsCursor }) => {
|
||||
const payload = { v: 1, f: folder, d: day, ...(fsCursor ? { c: fsCursor } : {}) };
|
||||
export const encodeCursor = ({ folder, day, fsCursor, daysCursor }) => {
|
||||
const payload = {
|
||||
v: 1,
|
||||
f: folder,
|
||||
d: day,
|
||||
...(fsCursor ? { c: fsCursor } : {}),
|
||||
...(daysCursor ? { dc: daysCursor } : {}),
|
||||
};
|
||||
return btoa(JSON.stringify(payload));
|
||||
};
|
||||
|
||||
@@ -153,7 +162,12 @@ export const decodeCursor = (cursor, folder) => {
|
||||
if ( payload.f !== folder ) {
|
||||
throw new PuterJSError('Cursor does not match the requested folder', 'invalid_request');
|
||||
}
|
||||
return { folder, day: payload.d, ...(typeof payload.c === 'string' ? { fsCursor: payload.c } : {}) };
|
||||
return {
|
||||
folder,
|
||||
day: payload.d,
|
||||
...(typeof payload.c === 'string' ? { fsCursor: payload.c } : {}),
|
||||
...(typeof payload.dc === 'string' ? { daysCursor: payload.dc } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
/** A readdir on a folder that was never created. @param {unknown} e */
|
||||
|
||||
@@ -12,36 +12,49 @@ import {
|
||||
/** @typedef {import('../../lib/types.js').ListPage<EmailSummary>} EmailListPage */
|
||||
/** @typedef {import('./lib/mailbox.js').ListPosition} ListPosition */
|
||||
|
||||
// Page size for the SDK's own walks over the mailbox's day folders.
|
||||
const DIRECTORY_PAGE_LIMIT = 1000;
|
||||
// Day folders read per directory page. Small on purpose: a listing resumes
|
||||
// from the page its cursor's day came from, so this bounds what every later
|
||||
// page re-reads before reaching its own day. About a month of a mailbox that
|
||||
// gets mail daily.
|
||||
const DAY_PAGE_LIMIT = 30;
|
||||
|
||||
/**
|
||||
* Every day folder in `folder`, newest first. A mailbox that was never
|
||||
* written to has no folder at all, which lists as empty.
|
||||
* The day folders in `folder`, newest first, read a directory page at a time
|
||||
* starting at `daysCursor`. Each day comes with the cursor of the page it was
|
||||
* read from, which is where a later listing can pick the walk back up. A
|
||||
* mailbox that was never written to has no folder at all, which walks as
|
||||
* empty.
|
||||
*
|
||||
* @this {import('./Email.js').EmailModule}
|
||||
* @param {import('../../index.js').Puter} puter
|
||||
* @param {EmailFolder} folder
|
||||
* @returns {Promise<string[]>}
|
||||
* @param {string | undefined} daysCursor
|
||||
* @returns {AsyncGenerator<{ day: string, daysCursor?: string }>}
|
||||
*/
|
||||
async function listDays (folder) {
|
||||
const fetchPage = pageParams => this.puter.fs.readdir({
|
||||
path: folderRoot(folder),
|
||||
limit: DIRECTORY_PAGE_LIMIT,
|
||||
sortBy: 'name',
|
||||
sortOrder: 'desc',
|
||||
...pageParams,
|
||||
});
|
||||
const days = [];
|
||||
try {
|
||||
for await ( const page of iteratePages(fetchPage) ) {
|
||||
for ( const entry of page.items ) {
|
||||
if ( entry.is_dir && isDayName(entry.name) ) days.push(entry.name);
|
||||
async function* walkDays (puter, folder, daysCursor) {
|
||||
let cursor = daysCursor ?? null;
|
||||
while ( true ) {
|
||||
/** @type {{ items: Array<Record<string, any>>, cursor?: string }} */
|
||||
let page;
|
||||
try {
|
||||
page = await puter.fs.readdir({
|
||||
path: folderRoot(folder),
|
||||
limit: DAY_PAGE_LIMIT,
|
||||
sortBy: 'name',
|
||||
sortOrder: 'desc',
|
||||
cursor,
|
||||
});
|
||||
} catch (e) {
|
||||
if ( ! isMissingDirectory(e) ) throw e;
|
||||
return;
|
||||
}
|
||||
for ( const entry of page.items ) {
|
||||
if ( entry.is_dir && isDayName(entry.name) ) {
|
||||
yield { day: entry.name, ...(cursor ? { daysCursor: cursor } : {}) };
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if ( ! isMissingDirectory(e) ) throw e;
|
||||
if ( ! page.cursor ) return;
|
||||
cursor = page.cursor;
|
||||
}
|
||||
return days.sort().reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,18 +69,20 @@ async function listDays (folder) {
|
||||
* @returns {Promise<EmailListPage>}
|
||||
*/
|
||||
async function fetchPage (folder, limit, position) {
|
||||
const days = await listDays.call(this, folder);
|
||||
const days = walkDays(this.puter, folder, position?.daysCursor);
|
||||
/** @type {EmailSummary[]} */
|
||||
const items = [];
|
||||
|
||||
// Resume at the cursor's day; a day folder deleted since the cursor was
|
||||
// issued means continuing from the next older one.
|
||||
let dayIndex = position ? days.findIndex(day => day <= position.day) : 0;
|
||||
if ( dayIndex === -1 ) return { items };
|
||||
let fsCursor = position && days[dayIndex] === position.day ? position.fsCursor : undefined;
|
||||
let next = await days.next();
|
||||
while ( position && ! next.done && next.value.day > position.day ) {
|
||||
next = await days.next();
|
||||
}
|
||||
let fsCursor = position && ! next.done && next.value.day === position.day ? position.fsCursor : undefined;
|
||||
|
||||
while ( dayIndex < days.length && items.length < limit ) {
|
||||
const day = days[dayIndex];
|
||||
while ( ! next.done && items.length < limit ) {
|
||||
const { day, daysCursor } = next.value;
|
||||
// Keep reading this day until the page is full or the day is spent:
|
||||
// entries that are not mail objects are skipped, and skipping must
|
||||
// not shorten the page while the same day still has messages.
|
||||
@@ -94,13 +109,14 @@ async function fetchPage (folder, limit, position) {
|
||||
if ( ! fsCursor ) break;
|
||||
}
|
||||
if ( fsCursor ) {
|
||||
return { items, cursor: encodeCursor({ folder, day, fsCursor }) };
|
||||
return { items, cursor: encodeCursor({ folder, day, fsCursor, daysCursor }) };
|
||||
}
|
||||
dayIndex += 1;
|
||||
next = await days.next();
|
||||
}
|
||||
|
||||
if ( dayIndex < days.length ) {
|
||||
return { items, cursor: encodeCursor({ folder, day: days[dayIndex] }) };
|
||||
if ( ! next.done ) {
|
||||
const { day, daysCursor } = next.value;
|
||||
return { items, cursor: encodeCursor({ folder, day, daysCursor }) };
|
||||
}
|
||||
return { items };
|
||||
}
|
||||
|
||||
@@ -79,6 +79,13 @@ describe('cursors', () => {
|
||||
expect(decodeCursor(encodeCursor({ folder: 'sent', day: '2024-03-15' }), 'sent')).toEqual({ folder: 'sent', day: '2024-03-15' });
|
||||
});
|
||||
|
||||
it('carries the day-listing cursor, and reads one issued without it', () => {
|
||||
const position = { folder: 'inbox', day: '2024-03-15', fsCursor: 'abc', daysCursor: 'dc1' };
|
||||
expect(decodeCursor(encodeCursor(position), 'inbox')).toEqual(position);
|
||||
const legacy = btoa(JSON.stringify({ v: 1, f: 'inbox', d: '2024-03-15', c: 'abc' }));
|
||||
expect(decodeCursor(legacy, 'inbox')).toEqual({ folder: 'inbox', day: '2024-03-15', fsCursor: 'abc' });
|
||||
});
|
||||
|
||||
it('treats null and undefined as the first page', () => {
|
||||
expect(decodeCursor(null, 'inbox')).toBeUndefined();
|
||||
expect(decodeCursor(undefined, 'inbox')).toBeUndefined();
|
||||
@@ -208,6 +215,61 @@ describe('list()', () => {
|
||||
expect(calls.every(c => c.path.startsWith('~/.mail/sent'))).toBe(true);
|
||||
});
|
||||
|
||||
it('resumes the day walk where the cursor left it instead of re-listing from the top', async () => {
|
||||
// 100 days, one message each: far more day folders than one page of them.
|
||||
const tree = { '~/.mail/objects': [] };
|
||||
const dayIds = [];
|
||||
for ( let i = 0; i < 100; i++ ) {
|
||||
const ms = Date.UTC(2024, 0, 1) + i * 24 * 60 * 60 * 1000;
|
||||
const day = new Date(ms).toISOString().slice(0, 10);
|
||||
const id = uuidAt(ms + 1000);
|
||||
tree['~/.mail/objects'].push(day);
|
||||
tree[`~/.mail/objects/${ day }`] = [`${ id }--${ toB64Url(day) }`];
|
||||
dayIds.unshift(id);
|
||||
}
|
||||
const { fs, calls } = fakeFs(tree);
|
||||
const email = moduleOver(fs);
|
||||
|
||||
const seen = [];
|
||||
let cursor = null;
|
||||
do {
|
||||
const before = calls.length;
|
||||
const page = await email.list.call(email, { limit: 7, cursor });
|
||||
seen.push(...page.items.map(m => m.id));
|
||||
const dayListings = calls.slice(before).filter(c => c.path === '~/.mail/objects');
|
||||
// Never more than the page holding the cursor's day plus the one
|
||||
// after it, however deep into the mailbox the cursor points.
|
||||
expect(dayListings.length).toBeLessThanOrEqual(2);
|
||||
if ( cursor ) {
|
||||
const resumedFrom = decodeCursor(cursor, 'inbox').daysCursor ?? null;
|
||||
expect(dayListings[0].cursor).toBe(resumedFrom);
|
||||
}
|
||||
cursor = page.cursor;
|
||||
} while ( cursor );
|
||||
expect(seen).toEqual(dayIds);
|
||||
// Only pages whose day sits in the first page of day folders start
|
||||
// there; the deeper ones resumed mid-folder.
|
||||
const fromTop = calls.filter(c => c.path === '~/.mail/objects' && ! c.cursor);
|
||||
expect(fromTop.length).toBeLessThanOrEqual(Math.ceil(30 / 7) + 1);
|
||||
expect(calls.some(c => c.path === '~/.mail/objects' && c.cursor)).toBe(true);
|
||||
});
|
||||
|
||||
it('resumes from a cursor issued before day-listing cursors existed', async () => {
|
||||
const { fs } = fakeFs(inboxTree);
|
||||
const email = moduleOver(fs);
|
||||
const legacy = btoa(JSON.stringify({ v: 1, f: 'inbox', d: '2024-03-15' }));
|
||||
const page = await email.list.call(email, { limit: 10, cursor: legacy });
|
||||
expect(page.items.map(m => m.id)).toEqual([ids.d15a, ids.d14b, ids.d14a]);
|
||||
});
|
||||
|
||||
it('continues from the next older day when the cursor day was deleted', async () => {
|
||||
const { fs } = fakeFs({ ...inboxTree, '~/.mail/objects': ['2024-03-14', '2024-03-16'] });
|
||||
const email = moduleOver(fs);
|
||||
const gone = encodeCursor({ folder: 'inbox', day: '2024-03-15', fsCursor: '5' });
|
||||
const page = await email.list.call(email, { limit: 10, cursor: gone });
|
||||
expect(page.items.map(m => m.id)).toEqual([ids.d14b, ids.d14a]);
|
||||
});
|
||||
|
||||
it('rejects offset, includeTotal, and a foreign cursor before any request', async () => {
|
||||
const { fs, calls } = fakeFs(inboxTree);
|
||||
const email = moduleOver(fs);
|
||||
|
||||
@@ -561,20 +561,6 @@ 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,9 +480,10 @@
|
||||
* @property {boolean} [translate]
|
||||
* @property {number} [temperature]
|
||||
* @property {boolean} [logprobs]
|
||||
* @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 {string[]} [timestamp_granularities]
|
||||
* @property {string} [chunking_strategy]
|
||||
* @property {string[]} [known_speaker_names]
|
||||
* @property {string[]} [known_speaker_references]
|
||||
* @property {Record<string, unknown>} [extra_body]
|
||||
* @property {boolean} [format]
|
||||
* @property {boolean} [diarize]
|
||||
|
||||
@@ -1300,18 +1300,6 @@ 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