Compare commits

..
Author SHA1 Message Date
Nariman Jelveh 6704fa64ed Answer a framed email confirmation closed with Escape with the code check it was closed during
Escape in the framed code or save-account window closes that window, and the window's answer closes the frame. A code already sent is waited for, as it is when the window's close button is used. Before, Escape closed the frame and answered false while the check went on to confirm the email.
2026-11-09 12:56:11 -07:00
Nariman Jelveh 917daf12af Answer a save-account request made while the save-account window is open
The window opens once, so the second request got no window and never
answered, which left an email confirmation that asked for it waiting too.
2026-11-09 12:37:32 -07:00
Nariman Jelveh f7c0fdda9f Hide the save-account window while its email code window shows
A save-account window with a backdrop stayed under the code window's own, so
the page behind was dimmed twice, and on a phone the window showed around the
code window's edge.
2026-11-09 12:36:48 -07:00
Nariman Jelveh 4efd966be9 Leave the email code window unsettled when any logout closes it
Only a logout from its footer did. One from elsewhere, such as after a 401,
answered false, so the boot's email gate opened a new code window while the
page was navigating away.
2026-11-09 12:34:43 -07:00
Nariman Jelveh 11c519ed08 Skip the email confirmation frame on calls after the user confirmed in one
The page's cached whoami kept reading the email as unconfirmed, so each later
call framed the GUI again and flashed its loading scrim before resolving true.
2026-11-09 12:34:21 -07:00
Nariman Jelveh 41f6edeab1 Show why a new email code couldn't be sent
A failed re-send, such as one past the hourly limit or a network failure,
showed nothing, so the link seemed to do nothing.
2026-11-09 12:33:13 -07:00
Nariman Jelveh 7ca1d97ef3 Keep the email code window's answer in step with a code check it was closed during
Closing the window while a code was being checked answered false, and the
check went on to confirm the email. A code not sent yet is no longer sent, and
the window waits for one already sent before it closes.
2026-11-09 12:32:34 -07:00
Nariman Jelveh 40eed41ce5 Read the email code from the code window it was typed in
A second code window read the inputs of the first one on the page, so typing
in it never filled in the code or enabled its button.
2026-11-09 12:31:09 -07:00
Nariman Jelveh 3ef18a2259 Answer an email confirmation asked for while one is showing with the windows already open
A second walk-through opened a second code window over the first, and for a
temporary account it never answered, since the save-account window opens once.
2026-11-09 12:30:48 -07:00
Nariman Jelveh 1621da6bf9 Let the email code window recover from a failed code check with no response body
A network failure has no JSON body, so reading its error threw, leaving the
window spinning with its inputs disabled. It now shows an unknown-error message
and lets the user try again.
2026-11-09 11:47:11 -07:00
Nariman Jelveh 10e4606b9c Close the framed email confirmation on Escape in the save-account window
A temporary account sees the save-account window first, where Escape did
nothing, while it closed the code window that follows.
2026-11-09 11:46:02 -07:00
Nariman Jelveh 6d06c23cc7 Skip the email confirmation frame when the page knows the email is confirmed
A confirmed email never goes back to unconfirmed, so requestEmailConfirmation()
on a first-party page resolves { response: true } from the page's whoami
instead of booting the GUI in a frame behind a loading scrim.
2026-11-09 11:45:18 -07:00
Nariman Jelveh b30e1c9af5 Answer requestEmailConfirmation() once, and show it in a frame on first-party pages
puter.ui.requestEmailConfirmation() in an app opened a second code dialog
after the first: a confirmed user got a dialog they couldn't complete and
two answers, and an unconfirmed one entered the code and then got a second
dialog, whose result was the only answer. The desktop handler now runs one
walk-through and answers once with its result.

That walk-through is ensureConfirmedEmail: save a temporary account, then
ask an unconfirmed email for its code, each window at most once. Publishing
a website or worker uses it too, replacing two copies of the same steps.
The code window now resolves false when it closes without a confirmation,
so dismissing it answers the app instead of leaving it waiting, and it
marks window.user confirmed before resolving, so a caller that checks next
doesn't ask again before the refresh lands. A logout from its footer still
leaves it unsettled, so the boot gates don't reopen it. In the dashboard
the windows open above the app's window, which stays on top and covered
them.

On a signed-in page one label under the Puter origin, the call now frames
/embed/request-email-confirmation over the page, served like the upgrade
frame, and resolves { response }, the field an app reads from the
desktop's answer. The GUI shows it only for the framing page's own user
with no phone, card or password check waiting. It skips the boot's
non-closable email gate for this action, so the user gets one code window
they can close or Escape. An email already confirmed resolves true without
showing the frame. A frame that can't show it resolves { response: false }.
2026-11-09 11:15:14 -07:00
61 changed files with 423 additions and 2979 deletions

No files matched your search

-73
View File
@@ -21,8 +21,6 @@
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -202,77 +200,6 @@ describe('S3Client — remote endpoint configuration', () => {
});
});
describe('S3Client — bucket in another region', () => {
let server: Server;
let client: S3Client;
let signedRegions: string[];
// Answers like a bucket that lives in us-west-2: a request signed for any
// other region gets S3's 301 naming the bucket's region.
beforeEach(async () => {
signedRegions = [];
server = createServer((req, res) => {
const region =
/Credential=[^/]+\/\d{8}\/([^/]+)\//.exec(
req.headers.authorization ?? '',
)?.[1] ?? '';
signedRegions.push(region);
if (region !== 'us-west-2') {
res.writeHead(301, {
'content-type': 'application/xml',
'x-amz-bucket-region': 'us-west-2',
});
res.end(
'<?xml version="1.0" encoding="UTF-8"?><Error><Code>PermanentRedirect</Code><Message>The bucket you are attempting to access must be addressed using the specified endpoint.</Message></Error>',
);
return;
}
res.writeHead(200, {
'content-type': 'text/plain',
'content-length': '5',
});
res.end('hello');
});
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve),
);
const { port } = server.address() as AddressInfo;
client = new S3Client({
port: 0,
extensions: [],
s3: {
s3Config: {
endpoint: `http://127.0.0.1:${port}`,
accessKeyId: 'fakeAccessKeyId',
secretAccessKey: 'fakeSecretAccessKey',
forcePathStyle: true,
},
},
} as unknown as IConfig);
await client.onServerStart();
});
afterEach(async () => {
await client.onServerShutdown();
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it("retries a read in the bucket's region when S3 redirects it", async () => {
const result = await client
.get('ap-northeast-2')
.send(
new GetObjectCommand({
Bucket: 'other-bucket',
Key: 'index.html',
}),
);
expect(await result.Body?.transformToString()).toBe('hello');
expect(signedRegions).toEqual(['ap-northeast-2', 'us-west-2']);
});
});
describe('S3Client — legacy storage migration', () => {
let client: S3Client;
let dir: string;
-3
View File
@@ -235,9 +235,6 @@ export class S3Client extends PuterClient {
const client = new AwsS3Client({
region,
// Rows without a stored region are read with the server's own, so
// retry where S3's 301 says the bucket actually lives.
followRegionRedirects: true,
requestStreamBufferSize: 32 * 1024,
requestHandler: new NodeHttpHandler({
socketTimeout: 5000,
@@ -2222,166 +2222,3 @@ describe('ChatCompletionDriver.countTokens', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
});
describe('ChatCompletionDriver service speed', () => {
it.each([
[{ serviceSpeed: 'ultrafast' }, 'fast', 0.028],
[{ speed: 'fast' }, 'fast', 0.028],
[{ serviceSpeed: 'standard', speed: 'fast' }, 'standard', 0.014],
] as const)('uses the Claude catalog rate card for %j', async (options, speed, cents) => {
const sent: Record<string, unknown>[] = [];
vi.stubGlobal('fetch', async (input: Request | string, init?: RequestInit) => {
const body = input instanceof Request
? await input.clone().json()
: JSON.parse(String(init?.body));
sent.push(body);
return new Response(JSON.stringify({
id: 'claude-speed-test', type: 'message', role: 'assistant',
model: body.model, stop_reason: 'end_turn',
content: [{ type: 'text', text: 'OK' }],
usage: { input_tokens: 10, output_tokens: 5, speed },
}), { headers: { 'content-type': 'application/json' } });
});
const d = new ChatCompletionDriver(
{ providers: { claude: { apiKey: 'test-key' }, ollama: { enabled: false } } } as never,
server.clients, server.stores, server.services,
);
try {
await d.onServerStart();
const result = await withTestActor(() => d.complete({
model: 'claude-opus-5-5', provider: 'claude',
messages: [{ role: 'user', content: 'Hello' }],
fallback: false, max_tokens: 32, normalize: true, ...options,
}));
expect(sent).toHaveLength(1);
expect(sent[0]!.speed).toBe(speed === 'fast' ? 'fast' : undefined);
expect(result).toMatchObject({
message: { content: 'OK' }, usage: { usd_cents: cents },
});
} finally {
d.onServerShutdown();
vi.unstubAllGlobals();
}
});
it.each([
null, false, true, 0, 1, -1, NaN, Infinity, '', 'invalid', 'FAST',
'fast ', ' fast', 'ultraFast', [], ['fast'], {}, { speed: 'fast' },
{ toString: 'fast' },
])('rejects invalid speed %j before sending a request', async (serviceSpeed) => {
await expect(
withTestActor(() =>
driver.complete({
model: 'fake',
messages: [{ role: 'user', content: 'Hello' }],
serviceSpeed: serviceSpeed as never,
}),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'invalid_service_speed',
});
});
it.each([
['openai-responses', true],
['openai-responses', false],
['openai-completion', false],
] as const)(
'settles unavailable tiers at standard rates on %s with provider fallback disabled (admission rejection: %s)',
async (provider, rejectFast) => {
const sent: Record<string, unknown>[] = [];
vi.stubGlobal(
'fetch',
async (_input: unknown, init?: RequestInit) => {
const body =
_input instanceof Request
? await _input.clone().json()
: JSON.parse(String(init?.body));
sent.push(body);
if (rejectFast && body.service_tier !== 'default') {
return new Response(
JSON.stringify({
error: {
message: `${body.service_tier} service_tier capacity unavailable`,
type: 'invalid_request_error',
},
}),
{
status: 400,
headers: { 'content-type': 'application/json' },
},
);
}
return new Response(
JSON.stringify({
id: 'response-test',
object: 'response',
model: body.model,
status: 'completed',
service_tier: 'default',
output: [
{
type: 'message',
role: 'assistant',
content: [
{ type: 'output_text', text: 'OK' },
],
},
],
usage: {
input_tokens: 10,
output_tokens: 2,
input_tokens_details: { cached_tokens: 0 },
},
}),
{ headers: { 'content-type': 'application/json' } },
);
},
);
const d = new ChatCompletionDriver(
{
providers: {
'openai-completion': { apiKey: 'test-key' },
ollama: { enabled: false },
},
} as never,
server.clients,
server.stores,
server.services,
);
try {
await d.onServerStart();
const result = (await withTestActor(() =>
d.complete({
model: 'gpt-6.1-sol',
provider,
messages: [{ role: 'user', content: 'Hello' }],
serviceSpeed: 'ultrafast',
fallback: false,
max_tokens: 32,
normalize: true,
}),
)) as {
message: { content: string };
usageDetails: { serviceSpeed: string };
usage: { usd_cents: number };
};
expect(sent.map((body) => body.service_tier)).toEqual(
rejectFast
? ['ultrafast', 'priority', 'default']
: ['ultrafast'],
);
expect(sent.every((body) => body.model === 'gpt-6.1-sol')).toBe(
true,
);
expect(result.message.content).toBe('OK');
expect(result.usageDetails.serviceSpeed).toBe('standard');
expect(result.usage.usd_cents).toBeCloseTo(0.004, 8);
} finally {
d.onServerShutdown();
vi.unstubAllGlobals();
}
},
);
});
@@ -78,11 +78,6 @@ import type {
UsageDetails,
} from './types.js';
import { normalize_tools_object } from './utils/FunctionCalling.js';
import {
selectServiceSpeed,
lowerServiceSpeed,
isServiceSpeedUnavailable,
} from './utils/serviceSpeed.js';
import {
messagesHavePuterPaths,
modelSupportsModality,
@@ -679,16 +674,6 @@ export class ChatCompletionDriver extends PuterDriver {
}
async complete(args: ICompleteArguments): Promise<IChatCompleteResult> {
if (
args.serviceSpeed !== undefined &&
!['standard', 'fast', 'ultrafast'].includes(args.serviceSpeed)
) {
throw new HttpError(
400,
'serviceSpeed must be standard, fast, or ultrafast',
{ legacyCode: 'invalid_service_speed' },
);
}
const abortSignal = Context.get('abortSignal');
abortSignal?.throwIfAborted();
const actor = Context.get('actor');
@@ -715,16 +700,15 @@ export class ChatCompletionDriver extends PuterDriver {
args.model = this.#providers[intendedProvider].getDefaultModel();
}
const initialModel = await this.#resolveLoadedModel(
let model = await this.#resolveLoadedModel(
args.model,
intendedProvider,
);
if (!initialModel) {
if (!model) {
throw new HttpError(400, `Model not found: ${args.model}`, {
legacyCode: 'bad_request',
});
}
let model: IChatModel = initialModel;
// A pinned provider without a route for the model normally resolves
// to another vendor; with `fallback: false` that is the one thing the
// caller asked us not to do.
@@ -856,6 +840,18 @@ export class ChatCompletionDriver extends PuterDriver {
// The instant the current attempt is priced at, from its hold to its
// charge, so a rate change mid-request can't split the two.
let pricingTime = new Date();
if (!useFakeProvider) {
({
hold,
fundsCap,
costFactor,
promptTokenEstimate: attemptPromptTokenEstimate,
} = await this.#applyCreditGate(actor, model, args, {
promptTokenEstimate,
requestedMaxTokens,
pricingTime,
}));
}
// First attempt
const provider = this.#providers[model.provider!];
@@ -903,134 +899,24 @@ export class ChatCompletionDriver extends PuterDriver {
return attempt;
};
let variantSpeed: ICompleteArguments['serviceSpeed'];
let creditGateError: unknown;
const runAttempt = async (
attemptProvider: IChatProvider,
baseModel: IChatModel,
attemptModel: IChatModel,
) => {
const offers =
args.serviceSpeed !== undefined &&
args.serviceSpeed !== 'standard'
? ((await attemptProvider.serviceSpeedModels?.(
baseModel,
)) ?? {})
: {};
let requestedSpeed =
args.serviceSpeed ??
(baseModel.provider === 'claude' && args.speed === 'fast'
? 'fast'
: undefined);
for (;;) {
const selection = selectServiceSpeed(
baseModel,
{ ...args, serviceSpeed: requestedSpeed },
(id) => {
const variant = this.#resolveModel(
id,
baseModel.provider!,
);
return variant &&
variant.provider === baseModel.provider
? variant
: undefined;
},
offers,
);
const attemptModel = selection.model;
const requestArgs = {
...selection.args,
if (!useFakeProvider) {
await this.#resolvePuterPaths(attemptProvider, args, actor);
}
// Last point where a caller who left costs nothing.
abortSignal?.throwIfAborted();
return attemptProvider.complete(
{
...args,
model: attemptModel.id,
provider: attemptModel.provider,
};
const catalogModel = this.#resolveModel(
attemptModel.id,
attemptModel.provider!,
);
const rawModel = this.#catalogEntries.get(
catalogModel ?? baseModel,
);
const snapshot = rawModel
? {
...rawModel,
costs: attemptModel.costs,
pricingOverrides: attemptModel.pricingOverrides,
serviceSpeedPricing: attemptModel.serviceSpeedPricing,
}
: attemptModel;
if (!useFakeProvider) {
try {
({
hold,
fundsCap,
costFactor,
promptTokenEstimate: attemptPromptTokenEstimate,
} = await this.#applyCreditGate(
actor,
attemptModel,
requestArgs,
{
promptTokenEstimate,
requestedMaxTokens,
pricingTime,
},
));
} catch (error) {
creditGateError = error;
throw error;
}
await this.#resolvePuterPaths(
attemptProvider,
requestArgs,
actor,
);
}
abortSignal?.throwIfAborted();
try {
const result = await attemptProvider.complete(
requestArgs,
snapshot,
pricingTime,
);
model = attemptModel;
variantSpeed =
attemptModel.id !== baseModel.id
? selection.speed
: undefined;
if (
args.serviceSpeed !== undefined &&
'message' in result
) {
const messageResult = result as IChatMessageResult;
const details =
messageResult.usageDetails ??
usageDetailsFromUsage(messageResult.usage, model);
messageResult.usageDetails = {
...details,
serviceSpeed:
variantSpeed ??
details?.serviceSpeed ??
details?.speed ??
'standard',
};
}
return result;
} catch (error) {
await hold.release();
hold = NO_CREDIT_HOLD;
if (
args.serviceSpeed === undefined ||
selection.speed === 'standard' ||
!isServiceSpeedUnavailable(
error,
attemptModel.id !== baseModel.id,
)
)
throw error;
requestedSpeed = lowerServiceSpeed(selection.speed);
pricingTime = new Date();
}
}
},
this.#catalogEntries.get(attemptModel),
pricingTime,
);
};
try {
@@ -1098,17 +984,29 @@ export class ChatCompletionDriver extends PuterDriver {
// The previous attempt released its hold when it failed, so
// this one starts from nothing held.
pricingTime = new Date();
if (!useFakeProvider) {
({
hold,
fundsCap,
costFactor,
promptTokenEstimate: attemptPromptTokenEstimate,
} = await this.#applyCreditGate(actor, fallback, args, {
promptTokenEstimate,
requestedMaxTokens,
pricingTime,
}));
}
tried.add(routeId(fallback.provider!, fallback.id));
try {
res = await runAttempt(fbProvider, fallback);
model = fallback;
lastError = null;
console.warn(
`[ai-chat] fallback served (${completionId}, ${first.provider}:${first.model} -> ${fallback.provider}:${fallback.id}, first ${first.status ?? 'none'}${first.upstreamType ? `/${first.upstreamType}` : ''})`,
);
} catch (fbErr) {
if (fbErr === creditGateError) throw fbErr;
await hold.release();
hold = NO_CREDIT_HOLD;
abortSignal?.throwIfAborted();
@@ -1264,20 +1162,6 @@ export class ChatCompletionDriver extends PuterDriver {
usageDetailsFromUsage(usage, model),
);
}
if (
args.serviceSpeed !== undefined &&
chatStream.usageDetails
) {
const details = chatStream.usageDetails;
chatStream.setUsageDetails({
...details,
serviceSpeed:
variantSpeed ??
details.serviceSpeed ??
details.speed ??
'standard',
});
}
withClaudeThinkingTokens(
enrichedUsage,
model,
@@ -1590,7 +1474,12 @@ export class ChatCompletionDriver extends PuterDriver {
const metering = this.services.metering;
const { requestedMaxTokens, pricingTime } = estimates;
let { promptTokenEstimate } = estimates;
// Providers reserve tool calls using the same caps they send upstream.
// A provider can name its own cost keys for this request (Claude's
// fast-mode rate card), cap its output, and size an extra reservation
// the per-token math can't see (a web-search/advisor tool that could
// run several times before the model answers) — the same clamps it
// applies when building the request, so the hold and the request
// agree.
const pricing =
this.#providers[model.provider!]?.requestPricing?.(args, model, {
promptTokenEstimate,
@@ -41,7 +41,6 @@ import {
meterChatUsage,
type MeterChatUsageOptions,
} from '../utils/meterChatUsage.js';
import { servedServiceSpeed } from '../utils/serviceSpeed.js';
import { catalogPrice, costKeys } from '../utils/pricing.js';
import { normalizeModelKey } from '../utils/modelRouting.js';
import * as OpenAIUtil from '../utils/OpenAIUtil.js';
@@ -233,8 +232,6 @@ export class OpenAICompatProvider implements IChatProvider {
)
: {}),
};
if (args.serviceSpeed !== undefined && args.service_tier !== undefined)
shared.service_tier = args.service_tier;
const params = this.vendorParams(shared, args, model, actor);
let pricingTime = attemptPricingTime ?? new Date();
@@ -274,29 +271,15 @@ export class OpenAICompatProvider implements IChatProvider {
source,
model,
);
const serviceSpeed = servedServiceSpeed(source);
if (usageDetails)
source.setUsageDetails?.({
...usageDetails,
...(args.serviceSpeed !== undefined
? { serviceSpeed }
: {}),
});
if (usageDetails) source.setUsageDetails?.(usageDetails);
const metered = meterChatUsage(
this.#metering,
actor,
this.meteringModelKey(model.id),
model,
usage,
{ ...meterOptions, pricingTime, serviceSpeed },
{ ...meterOptions, pricingTime },
);
if (args.serviceSpeed !== undefined && !usageDetails) {
const details = OpenAIUtil.usageDetailsFromTrackedUsage(
metered.usage,
);
if (details)
source.setUsageDetails?.({ ...details, serviceSpeed });
}
source.setUsageCosts(metered.costs);
return this.reportedUsage(metered.usage);
},
@@ -174,9 +174,6 @@ export const mapAlibabaCatalogModel = (
: undefined;
return {
...existing,
serviceSpeedVariants: {
fast: id === 'glm-5.2' ? 'glm-5.2-fast-preview' : `${id}-prime`,
},
id,
puterId,
name: raw.name ?? existing?.name ?? id,
@@ -37,7 +37,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen-max-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 160,
@@ -61,7 +60,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen-plus-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 40,
@@ -83,7 +81,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.5-plus-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 40,
@@ -105,7 +102,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.6-plus-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 50,
@@ -127,7 +123,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.6-flash-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 19,
@@ -148,7 +143,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.7-plus-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 50,
@@ -170,7 +164,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.7-max-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 250,
@@ -195,7 +188,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.8-max-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 200,
@@ -218,7 +210,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.8-max-0902-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 200,
@@ -243,7 +234,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen-flash-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 5,
@@ -267,7 +257,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3-coder-flash-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 30,
@@ -293,7 +282,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.5-397b-a17b-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 60,
@@ -317,7 +305,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.5-122b-a10b-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 40,
@@ -341,7 +328,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.5-35b-a3b-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 25,
@@ -365,7 +351,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.5-27b-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 30,
@@ -391,7 +376,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.6-35b-a3b-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 24.8,
@@ -415,7 +399,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.6-27b-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 60,
@@ -439,7 +422,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3-vl-plus-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 20,
@@ -465,7 +447,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3-omni-flash-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 43,
@@ -488,7 +469,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen-mt-plus-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 246,
@@ -511,7 +491,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen-plus-2025-01-25-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 40,
@@ -532,7 +511,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen-plus-2025-04-28-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 40,
@@ -553,7 +531,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen-plus-2025-07-14-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 40,
@@ -576,7 +553,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen-plus-2025-12-01-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 40,
@@ -598,7 +574,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.7-max-2026-05-20-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 250,
@@ -619,7 +594,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.7-max-2026-06-08-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 250,
@@ -640,7 +614,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.7-max-2026-05-17-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 250,
@@ -662,7 +635,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.7-max-preview-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 250,
@@ -685,7 +657,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.5-plus-2026-02-15-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 40,
@@ -707,7 +678,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.5-plus-2026-04-20-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 40,
@@ -730,7 +700,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.5-flash-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 10,
@@ -753,7 +722,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.7-flash-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 3,
@@ -775,7 +743,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.8-flash-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 15,
@@ -798,7 +765,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.8-27b-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 50,
@@ -819,7 +785,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.8-2.4t-a95b-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 200,
@@ -844,7 +809,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.5-omni-flash-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 40,
@@ -868,7 +832,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.5-omni-plus-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 140,
@@ -892,7 +855,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen3.8-omni-flash-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 15,
@@ -915,7 +877,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen-mt-flash-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 16,
@@ -936,7 +897,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'qwen-mt-lite-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 12,
@@ -959,7 +919,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'glm-5.1-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 140,
@@ -979,7 +938,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'glm-5.2-fast-preview' },
costs: {
tokens: 1_000_000,
prompt_tokens: 140,
@@ -1000,7 +958,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'glm-5.2-fast-preview-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 280,
@@ -1024,7 +981,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'deepseek-v4-flash-0731-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 44,
@@ -1047,7 +1003,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'deepseek-v4.1-flash-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 30,
@@ -1068,7 +1023,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'deepseek-v4-pro-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 240,
@@ -1091,7 +1045,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'deepseek-v4-pro-0813-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 132,
@@ -1112,7 +1065,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'kimi-k2.7-code-prime' },
costs: {
tokens: 1_000_000,
prompt_tokens: 95,
@@ -20,10 +20,6 @@
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
import { OpenAI } from 'openai';
import { ChatCompletionCreateParams } from 'openai/resources/index.js';
import {
serviceSpeedBillingModel,
servedServiceSpeed,
} from '../../utils/serviceSpeed.js';
import { Context } from '../../../../core/context.js';
import type { FSService } from '../../../../services/fs/FSService.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
@@ -157,7 +153,6 @@ export class AzureChatProvider implements IChatProvider {
async complete(
params: ICompleteArguments,
resolved?: IChatModel,
): ReturnType<IChatProvider['complete']> {
const {
max_tokens,
@@ -185,9 +180,7 @@ export class AzureChatProvider implements IChatProvider {
{ legacyCode: 'bad_request' },
);
}
return await (resolved
? this.#responsesProvider.complete(params, resolved)
: this.#responsesProvider.complete(params));
return await this.#responsesProvider.complete(params);
}
// Inline compaction is a Responses-API feature; chat.completions can't
// express `context_management` or a `compaction` content block.
@@ -201,9 +194,7 @@ export class AzureChatProvider implements IChatProvider {
{ legacyCode: 'bad_request' },
);
}
return await (resolved
? this.#responsesProvider.complete(params, resolved)
: this.#responsesProvider.complete(params));
return await this.#responsesProvider.complete(params);
}
// Validate messages
if (!Array.isArray(messages)) {
@@ -216,11 +207,9 @@ export class AzureChatProvider implements IChatProvider {
model = model ?? this.#defaultModel;
const modelUsed =
resolved ??
(this.models().find((m) =>
this.models().find((m) =>
[m.id, ...(m.aliases || [])].includes(model),
) ||
this.models().find((m) => m.id === this.getDefaultModel())!);
) || this.models().find((m) => m.id === this.getDefaultModel())!;
// messages.unshift({
// role: 'system',
@@ -290,9 +279,6 @@ export class AzureChatProvider implements IChatProvider {
}),
messages: messages,
model: modelUsed.id,
...(params.service_tier !== undefined
? { service_tier: params.service_tier }
: {}),
...(mappedTools?.length ? { tools: mappedTools } : {}),
...(max_tokens !== undefined
? { max_completion_tokens: max_tokens }
@@ -329,12 +315,7 @@ export class AzureChatProvider implements IChatProvider {
);
return OpenAiUtil.handle_completion_output({
usage_calculator: ({
service_tier,
usage,
setUsageCosts,
setUsageDetails,
}) => {
usage_calculator: ({ usage }) => {
const cachedTokens =
usage.prompt_tokens_details?.cached_tokens ?? 0;
const trackedUsage = {
@@ -354,20 +335,9 @@ export class AzureChatProvider implements IChatProvider {
const costsOverrideFromModel = buildCostsOverride(
trackedUsage,
serviceSpeedBillingModel(
modelUsed,
servedServiceSpeed({ service_tier }),
),
modelUsed,
);
setUsageCosts(costsOverrideFromModel);
if (params.serviceSpeed !== undefined)
setUsageDetails({
...OpenAiUtil.usageDetailsFromTrackedUsage(
trackedUsage,
)!,
serviceSpeed: servedServiceSpeed({ service_tier }),
});
this.#meteringService.utilRecordUsageObject(
trackedUsage,
actor!,
@@ -19,10 +19,6 @@
import { OpenAI } from 'openai';
import { ResponseCreateParams } from 'openai/resources/responses/responses.mjs';
import {
serviceSpeedBillingModel,
servedServiceSpeed,
} from '../../utils/serviceSpeed.js';
import { Context } from '../../../../core/context.js';
import type { FSService } from '../../../../services/fs/FSService.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
@@ -156,41 +152,37 @@ export class AzureResponsesProvider implements IChatProvider {
: {};
}
async complete(
{
messages,
model,
max_tokens,
moderation,
tools,
tool_choice,
parallel_tool_calls,
include,
conversation,
compaction,
context_management,
previous_response_id,
instructions,
metadata,
prompt,
prompt_cache_key,
prompt_cache_retention,
store,
top_p,
truncation,
background,
service_tier,
serviceSpeed: requestedServiceSpeed,
verbosity,
stream,
reasoning,
reasoning_effort,
temperature,
text,
outputFormat,
}: ICompleteArguments,
resolved?: IChatModel,
): ReturnType<IChatProvider['complete']> {
async complete({
messages,
model,
max_tokens,
moderation,
tools,
tool_choice,
parallel_tool_calls,
include,
conversation,
compaction,
context_management,
previous_response_id,
instructions,
metadata,
prompt,
prompt_cache_key,
prompt_cache_retention,
store,
top_p,
truncation,
background,
service_tier,
verbosity,
stream,
reasoning,
reasoning_effort,
temperature,
text,
outputFormat,
}: ICompleteArguments): ReturnType<IChatProvider['complete']> {
// Validate messages
if (!Array.isArray(messages)) {
throw new HttpError(400, '`messages` must be an array', {
@@ -208,13 +200,12 @@ export class AzureResponsesProvider implements IChatProvider {
model = model ?? this.#defaultModel;
const modelUsed =
resolved ??
(this.models({ no_restrictions: true }).find((m) =>
this.models({ no_restrictions: true }).find((m) =>
[m.id, ...(m.aliases || [])].includes(model),
) ||
this.models({ no_restrictions: true }).find(
(m) => m.id === this.getDefaultModel(),
)!);
this.models({ no_restrictions: true }).find(
(m) => m.id === this.getDefaultModel(),
)!;
const userIdentifier = upstreamUserIdentifier(actor);
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
@@ -344,11 +335,9 @@ export class AzureResponsesProvider implements IChatProvider {
return OpenAiUtil.handle_completion_output_responses_api({
usage_calculator: ({
usage,
service_tier,
webSearchCalls,
tool_usage,
setUsageCosts,
setUsageDetails,
}) => {
const numWebSearches: number =
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -374,10 +363,7 @@ export class AzureResponsesProvider implements IChatProvider {
const costsOverrideFromModel = buildCostsOverride(
trackedUsage,
serviceSpeedBillingModel(
modelUsed,
servedServiceSpeed({ service_tier }),
),
modelUsed,
);
if (numWebSearches) {
costsOverrideFromModel.web_search_calls =
@@ -388,14 +374,6 @@ export class AzureResponsesProvider implements IChatProvider {
});
}
setUsageCosts?.(costsOverrideFromModel);
if (requestedServiceSpeed !== undefined)
setUsageDetails?.({
...OpenAiUtil.usageDetailsFromTrackedUsage(
trackedUsage,
)!,
serviceSpeed: servedServiceSpeed({ service_tier }),
});
this.#meteringService.utilRecordUsageObject(
trackedUsage,
actor,
@@ -40,11 +40,7 @@ const STANDALONE = [
];
// Fields an Azure deployment genuinely differs from its source on, by puterId.
const OVERRIDES: Record<string, string[]> = {
'azure:x-ai/grok-4.3': ['serviceSpeedRates'],
'azure:x-ai/grok-4-20-non-reasoning': ['serviceSpeedRates'],
'azure:x-ai/grok-4-20-reasoning': ['serviceSpeedRates'],
};
const OVERRIDES: Record<string, string[]> = {};
const IDENTITY = ['id', 'puterId', 'aliases'];
@@ -69,18 +65,6 @@ const mirrored = AZURE_MODELS.filter(
).map((m) => [m.puterId!, m] as const);
describe('AZURE_MODELS mirroring', () => {
it('inherits OpenAI fast rates without enabling direct-only speed tiers', () => {
for (const model of AZURE_MODELS) {
expect(model.serviceSpeedRates?.ultrafast).toBeUndefined();
if (model.puterId?.startsWith('azure:x-ai/')) {
expect(model.serviceSpeedRates?.fast).toBeUndefined();
}
}
const azure = AZURE_MODELS.find((model) => model.id === 'gpt-5.4')!;
expect(azure.serviceSpeedRates).toEqual({
fast: sourceOf(azure)!.serviceSpeedRates!.fast,
});
});
it('has a direct-provider source for every entry not listed as standalone', () => {
const orphans = mirrored
.filter(([, m]) => !sourceOf(m))
@@ -22,8 +22,10 @@ import { OPEN_AI_MODELS } from '../openai/models.js';
import { XAI_MODELS } from '../xai/models.js';
/**
* Azure shares its source model's list prices, context tiers, and limits.
* Identity and available service speeds belong to the deployment.
* An Azure deployment of a model we also serve direct. Every field but the
* identity comes from the source entry, so prices, long-context tiers and
* limits can't drift from it. `name` is the vendor-qualified name both catalogs
* share (`openai/gpt-5.4`); extra fields override the source.
*/
const mirror = (
source: readonly IChatModel[],
@@ -39,11 +41,6 @@ const mirror = (
puterId: `azure:${name}`,
id: azure.id,
...shared,
serviceSpeedRates:
source === OPEN_AI_MODELS &&
shared.serviceSpeedRates?.fast !== undefined
? { fast: shared.serviceSpeedRates.fast }
: undefined,
aliases: [name],
...azure,
};
@@ -130,13 +127,6 @@ export const AZURE_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: {
fast: {
prompt_tokens: 250,
cached_tokens: 25,
completion_tokens: 2000,
},
},
costs: {
tokens: 1_000_000,
prompt_tokens: 125,
@@ -181,13 +171,6 @@ export const AZURE_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: {
fast: {
prompt_tokens: 45,
cached_tokens: 4.5,
completion_tokens: 360,
},
},
costs: {
tokens: 1_000_000,
prompt_tokens: 25,
@@ -147,22 +147,6 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))('ClaudeProvider (integration)', () => {
},
);
it('returns a completion from claude-fable-5-2', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
const provider = buildProvider();
const result = await withTestActor(() =>
provider.complete({
model: 'claude-fable-5-2',
messages: [{ role: 'user', content: 'Reply with OK.' }],
reasoning_effort: 'low',
max_tokens: 128,
}),
);
const content = (result as {
message: { content: Array<{ type: string; text?: string }> };
}).message.content;
expect(content.find((block) => block.type === 'text')?.text?.length).toBeGreaterThan(0);
});
// (a) Claude Code-shaped system blocks, attribution header first — the
// pipeline must never merge or reorder system blocks: the header is
// only recognized by Anthropic when it is the sole content of an
@@ -1173,7 +1173,6 @@ describe('ClaudeProvider.complete request shape', () => {
});
it.each([
'claude-fable-5-2',
'claude-fable-5-1',
'claude-haiku-5-5',
'claude-opus-5-5',
@@ -1199,7 +1198,6 @@ describe('ClaudeProvider.complete request shape', () => {
);
it.each([
'claude-fable-5-2',
'claude-fable-5-1',
'claude-haiku-5-5',
'claude-opus-5-5',
@@ -1305,73 +1303,6 @@ describe('ClaudeProvider model resolution', () => {
);
});
it.each([
'claude-fable-5-2',
'claude-fable-5-2-latest',
'claude-fable-5.2',
'anthropic/claude-fable-5-2',
])(
'routes %s to Fable 5.2 with published rates',
async (model) => {
const { provider } = makeProvider();
messagesCreateMock.mockResolvedValueOnce({
content: [{ type: 'text', text: 'ok' }],
usage: {
input_tokens: 100,
output_tokens: 50,
cache_read_input_tokens: 1000,
cache_creation_input_tokens: 200,
cache_creation: {
ephemeral_5m_input_tokens: 150,
ephemeral_1h_input_tokens: 50,
},
},
});
await withTestActor(() =>
provider.complete({
model,
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(messagesCreateMock.mock.calls[0]![0].model).toBe(
'claude-fable-5-2',
);
const fable52 = CLAUDE_MODELS.find(
(m) => m.id === 'claude-fable-5-2',
)!;
expect(fable52.costs).toMatchObject({
input_tokens: 800,
output_tokens: 4000,
cache_read_input_tokens: 20,
ephemeral_5m_input_tokens: 1000,
ephemeral_1h_input_tokens: 1600,
});
expect(fable52.serviceSpeedRates?.fast).toMatchObject({
input_tokens: 1600,
output_tokens: 8000,
cache_read_input_tokens: 40,
ephemeral_5m_input_tokens: 2000,
ephemeral_1h_input_tokens: 3200,
});
expect(fable52.context).toBe(1_000_000);
expect(fable52.max_tokens).toBe(128_000);
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
expect(prefix).toBe('claude:claude-fable-5-2');
expect(overrides).toMatchObject({
input_tokens: 100 * Number(fable52.costs.input_tokens),
output_tokens: 50 * Number(fable52.costs.output_tokens),
cache_read_input_tokens:
1000 * Number(fable52.costs.cache_read_input_tokens),
ephemeral_5m_input_tokens:
150 * Number(fable52.costs.ephemeral_5m_input_tokens),
ephemeral_1h_input_tokens:
50 * Number(fable52.costs.ephemeral_1h_input_tokens),
});
},
);
it('routes the bare claude-fable alias to fable 5.1 rather than fable 5', async () => {
const { provider } = makeProvider();
messagesCreateMock.mockResolvedValueOnce(baseResponse);
@@ -2380,20 +2311,16 @@ describe('ClaudeProvider metering', () => {
expect('thinking_tokens' in overrides).toBe(false);
});
it.each(['claude-opus-5-5', 'claude-fable-5-2'])('bills at fast rates for %s when the upstream reports fast usage', async (model) => {
it('bills at the fast_* rates when the upstream reports usage.speed:"fast"', async () => {
const { provider } = makeProvider();
messagesCreateMock.mockResolvedValueOnce({
content: [{ type: 'text', text: 'hi' }],
usage: {
input_tokens: 10, output_tokens: 5, speed: 'fast',
cache_creation: { ephemeral_5m_input_tokens: 7, ephemeral_1h_input_tokens: 3 },
cache_read_input_tokens: 20,
},
usage: { input_tokens: 10, output_tokens: 5, speed: 'fast' },
});
const result = (await withTestActor(() =>
provider.complete({
model,
model: 'claude-opus-5-5',
messages: [{ role: 'user', content: 'hi' }],
speed: 'fast',
}),
@@ -2404,15 +2331,11 @@ describe('ClaudeProvider metering', () => {
expect('input_tokens' in result.usage).toBe(false);
expect(result.usageDetails.speed).toBe('fast');
const selectedModel = CLAUDE_MODELS.find((m) => m.id === model)!;
const opus = CLAUDE_MODELS.find((m) => m.id === 'claude-opus-5-5')!;
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
expect(prefix).toBe(`claude:${model}`);
const fastRates = selectedModel.serviceSpeedRates!.fast as Record<string, number>;
expect(overrides.fast_input_tokens).toBe(10 * fastRates.input_tokens);
expect(overrides.fast_output_tokens).toBe(5 * fastRates.output_tokens);
expect(overrides.fast_ephemeral_5m_input_tokens).toBe(7 * fastRates.ephemeral_5m_input_tokens);
expect(overrides.fast_ephemeral_1h_input_tokens).toBe(3 * fastRates.ephemeral_1h_input_tokens);
expect(overrides.fast_cache_read_input_tokens).toBe(20 * fastRates.cache_read_input_tokens);
expect(prefix).toBe('claude:claude-opus-5-5');
expect(overrides.fast_input_tokens).toBe(10 * Number(opus.costs.fast_input_tokens));
expect(overrides.fast_output_tokens).toBe(5 * Number(opus.costs.fast_output_tokens));
});
it('bills at standard rates when fast mode was requested but not granted by the upstream', async () => {
@@ -2434,15 +2357,13 @@ describe('ClaudeProvider metering', () => {
expect('fast_input_tokens' in result.usage).toBe(false);
});
it.each([
[429, "This request would exceed your organization's rate limit of 0 fast mode input tokens per minute"],
[529, 'Overloaded'],
[529, 'Fast mode capacity unavailable'],
])('retries at standard speed on Claude after fast capacity failure %s', async (status, message) => {
it('retries at standard speed on Claude when fast mode is rate limited', async () => {
const { provider } = makeProvider();
const fastLimited = Object.assign(
new Error(message),
{ status },
new Error(
"This request would exceed your organization's rate limit of 0 fast mode input tokens per minute",
),
{ status: 429 },
);
messagesCreateMock.mockRejectedValueOnce(fastLimited).mockResolvedValueOnce({
content: [{ type: 'text', text: 'hi' }],
@@ -2666,13 +2587,13 @@ describe('ClaudeProvider.requestPricing', () => {
const haiku = CLAUDE_MODELS.find((m) => m.id === 'claude-haiku-4-5-20251001')!;
const opus55 = CLAUDE_MODELS.find((m) => m.id === 'claude-opus-5-5')!;
it('leaves token reservation keys to the selected catalog rate card', () => {
it('returns fast cost keys only when the model has fast rates and fast was requested', () => {
const { provider } = makeProvider();
expect(
provider.requestPricing({ speed: 'fast', tools: [] } as never, opus55, {
promptTokenEstimate: 10,
}),
).toEqual({});
).toMatchObject({ inputKey: 'fast_input_tokens', outputKey: 'fast_output_tokens' });
expect(
provider.requestPricing({ speed: 'fast', tools: [] } as never, haiku, {
promptTokenEstimate: 10,
@@ -43,7 +43,6 @@ import {
} from '../../utils/mediaParts.js';
import { isToolChoice, toolChoiceFromWire } from '../../utils/openaiParams.js';
import { buildCostsOverride } from '../../utils/pricing.js';
import { serviceSpeedCosts } from '../../utils/serviceSpeed.js';
import type {
AIChatStream,
AIChatTextStream,
@@ -108,21 +107,20 @@ const toAnthropicMediaPart = (part: any): any => {
return part;
};
/** Fast quota or overload failures can be retried at standard speed. */
const isFastModeUnavailable = (e: unknown): boolean => {
/** A 429 from fast mode's separate quota, not the model's normal limits. */
const isFastModeRateLimit = (e: unknown): boolean => {
const err = e as { status?: unknown; message?: unknown };
return (
typeof err?.message === 'string' &&
((err?.status === 429 && /fast mode/i.test(err.message)) ||
(err?.status === 529 && /overload|fast mode/i.test(err.message)))
err?.status === 429 &&
typeof err.message === 'string' &&
/fast mode/i.test(err.message)
);
};
// Models whose current turn rejects a non-default temperature/top_p/top_k
// outright. Fable 5/5.1/5.2, Sonnet 5/5.5, Haiku 5.5, and Opus 4.7+.
// outright. Fable 5/5.1, Sonnet 5/5.5, Haiku 5.5, and Opus 4.7+.
const OMITS_SAMPLING_PARAMS = new Set([
'claude-haiku-5-5',
'claude-fable-5-2',
'claude-fable-5-1',
'claude-fable-5',
'claude-sonnet-5-5',
@@ -135,7 +133,6 @@ const OMITS_SAMPLING_PARAMS = new Set([
const SUPPORTS_EFFORT = new Set([
'claude-haiku-5-5',
'claude-fable-5-2',
'claude-fable-5-1',
'claude-fable-5',
'claude-sonnet-5-5',
@@ -301,12 +298,20 @@ export class ClaudeProvider implements IChatProvider {
return `claude:${modelId}`;
}
/** Reserves tools that can run several times before the model answers. */
/**
* Sizes the credit hold for Claude-only extras the generic per-token math
* can't see: fast mode's own rate card, and a web-search/advisor tool that
* could run several times before the model answers.
*/
requestPricing(
args: ICompleteArguments,
model: IChatModel,
est: { promptTokenEstimate: number },
): { extraCost?: number } {
): { inputKey?: string; outputKey?: string; extraCost?: number } {
const fast =
args.speed === 'fast' &&
model.costs?.fast_output_tokens !== undefined;
let extraCost = 0;
for (const raw of args.tools ?? []) {
const tool = raw as Record<string, unknown>;
@@ -345,6 +350,12 @@ export class ClaudeProvider implements IChatProvider {
}
return {
...(fast
? {
inputKey: 'fast_input_tokens',
outputKey: 'fast_output_tokens',
}
: {}),
...(extraCost > 0 ? { extraCost } : {}),
};
}
@@ -353,9 +364,11 @@ export class ClaudeProvider implements IChatProvider {
try {
return await this.#completeOnce(args);
} catch (e) {
// Retry fast-capacity failures on Claude at standard speed before
// considering a different provider.
if (args.speed === 'fast' && isFastModeUnavailable(e)) {
// Fast mode has its own quota. Anthropic's advice on that 429 is to
// drop `speed` and run at standard speed; doing it here keeps the
// request on Claude instead of marking the route unhealthy and
// replaying it on a reseller.
if (args.speed === 'fast' && isFastModeRateLimit(e)) {
return await this.#completeOnce({ ...args, speed: undefined });
}
throw e;
@@ -624,7 +637,7 @@ export class ClaudeProvider implements IChatProvider {
: undefined;
const fast =
args.speed === 'fast' &&
modelUsed.serviceSpeedRates?.fast !== undefined;
modelUsed.costs?.fast_output_tokens !== undefined;
const safeguardsPolicy = applySafeguardsPolicy(
args.safeguards,
args.anthropicBetas,
@@ -1058,7 +1071,6 @@ export class ClaudeProvider implements IChatProvider {
// keeps reasoning visible in the stream.
if (
modelId === 'claude-haiku-5-5' ||
modelId === 'claude-fable-5-2' ||
modelId === 'claude-fable-5-1' ||
modelId === 'claude-fable-5' ||
modelId === 'claude-sonnet-5-5' ||
@@ -1493,8 +1505,7 @@ export class ClaudeProvider implements IChatProvider {
const fast =
raw.speed === 'fast' &&
modelUsed.serviceSpeedRates?.fast !== undefined;
// Keep published usage keys while resolving rates from the common catalog shape.
modelUsed.costs?.fast_output_tokens !== undefined;
const executorUsage: Record<string, number> = fast
? Object.fromEntries(
Object.entries(exec).map(([k, v]) => [`fast_${k}`, v]),
@@ -1506,24 +1517,7 @@ export class ClaudeProvider implements IChatProvider {
const fetches = raw.server_tool_use?.web_fetch_requests ?? 0;
if (fetches) executorUsage.web_fetch_requests = fetches;
const fastRates = fast
? serviceSpeedCosts(modelUsed, 'fast')
: undefined;
const billingModel = fastRates
? {
...modelUsed,
costs: {
...modelUsed.costs,
...Object.fromEntries(
Object.keys(exec).map((key) => [
`fast_${key}`,
fastRates[key],
]),
),
},
}
: modelUsed;
const executorCosts = buildCostsOverride(executorUsage, billingModel);
const executorCosts = buildCostsOverride(executorUsage, modelUsed);
let advisorUsage: Record<string, number> | undefined;
let advisorCosts: Record<string, number> | undefined;
@@ -55,46 +55,6 @@ export const CLAUDE_MODELS: IChatModel[] = [
context: 1_000_000,
max_tokens: 128_000,
},
{
puterId: 'anthropic:anthropic/claude-fable-5-2',
id: 'claude-fable-5-2',
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
open_weights: false,
tool_call: true,
release_date: '2026-11-09',
aliases: [
'claude-fable-5-2-latest',
'claude-fable-5.2',
'anthropic/claude-fable-5-2',
],
name: 'Claude Fable 5.2',
midConversationSystem: true,
costs_currency: 'usd-cents',
input_cost_key: 'input_tokens',
output_cost_key: 'output_tokens',
// Source: https://www.anthropic.com/claude-fable-and-mythos-5-2
serviceSpeedRates: {
fast: {
input_tokens: 1600,
output_tokens: 8000,
ephemeral_5m_input_tokens: 1600 * 1.25,
ephemeral_1h_input_tokens: 1600 * 2,
cache_read_input_tokens: 1600 * 0.025,
},
},
costs: {
tokens: 1_000_000,
input_tokens: 800,
ephemeral_5m_input_tokens: 800 * 1.25,
ephemeral_1h_input_tokens: 800 * 2,
// 0.025x input, not the usual 0.1x.
cache_read_input_tokens: 800 * 0.025,
output_tokens: 4000,
web_search_requests: 1_000_000, // $10/1k requests
},
context: 1000000,
max_tokens: 128000,
},
{
puterId: 'anthropic:anthropic/claude-fable-5-1',
id: 'claude-fable-5-1',
@@ -230,15 +190,6 @@ export const CLAUDE_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'input_tokens',
output_cost_key: 'output_tokens',
serviceSpeedRates: {
fast: {
input_tokens: 800,
output_tokens: 4000,
ephemeral_5m_input_tokens: 1000,
ephemeral_1h_input_tokens: 1600,
cache_read_input_tokens: 40,
},
},
costs: {
tokens: 1_000_000,
input_tokens: 400,
@@ -248,6 +199,12 @@ export const CLAUDE_MODELS: IChatModel[] = [
cache_read_input_tokens: 400 * 0.05,
output_tokens: 2000,
web_search_requests: 1_000_000,
// Fast mode (`speed:'fast'`), live-verified.
fast_input_tokens: 800,
fast_output_tokens: 4000,
fast_ephemeral_5m_input_tokens: 1000,
fast_ephemeral_1h_input_tokens: 1600,
fast_cache_read_input_tokens: 40, // 0.05x fast_input_tokens
},
context: 1000000,
max_tokens: 128000,
@@ -266,15 +223,6 @@ export const CLAUDE_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'input_tokens',
output_cost_key: 'output_tokens',
serviceSpeedRates: {
fast: {
input_tokens: 1000,
output_tokens: 5000,
ephemeral_5m_input_tokens: 1250,
ephemeral_1h_input_tokens: 2000,
cache_read_input_tokens: 100,
},
},
costs: {
tokens: 1_000_000,
input_tokens: 500,
@@ -283,6 +231,11 @@ export const CLAUDE_MODELS: IChatModel[] = [
cache_read_input_tokens: 500 * 0.1,
output_tokens: 2500,
web_search_requests: 1_000_000,
fast_input_tokens: 1000,
fast_output_tokens: 5000,
fast_ephemeral_5m_input_tokens: 1250,
fast_ephemeral_1h_input_tokens: 2000,
fast_cache_read_input_tokens: 100,
},
context: 1000000,
max_tokens: 128000,
@@ -305,15 +258,6 @@ export const CLAUDE_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'input_tokens',
output_cost_key: 'output_tokens',
serviceSpeedRates: {
fast: {
input_tokens: 1000,
output_tokens: 5000,
ephemeral_5m_input_tokens: 1250,
ephemeral_1h_input_tokens: 2000,
cache_read_input_tokens: 100,
},
},
costs: {
tokens: 1_000_000,
input_tokens: 500,
@@ -322,6 +266,11 @@ export const CLAUDE_MODELS: IChatModel[] = [
cache_read_input_tokens: 500 * 0.1,
output_tokens: 2500,
web_search_requests: 1_000_000,
fast_input_tokens: 1000,
fast_output_tokens: 5000,
fast_ephemeral_5m_input_tokens: 1250,
fast_ephemeral_1h_input_tokens: 2000,
fast_cache_read_input_tokens: 100,
},
context: 1000000,
max_tokens: 128000,
@@ -84,14 +84,8 @@ export class GeminiChatProvider extends OpenAICompatProvider {
chat: {
completions: {
create: (params, options) => {
const { model, service_tier } = params as {
model: string;
service_tier?: string;
};
if (
!GEMINI_NATIVE_MODELS.has(model) &&
(service_tier === undefined || !native)
) {
const { model } = params as { model: string };
if (!GEMINI_NATIVE_MODELS.has(model)) {
return compat.chat.completions.create(
params,
options,
@@ -109,12 +103,7 @@ export class GeminiChatProvider extends OpenAICompatProvider {
},
},
};
const models: IChatModel[] = native
? GEMINI_MODELS
: COMPAT_ONLY_MODELS.map((model) => ({
...model,
serviceSpeeds: ['standard'],
}));
const models = native ? GEMINI_MODELS : COMPAT_ONLY_MODELS;
super(meteringService, {
client,
defaultModel: 'gemini-2.5-flash',
@@ -194,10 +183,7 @@ export class GeminiChatProvider extends OpenAICompatProvider {
}
protected override meteredUsage(source: UsageSource, model: IChatModel) {
if (
GEMINI_NATIVE_MODELS.has(model.id) ||
'promptTokenCount' in (source.usage ?? {})
) {
if (GEMINI_NATIVE_MODELS.has(model.id)) {
return nativeMeteredUsage(
source.usage as unknown as
GenerateContentResponseUsageMetadata | undefined,
@@ -80,7 +80,6 @@ interface ChatParams {
temperature?: number;
reasoning_effort?: 'minimal' | 'low' | 'medium' | 'high';
stream?: boolean;
service_tier?: string;
}
/** Ids made up for calls Gemini returned without one; never sent back. */
@@ -241,12 +240,6 @@ const toGenerateContentRequest = (
? toolConfigOf(params.tool_choice)
: undefined;
const config: GenerateContentConfig = {
...(params.service_tier !== undefined
? {
serviceTier:
params.service_tier as GenerateContentConfig['serviceTier'],
}
: {}),
...(signal ? { abortSignal: signal } : {}),
...(systemInstruction ? { systemInstruction } : {}),
...(params.max_completion_tokens !== undefined
@@ -142,7 +142,6 @@ export const GEMINI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.8 },
costs: ARGON_LAUNCH_COSTS,
pricingOverrides: listPricingFrom(
'2026-12-01T00:00:00Z',
@@ -175,7 +174,6 @@ export const GEMINI_MODELS: IChatModel[] = [
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
// Google serves this endpoint with gemini-3.6-flash and bills its rates.
serviceSpeedRates: { fast: 1.8 },
costs: FLASH_LAUNCH_COSTS,
pricingOverrides: FLASH_LIST_PRICING,
},
@@ -203,7 +201,6 @@ export const GEMINI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.8 },
costs: {
tokens: 1_000_000,
prompt_tokens: 30,
@@ -231,7 +228,6 @@ export const GEMINI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.8 },
costs: FLASH_LAUNCH_COSTS,
pricingOverrides: FLASH_LIST_PRICING,
},
@@ -254,7 +250,6 @@ export const GEMINI_MODELS: IChatModel[] = [
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
// Google serves this endpoint with gemini-3.8-flash and bills its rates.
serviceSpeedRates: { fast: 1.8 },
costs: FLASH_LAUNCH_COSTS,
pricingOverrides: FLASH_LIST_PRICING,
},
@@ -275,7 +270,6 @@ export const GEMINI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.8 },
costs: FLASH_LAUNCH_COSTS,
pricingOverrides: FLASH_LIST_PRICING,
},
@@ -297,7 +291,6 @@ export const GEMINI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.8 },
costs: {
tokens: 1_000_000,
prompt_tokens: 25,
@@ -325,7 +318,6 @@ export const GEMINI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.8 },
costs: {
tokens: 1_000_000,
prompt_tokens: 50,
@@ -352,7 +344,6 @@ export const GEMINI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.8 },
costs: {
tokens: 1_000_000,
prompt_tokens: 30,
@@ -383,7 +374,6 @@ export const GEMINI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.8 },
costs: {
tokens: 1_000_000,
prompt_tokens: 10,
@@ -412,7 +402,6 @@ export const GEMINI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.8 },
costs: {
tokens: 1_000_000,
prompt_tokens: 125,
@@ -459,7 +448,6 @@ export const GEMINI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.8 },
costs: {
tokens: 1_000_000,
prompt_tokens: 200,
@@ -266,15 +266,6 @@ export class InfronProvider extends OpenAICompatProvider {
return coerced_models;
}
async serviceSpeedModels(model: IChatModel) {
const variant = (await this.models()).find(
(entry) => entry.id === `${model.id}:priority`,
);
return variant
? { fast: { ...variant, provider: model.provider } }
: {};
}
protected override vendorParams(
params: Record<string, unknown>,
_args: unknown,
@@ -43,8 +43,6 @@ export const MINIMAX_MODELS: MiniMaxChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.5 },
serviceSpeedVariants: { fast: 'minimax-m3-highspeed' },
costs: {
tokens: 1_000_000,
prompt_tokens: 30, // $0.30 per 1M (input <= 512k tokens)
@@ -72,7 +70,6 @@ export const MINIMAX_MODELS: MiniMaxChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'minimax-m2.7-highspeed' },
costs: {
tokens: 1_000_000,
prompt_tokens: 30, // $0.30 per 1M
@@ -125,7 +122,6 @@ export const MINIMAX_MODELS: MiniMaxChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'minimax-m2.5-highspeed' },
costs: {
tokens: 1_000_000,
prompt_tokens: 30, // $0.30 per 1M
@@ -178,7 +174,6 @@ export const MINIMAX_MODELS: MiniMaxChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'minimax-m2.1-highspeed' },
costs: {
tokens: 1_000_000,
prompt_tokens: 30, // $0.30 per 1M
@@ -227,7 +222,6 @@ export const MINIMAX_MODELS: MiniMaxChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'minimax-m2-highspeed' },
costs: {
tokens: 1_000_000,
prompt_tokens: 30, // $0.30 per 1M
@@ -20,12 +20,13 @@
/**
* Offline unit tests for MistralAIProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
* constructs MistralAIProvider directly against the live wired
* `MeteringService` so the recording side is exercised end-to- end. The Mistral
* SDK is mocked at the module boundary (the real network egress point) so the
* provider never reaches the network. The companion integration test
* (MistralAiProvider.integration.test.ts) exercises the real Mistral endpoint.
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs MistralAIProvider directly against the live
* wired `MeteringService` so the recording side is exercised end-to-
* end. The Mistral SDK is mocked at the module boundary (the real
* network egress point) so the provider never reaches the network.
* The companion integration test (MistralAiProvider.integration.test.ts)
* exercises the real Mistral endpoint.
*/
import { Context } from '../../../../core/context.js';
@@ -146,9 +147,7 @@ describe('MistralAIProvider construction', () => {
it('constructs the Mistral SDK with the configured API key', () => {
makeProvider();
expect(mistralCtor).toHaveBeenCalledTimes(1);
expect(mistralCtor).toHaveBeenCalledWith(
expect.objectContaining({ apiKey: 'test-key' }),
);
expect(mistralCtor).toHaveBeenCalledWith({ apiKey: 'test-key' });
});
});
@@ -607,11 +606,7 @@ describe('MistralAIProvider.complete non-stream output', () => {
if (stream) {
streamMock.mockResolvedValueOnce(
asAsyncIterable([
{
data: {
choices: [{ delta: { content: 'hello' } }],
},
},
{ data: { choices: [{ delta: { content: 'hello' } }] } },
{ data: { choices: [], usage } },
]),
);
@@ -648,11 +643,7 @@ describe('MistralAIProvider.complete non-stream output', () => {
expect(result).toMatchObject({ message: { content: 'hello' } });
}
expect(recordSpy).toHaveBeenCalledWith(
{
prompt_tokens: 5,
cached_tokens: 1008,
completion_tokens: 30,
},
{ prompt_tokens: 5, cached_tokens: 1008, completion_tokens: 30 },
SYSTEM_ACTOR,
`mistral:${model}`,
{
@@ -697,7 +688,8 @@ describe('MistralAIProvider.complete non-stream output', () => {
});
expect(recordSpy).toHaveBeenCalledTimes(1);
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
const [usage, actor, prefix, overrides] =
recordSpy.mock.calls[0]!;
expect(usage).toEqual({
prompt_tokens: 100,
completion_tokens: 50,
@@ -726,11 +718,15 @@ describe('MistralAIProvider.complete non-stream output', () => {
content: [
{
type: 'thinking',
thinking: [{ type: 'text', text: 'step one.' }],
thinking: [
{ type: 'text', text: 'step one.' },
],
},
{
type: 'thinking',
thinking: [{ type: 'text', text: 'step two.' }],
thinking: [
{ type: 'text', text: 'step two.' },
],
},
{ type: 'text', text: 'the answer' },
],
@@ -920,7 +916,8 @@ describe('MistralAIProvider.complete streaming', () => {
// mistral-small-2603: prompt=15, completion=60.
expect(recordSpy).toHaveBeenCalledTimes(1);
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
const [, , prefix, overrides] =
recordSpy.mock.calls[0]!;
expect(prefix).toBe('mistral:mistral-small-2603');
expect(overrides).toMatchObject({
prompt_tokens: 4 * 15,
@@ -1118,7 +1115,7 @@ describe('MistralAIProvider image_url coercion', () => {
usage: { promptTokens: 1, completionTokens: 1 },
};
it("rewrites canonical image_url parts to the SDK's camelCase imageUrl", async () => {
it('rewrites canonical image_url parts to the SDK\'s camelCase imageUrl', async () => {
const { provider } = makeProvider();
completeMock.mockResolvedValueOnce(baseCompletion);
@@ -1178,8 +1175,7 @@ describe('MistralAIProvider image_url coercion', () => {
},
{
type: 'image_url',
image_url:
'https://example.com/already-flat.png',
image_url: 'https://example.com/already-flat.png',
},
],
},
@@ -1255,9 +1251,7 @@ describe('MistralAIProvider image_url coercion', () => {
{ type: 'text', text: 'what is in this image?' },
{
type: 'image_url',
image_url: {
url: 'https://example.com/photo.jpg',
},
image_url: { url: 'https://example.com/photo.jpg' },
},
],
},
@@ -1266,11 +1260,7 @@ describe('MistralAIProvider image_url coercion', () => {
);
const [args] = completeMock.mock.calls[0]!;
const parts = args.messages[0].content as {
type: string;
text?: string;
imageUrl?: unknown;
}[];
const parts = args.messages[0].content as { type: string; text?: string; imageUrl?: unknown }[];
const textPart = parts.find((p) => p.type === 'text');
const imgPart = parts.find((p) => p.type === 'image_url');
expect(textPart?.text).toBe('what is in this image?');
@@ -1310,41 +1300,3 @@ describe('MistralAIProvider.checkModeration', () => {
);
});
});
describe('Mistral service speed wire mapping', () => {
it('adds the tier after SDK serialization and removes its internal header', async () => {
makeProvider();
const options = mistralCtor.mock.calls[0][0] as {
httpClient: import('@mistralai/mistralai/lib/http.js').HTTPClient;
};
let sent: Request | undefined;
vi.stubGlobal('fetch', async (input: Request) => {
sent = input;
return new Response('{}', {
headers: { 'content-type': 'application/json' },
});
});
try {
await options.httpClient.request(
new Request('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-puter-service-tier': 'auto',
},
body: JSON.stringify({
model: 'mistral-medium-latest',
messages: [],
}),
}),
);
expect(sent?.headers.has('x-puter-service-tier')).toBe(false);
expect(await sent?.json()).toMatchObject({
model: 'mistral-medium-latest',
service_tier: 'auto',
});
} finally {
vi.unstubAllGlobals();
}
});
});
@@ -17,8 +17,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { HTTPClient } from '@mistralai/mistralai/lib/http.js';
import { servedServiceSpeed } from '../../utils/serviceSpeed.js';
import { Mistral } from '@mistralai/mistralai';
import { ChatCompletionResponse } from '@mistralai/mistralai/models/components/chatcompletionresponse.js';
import { MistralError } from '@mistralai/mistralai/models/errors/mistralerror.js';
@@ -28,7 +26,6 @@ import { Context } from '../../../../core/context.js';
import { HttpError } from '../../../../core/http/HttpError.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type {
IChatModel,
IChatCompleteResult,
IChatProvider,
ICompleteArguments,
@@ -109,7 +106,6 @@ const coerceMistralUsage = (usage: UsageInfo) => {
prompt_tokens: (usage.promptTokens ?? 0) - cachedTokens,
completion_tokens: usage.completionTokens ?? 0,
prompt_tokens_details: { cached_tokens: cachedTokens },
service_tier: usage.additionalProperties?.service_tier,
};
};
@@ -119,24 +115,7 @@ export class MistralAIProvider implements IChatProvider {
#meteringService: MeteringService;
constructor(config: { apiKey: string }, meteringService: MeteringService) {
const httpClient = new HTTPClient();
// The SDK strips service_tier during body serialization.
httpClient.addHook('beforeRequest', async (request) => {
const tier = request.headers.get('x-puter-service-tier');
if (!tier) return;
const headers = new Headers(request.headers);
headers.delete('x-puter-service-tier');
const body = await request.json();
return new Request(request, {
headers,
body: JSON.stringify({
...body,
service_tier: tier === 'auto' ? 'auto' : 'standard_only',
}),
});
});
this.#client = new Mistral({
httpClient,
apiKey: config.apiKey,
});
this.#meteringService = meteringService;
@@ -202,22 +181,17 @@ export class MistralAIProvider implements IChatProvider {
return `mistral:${modelId}`;
}
async complete(
{
messages,
stream,
model,
tools,
max_tokens,
temperature,
normalize,
response,
custom,
service_tier,
serviceSpeed: requestedServiceSpeed,
}: ICompleteArguments,
resolved?: IChatModel,
): Promise<IChatCompleteResult> {
async complete({
messages,
stream,
model,
tools,
max_tokens,
temperature,
normalize,
response,
custom,
}: ICompleteArguments): Promise<IChatCompleteResult> {
// Mistral's reasoning prompt mode: with `prompt_mode: 'reasoning'`,
// reasoning-capable models return their thinking as structured ThinkChunk
// content (which the splitter below separates into `reasoning`)
@@ -243,13 +217,10 @@ export class MistralAIProvider implements IChatProvider {
}
const selectedModel =
resolved ??
((await this.models()).find((m) =>
(await this.models()).find((m) =>
[m.id, ...(m.aliases || [])].includes(model),
) ||
(await this.models()).find(
(m) => m.id === this.getDefaultModel(),
)!);
(await this.models()).find((m) => m.id === this.getDefaultModel())!;
const actor = Context.get('actor');
let completion: Awaited<
ReturnType<Mistral['chat']['complete' | 'stream']>
@@ -271,9 +242,6 @@ export class MistralAIProvider implements IChatProvider {
// The SDK retries nothing by default and has no timeout once a
// signal is passed; a stream is bounded by its caller instead.
{
...(service_tier !== undefined
? { headers: { 'x-puter-service-tier': service_tier } }
: {}),
signal: stream
? Context.get('upstreamSignal')
: withSdkTimeout(Context.get('upstreamSignal')),
@@ -430,8 +398,7 @@ export class MistralAIProvider implements IChatProvider {
},
completion: completion as ChatCompletionResponse,
stream,
usage_calculator: ({ usage, setUsageCosts, setUsageDetails }) => {
const serviceSpeed = servedServiceSpeed({ usage });
usage_calculator: ({ usage, setUsageCosts }) => {
// `coerceMistralUsage` has already taken cached reads out of
// `prompt_tokens`.
const metered = meterChatUsage(
@@ -440,16 +407,8 @@ export class MistralAIProvider implements IChatProvider {
this.meteringModelKey(selectedModel.id),
selectedModel,
OpenAIUtil.extractMeteredUsage(usage),
{ serviceSpeed },
);
setUsageCosts(metered.costs);
if (requestedServiceSpeed !== undefined)
setUsageDetails({
...OpenAIUtil.usageDetailsFromTrackedUsage(
metered.usage,
)!,
serviceSpeed,
});
return metered.usage;
},
});
@@ -42,7 +42,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.75 },
costs: {
tokens: 1_000_000,
prompt_tokens: 136,
@@ -76,7 +75,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.75 },
costs: {
tokens: 1_000_000,
prompt_tokens: 150,
@@ -107,7 +105,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.75 },
costs: {
tokens: 1_000_000,
prompt_tokens: 50,
@@ -138,7 +135,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.75 },
costs: {
tokens: 1_000_000,
prompt_tokens: 15,
@@ -163,7 +159,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.75 },
costs: {
tokens: 1_000_000,
prompt_tokens: 140,
@@ -193,7 +188,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.75 },
costs: {
tokens: 1_000_000,
prompt_tokens: 30,
@@ -219,7 +213,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.75 },
costs: {
tokens: 1_000_000,
prompt_tokens: 20,
@@ -245,7 +238,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.75 },
costs: {
tokens: 1_000_000,
prompt_tokens: 15,
@@ -270,7 +262,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 1.75 },
costs: {
tokens: 1_000_000,
prompt_tokens: 10,
@@ -30,7 +30,6 @@ export const MOONSHOT_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'kimi-k3.1-pro-highspeed' },
costs: {
tokens: 1_000_000,
prompt_tokens: 200, // $2.00 per 1M
@@ -78,7 +77,6 @@ export const MOONSHOT_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'kimi-k3-highspeed' },
costs: {
tokens: 1_000_000,
prompt_tokens: 300, // $3.00 per 1M
@@ -106,7 +104,6 @@ export const MOONSHOT_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'kimi-k2.6-highspeed' },
costs: {
tokens: 1_000_000,
prompt_tokens: 95, // $0.95 per 1M
@@ -129,7 +126,6 @@ export const MOONSHOT_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedVariants: { fast: 'kimi-k2.7-code-highspeed' },
costs: {
tokens: 1_000_000,
prompt_tokens: 95,
@@ -20,7 +20,6 @@
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
import { OpenAI } from 'openai';
import { ChatCompletionCreateParams } from 'openai/resources/index.js';
import { servedServiceSpeed } from '../../utils/serviceSpeed.js';
import { Context } from '../../../../core/context.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type {
@@ -118,7 +117,6 @@ export class OpenAiChatProvider implements IChatProvider {
async complete(
params: ICompleteArguments,
resolved?: IChatModel,
): ReturnType<IChatProvider['complete']> {
const {
max_tokens,
@@ -136,11 +134,6 @@ export class OpenAiChatProvider implements IChatProvider {
} = params;
let { messages, model } = params;
const cacheOptions = promptCacheOptionsToWire(prompt_cache_options);
if (params.service_tier === 'ultrafast' && this.#responsesProvider) {
return resolved
? this.#responsesProvider.complete(params, resolved)
: this.#responsesProvider.complete(params);
}
if (
tools?.filter((e) => isWebSearchTool(e as Record<string, unknown>))
.length
@@ -154,9 +147,7 @@ export class OpenAiChatProvider implements IChatProvider {
{ legacyCode: 'bad_request' },
);
}
return await (resolved
? this.#responsesProvider.complete(params, resolved)
: this.#responsesProvider.complete(params));
return await this.#responsesProvider.complete(params);
}
// Inline compaction is a Responses-API feature; chat.completions can't
// express `context_management` or a `compaction` content block.
@@ -170,9 +161,7 @@ export class OpenAiChatProvider implements IChatProvider {
{ legacyCode: 'bad_request' },
);
}
return await (resolved
? this.#responsesProvider.complete(params, resolved)
: this.#responsesProvider.complete(params));
return await this.#responsesProvider.complete(params);
}
// Only the Responses API writes 12h cache entries or takes a lifetime
// on a `prompt_cache_breakpoint`; Chat Completions rejects both.
@@ -187,9 +176,7 @@ export class OpenAiChatProvider implements IChatProvider {
{ legacyCode: 'bad_request' },
);
}
return await (resolved
? this.#responsesProvider.complete(params, resolved)
: this.#responsesProvider.complete(params));
return await this.#responsesProvider.complete(params);
}
// Validate messages
if (!Array.isArray(messages)) {
@@ -202,11 +189,9 @@ export class OpenAiChatProvider implements IChatProvider {
model = model ?? this.#defaultModel;
const modelUsed =
resolved ??
(this.models().find((m) =>
this.models().find((m) =>
[m.id, ...(m.aliases || [])].includes(model),
) ||
this.models().find((m) => m.id === this.getDefaultModel())!);
) || this.models().find((m) => m.id === this.getDefaultModel())!;
// messages.unshift({
// role: 'system',
@@ -263,9 +248,6 @@ export class OpenAiChatProvider implements IChatProvider {
: {}),
messages: messages,
model: modelUsed.id,
...(params.service_tier !== undefined
? { service_tier: params.service_tier }
: {}),
...(mappedTools?.length ? { tools: mappedTools } : {}),
...(max_tokens !== undefined
? { max_completion_tokens: max_tokens }
@@ -302,13 +284,7 @@ export class OpenAiChatProvider implements IChatProvider {
);
return OpenAiUtil.handle_completion_output({
usage_calculator: ({
usage,
service_tier,
setUsageCosts,
setUsageDetails,
}) => {
const serviceSpeed = servedServiceSpeed({ service_tier });
usage_calculator: ({ usage, setUsageCosts, setUsageDetails }) => {
// GPT-5.6 and later report cache writes inside
// `prompt_tokens`, like cached reads; Chat Completions only
// writes the default 30m entries, billed at 1.25x input.
@@ -320,17 +296,13 @@ export class OpenAiChatProvider implements IChatProvider {
this.meteringModelKey(modelUsed.id),
modelUsed,
trackedUsage,
{ serviceSpeed },
);
setUsageCosts(metered.costs);
setUsageDetails({
...OpenAiUtil.usageDetailsFromTrackedUsage(metered.usage, {
setUsageDetails(
OpenAiUtil.usageDetailsFromTrackedUsage(metered.usage, {
writeTtl: '30m',
})!,
...(params.serviceSpeed !== undefined
? { serviceSpeed }
: {}),
});
);
return metered.usage;
},
stream,
@@ -19,7 +19,6 @@
import { OpenAI } from 'openai';
import { ResponseCreateParams } from 'openai/resources/responses/responses.mjs';
import { servedServiceSpeed } from '../../utils/serviceSpeed.js';
import { Context } from '../../../../core/context.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type {
@@ -135,42 +134,38 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
return rate ? { extraCost: rate } : {};
}
async complete(
{
messages,
model,
max_tokens,
moderation,
tools,
tool_choice,
parallel_tool_calls,
include,
conversation,
compaction,
context_management,
previous_response_id,
instructions,
metadata,
prompt,
prompt_cache_key,
prompt_cache_retention,
prompt_cache_options,
store,
top_p,
truncation,
background,
service_tier,
serviceSpeed: requestedServiceSpeed,
verbosity,
stream,
reasoning,
reasoning_effort,
temperature,
text,
outputFormat,
}: ICompleteArguments,
resolved?: IChatModel,
): ReturnType<IChatProvider['complete']> {
async complete({
messages,
model,
max_tokens,
moderation,
tools,
tool_choice,
parallel_tool_calls,
include,
conversation,
compaction,
context_management,
previous_response_id,
instructions,
metadata,
prompt,
prompt_cache_key,
prompt_cache_retention,
prompt_cache_options,
store,
top_p,
truncation,
background,
service_tier,
verbosity,
stream,
reasoning,
reasoning_effort,
temperature,
text,
outputFormat,
}: ICompleteArguments): ReturnType<IChatProvider['complete']> {
// Validate messages
if (!Array.isArray(messages)) {
throw new HttpError(400, '`messages` must be an array', {
@@ -188,13 +183,12 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
model = model ?? this.#defaultModel;
const modelUsed =
resolved ??
(this.models({ no_restrictions: true }).find((m) =>
this.models({ no_restrictions: true }).find((m) =>
[m.id, ...(m.aliases || [])].includes(model),
) ||
this.models({ no_restrictions: true }).find(
(m) => m.id === this.getDefaultModel(),
)!);
this.models({ no_restrictions: true }).find(
(m) => m.id === this.getDefaultModel(),
)!;
// messages.unshift({
// role: 'system',
@@ -328,7 +322,6 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
return OpenAiUtil.handle_completion_output_responses_api({
usage_calculator: ({
usage,
service_tier,
webSearchCalls,
setUsageCosts,
setUsageDetails,
@@ -354,31 +347,23 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
// model is a reasoning model, not a static per-model
// cost-table entry — the model's rates would otherwise
// price it (wrongly) at the input/output token rate.
{
serviceSpeed: servedServiceSpeed({ service_tier }),
...(webSearchCalls
? {
costOverrides: {
web_search_calls:
webSearchCalls *
(webSearchCallRate(
tools,
modelUsed.id,
) ?? 0),
},
}
: {}),
},
webSearchCalls
? {
costOverrides: {
web_search_calls:
webSearchCalls *
(webSearchCallRate(tools, modelUsed.id) ??
0),
},
}
: {},
);
setUsageCosts?.(metered.costs);
setUsageDetails?.({
...(requestedServiceSpeed !== undefined
? { serviceSpeed: servedServiceSpeed({ service_tier }) }
: {}),
...OpenAiUtil.usageDetailsFromTrackedUsage(metered.usage, {
setUsageDetails?.(
OpenAiUtil.usageDetailsFromTrackedUsage(metered.usage, {
writeTtl: '30m',
})!,
});
);
return metered.usage;
},
stream,
@@ -46,7 +46,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 2 },
costs: {
tokens: 1_000_000,
prompt_tokens: 100,
@@ -73,7 +72,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 2, ultrafast: 6 },
costs: {
tokens: 1_000_000,
prompt_tokens: 200,
@@ -100,7 +98,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 2 },
costs: {
tokens: 1_000_000,
prompt_tokens: 200,
@@ -127,7 +124,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 2 },
costs: {
tokens: 1_000_000,
prompt_tokens: 10,
@@ -154,7 +150,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 2, ultrafast: 6 },
costs: {
tokens: 1_000_000,
prompt_tokens: 1000,
@@ -181,7 +176,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
// OpenAI's promotional pricing, guaranteed only through 2026-11-21.
serviceSpeedRates: { fast: 2 },
costs: {
tokens: 1_000_000,
prompt_tokens: 400,
@@ -207,7 +201,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 2 },
costs: {
tokens: 1_000_000,
prompt_tokens: 200,
@@ -233,7 +226,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 2 },
costs: {
tokens: 1_000_000,
prompt_tokens: 20,
@@ -259,13 +251,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: {
fast: {
prompt_tokens: 1250,
cached_tokens: 125,
completion_tokens: 7500,
},
},
costs: {
tokens: 1_000_000,
prompt_tokens: 500,
@@ -310,13 +295,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: {
fast: {
prompt_tokens: 500,
cached_tokens: 50,
completion_tokens: 3000,
},
},
costs: {
tokens: 1_000_000,
prompt_tokens: 250,
@@ -360,13 +338,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: {
fast: {
prompt_tokens: 150,
cached_tokens: 15,
completion_tokens: 900,
},
},
costs: {
tokens: 1_000_000,
prompt_tokens: 75,
@@ -454,13 +425,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: {
fast: {
prompt_tokens: 350,
cached_tokens: 35,
completion_tokens: 2800,
},
},
costs: {
tokens: 1_000_000,
prompt_tokens: 175,
@@ -482,13 +446,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: {
fast: {
prompt_tokens: 250,
cached_tokens: 25,
completion_tokens: 2000,
},
},
costs: {
tokens: 1_000_000,
prompt_tokens: 125,
@@ -510,13 +467,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: {
fast: {
prompt_tokens: 425,
cached_tokens: 212.5,
completion_tokens: 1700,
},
},
costs: {
tokens: 1_000_000,
prompt_tokens: 250,
@@ -540,13 +490,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: {
fast: {
prompt_tokens: 25,
cached_tokens: 12.5,
completion_tokens: 100,
},
},
costs: {
tokens: 1_000_000,
prompt_tokens: 15,
@@ -566,13 +509,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: {
fast: {
prompt_tokens: 350,
cached_tokens: 87.5,
completion_tokens: 1400,
},
},
costs: {
tokens: 1_000_000,
prompt_tokens: 200,
@@ -594,13 +530,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: {
fast: {
prompt_tokens: 70,
cached_tokens: 17.5,
completion_tokens: 280,
},
},
costs: {
tokens: 1_000_000,
prompt_tokens: 40,
@@ -642,13 +571,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: {
fast: {
prompt_tokens: 425,
cached_tokens: 212.5,
completion_tokens: 1700,
},
},
costs: {
tokens: 1_000_000,
prompt_tokens: 250,
@@ -20,14 +20,15 @@
/**
* Offline unit tests for OpenRouterProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
* constructs OpenRouterProvider directly against the live wired
* `MeteringService` so the recording side is exercised end-to- end. OpenRouter
* is OpenAI-compatible, so the OpenAI SDK is mocked at the module boundary; the
* model catalog is fetched via `axios` which is mocked at its module boundary
* too. Both are the real network egress points. The companion integration test
* (OpenRouterProvider.integration.test.ts) exercises the real OpenRouter
* endpoint.
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs OpenRouterProvider directly against the live
* wired `MeteringService` so the recording side is exercised end-to-
* end. OpenRouter is OpenAI-compatible, so the OpenAI SDK is mocked
* at the module boundary; the model catalog is fetched via `axios`
* which is mocked at its module boundary too. Both are the real
* network egress points.
* The companion integration test (OpenRouterProvider.integration.test.ts)
* exercises the real OpenRouter endpoint.
*/
import { Writable } from 'node:stream';
@@ -299,20 +300,16 @@ describe('OpenRouterProvider model catalog', () => {
it('coerces string prices and normalizes conditional tiers', async () => {
const { provider } = makeProvider();
const models = await provider.models();
const sol = models.find(
(m) => m.id === 'openrouter:openai/gpt-6.1-sol',
)!;
const sol = models.find((m) => m.id === 'openrouter:openai/gpt-6.1-sol')!;
expect(sol.costs).toEqual({
tokens: 1_000_000,
prompt: 200,
completion: 1000,
});
expect(sol.pricingOverrides).toEqual([
{
minPromptTokens: 272000,
costs: { prompt: 400, completion: 1500 },
},
]);
expect(sol.pricingOverrides).toEqual([{
minPromptTokens: 272000,
costs: { prompt: 400, completion: 1500 },
}]);
});
it('caches the coerced model list after the first axios round-trip', async () => {
@@ -371,12 +368,9 @@ describe('OpenRouterProvider model catalog', () => {
it('does not reinterpret catalog-added timestamps as release dates', async () => {
const { provider } = makeProvider();
expect(
(await provider.models()).every(
(model) => model.release_date === undefined,
),
).toBe(true);
expect((await provider.models()).every(model => model.release_date === undefined)).toBe(true);
});
});
// ── Disallowed model gate ───────────────────────────────────────────
@@ -874,14 +868,11 @@ describe('OpenRouterProvider billed cost through the driver', () => {
event.factor = factor;
};
server.clients.event.on('ai.cost.factor.*', listener as never);
return () =>
server.clients.event.off('ai.cost.factor.*', listener as never);
return () => server.clients.event.off('ai.cost.factor.*', listener as never);
};
/** Μ¢ the ledger was handed for every usage recorded so far. */
const ledgerTotal = (
spy: MockInstance<MeteringService['batchIncrementUsages']>,
) =>
/** µ¢ the ledger was handed for every usage recorded so far. */
const ledgerTotal = (spy: MockInstance<MeteringService['batchIncrementUsages']>) =>
spy.mock.calls
.flatMap(([, usages]) => usages)
.reduce((total, usage) => total + (usage.costOverride ?? 0), 0);
@@ -937,10 +928,7 @@ describe('OpenRouterProvider billed cost through the driver', () => {
it('reports what a billed completion was charged, after the cost factor', async () => {
const driver = await makeDriver();
const stopFactor = withCostFactor(1.6);
const recorded = vi.spyOn(
server.services.metering,
'batchIncrementUsages',
);
const recorded = vi.spyOn(server.services.metering, 'batchIncrementUsages');
createMock.mockResolvedValueOnce({
choices: [
{
@@ -1035,10 +1023,7 @@ describe('OpenRouterProvider billed cost through the driver', () => {
it('ends a stream with what it was charged at the catalog rates, after the cost factor', async () => {
const driver = await makeDriver();
const stopFactor = withCostFactor(1.6);
const recorded = vi.spyOn(
server.services.metering,
'batchIncrementUsages',
);
const recorded = vi.spyOn(server.services.metering, 'batchIncrementUsages');
createMock.mockReturnValueOnce(
asAsyncIterable([
{ choices: [{ delta: { content: 'hello' } }] },
@@ -1141,180 +1126,31 @@ 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' }] },
});
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' });
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);
},
);
});
describe('OpenRouter service speed offerings', () => {
it('loads and caches tier prices while reserving for endpoint and context differences', async () => {
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 base = {
...(await provider.models())[0],
provider: 'openrouter',
};
axiosRequestMock.mockClear();
axiosRequestMock.mockResolvedValueOnce({
data: {
data: {
endpoints: [
{
service_tier: 'priority',
pricing: {
prompt: '0.000004',
completion: '0.00002',
input_cache_read: '0.0000002',
},
},
{
service_tier: 'priority',
pricing: {
prompt: '0.000005',
completion: '0.00001',
},
},
{
service_tier: 'ultrafast',
pricing: {
prompt: '0.000012',
completion: '0.00006',
overrides: [
{
min_prompt_tokens: 272000,
prompt: '0.000024',
completion: '0.00009',
},
],
},
},
{
service_tier: 'priority',
pricing: { prompt: 'invalid' },
},
],
},
},
});
const offers = await provider.serviceSpeedModels(base);
expect(offers.fast?.costs).toMatchObject({
prompt: 500,
completion: 2000,
input_cache_read: 20,
});
expect(offers.ultrafast?.costs).toMatchObject({
prompt: 2400,
completion: 9000,
});
expect(await provider.serviceSpeedModels(base)).toBe(offers);
expect(axiosRequestMock).toHaveBeenCalledTimes(1);
});
it('uses standard when the tier catalog cannot be fetched', async () => {
const { provider } = makeProvider();
const base = {
...(await provider.models())[0],
provider: 'openrouter',
};
axiosRequestMock.mockRejectedValueOnce(new Error('Unavailable'));
expect(await provider.serviceSpeedModels(base)).toEqual({});
});
});
describe('OpenRouter speed billing', () => {
it('requires an authoritative bill instead of charging the reservation ceiling', async () => {
const { selectServiceSpeed } =
await import('../../utils/serviceSpeed.js');
const { provider } = makeProvider();
const base = {
...(await provider.models())[0],
provider: 'openrouter',
};
const selected = selectServiceSpeed(
base,
{
model: base.id,
messages: [{ role: 'user', content: 'Hello' }],
serviceSpeed: 'fast',
},
() => undefined,
{
fast: {
...base,
costs: { ...base.costs, prompt: 500, completion: 2000 },
},
},
);
const completion = {
choices: [
{
index: 0,
message: { role: 'assistant', content: 'OK' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 10, completion_tokens: 1 },
};
createMock.mockResolvedValueOnce({
...completion,
service_tier: 'priority',
usage: { ...completion.usage, cost: undefined },
});
await expect(
withTestActor(() =>
provider.complete(selected.args, selected.model),
),
).rejects.toMatchObject({ legacyCode: 'model_pricing_unavailable' });
expect(recordSpy).not.toHaveBeenCalled();
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);
});
});
@@ -22,10 +22,8 @@ import { OpenAI } from 'openai';
import { HttpError, isHttpError } from '../../../../core/http/HttpError.js';
import { UpstreamResponseError } from '../../../util/upstreamErrors.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { IChatModel, ICompleteArguments, ModelCost } from '../../types.js';
import { BoundedTtlMap } from '../../../../util/boundedTtlMap.js';
import type { IChatModel, ICompleteArguments } from '../../types.js';
import { cachedRemoteCatalog } from '../../utils/cachedRemoteCatalog.js';
import { servedServiceSpeed } from '../../utils/serviceSpeed.js';
import { catalogPrice } from '../../utils/pricing.js';
import { mapOpenRouterPricing } from './pricing.js';
import { sdkClientOptions } from '../../utils/sdkClient.js';
@@ -66,75 +64,6 @@ const isPolicyBlock = (err: unknown): boolean => {
export class OpenRouterProvider extends OpenAICompatProvider {
#apiBaseUrl: string;
#speedCatalogs = new BoundedTtlMap<
string,
() => Promise<Partial<Record<'fast' | 'ultrafast', IChatModel>>>
>({ maxEntries: 256 });
async serviceSpeedModels(model: IChatModel) {
let load = this.#speedCatalogs.get(model.id);
if (!load) {
load = cachedRemoteCatalog({
name: 'OpenRouter speed catalog',
fallback: {} as Partial<
Record<'fast' | 'ultrafast', IChatModel>
>,
fetch: async (signal) => {
const id = model.id.replace(/^openrouter:/, '');
const response = await axios.request({
url: `${this.#apiBaseUrl}/models/${id}/endpoints`,
method: 'GET',
signal,
maxContentLength: 2 * 1024 * 1024,
});
const offers: Partial<
Record<'fast' | 'ultrafast', IChatModel>
> = {};
for (const endpoint of response.data?.data?.endpoints ??
[]) {
const speed =
endpoint.service_tier === 'ultrafast'
? 'ultrafast'
: ['priority', 'fast'].includes(
endpoint.service_tier,
)
? 'fast'
: undefined;
if (!speed || !endpoint.pricing) continue;
const pricing = mapOpenRouterPricing(endpoint.pricing);
if (
pricing.costs.prompt === undefined ||
pricing.costs.completion === undefined
)
continue;
// Reserve for the most expensive eligible endpoint and context tier.
const costs: ModelCost = {
...offers[speed]?.costs,
tokens: model.costs.tokens,
};
for (const rates of [
pricing.costs,
...pricing.overrides.map(
(override) => override.costs,
),
]) {
for (const [key, value] of Object.entries(rates))
costs[key] = Math.max(costs[key] ?? 0, value);
}
offers[speed] = {
...model,
costs,
pricingOverrides: [],
};
}
return offers;
},
});
this.#speedCatalogs.set(model.id, load);
}
return load();
}
constructor(config: ChatProviderConfig, meteringService: MeteringService) {
const apiBaseUrl = config.apiBaseUrl || 'https://openrouter.ai/api/v1';
super(meteringService, {
@@ -273,17 +202,8 @@ export class OpenRouterProvider extends OpenAICompatProvider {
return { ...params, usage: { include: true } };
}
protected override meteredUsage(source: UsageSource, model: IChatModel) {
protected override meteredUsage(source: UsageSource) {
const usage = source.usage as OpenrouterUsage;
if (
model.serviceSpeedPricing &&
servedServiceSpeed(source) !== 'standard' &&
catalogPrice(usage.cost) === undefined
) {
throw new HttpError(503, 'Service tier billing is unavailable', {
legacyCode: 'model_pricing_unavailable',
});
}
return {
usage: {
prompt:
@@ -20,12 +20,13 @@
/**
* Offline unit tests for XAIProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
* constructs XAIProvider directly against the live wired `MeteringService` so
* the recording side is exercised end-to-end. xAI is OpenAI-compatible so the
* OpenAI SDK is mocked at the module boundary; that's the real network egress
* point. The companion integration test (XAIProvider.integration.test.ts)
* exercises the real xAI endpoint.
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs XAIProvider directly against the live wired
* `MeteringService` so the recording side is exercised end-to-end.
* xAI is OpenAI-compatible so the OpenAI SDK is mocked at the module
* boundary; that's the real network egress point. The companion
* integration test (XAIProvider.integration.test.ts) exercises the
* real xAI endpoint.
*/
import { Writable } from 'node:stream';
@@ -932,7 +933,8 @@ describe('XAIProvider.complete non-stream output', () => {
// namespace, actor, and a costsOverride priced from
// models.ts (grok-4.6: prompt=200, completion=600, cached=50).
expect(recordSpy).toHaveBeenCalledTimes(1);
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
const [usage, actor, prefix, overrides] =
recordSpy.mock.calls[0]!;
expect(usage).toEqual({
prompt_tokens: 90,
completion_tokens: 50,
@@ -1020,7 +1022,8 @@ describe('XAIProvider.complete non-stream output', () => {
}),
);
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
const [usage, , , overrides] =
recordSpy.mock.calls[0]!;
expect(usage.cached_tokens).toBe(0);
// 0 cached tokens × any rate = 0 metered cost.
expect(overrides).toMatchObject({ cached_tokens: 0 });
@@ -1078,7 +1081,8 @@ describe('XAIProvider.complete streaming', () => {
// grok-build-0.1 costs: prompt=100, completion=200, cached=20.
expect(recordSpy).toHaveBeenCalledTimes(1);
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
const [, , prefix, overrides] =
recordSpy.mock.calls[0]!;
expect(prefix).toBe('xai:grok-build-0.1');
expect(overrides).toEqual({
prompt_tokens: 3 * 100,
@@ -1196,60 +1200,3 @@ describe('XAIProvider.checkModeration', () => {
);
});
});
describe('xAI service speed billing', () => {
it('records the reported Priority charge with cached input and reasoning', async () => {
const { selectServiceSpeed } =
await import('../../utils/serviceSpeed.js');
const { provider } = makeProvider();
const base = {
...(await provider.models()).find(
(entry) => entry.id === 'grok-4.7',
)!,
provider: 'xai',
};
const selected = selectServiceSpeed(
base,
{
model: base.id,
messages: [{ role: 'user', content: 'Hello' }],
serviceSpeed: 'ultrafast',
},
() => undefined,
);
createMock.mockResolvedValueOnce({
choices: [
{
index: 0,
message: { role: 'assistant', content: 'OK' },
finish_reason: 'stop',
},
],
service_tier: 'priority',
usage: {
prompt_tokens: 1247,
completion_tokens: 1,
total_tokens: 1282,
prompt_tokens_details: { cached_tokens: 1152 },
completion_tokens_details: { reasoning_tokens: 34 },
cost_in_usd_ticks: 19520000,
},
});
const result = await withTestActor(() =>
provider.complete(selected.args, selected.model),
);
expect(result).toMatchObject({
usageDetails: {
serviceSpeed: 'fast',
outputTokens: 35,
reasoningTokens: 34,
},
});
expect(recordSpy).toHaveBeenCalledWith(
expect.objectContaining({ billedUsage: 1, completion_tokens: 35 }),
expect.anything(),
'xai:grok-4.7',
expect.objectContaining({ billedUsage: 195200 }),
);
});
});
@@ -19,8 +19,6 @@
import { OpenAI } from 'openai';
import { ChatCompletionCreateParams } from 'openai/resources/index.js';
import { catalogPrice } from '../../utils/pricing.js';
import { servedServiceSpeed } from '../../utils/serviceSpeed.js';
import { Context } from '../../../../core/context.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
@@ -76,15 +74,7 @@ export class XAIProvider implements IChatProvider {
}
async complete(
{
messages,
stream,
model,
tools,
max_tokens,
service_tier,
serviceSpeed: requestedServiceSpeed,
}: ICompleteArguments,
{ messages, stream, model, tools, max_tokens }: ICompleteArguments,
resolved?: IChatModel,
): Promise<IChatCompleteResult> {
const actor = Context.get('actor');
@@ -98,7 +88,6 @@ export class XAIProvider implements IChatProvider {
{
messages,
model: modelUsed.id,
...(service_tier !== undefined ? { service_tier } : {}),
...(tools ? { tools } : {}),
...(max_tokens !== undefined ? { max_tokens } : {}),
stream,
@@ -118,49 +107,18 @@ export class XAIProvider implements IChatProvider {
}
return OpenAIUtil.handle_completion_output({
usage_calculator: ({
usage,
service_tier: servedTier,
setUsageCosts,
setUsageDetails,
}) => {
const serviceSpeed = servedServiceSpeed({
service_tier: servedTier,
});
usage_calculator: ({ usage, setUsageCosts }) => {
// xAI reports cached reads inside `prompt_tokens`; bill them
// only at the cached rate.
const metered = OpenAIUtil.extractMeteredUsage(usage);
const reasoningTokens =
usage.completion_tokens_details?.reasoning_tokens ?? 0;
const trackedUsage = OpenAIUtil.splitCachedPrompt(usage);
trackedUsage.completion_tokens += reasoningTokens;
const reportedTicks = catalogPrice(
(usage as typeof usage & { cost_in_usd_ticks?: number })
.cost_in_usd_ticks,
);
const { costs } = meterChatUsage(
this.#meteringService,
actor,
this.meteringModelKey(modelUsed.id),
modelUsed,
trackedUsage,
{
serviceSpeed,
authoritativeUsd:
reportedTicks === undefined
? undefined
: reportedTicks / 10_000_000_000,
},
OpenAIUtil.splitCachedPrompt(usage),
);
setUsageCosts(costs);
if (requestedServiceSpeed !== undefined)
setUsageDetails?.({
...OpenAIUtil.usageDetailsFromTrackedUsage(
trackedUsage,
)!,
serviceSpeed,
...(reasoningTokens ? { reasoningTokens } : {}),
});
return metered;
},
stream,
@@ -42,7 +42,6 @@ export const XAI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 2 },
costs: {
tokens: 1_000_000,
prompt_tokens: 200,
@@ -66,7 +65,6 @@ export const XAI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 2 },
costs: {
tokens: 1_000_000,
prompt_tokens: 200,
@@ -89,7 +87,6 @@ export const XAI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 2 },
costs: {
tokens: 1_000_000,
prompt_tokens: 200,
@@ -112,7 +109,6 @@ export const XAI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 2 },
costs: {
tokens: 1_000_000,
prompt_tokens: 125,
@@ -155,7 +151,6 @@ export const XAI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 2 },
costs: {
tokens: 1_000_000,
prompt_tokens: 125,
@@ -191,7 +186,6 @@ export const XAI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 2 },
costs: {
tokens: 1_000_000,
prompt_tokens: 125,
@@ -223,7 +217,6 @@ export const XAI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 2 },
costs: {
tokens: 1_000_000,
prompt_tokens: 125,
@@ -253,7 +246,6 @@ export const XAI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
serviceSpeedRates: { fast: 2 },
costs: {
tokens: 1_000_000,
prompt_tokens: 100,
+5 -36
View File
@@ -26,8 +26,6 @@
export type ModelCost = Record<string, number>;
export type ServiceSpeed = 'standard' | 'fast' | 'ultrafast';
export interface ModelModalities {
input: string[];
output: string[];
@@ -69,22 +67,6 @@ export interface IChatModel<T extends ModelCost = ModelCost> extends Record<
output_multiplier: number;
};
pricingOverrides?: ModelPricingOverride[];
/**
* A token-rate multiplier or explicit per-key rates merged with standard
* costs.
*/
serviceSpeedRates?: Partial<
Record<'fast' | 'ultrafast', number | ModelCost>
>;
serviceSpeedVariants?: { fast?: string };
/** Request-local pricing snapshot; never part of the model catalog. */
serviceSpeedPricing?: {
speed: ServiceSpeed;
standardCosts: ModelCost;
standardOverrides?: ModelPricingOverride[];
fastCosts?: ModelCost;
ultrafastCosts?: ModelCost;
};
context?: number;
max_tokens: number;
subscriberOnly?: boolean;
@@ -94,8 +76,6 @@ export interface IChatModel<T extends ModelCost = ModelCost> extends Record<
tool_call?: boolean;
responses_api?: boolean;
responses_api_only?: boolean;
/** Restricts service speeds for deployments with a limited API dialect. */
serviceSpeeds?: ServiceSpeed[];
/**
* Omitted preserves sampling; reasoningDisabled requires explicit effort
* none.
@@ -183,7 +163,6 @@ export interface UsageDetails {
webFetchRequests?: number;
speed?: 'fast' | 'standard';
serviceTier?: string;
serviceSpeed?: ServiceSpeed;
/**
* Per-pass breakdown when the upstream ran several passes (compaction,
* advisor).
@@ -219,7 +198,6 @@ export interface ICompleteArguments {
* request on another provider that serves the same model.
*/
fallback?: boolean;
serviceSpeed?: ServiceSpeed;
stream?: boolean;
model: string;
test_mode?: boolean;
@@ -239,7 +217,10 @@ export interface ICompleteArguments {
taskBudget?: { total: number; remaining?: number };
/** Claude only: top-level automatic `cache_control`. */
cacheControl?: CacheControl;
/** Claude only; fast is forwarded when the catalog declares fast rates. */
/**
* Claude only; forwarded only when the model's catalog entry has
* `fast_output_tokens`.
*/
speed?: 'fast' | 'standard';
/** Claude only. */
safeguards?: SafeguardRequest[];
@@ -279,15 +260,7 @@ export interface ICompleteArguments {
truncation?: 'auto' | 'disabled' | undefined;
background?: boolean;
service_tier?:
| 'auto'
| 'default'
| 'flex'
| 'scale'
| 'priority'
| 'fast'
| 'ultrafast'
| 'standard'
| undefined;
'auto' | 'default' | 'flex' | 'scale' | 'priority' | undefined;
max_tokens?: number;
temperature?: number;
reasoning?: { effort: ReasoningEffort } | undefined;
@@ -355,10 +328,6 @@ export interface IChatMessageResult {
export type IChatCompleteResult = IChatStreamResult | IChatMessageResult;
export interface IChatProvider {
/** Account/catalog-specific speed offerings, fetched before the credit hold. */
serviceSpeedModels?(
model: IChatModel,
): Promise<Partial<Record<'fast' | 'ultrafast', IChatModel>>>;
models(extra_params?: unknown): IChatModel[] | Promise<IChatModel[]>;
getDefaultModel(): string;
/**
@@ -650,15 +650,12 @@ export const create_chat_stream_handler =
block.end();
};
let serviceTier;
let last_usage = null;
let last_extra_content = null;
let finish_reason = null;
for await (let chunk of completion) {
await chatStream.waitForDrain();
chunk = deviations.chunk_but_like_actually(chunk);
if (chunk.service_tier !== undefined)
serviceTier = chunk.service_tier;
const chunk_usage = deviations.index_usage_from_stream_chunk(chunk);
if (chunk_usage) last_usage = chunk_usage;
if (chunk.choices.length < 1) continue;
@@ -749,7 +746,6 @@ export const create_chat_stream_handler =
const usage = last_usage
? usage_calculator({
usage: last_usage,
service_tier: serviceTier,
extra_content: last_extra_content,
setUsageCosts: (c) => chatStream.setUsageCosts(c),
setUsageDetails: (d) => {
@@ -783,7 +779,6 @@ export const create_chat_stream_handler_responses_api =
const message = chatStream.message();
const textblock = message.contentBlock({ type: 'text' });
let serviceTier;
let last_usage = null;
let completed = false;
let sawFunctionCall = false;
@@ -792,8 +787,6 @@ export const create_chat_stream_handler_responses_api =
let toolUsage = null;
for await (const chunk of completion) {
await chatStream.waitForDrain();
if (chunk.response?.service_tier !== undefined)
serviceTier = chunk.response.service_tier;
if (chunk.type === 'response.failed' || chunk.type === 'error') {
throw new Error(
chunk.response?.error?.message ??
@@ -925,7 +918,6 @@ export const create_chat_stream_handler_responses_api =
const usage = last_usage
? usage_calculator({
usage: last_usage,
service_tier: serviceTier,
webSearchCalls,
tool_usage: toolUsage,
setUsageCosts: (c) => chatStream.setUsageCosts(c),
@@ -980,7 +972,6 @@ export const handle_completion_output = async (
/**
* @type {Record<string, unknown> & {
* usage_calculator: (args: {
* service_tier?: string;
* usage: import('openai/resources/completions.mjs').CompletionUsage;
* setUsageCosts: (costs: Record<string, number>) => void;
* setUsageDetails: (
@@ -1075,7 +1066,6 @@ export const handle_completion_output = async (
* @param {((text: string) => Promise<{ flagged: boolean }>) | undefined} [params.moderate]
* @param {(args: {
* usage: import('openai/resources/responses/responses.mjs').ResponseUsage;
* service_tier?: string;
* webSearchCalls?: number;
* tool_usage?: Record<string, unknown>;
* setUsageCosts?: (costs: Record<string, number>) => void;
@@ -19,15 +19,13 @@
import type { Actor } from '../../../core/actor.js';
import type { MeteringService } from '../../../services/metering/MeteringService.js';
import type { IChatModel, ServiceSpeed } from '../types.js';
import { serviceSpeedBillingModel } from './serviceSpeed.js';
import type { IChatModel } from '../types.js';
import { buildCostsOverride } from './pricing.js';
/** Microcents per US dollar. */
const MICROCENTS_PER_USD = 100_000_000;
export interface MeterChatUsageOptions {
serviceSpeed?: ServiceSpeed;
/**
* What the upstream says it billed, in USD. Recorded as one `billedUsage`
* line instead of pricing the tokens, which are still recorded at zero.
@@ -79,15 +77,10 @@ export const meterChatUsage = (
authoritativeUsd,
costOverrides,
pricingTime,
serviceSpeed = 'standard',
}: MeterChatUsageOptions = {},
): { usage: Record<string, number>; costs: Record<string, number> } => {
const priced = {
...buildCostsOverride(
usage,
serviceSpeedBillingModel(model, serviceSpeed),
pricingTime,
),
...buildCostsOverride(usage, model, pricingTime),
...costOverrides,
};
if (authoritativeUsd !== undefined) {
@@ -1,150 +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.
*
* Puter 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 Puter. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, expect, it } from 'vitest';
import type { IChatModel, ServiceSpeed } from '../types.js';
import { ALIBABA_MODELS } from '../providers/alibaba/models.js';
import { AZURE_MODELS } from '../providers/azure/models.js';
import { BYTEPLUS_MODELS } from '../providers/byteplus/models.js';
import { CLAUDE_MODELS } from '../providers/claude/models.js';
import { DEEPSEEK_MODELS } from '../providers/deepseek/models.js';
import { GEMINI_MODELS } from '../providers/gemini/models.js';
import { GROQ_MODELS } from '../providers/groq/models.js';
import { HOONIFY_MODELS } from '../providers/hoonify/models.js';
import { META_MODELS } from '../providers/meta/models.js';
import { MINIMAX_MODELS } from '../providers/minimax/models.js';
import { MISTRAL_MODELS } from '../providers/mistral/models.js';
import { MOONSHOT_MODELS } from '../providers/moonshot/models.js';
import { OPEN_AI_MODELS } from '../providers/openai/models.js';
import { XAI_MODELS } from '../providers/xai/models.js';
import { ZAI_MODELS } from '../providers/zai/models.js';
import { shouldPresentAsOpenAI } from './normalizeToOpenAI.js';
import {
isServiceSpeedUnavailable,
lowerServiceSpeed,
selectServiceSpeed,
servedServiceSpeed,
serviceSpeedBillingModel,
} from './serviceSpeed.js';
const catalogs: Array<[string, IChatModel[]]> = [
['alibaba', ALIBABA_MODELS],
['azure-openai', AZURE_MODELS],
['byteplus', BYTEPLUS_MODELS],
['claude', CLAUDE_MODELS],
['deepseek', DEEPSEEK_MODELS],
['gemini', GEMINI_MODELS],
['groq', GROQ_MODELS],
['hoonify', HOONIFY_MODELS],
['meta', META_MODELS],
['minimax', MINIMAX_MODELS],
['mistral', MISTRAL_MODELS],
['moonshotai', MOONSHOT_MODELS],
['openai-completion', OPEN_AI_MODELS],
['openai-responses', OPEN_AI_MODELS],
['xai', XAI_MODELS],
['zai', ZAI_MODELS],
];
const speeds: ServiceSpeed[] = ['standard', 'fast', 'ultrafast'];
describe('service speed seeded fuzzing', () => {
it('preserves catalog, request, normalization, and billing invariants across model changes', () => {
let seed = 0x51eed;
const pick = <T>(values: T[]): T => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return values[seed % values.length]!;
};
const seenProviders = new Set<string>();
for (let index = 0; index < 10_000; index++) {
const [provider, catalog] = pick(catalogs);
seenProviders.add(provider);
const base = { ...structuredClone(pick(catalog)), provider };
const before = structuredClone(base);
Object.freeze(base.costs);
Object.freeze(base);
const args = Object.freeze({
model: base.id,
messages: [{ role: 'user', content: `Case ${index}` }],
serviceSpeed: pick([...speeds, undefined]),
normalize: pick([true, false, undefined]),
temperature: pick([0, 0.5, 1]),
service_tier: pick(['flex' as const, 'ultrafast' as const, undefined]),
speed: pick(['standard' as const, 'fast' as const, undefined]),
});
const selected = selectServiceSpeed(base, args, (id) => {
const variant = catalog.find((entry) => entry.id === id);
return variant && { ...variant, provider };
});
expect(base).toEqual(before);
expect(selected.args.messages).toBe(args.messages);
expect(selected.args.temperature).toBe(args.temperature);
expect(selected.model.provider).toBe(provider);
expect(selected.model.release_date).toBe(base.release_date);
expect(
shouldPresentAsOpenAI(selected.args, selected.model.release_date),
).toBe(shouldPresentAsOpenAI(args, base.release_date));
if (args.serviceSpeed === undefined) {
expect(selected.args).toBe(args);
expect(selected.model).toBe(base);
continue;
}
expect(speeds.indexOf(selected.speed)).toBeLessThanOrEqual(
speeds.indexOf(args.serviceSpeed),
);
expect(selected.args.serviceSpeed).toBe(selected.speed);
if (args.serviceSpeed === 'standard') {
expect(selected.args.speed).not.toBe('fast');
expect(selected.args.service_tier).not.toBe('ultrafast');
}
for (const cost of Object.values(selected.model.costs)) {
expect(Number.isFinite(cost)).toBe(true);
expect(cost).toBeGreaterThanOrEqual(0);
}
if (selected.model.serviceSpeedPricing) {
const settled = serviceSpeedBillingModel(selected.model, 'standard');
expect(settled.costs).toEqual(base.costs);
expect(settled.pricingOverrides).toEqual(base.pricingOverrides);
}
let speed = selected.speed;
for (let attempts = 0; speed !== 'standard'; attempts++) {
expect(attempts).toBeLessThan(2);
speed = lowerServiceSpeed(speed);
}
}
expect([...seenProviders].sort()).toEqual(catalogs.map(([provider]) => provider).sort());
});
it('handles malformed upstream tier and admission-error metadata without coercion or retries', () => {
const values = [null, false, 0, 429, '', 'unrelated', [], {},
['service_tier capacity unavailable'],
{ toString: null }, { toString: 'priority capacity unavailable' }];
for (const value of values) {
expect(isServiceSpeedUnavailable(value)).toBe(false);
for (const status of [undefined, 400, 401, 403, 404, 429, 500, 503]) {
for (const variant of [false, true]) {
expect(isServiceSpeedUnavailable({ status, message: value }, variant)).toBe(false);
expect(isServiceSpeedUnavailable({ status, error: { message: value } }, variant)).toBe(false);
}
}
expect(servedServiceSpeed({ usage: value })).toBe('standard');
expect(servedServiceSpeed({ service_tier: value })).toBe('standard');
expect(servedServiceSpeed({ usage: { speed: value, service_tier: value } })).toBe('standard');
}
});
});
@@ -1,148 +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 {
makeMeteringStub,
optionalEnv,
withTestActor,
} from '../../integrationTestUtil.js';
import type {
IChatMessageResult,
IChatProvider,
ICompleteArguments,
} from '../types.js';
import { OpenAiResponsesChatProvider } from '../providers/openai/OpenAiChatResponsesProvider.js';
import { GeminiChatProvider } from '../providers/gemini/GeminiChatProvider.js';
import { MiniMaxProvider } from '../providers/minimax/MiniMaxProvider.js';
import { MoonshotProvider } from '../providers/moonshot/MoonshotProvider.js';
import { MistralAIProvider } from '../providers/mistral/MistralAiProvider.js';
import { selectServiceSpeed } from './serviceSpeed.js';
type Case = {
name: string;
env: string;
provider: string;
model: string;
build: (key: string) => IChatProvider;
};
const cases: Case[] = [
{
name: 'OpenAI Ultrafast',
env: 'PUTER_TEST_AI_OPENAI_API_KEY',
provider: 'openai-responses',
model: 'gpt-6.1-sol',
build: (key) =>
new OpenAiResponsesChatProvider(
{ apiKey: key },
makeMeteringStub(),
),
},
{
name: 'Gemini Priority',
env: 'PUTER_TEST_AI_GEMINI_API_KEY',
provider: 'gemini',
model: 'gemini-3.8-flash',
build: (key) =>
new GeminiChatProvider({ apiKey: key }, makeMeteringStub()),
},
{
name: 'MiniMax Priority',
env: 'PUTER_TEST_AI_MINIMAX_API_KEY',
provider: 'minimax',
model: 'minimax-m3',
build: (key) =>
new MiniMaxProvider({ apiKey: key }, makeMeteringStub()),
},
{
name: 'Moonshot Highspeed',
env: 'PUTER_TEST_AI_MOONSHOT_API_KEY',
provider: 'moonshotai',
model: 'kimi-k3.1-pro',
build: (key) =>
new MoonshotProvider({ apiKey: key }, makeMeteringStub()),
},
{
name: 'Mistral served tier',
env: 'PUTER_TEST_AI_MISTRAL_API_KEY',
provider: 'mistral',
model: 'mistral-medium-latest',
build: (key) =>
new MistralAIProvider({ apiKey: key }, makeMeteringStub()),
},
];
for (const testCase of cases) {
describe.skipIf(!optionalEnv(testCase.env))(
`serviceSpeed ${testCase.name} (integration)`,
() => {
it(
'completes with a selected speed and normalized text',
{ timeout: 90_000 },
async () => {
const provider = testCase.build(optionalEnv(testCase.env)!);
const catalog = (await provider.models()).map((model) => ({
...model,
provider: testCase.provider,
}));
const base = catalog.find(
(model) =>
model.id === testCase.model ||
model.aliases?.includes(testCase.model),
)!;
expect(base).toBeDefined();
const args: ICompleteArguments = {
model: base.id,
messages: [
{ role: 'user', content: 'Reply with exactly OK.' },
],
serviceSpeed: 'ultrafast',
max_tokens: 256,
reasoning_effort: 'low',
normalize: true,
};
const selection = selectServiceSpeed(base, args, (id) =>
catalog.find((model) => model.id === id),
);
expect(selection.speed).toBe(
testCase.provider === 'openai-responses'
? 'ultrafast'
: 'fast',
);
const result = (await withTestActor(() =>
provider.complete(
{ ...selection.args, model: selection.model.id },
selection.model,
),
)) as IChatMessageResult;
expect(typeof result.message.content).toBe('string');
expect(result.message.content.length).toBeGreaterThan(0);
if (
['openai-responses', 'gemini', 'minimax'].includes(
testCase.provider,
)
)
expect(result.usageDetails?.serviceSpeed).toBe(
selection.speed,
);
},
);
},
);
}
@@ -1,368 +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 { Writable } from 'node:stream';
import { AIChatStream } from './Streaming.js';
import {
handle_completion_output,
handle_completion_output_responses_api,
} from './OpenAIUtil.js';
import type { IChatModel, ServiceSpeed } from '../types.js';
import {
selectServiceSpeed,
servedServiceSpeed,
serviceSpeedBillingModel,
isServiceSpeedUnavailable,
} from './serviceSpeed.js';
import { meterChatUsage } from './meterChatUsage.js';
import { OPEN_AI_MODELS } from '../providers/openai/models.js';
import { XAI_MODELS } from '../providers/xai/models.js';
import { GEMINI_MODELS } from '../providers/gemini/models.js';
import { CLAUDE_MODELS } from '../providers/claude/models.js';
const model = (provider: string, id: string): IChatModel => ({
provider,
id,
serviceSpeedRates: [...OPEN_AI_MODELS, ...XAI_MODELS, ...GEMINI_MODELS, ...CLAUDE_MODELS]
.find((entry) => entry.id === id)?.serviceSpeedRates,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
max_tokens: 1000,
costs: {
tokens: 1_000_000,
prompt_tokens: 200,
completion_tokens: 1000,
cached_tokens: 10,
web_search_requests: 1_000_000,
},
});
const request = (serviceSpeed?: ServiceSpeed) => ({
model: 'test',
messages: [{ role: 'user', content: 'Hello' }],
serviceSpeed,
});
const noVariant = () => undefined;
describe('service speed selection', () => {
it.each([2, { prompt_tokens: 400, completion_tokens: 2000 }])(
'uses catalog rates independently of provider and model names: %j',
(rates) => {
const entry = {
...model('catalog-provider', 'renamed-model'),
serviceSpeedRates: { fast: rates },
};
const selected = selectServiceSpeed(entry, request('ultrafast'), noVariant);
expect(selected.speed).toBe('fast');
expect(selected.model.costs.prompt_tokens).toBe(400);
expect(selected.model.costs.completion_tokens).toBe(2000);
expect(entry.costs.prompt_tokens).toBe(200);
},
);
it('preserves an omitted option and native parameters', () => {
const entry = model('openai-responses', 'gpt-6.1-sol');
const args = { ...request(), service_tier: 'flex' as const };
const selected = selectServiceSpeed(entry, args, noVariant);
expect(selected.model).toBe(entry);
expect(selected.args).toBe(args);
});
it.each([
'deepseek',
'zai',
'groq',
'byteplus',
'together-ai',
'meta',
'ollama',
'hoonify',
'neuralwatt',
'fake-chat',
])('uses standard when %s has no verified faster offering', (provider) => {
const selected = selectServiceSpeed(
model(provider, 'some-model'),
request('ultrafast'),
noVariant,
);
expect(selected.speed).toBe('standard');
expect(selected.args.service_tier).toBeUndefined();
});
it.each(['standard', 'fast', 'ultrafast'] as const)(
'selects %s without changing the model or catalog prices',
(speed) => {
const entry = model('openai-responses', 'gpt-6.1-sol');
const selected = selectServiceSpeed(
entry,
request(speed),
noVariant,
);
expect(selected.speed).toBe(speed);
expect(selected.model.id).toBe(entry.id);
expect(selected.model.costs.prompt_tokens).toBe(
200 * { standard: 1, fast: 2, ultrafast: 6 }[speed],
);
expect(selected.model.costs.web_search_requests).toBe(1_000_000);
expect(entry.costs.prompt_tokens).toBe(200);
},
);
it('downgrades ultrafast to fast on a model without ultrafast', () => {
const selected = selectServiceSpeed(
model('xai', 'grok-4.7'),
request('ultrafast'),
noVariant,
);
expect(selected.speed).toBe('fast');
expect(selected.args.service_tier).toBe('priority');
});
it('keeps a compatibility-only Gemini deployment at standard speed', () => {
const entry = {
...model('gemini', 'gemini-3.8-flash'),
serviceSpeeds: ['standard'] as ServiceSpeed[],
};
expect(
selectServiceSpeed(entry, request('ultrafast'), noVariant).speed,
).toBe('standard');
});
it('uses Claude speed independently of its service tier', () => {
const entry = model('claude', 'claude-opus-5-5');
expect(
selectServiceSpeed(entry, request('ultrafast'), noVariant).args
.speed,
).toBe('fast');
expect(
selectServiceSpeed(entry, request('ultrafast'), noVariant).args
.service_tier,
).toBeUndefined();
expect(
servedServiceSpeed({
usage: { speed: 'fast', service_tier: 'standard' },
}),
).toBe('fast');
});
it.each([
['minimax', 'minimax-m2.7', 'minimax-m2.7-highspeed'],
['moonshotai', 'kimi-k3.1-pro', 'kimi-k3.1-pro-highspeed'],
['alibaba', 'glm-5.3', 'glm-5.3-prime'],
['alibaba', 'glm-5.2', 'glm-5.2-fast-preview'],
['infron', 'infron:vendor/model', 'infron:vendor/model:priority'],
])(
'selects an equivalent %s variant and preserves normalization policy',
(provider, id, variantId) => {
const base = {
...model(provider, id),
serviceSpeedVariants: { fast: variantId },
release_date: '2026-08-01',
};
const variant = {
...model(provider, variantId),
release_date: '2026-10-01',
};
const selected = selectServiceSpeed(
base,
request('ultrafast'),
(candidate) => (candidate === variantId ? variant : undefined),
);
expect(selected.model.id).toBe(variantId);
expect(selected.model.release_date).toBe(base.release_date);
expect(selected.speed).toBe('fast');
expect(
selectServiceSpeed(base, request('fast'), noVariant).speed,
).toBe('standard');
},
);
it('keeps caller media on Gemini compatibility when native speed cannot carry it', () => {
const args = {
...request('fast'),
messages: [
{
role: 'user',
content: [
{
type: 'video_url',
video_url: { url: 'data:video/mp4;base64,AA==' },
},
],
},
],
};
expect(
selectServiceSpeed(
model('gemini', 'gemini-3.8-flash'),
args,
noVariant,
).speed,
).toBe('standard');
});
});
describe('served speed billing', () => {
it.each(['standard', 'fast', 'ultrafast'] as const)(
'settles an ultrafast hold at the served %s rate',
(speed) => {
const selected = selectServiceSpeed(
model('openai-responses', 'gpt-6.1-sol'),
request('ultrafast'),
noVariant,
).model;
const recorded: Array<Record<string, number> | undefined> = [];
const meter = {
utilRecordUsageObject: (
_usage: Record<string, number>,
_actor: unknown,
_key: string,
costs?: Record<string, number>,
) => {
recorded.push(costs);
},
};
const result = meterChatUsage(
meter as never,
undefined,
'openai:gpt-6.1-sol',
selected,
{ prompt_tokens: 10, completion_tokens: 5, cached_tokens: 20 },
{ serviceSpeed: speed },
);
expect(result.costs.prompt_tokens).toBe(
2000 * { standard: 1, fast: 2, ultrafast: 6 }[speed],
);
expect(result.costs.cached_tokens).toBe(
200 * { standard: 1, fast: 2, ultrafast: 6 }[speed],
);
expect(recorded).toEqual([result.costs]);
},
);
it('restores standard scheduled and long-context rates after a downgrade', () => {
const entry = model('openai-responses', 'gpt-6.1-sol');
entry.pricingOverrides = [
{
minPromptTokens: 100,
costs: { prompt_tokens: 400, completion_tokens: 1500 },
},
];
const selected = selectServiceSpeed(
entry,
request('ultrafast'),
noVariant,
).model;
expect(
serviceSpeedBillingModel(selected, 'standard').pricingOverrides,
).toEqual(entry.pricingOverrides);
expect(
serviceSpeedBillingModel(selected, 'fast').pricingOverrides?.[0]
.costs.prompt_tokens,
).toBe(800);
});
it.each([
[{ service_tier: 'priority' }, 'fast'],
[{ usage: { serviceTier: 'priority' } }, 'fast'],
[{ usage: { service_tier: 'standard' } }, 'standard'],
[{ service_tier: 'ultrafast' }, 'ultrafast'],
[{}, 'standard'],
])('reads actual speed from %j', (source, speed) => {
expect(servedServiceSpeed(source)).toBe(speed);
});
});
describe('speed admission failures', () => {
it.each([
[{ status: 429, message: 'Fast mode rate limit exceeded' }, true],
[{ status: 403, message: 'Ultrafast access unavailable' }, true],
[{ status: 400, message: 'service_tier is not supported' }, true],
[{ status: 401, message: 'Invalid API key' }, false],
[{ status: 429, message: 'Rate limit exceeded' }, false],
[{ status: 400, message: 'Invalid messages' }, false],
])('classifies %j without swallowing unrelated errors', (error, retry) => {
expect(isServiceSpeedUnavailable(error)).toBe(retry);
});
it('downgrades a missing speed variant without retrying an invalid base model', () => {
const error = { status: 404, message: 'Model not found' };
expect(isServiceSpeedUnavailable(error, true)).toBe(true);
expect(isServiceSpeedUnavailable(error)).toBe(false);
});
});
describe('streamed service tier metadata', () => {
it('retains a tier from an earlier Chat Completions chunk until usage arrives', async () => {
let servedTier: string | undefined;
const result = await handle_completion_output({
stream: true,
completion: (async function* () {
yield {
service_tier: 'priority',
choices: [{ index: 0, delta: { content: 'OK' } }],
};
yield {
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
};
yield {
choices: [],
usage: { prompt_tokens: 10, completion_tokens: 1 },
};
})(),
usage_calculator: (source) => {
servedTier = source.service_tier;
return source.usage;
},
});
const chatStream = new AIChatStream({
stream: new Writable({
write(_chunk, _encoding, callback) {
callback();
},
}),
});
await result.init_chat_stream!({ chatStream });
expect(servedTier).toBe('priority');
});
it('uses the final Responses tier when the provider downgrades a stream', async () => {
let servedTier: string | undefined;
const result = await handle_completion_output_responses_api({
stream: true,
completion: (async function* () {
yield {
type: 'response.created',
response: { service_tier: 'ultrafast' },
};
yield { type: 'response.output_text.delta', delta: 'OK' };
yield {
type: 'response.completed',
response: {
service_tier: 'default',
usage: { input_tokens: 10, output_tokens: 1 },
},
};
})(),
usage_calculator: (source) => {
servedTier = source.service_tier;
return source.usage;
},
});
const chatStream = new AIChatStream({
stream: new Writable({
write(_chunk, _encoding, callback) {
callback();
},
}),
});
await result.init_chat_stream!({ chatStream });
expect(servedTier).toBe('default');
});
});
@@ -1,261 +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 { requiredInputModalities } from './mediaParts.js';
import type {
IChatModel,
ICompleteArguments,
ModelCost,
ServiceSpeed,
} from '../types.js';
export const lowerServiceSpeed = (speed: ServiceSpeed): ServiceSpeed =>
speed === 'ultrafast' ? 'fast' : 'standard';
const tokenRates = (costs: ModelCost, multiplier: number): ModelCost =>
Object.fromEntries(
Object.entries(costs).map(([key, value]) => [
key,
key !== 'tokens' &&
/token|^prompt$|^completion$|^input_cache_/.test(key)
? value * multiplier
: value,
]),
);
export const serviceSpeedCosts = (
model: IChatModel,
speed: 'fast' | 'ultrafast',
): ModelCost | undefined => {
if (model.serviceSpeeds && !model.serviceSpeeds.includes(speed))
return undefined;
const rates = model.serviceSpeedRates?.[speed];
return typeof rates === 'number'
? tokenRates(model.costs, rates)
: rates
? { ...model.costs, ...rates }
: undefined;
};
export interface ServiceSpeedSelection {
model: IChatModel;
args: ICompleteArguments;
speed: ServiceSpeed;
}
/** Select only an equivalent model from the same provider's loaded catalog. */
export const selectServiceSpeed = (
model: IChatModel,
args: ICompleteArguments,
findVariant: (id: string) => IChatModel | undefined,
offers: Partial<Record<'fast' | 'ultrafast', IChatModel>> = {},
): ServiceSpeedSelection => {
if (args.serviceSpeed === undefined)
return { model, args, speed: 'standard' };
const provider = model.provider;
const fast =
offers.fast?.costs ??
(provider === 'gemini' &&
(requiredInputModalities(args.messages).some(
(modality) => !['text', 'image'].includes(modality),
) ||
args.messages.some(
(message) =>
Array.isArray(message.content) &&
message.content.some(
(part: Record<string, unknown>) =>
part &&
[
'input_audio',
'audio_url',
'document',
'file',
'input_file',
].includes(String(part.type)),
),
))
? undefined
: serviceSpeedCosts(model, 'fast'));
const ultra =
offers.ultrafast?.costs ?? serviceSpeedCosts(model, 'ultrafast');
let speed = args.serviceSpeed;
const variantId = model.serviceSpeedVariants?.fast;
const variant =
offers.fast && offers.fast.id !== model.id
? offers.fast
: variantId
? findVariant(variantId)
: undefined;
while (speed !== 'standard') {
if (speed === 'ultrafast' && ultra) break;
if (speed === 'fast' && (fast || variant)) break;
speed = lowerServiceSpeed(speed);
}
const request: ICompleteArguments = {
...args,
serviceSpeed: speed,
speed: undefined,
service_tier: undefined,
};
if (speed === 'fast' && variant) {
return {
model: { ...variant, release_date: model.release_date },
args: request,
speed,
};
}
if (provider === 'gemini' && fast)
request.service_tier = speed === 'fast' ? 'priority' : 'standard';
else if (provider === 'claude')
request.speed = speed === 'fast' ? 'fast' : 'standard';
else if (provider === 'mistral')
request.service_tier = speed === 'fast' ? 'auto' : 'default';
else if (
provider === 'minimax' &&
model.serviceSpeedRates?.fast !== undefined
)
request.service_tier = speed === 'fast' ? 'priority' : 'standard';
else if (fast || ultra)
request.service_tier =
speed === 'ultrafast'
? 'ultrafast'
: speed === 'fast'
? 'priority'
: 'default';
if (
speed === 'standard' &&
[
'openai-completion',
'openai-responses',
'azure-openai',
'azure-openai-responses',
'openrouter',
].includes(provider ?? '')
)
request.service_tier = 'default';
const selectedCosts =
speed === 'ultrafast' ? ultra : speed === 'fast' ? fast : undefined;
return {
args: request,
speed,
model: selectedCosts
? {
...model,
costs: selectedCosts,
pricingOverrides: model.pricingOverrides?.map((override) => ({
...override,
costs: Object.fromEntries(
Object.entries(override.costs).map(([key, value]) => [
key,
model.costs[key] > 0
? (value *
(selectedCosts[key] ??
model.costs[key])) /
model.costs[key]
: value,
]),
),
})),
serviceSpeedPricing: {
speed,
standardCosts: model.costs,
standardOverrides: model.pricingOverrides,
fastCosts: fast,
ultrafastCosts: ultra,
},
}
: model,
};
};
/** The response is authoritative; an absent tier never earns a premium. */
export const servedServiceSpeed = (
source: Record<string, unknown>,
): ServiceSpeed => {
const usage = source.usage as Record<string, unknown> | undefined;
const tier =
usage?.speed ??
usage?.serviceTier ??
usage?.service_tier ??
source.service_tier;
return tier === 'ultrafast'
? 'ultrafast'
: tier === 'fast' || tier === 'priority'
? 'fast'
: 'standard';
};
export const serviceSpeedBillingModel = (
model: IChatModel,
speed: ServiceSpeed,
): IChatModel => {
const pricing = model.serviceSpeedPricing;
if (!pricing || pricing.speed === speed) return model;
const costs =
speed === 'fast'
? (pricing.fastCosts ?? pricing.standardCosts)
: pricing.standardCosts;
return {
...model,
costs,
pricingOverrides: pricing.standardOverrides?.map((override) => ({
...override,
costs: Object.fromEntries(
Object.entries(override.costs).map(([key, value]) => [
key,
pricing.standardCosts[key] > 0
? (value * (costs[key] ?? pricing.standardCosts[key])) /
pricing.standardCosts[key]
: value,
]),
),
})),
};
};
/** Retry only failures that explicitly concern admission to the faster tier. */
export const isServiceSpeedUnavailable = (
error: unknown,
variant = false,
): boolean => {
const e = error as {
status?: number;
statusCode?: number;
message?: unknown;
error?: { message?: unknown };
};
const status = e?.status ?? e?.statusCode;
const message = e?.error?.message ?? e?.message ?? '';
if (typeof message !== 'string') return false;
if (
variant &&
[400, 404].includes(status ?? 0) &&
/model.*(?:not found|not exist|unavailable)/i.test(message)
)
return true;
return (
[400, 403, 429, 503].includes(status ?? 0) &&
/fast mode|ultrafast|service[_ ]?tier|priority (?:tier|processing|capacity)|speed parameter/i.test(
message,
) &&
/unavailable|unsupported|not support|rate.limit|quota|capacity|not enabled|not allowed|access|exceed|invalid value/i.test(
message,
)
);
};
-63
View File
@@ -30,7 +30,6 @@ An object containing the following properties:
| Option | Type | Description |
|--------|------|-------------|
| `model` | `String` | The model you want to use for the completion. If not specified, defaults to `gpt-5.4-nano`. More than 500 models are available from providers including OpenAI, Anthropic, Google, Alibaba Cloud, xAI, Mistral, OpenRouter, Infron, and others. For a full list, see the [AI models list](https://developer.puter.com/ai/models/) page. |
| `serviceSpeed` | `String` | Preferred service speed, `"standard"`, `"fast"`, or `"ultrafast"`. Unavailable speeds fall back from ultrafast to fast, then standard. Omitting it preserves the model's default service. Faster service may cost more. See [Service speed](#service-speed) and [Model-specific features](#model-specific-features). |
| `stream` | `Boolean` | A boolean indicating whether you want to stream the completion. Defaults to `false`. |
| `max_tokens` | `Number` | The maximum number of tokens to generate in the completion. By default, the specific model's maximum is used. |
| `temperature` | `Number` | A number between 0 and 2 indicating the randomness of the completion. Lower values make the output more focused and deterministic, while higher values make it more random. By default, the model's own temperature is used. See [Model-specific features](#model-specific-features). |
@@ -139,8 +138,6 @@ by a newline. Use the async iterator to receive every chunk as an object.
In case of an error, the `Promise` will reject with an error message.
An unrecognized `serviceSpeed` value rejects with `invalid_service_speed`.
For streaming responses, an error from the AI provider is returned as a chunk
with `type: "error"` and a `message` describing the failure. This also happens
if the provider stops sending the response before it is complete. Check for
@@ -373,20 +370,10 @@ const response = await puter.ai.chat('Hello', {
With `provider` set as well, the request is answered by that provider or fails. If that provider does not serve the model, the request fails with a `400` instead of going to another provider. The error is the same one you would get if every provider had failed, for example `400 upstream_provider_unavailable` or `429 upstream_rate_limited`.
## Service speed
The `serviceSpeed` option selects service capacity independently of reasoning effort. If the requested speed is unavailable for the selected model, the request uses the next supported speed in the order `ultrafast`, `fast`, `standard`.
A faster tier that rejects a request because its capacity or access is unavailable can retry at a lower speed on the same provider. The `fallback` option controls retries on other providers separately. Once streaming output starts, a failed stream ends with an error instead of restarting at another speed.
The `usageDetails.serviceSpeed` field reports the served speed. Billing uses the tier or equivalent model that served the request. A provider downgrade is billed at the lower tier's rates. Response normalization follows the same rule as a request without this option, including when a faster equivalent model is selected.
## Model-specific features
### OpenAI models
Fast service is available on models with a published Fast rate card. Ultrafast is available for GPT-6.1 Sol and GPT-6 Astra. Other models fall back to fast or standard. Cache and long-context rates apply to the served tier.
#### `temperature`
Some OpenAI reasoning models ignore `temperature` and `top_p`. GPT-5.6, GPT-6 Sol/Luna, and GPT-6.1 Terra accept them only with `reasoning_effort: 'none'`, and GPT-6 Astra, GPT-6.1 Sol, and GPT-5.3 Codex always ignore them.
@@ -431,8 +418,6 @@ The `usageDetails` object on every result carries the same counts under provider
### Anthropic models
Fast service is available on Claude models with a Fast rate card. Ultrafast falls back to fast. When the account has no Fast quota, the request retries at standard speed. The upstream service tier and inference speed are separate fields.
#### Prompt Caching
Specific to Anthropic models, you can use the cache control feature, allowing you to optimize costs for repeated prompts.
@@ -457,8 +442,6 @@ You can find the implementation in our [prompt caching example](/playground/ai-c
### Gemini models
Fast service uses Priority inference on supported text and image requests. Other media use standard service to preserve input handling.
#### `temperature`
`gemini-4-argon` ignores `temperature`. `gemini-3.1-flash-lite` and `gemini-3-flash-preview` use it.
@@ -560,8 +543,6 @@ for await (const part of resp) {
### Moonshot Kimi models
Models with an equivalent Highspeed variant select that variant for fast service. Other models use standard service.
#### `reasoning_effort`
Kimi K3.1 Pro accepts `low`, `high` and `max`, uses `max` when you leave it unset, and fails with `400 bad_request` on any other value.
@@ -576,30 +557,6 @@ Moonshot's Kimi K3 and Kimi K3.1 Pro models cache prompt prefixes on their own a
Muse Spark always reasons, so `none` is ignored for it.
### xAI models
Fast service requests Priority processing. Token charges, including cached input and reasoning, follow the served tier.
### MiniMax models
MiniMax M3 uses Priority admission for fast service. Earlier models with an equivalent Highspeed variant use that variant. Other models use standard service.
### Mistral models
Fast service requests Priority when the account and model are eligible. The provider can serve the request at standard speed.
### Alibaba Cloud models
Models with an equivalent Prime or Fast variant select that variant for fast service. Other models use standard service.
### OpenRouter models
Faster tiers come from the selected model's endpoint catalog. A tier can fall back upstream. Billing uses the endpoint that serves the request.
### Infron models
Models with a Priority offering select that offering for fast service. Other models use standard service.
## Examples
### Ask GPT-6 Luna a question
@@ -1135,23 +1092,3 @@ Policy 8 - Account Management: Each Enterprise and Ultimate customer is assigned
</body>
</html>
```
### Select service speed
For example, request the fastest supported service:
```html;ai-chat-service-speed
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script type="module">
const response = await puter.ai.chat('Explain closures in one paragraph.', {
model: 'gpt-6.1-sol',
serviceSpeed: 'ultrafast',
});
puter.print(response.message.content);
puter.print(response.usageDetails.serviceSpeed);
</script>
</body>
</html>
```
+1 -1
View File
@@ -64,7 +64,7 @@ Extra detail on why generation stopped (e.g. a refusal classification). Provider
#### `usageDetails` (Object)
A provider-neutral usage breakdown, present alongside `usage` on every result. `inputTokens` excludes cache reads/writes; `outputTokens` includes reasoning tokens. Fields: `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWrite5mTokens`, `cacheWrite30mTokens`, `cacheWrite1hTokens`, `cacheWrite12hTokens`, `reasoningTokens`, `webSearchRequests`, `webFetchRequests`, `speed` (`"fast"` or `"standard"`), `serviceTier`, `serviceSpeed` (`"standard"`, `"fast"`, or `"ultrafast"` when requested), and `iterations` (a per-pass breakdown when the upstream ran several passes, e.g. compaction or an advisor sub-call). Each `cacheWrite*Tokens` field counts the tokens written to the cache with that lifetime: Claude and Moonshot write 5 minute and 1 hour entries, OpenAI writes 30 minute and 12 hour entries.
A provider-neutral usage breakdown, present alongside `usage` on every result. `inputTokens` excludes cache reads/writes; `outputTokens` includes reasoning tokens. Fields: `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWrite5mTokens`, `cacheWrite30mTokens`, `cacheWrite1hTokens`, `cacheWrite12hTokens`, `reasoningTokens`, `webSearchRequests`, `webFetchRequests`, `speed` (`"fast"` or `"standard"`), `serviceTier`, and `iterations` (a per-pass breakdown when the upstream ran several passes, e.g. compaction or an advisor sub-call). Each `cacheWrite*Tokens` field counts the tokens written to the cache with that lifetime: Claude and Moonshot write 5 minute and 1 hour entries, OpenAI writes 30 minute and 12 hour entries.
Prefer `usageDetails` over `usage` for anything beyond the raw billing totals. Claude's `usage.thinking_tokens` is informational: it's included in `output_tokens` and not billed separately — `usageDetails.reasoningTokens` carries the same count.
+1 -1
View File
@@ -102,7 +102,7 @@ Extra detail on why generation stopped (e.g. a refusal classification). Provider
#### `usageDetails` (Object)
A provider-neutral usage breakdown, present on the final `usage` chunk. `inputTokens` excludes cache reads/writes; `outputTokens` includes reasoning tokens. Fields: `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWrite5mTokens`, `cacheWrite30mTokens`, `cacheWrite1hTokens`, `cacheWrite12hTokens`, `reasoningTokens`, `webSearchRequests`, `webFetchRequests`, `speed` (`"fast"` or `"standard"`), `serviceTier`, `serviceSpeed` (`"standard"`, `"fast"`, or `"ultrafast"` when requested), and `iterations` (a per-pass breakdown when the upstream ran several passes, e.g. compaction or an advisor sub-call). Each `cacheWrite*Tokens` field counts the tokens written to the cache with that lifetime: Claude and Moonshot write 5 minute and 1 hour entries, OpenAI writes 30 minute and 12 hour entries.
A provider-neutral usage breakdown, present on the final `usage` chunk. `inputTokens` excludes cache reads/writes; `outputTokens` includes reasoning tokens. Fields: `inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWrite5mTokens`, `cacheWrite30mTokens`, `cacheWrite1hTokens`, `cacheWrite12hTokens`, `reasoningTokens`, `webSearchRequests`, `webFetchRequests`, `speed` (`"fast"` or `"standard"`), `serviceTier`, and `iterations` (a per-pass breakdown when the upstream ran several passes, e.g. compaction or an advisor sub-call). Each `cacheWrite*Tokens` field counts the tokens written to the cache with that lifetime: Claude and Moonshot write 5 minute and 1 hour entries, OpenAI writes 30 minute and 12 hour entries.
#### `message` (String)
-6
View File
@@ -49,12 +49,6 @@ const examples = [
slug: 'ai-chatgpt',
source: '/playground/examples/ai-chatgpt.html',
},
{
title: 'Select Service Speed',
description: 'Request faster AI service and inspect the speed that served the response.',
slug: 'ai-chat-service-speed',
source: '/playground/examples/ai-chat-service-speed.html',
},
{
title: 'Image Analysis',
description: 'Analyze images with AI using Puter.js GPT Vision API. Run and modify this code example instantly in your browser.',
@@ -1,13 +0,0 @@
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script type="module">
const response = await puter.ai.chat('Explain closures in one paragraph.', {
model: 'gpt-6.1-sol',
serviceSpeed: 'ultrafast',
});
puter.print(response.message.content);
puter.print(response.usageDetails.serviceSpeed);
</script>
</body>
</html>
-5
View File
@@ -226,11 +226,6 @@ Image and video inputs to [`txt2img()`](/AI/txt2img/) and [`txt2vid()`](/AI/txt2
puter.js uploads a `Blob`, a `File` or a `data:` URI over 1 MiB to a temporary Puter file for the call. It counts against the user's storage until puter.js deletes it after the call.
### Chat service speed catalogs
OpenRouter speed endpoint catalogs are cached for 15 minutes, with at most 256 model entries per backend process. A catalog fetch times out after 10 seconds and accepts at most 2 MiB. Failed fetches are remembered for 30 seconds. If no usable speed offering is available, the request uses standard service.
### Remote chat image inlining
For chat providers that require remote image URLs to be inlined (currently Moonshot):
+2 -3
View File
@@ -20,7 +20,6 @@
import download from './helpers/download.js';
import ensureConfirmedEmail from './helpers/ensureConfirmedEmail.js';
import item_icon from './helpers/itemIcon.js';
import { atPath } from './helpers/pathElements.js';
import socialLink from './helpers/socialLink.js';
import update_mouse_position from './helpers/updateMousePosition.js';
import path from './lib/path.js';
@@ -1703,7 +1702,7 @@ const ipc_listener = async (event, handled) => {
$(`.item[data-uid="${res.uid}"]`).removeItems();
// now add new items
UIItem({
appendTo: atPath('.item-container', path.dirname(target_path)),
appendTo: $(`.item-container[data-path="${html_encode(path.dirname(target_path))}" i]`),
immutable: res.immutable || res.writable === false,
associated_app_name: res.associated_app?.name,
path: target_path,
@@ -1717,7 +1716,7 @@ const ipc_listener = async (event, handled) => {
suggested_apps: res.suggested_apps,
});
// sort each window
atPath('.item-container', path.dirname(target_path)).each(function () {
$(`.item-container[data-path="${html_encode(path.dirname(target_path))}" i]`).each(function () {
window.sort_items(this, $(this).attr('data-sort_by'), $(this).attr('data-sort_order'));
});
$(el_filedialog_window).close();
+42 -17
View File
@@ -34,7 +34,6 @@ import UIWindowProgress from './UIWindowProgress.js';
import UITaskbar from './UITaskbar.js';
import new_context_menu_item from '../helpers/newContextMenuItem.js';
import refresh_item_container from '../helpers/refreshItemContainer.js';
import { atPath, retargetPaths, underPath } from '../helpers/pathElements.js';
import changeLanguage from '../i18n/i18nChangeLanguage.js';
import UIWindowTaskManager from './UIWindowTaskManager.js';
import truncate_filename from '../helpers/truncateFilename.js';
@@ -417,12 +416,12 @@ async function UIDesktop (options) {
}
// hide all UIItems with matching uids
atPath('.item', item.path).fadeOut(150, function () {
$(`.item[data-path='${item.path}']`).fadeOut(150, function () {
// close all windows with matching uids
// $('.window-' + item.uid).close();
// close all windows that belong to a descendant of this item
// todo this has to be case-insensitive
underPath('.window', item.path).close();
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
$(`.window[data-path^="${item.path}/"]`).close();
});
});
@@ -460,7 +459,20 @@ async function UIDesktop (options) {
$(`.item[data-uid='${item.uid}']`).attr('data-path', new_path);
$(`.window-${item.uid}`).attr('data-path', new_path);
retargetPaths(item.old_path, new_path, item.name);
// Update all elements that have matching paths
$(`[data-path="${html_encode(item.old_path)}" i]`).each(function () {
$(this).attr('data-path', new_path);
if ( $(this).hasClass('window-navbar-path-dirname') )
{
$(this).text(item.name);
}
});
// Update all elements whose paths start with old_path
$(`[data-path^="${`${html_encode(item.old_path) }/`}"]`).each(function () {
const new_el_path = $(this).attr('data-path').replace(`${item.old_path }/`, `${new_path }/`);
$(this).attr('data-path', new_el_path);
});
// Update all exact-matching windows
$(`.window-${item.uid}`).each(function () {
@@ -495,7 +507,7 @@ async function UIDesktop (options) {
const metadata = parse_item_metadata(fsentry.metadata);
// update all shortcut_to_path
atPath('.item', resp.old_path, 'data-shortcut_to_path').attr('data-shortcut_to_path', fsentry.path);
$(`.item[data-shortcut_to_path="${html_encode(resp.old_path)}" i]`).attr('data-shortcut_to_path', html_encode(fsentry.path));
// remove all items with matching uids
$(`.item[data-uid='${fsentry.uid}']`).fadeOut(150, function () {
@@ -512,16 +524,16 @@ async function UIDesktop (options) {
// if trashing, close windows of trashed items and its descendants
if ( dest_path === window.trash_path ) {
atPath('.window', resp.old_path).close();
// todo this has to be case-insensitive
underPath('.window', resp.old_path).close();
$(`.window[data-path="${html_encode(resp.old_path)}" i]`).close();
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
$(`.window[data-path^="${html_encode(resp.old_path)}/"]`).close();
}
// update all paths of its and its descendants' open windows
else {
// todo this has to be case-insensitive
underPath('.window', resp.old_path).add(atPath('.window', resp.old_path)).each(function () {
window.update_window_path(this, fsentry.path + $(this).attr('data-path').slice(resp.old_path.length));
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
$(`.window[data-path^="${html_encode(resp.old_path)}/"], .window[data-path="${html_encode(resp.old_path)}" i]`).each(function () {
window.update_window_path(this, $(this).attr('data-path').replace(resp.old_path, fsentry.path));
});
}
@@ -548,7 +560,7 @@ async function UIDesktop (options) {
// create new item on matching containers
UIItem({
appendTo: atPath('.item-container', dest_path),
appendTo: $(`.item-container[data-path='${html_encode(dest_path)}' i]`),
immutable: fsentry.immutable || fsentry.writable === false,
uid: fsentry.uid,
path: fsentry.path,
@@ -573,8 +585,8 @@ async function UIDesktop (options) {
// if these new path have any open parents that need to be updated
fsentry.parent_dirs_created.forEach(async dir => {
let item_container = atPath('.item-container', path.dirname(dir.path));
if ( item_container.length > 0 && atPath('.item', dir.path).length === 0 ) {
let item_container = $(`.item-container[data-path='${html_encode(path.dirname(dir.path))}' i]`);
if ( item_container.length > 0 && $(`.item[data-path="${html_encode(dir.path)}" i]`).length === 0 ) {
UIItem({
appendTo: item_container,
immutable: false,
@@ -594,7 +606,7 @@ async function UIDesktop (options) {
});
}
//sort each container
atPath('.item-container', dest_path).each(function () {
$(`.item-container[data-path='${html_encode(dest_path)}' i]`).each(function () {
window.sort_items(this, $(this).attr('data-sort_by'), $(this).attr('data-sort_order'));
});
});
@@ -681,7 +693,20 @@ async function UIDesktop (options) {
$(`.item[data-uid='${item.uid}']`).attr('data-path', new_path);
$(`.window-${item.uid}`).attr('data-path', new_path);
retargetPaths(item.old_path, new_path, item.name);
// Update all elements that have matching paths
$(`[data-path="${html_encode(item.old_path)}" i]`).each(function () {
$(this).attr('data-path', new_path);
if ( $(this).hasClass('window-navbar-path-dirname') )
{
$(this).text(item.name);
}
});
// Update all elements whose paths start with old_path
$(`[data-path^="${`${html_encode(item.old_path) }/`}"]`).each(function () {
const new_el_path = $(this).attr('data-path').replace(`${item.old_path }/`, `${new_path }/`);
$(this).attr('data-path', new_el_path);
});
// Update all exact-matching windows
$(`.window-${item.uid}`).each(function () {
+6 -7
View File
@@ -25,7 +25,6 @@ import UIAuthLogin from './Auth/UIAuthLogin.js';
import UIWindowItemProperties from './UIWindowItemProperties.js';
import new_context_menu_item from '../helpers/newContextMenuItem.js';
import refresh_item_container from '../helpers/refreshItemContainer.js';
import { atPath } from '../helpers/pathElements.js';
import UIWindowAppFeedback from './UIWindowAppFeedback.js';
import launch_app from '../helpers/launchApp.js';
import publish_as_website from '../helpers/publishAsWebsite.js';
@@ -3349,7 +3348,7 @@ window.update_window_path = async function (el_window, target_path) {
// update sidebar items' active status
$(el_window).find('.window-sidebar-item').removeClass('window-sidebar-item-active');
atPath($(el_window).find('.window-sidebar-item'), target_path).addClass('window-sidebar-item-active');
$(el_window).find(`.window-sidebar-item[data-path="${html_encode(target_path)}"]`).addClass('window-sidebar-item-active');
// clean
$(el_window).find('.explore-table-headers-th > .header-sort-icon').html('');
@@ -3433,8 +3432,8 @@ window.update_window_path = async function (el_window, target_path) {
}
}
$(el_window).attr('data-path', target_path);
$(el_window).attr('data-name', path.basename(target_path));
$(el_window).attr('data-path', html_encode(target_path));
$(el_window).attr('data-name', html_encode(path.basename(target_path)));
// /stat
if ( target_path === window.shared_path ) {
@@ -3448,7 +3447,7 @@ window.update_window_path = async function (el_window, target_path) {
try {
puter.fs.stat({ path: target_path, consistency: 'eventual' }).then(fsentry => {
// the window may have moved on while this stat was in flight
if ( $(el_window).attr('data-path') !== target_path ) {
if ( $(el_window).attr('data-path') !== html_encode(target_path) ) {
return;
}
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
@@ -3492,9 +3491,9 @@ window.update_window_path = async function (el_window, target_path) {
$(el_window).find('.window-head-title').text(fsentry.name);
}
// data-name
$(el_window).attr('data-name', fsentry.name);
$(el_window).attr('data-name', html_encode(fsentry.name));
// data-path
$(el_window).attr('data-path', target_path);
$(el_window).attr('data-path', html_encode(target_path));
$(el_window_navbar_path_input).val(target_path);
$(el_window_navbar_path_input).attr('data-path', target_path);
// update layout
+1 -2
View File
@@ -19,7 +19,6 @@
import UIWindow from './UIWindow.js';
import UIWindowMyWebsites from './UIWindowMyWebsites.js';
import { underPath } from '../helpers/pathElements.js';
async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_dir_path) {
let h = '';
@@ -111,7 +110,7 @@ async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_d
});
// find all items whose path starts with target_dir_path
underPath('.item', target_dir_path).each(function () {
$(`.item[data-path^="${target_dir_path}/"]`).each(function () {
// show the link badge
$(this).find('.item-has-website-url-badge').show();
// update item's website_url attribute
+1 -2
View File
@@ -19,7 +19,6 @@
import UIWindow from './UIWindow.js';
import UIWindowMyWebsites from './UIWindowMyWebsites.js';
import { underPath } from '../helpers/pathElements.js';
async function UIWindowPublishWorker (target_dir_uid, target_dir_name, target_dir_path) {
let h = '';
@@ -118,7 +117,7 @@ async function UIWindowPublishWorker (target_dir_uid, target_dir_name, target_di
});
// find all items whose path starts with target_dir_path
underPath('.item', target_dir_path).each(function () {
$(`.item[data-path^="${target_dir_path}/"]`).each(function () {
// show the link badge
// $(this).find('.item-has-website-url-badge').show();
// update item's website_url attribute
+46 -26
View File
@@ -23,7 +23,6 @@ import { archiveEntryPath } from './helpers/archiveEntryPath.js';
import get_html_element_from_options from './helpers/getHtmlElementFromOptions.js';
import globToRegExp from './helpers/globToRegExp.js';
import item_icon from './helpers/itemIcon.js';
import { atPath, retargetPaths, underPath } from './helpers/pathElements.js';
import { is_owned_by_me, trash_path_for } from './helpers/pathOwner.js';
import { invalidate_shared_roots } from './helpers/sharedAccess.js';
import truncate_filename from './helpers/truncateFilename.js';
@@ -1179,7 +1178,7 @@ window.create_folder = async (basedir, appendto_element) => {
rename: true,
overwrite: false,
success: function (data) {
const el_created_dir = atPath($(appendto_element).find('.item'), `${dirname}/${data.name}`);
const el_created_dir = $(appendto_element).find(`.item[data-path="${html_encode(dirname)}/${html_encode(data.name)}"]`);
if ( el_created_dir.length > 0 ) {
window.activate_item_name_editor(el_created_dir);
@@ -1212,7 +1211,7 @@ window.create_file = async (options) => {
generateThumbnails: true,
thumbnailGenerator: createUploadThumbnailGenerator(),
success: async function (data) {
const created_file = atPath($(appendto_element).find('.item'), `${dirname}/${data.name}`);
const created_file = $(appendto_element).find(`.item[data-path="${html_encode(dirname)}/${html_encode(data.name)}"]`);
if ( created_file.length > 0 ) {
window.activate_item_name_editor(created_file);
@@ -1584,8 +1583,8 @@ window.delete_item = async function (el_item, descendants_only = false) {
// close all windows with matching uids
$(`.window-${ $(el_item).attr('data-uid')}`).close();
// close all windows that belong to a descendant of this item
// todo this has to be case-insensitive
underPath('.window', $(el_item).attr('data-path')).close();
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
$(`.window[data-path^="${$(el_item).attr('data-path')}/"]`).close();
});
try {
@@ -1606,7 +1605,7 @@ window.delete_item = async function (el_item, descendants_only = false) {
window.update_explorer_footer_selected_items_count(this);
});
// update all shortcuts to this item
atPath('.item', $(el_item).attr('data-path'), 'data-shortcut_to_path').attr('data-shortcut_to_path', '');
$(`.item[data-shortcut_to_path="${html_encode($(el_item).attr('data-path'))}" i]`).attr('data-shortcut_to_path', '');
});
} catch ( err ) {
// Nothing was deleted: bring back what was faded out above.
@@ -1620,7 +1619,7 @@ window.move_clipboard_items = function (el_target_container, target_path) {
let el_items = [];
if ( window.clipboard.length > 0 ) {
for ( let i = 0; i < window.clipboard.length; i++ ) {
el_items.push(atPath('.item', window.clipboard[i]));
el_items.push($(`.item[data-path="${html_encode(window.clipboard[i])}" i]`));
}
if ( el_items.length > 0 )
{
@@ -1962,18 +1961,24 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// skip next loop iteration because this iteration was successful
item_with_same_name_already_exists = false;
const moved_from_path = $(el_item).attr('data-path');
// update all shortcut_to_path
atPath('.item', moved_from_path, 'data-shortcut_to_path').attr('data-shortcut_to_path', fsentry.path);
// update all shortcut_to_path — compare raw attribute values
// (item rows store paths unencoded, so an html_encode()d
// selector misses names containing & < > " ')
const moved_from_path_lc = String($(el_item).attr('data-path') || '').toLowerCase();
$('.item[data-shortcut_to_path]').filter(function () {
return String($(this).attr('data-shortcut_to_path')).toLowerCase() === moved_from_path_lc;
}).attr('data-shortcut_to_path', fsentry.path);
// Remove all items with matching uids from their OLD location(s).
// Exclude any row already at the item's new path: a concurrent
// item.moved socket handler may have just created a row at the
// destination (e.g. the dashboard file view showing the target
// directory), and removing by uid alone would delete it too.
const $moved_items = $(`.item[data-uid='${$(el_item).attr('data-uid')}']`);
$moved_items.not(atPath($moved_items, fsentry.path)).fadeOut(150, function () {
// Raw case-insensitive compare, for the same reason as above.
const dest_item_path_lc = fsentry.path.toLowerCase();
$(`.item[data-uid='${$(el_item).attr('data-uid')}']`).not(function () {
return String($(this).attr('data-path') || '').toLowerCase() === dest_item_path_lc;
}).fadeOut(150, function () {
// find all parent windows that contain this item
let parent_windows = $(`.item[data-uid='${$(el_item).attr('data-uid')}']`).closest('.window');
// remove this item
@@ -1987,23 +1992,23 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// if trashing, close windows of trashed items and its descendants
if ( is_trashing ) {
atPath('.window', moved_from_path).close();
// todo this has to be case-insensitive
underPath('.window', moved_from_path).close();
$(`.window[data-path="${html_encode($(el_item).attr('data-path'))}" i]`).close();
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
$(`.window[data-path^="${html_encode($(el_item).attr('data-path'))}/"]`).close();
}
// update all paths of its and its descendants' open windows
else {
// todo this has to be case-insensitive
underPath('.window', moved_from_path).add(atPath('.window', moved_from_path)).each(function () {
window.update_window_path(this, path.join(item_dest_path, fsentry.name) + $(this).attr('data-path').slice(moved_from_path.length));
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
$(`.window[data-path^="${html_encode($(el_item).attr('data-path'))}/"], .window[data-path="${html_encode($(el_item).attr('data-path'))}" i]`).each(function () {
window.update_window_path(this, $(this).attr('data-path').replace($(el_item).attr('data-path'), path.join(item_dest_path, fsentry.name)));
});
}
// The dashboard's Back/Forward follows a moved folder, and
// forgets a trashed one.
if ( $(el_item).attr('data-is_dir') === '1' ) {
window.dashboard_object?.folderMoved?.(moved_from_path, is_trashing ? null : fsentry.path);
window.dashboard_object?.folderMoved?.($(el_item).attr('data-path'), is_trashing ? null : fsentry.path);
}
if ( is_trashing ) {
@@ -2027,7 +2032,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// create new item on matching containers
const options = {
appendTo: atPath('.item-container', item_dest_path),
appendTo: $(`.item-container[data-path="${html_encode(item_dest_path)}" i]`),
immutable: fsentry.immutable || (fsentry.writable === false),
associated_app_name: fsentry.associated_app?.name,
uid: fsentry.uid,
@@ -2058,8 +2063,8 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// see if any of the directories in the path of this file is new AND
// if these new path have any open parents that need to be updated
resp.parent_dirs_created?.forEach(async dir => {
let item_container = atPath('.item-container', path.dirname(dir.path));
if ( item_container.length > 0 && atPath('.item', dir.path).length === 0 ) {
let item_container = $(`.item-container[data-path="${html_encode(path.dirname(dir.path))}" i]`);
if ( item_container.length > 0 && $(`.item[data-path="${html_encode(dir.path)}" i]`).length === 0 ) {
UIItem({
appendTo: item_container,
immutable: false,
@@ -2085,7 +2090,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
});
//sort each container
atPath('.item-container', item_dest_path).each(function () {
$(`.item-container[data-path="${html_encode(item_dest_path)}" i]`).each(function () {
window.sort_items(this, $(this).attr('data-sort_by'), $(this).attr('data-sort_order'));
});
} catch ( err ) {
@@ -2280,7 +2285,9 @@ window.updateSubdomainsForItems = async function (fsentries, container) {
// If not found by uid, try path-based search
if ( $item.length === 0 && fsentry.path ) {
$item = atPath('.item', fsentry.path);
// Escape special characters in path for jQuery selector
const escapedPath = fsentry.path.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
$item = $(document).find(`.item[data-path="${escapedPath}"]`);
}
if ( $item.length > 0 ) {
@@ -3294,7 +3301,20 @@ window.rename_file = async (options, new_name, old_name, old_path, el_item, el_i
$(`.item[data-uid='${$(el_item).attr('data-uid')}']`).attr('data-path', new_path);
$(`.window-${options.uid}`).attr('data-path', new_path);
retargetPaths(old_path, new_path, new_name);
// Update all elements that have matching paths
$(`[data-path="${html_encode(old_path)}" i]`).each(function () {
$(this).attr('data-path', new_path);
if ( $(this).hasClass('window-navbar-path-dirname') )
{
$(this).text(new_name);
}
});
// Update the paths of all elements whose paths start with `old_path`
$(`[data-path^="${`${html_encode(old_path) }/`}"]`).each(function () {
const new_el_path = $(this).attr('data-path').replace(`${old_path }/`, `${new_path}/`);
$(this).attr('data-path', new_el_path);
});
// Update the 'Sites Cache'
if ( $(el_item).attr('data-has_website') === '1' )
-148
View File
@@ -1,148 +0,0 @@
// @vitest-environment jsdom
/*
* 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 jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
vi.mock('./UI/UIAlert.js', () => ({ default: vi.fn() }));
vi.mock('./UI/UIItem.js', () => ({ default: vi.fn() }));
vi.mock('./UI/Auth/UIAuthLogin.js', () => ({ default: vi.fn() }));
vi.mock('./UI/UIWindowProgress.js', () => ({ default: vi.fn() }));
vi.mock('./UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
globalThis.$ = jQuery;
globalThis.jQuery = jQuery;
globalThis.i18n = key => key;
globalThis.def = globalThis.def ?? (value => value);
globalThis.use = globalThis.use ?? (() => class {});
globalThis.html_encode = value => jQuery('<div>').text(value).html();
await import('./helpers.js');
const { default: apply_item_added_to_containers } = await import('./helpers/applyItemAddedToContainers.js');
const { default: UIItem } = await import('./UI/UIItem.js');
const { atPath, retargetPaths, underPath } = await import('./helpers/pathElements.js');
// A plain name, then names holding characters a selector or HTML treats specially.
const NAMES = ['Reports 2024', 'R&D', "Dan's files", 'the "big" one', 'a<b>c', 'back\\slash'];
const el = (className, attrs) => jQuery('<div>').addClass(className).attr(attrs).appendTo(document.body);
beforeEach(() => {
document.body.innerHTML = '';
jQuery.fx.off = true;
jQuery.fn.close = vi.fn();
jQuery.fn.removeItems = vi.fn();
window.icons = { 'folder.svg': 'folder.svg' };
window.actions_history = [];
window.user = { username: 'alice' };
window.update_window_path = vi.fn();
window.sort_items = vi.fn();
window.update_explorer_footer_item_count = vi.fn();
window.update_explorer_footer_selected_items_count = vi.fn();
window.determine_website_url = () => '';
UIItem.mockClear();
});
describe.each(NAMES)('a folder named %s', (name) => {
const folderPath = `/alice/${name}`;
it('keeps its open subfolders and their items on the new path when renamed', async () => {
const $item = el('item', { 'data-uid': 'u1', 'data-path': folderPath, 'data-name': name });
const $crumb = el('window-navbar-path-dirname', { 'data-path': folderPath });
const $subWindow = el('window', { 'data-path': `${folderPath}/sub` });
const $subItem = el('item', { 'data-uid': 'u2', 'data-path': `${folderPath}/sub/a.txt` });
globalThis.puter = { fs: { rename: ({ success }) => success({ uid: 'u1', name: 'Plain', is_dir: true }) } };
await new Promise(resolve => {
window.rename_file({ uid: 'u1', name, path: folderPath, is_dir: true }, 'Plain', name, folderPath,
$item[0], null, null, null, '', false, resolve);
});
expect($item.attr('data-path')).toBe('/alice/Plain');
expect($crumb.attr('data-path')).toBe('/alice/Plain');
expect($crumb.text()).toBe('Plain');
expect($subWindow.attr('data-path')).toBe('/alice/Plain/sub');
expect($subItem.attr('data-path')).toBe('/alice/Plain/sub/a.txt');
});
it('closes the windows of its subfolders when deleted', async () => {
const $item = el('item', { 'data-uid': 'u1', 'data-path': folderPath });
el('window', { 'data-path': `${folderPath}/sub` });
el('window', { 'data-path': '/alice/Other/sub' });
el('item', { 'data-uid': 'u3', 'data-shortcut_to_path': folderPath });
globalThis.puter = { fs: { delete: vi.fn().mockResolvedValue() } };
await window.delete_item($item[0]);
const closed = jQuery.fn.close.mock.contexts.flatMap($set => $set.toArray().map(w => jQuery(w).attr('data-path')));
expect(closed).toEqual([`${folderPath}/sub`]);
expect(puter.fs.delete).toHaveBeenCalled();
expect(jQuery('[data-uid="u3"]').attr('data-shortcut_to_path')).toBe('');
});
it('shows an item another tab added to it', async () => {
const $container = el('item-container', { 'data-path': folderPath });
await apply_item_added_to_containers({ uid: 'u9', dirpath: folderPath, path: `${folderPath}/new.txt`, name: 'new.txt', is_dir: true });
expect(UIItem).toHaveBeenCalledTimes(1);
expect(UIItem.mock.calls[0][0].appendTo.toArray()).toEqual([$container[0]]);
});
});
describe('retargetPaths', () => {
it('ignores the case of the renamed path itself, and leaves its siblings alone', () => {
const $window = el('window', { 'data-path': '/alice/docs' });
const $sibling = el('item', { 'data-path': '/alice/Docs 2/a.txt' });
retargetPaths('/alice/Docs', '/alice/Papers', 'Papers');
expect($window.attr('data-path')).toBe('/alice/Papers');
expect($sibling.attr('data-path')).toBe('/alice/Docs 2/a.txt');
});
it('writes a new name holding a replacement pattern as it is', () => {
const $item = el('item', { 'data-path': '/alice/Docs/a.txt' });
retargetPaths('/alice/Docs', '/alice/$& and $1', '$& and $1');
expect($item.attr('data-path')).toBe('/alice/$& and $1/a.txt');
});
it('changes nothing when the old path is unknown', () => {
const $item = el('item', { 'data-path': '/alice/Docs/a.txt' });
retargetPaths(undefined, '/alice/Papers', 'Papers');
expect($item.attr('data-path')).toBe('/alice/Docs/a.txt');
});
});
describe.each([undefined, null, ''])('a lookup for the path %s', (missing) => {
it('matches nothing, including elements that have no path', () => {
el('item', { 'data-path': '/alice/a.txt', 'data-shortcut_to_path': '' });
el('window', { 'data-path': '' });
el('window', { 'data-path': '/alice/Docs' });
expect(atPath('.item', missing, 'data-shortcut_to_path')).toHaveLength(0);
expect(atPath('.window', missing)).toHaveLength(0);
expect(underPath('.window', missing)).toHaveLength(0);
});
});
@@ -19,7 +19,6 @@
import UIItem from '../UI/UIItem.js';
import item_icon from './itemIcon.js';
import { atPath } from './pathElements.js';
import { select_added_item_if_pending } from './uploadSelection.js';
/**
@@ -38,7 +37,7 @@ import { select_added_item_if_pending } from './uploadSelection.js';
* @param {object} item fsentry from the `item.added` event
*/
const apply_item_added_to_containers = async function (item) {
const $containers = atPath('.item-container', item.dirpath);
const $containers = $(`.item-container[data-path='${html_encode(item.dirpath)}' i]`);
if ( $containers.length === 0 ) {
return;
}
-73
View File
@@ -1,73 +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/>.
*/
/**
* Narrows `elements` to those whose `attr` is `path`, ignoring case. Reads the
* attribute, because a path can hold any character a selector can't.
*
* @param {string | Element | JQuery} elements - a selector or a set of elements
* @param {string} path
* @param {string} [attr]
* @returns {JQuery}
*/
export const atPath = (elements, path, attr = 'data-path') => {
// Without this, a missing path matches every element whose attribute is empty.
if ( ! path ) return $();
const wanted = String(path).toLowerCase();
return $(elements).filter(function () {
const value = this.getAttribute(attr);
return value !== null && value.toLowerCase() === wanted;
});
};
/**
* Narrows `elements` to those whose `data-path` is inside the folder at `path`.
*
* @param {string | Element | JQuery} elements - a selector or a set of elements
* @param {string} path
* @returns {JQuery}
*/
export const underPath = (elements, path) => {
if ( ! path ) return $();
const prefix = `${path}/`;
return $(elements).filter(function () {
return this.getAttribute('data-path')?.startsWith(prefix) === true;
});
};
/**
* Points every element at `oldPath`, or inside it, to `newPath`.
*
* @param {string} oldPath
* @param {string} newPath
* @param {string} newName - shown by the breadcrumbs that pointed at `oldPath`
*/
export const retargetPaths = (oldPath, newPath, newName) => {
if ( ! oldPath ) return;
const $inside = underPath('[data-path]', oldPath);
atPath('[data-path]', oldPath).each(function () {
$(this).attr('data-path', newPath);
if ( $(this).hasClass('window-navbar-path-dirname') ) {
$(this).text(newName);
}
});
$inside.each(function () {
$(this).attr('data-path', newPath + $(this).attr('data-path').slice(oldPath.length));
});
};
+2 -2
View File
@@ -103,9 +103,9 @@ const refresh_item_container = function (el_item_container, options) {
$(el_window).attr('data-sort_order', fsentry.sort_order ?? 'asc');
$(el_window).attr('data-layout', fsentry.layout ?? 'icons');
// data-name
$(el_window).attr('data-name', fsentry.name);
$(el_window).attr('data-name', html_encode(fsentry.name));
// data-path
$(el_window).attr('data-path', container_path);
$(el_window).attr('data-path', html_encode(container_path));
$(el_window).find('.window-navbar-path-input').val(container_path);
$(el_window).find('.window-navbar-path-input').attr('data-path', container_path);
}
@@ -167,15 +167,6 @@ describe('refresh_item_container after the window moves to another folder', () =
expect($('.window').attr('data-uid')).toBe('pictures');
});
it('stores a folder name holding HTML characters on the window as it is', async () => {
navigate('/alice/R&D');
pending.stat['/alice/R&D'].resolve({ id: 'rd', name: 'R&D' });
await vi.waitFor(() => expect($('.window').attr('data-uid')).toBe('rd'));
expect($('.window').attr('data-name')).toBe('R&D');
});
it('drops a listing that arrives just before the window moves on', async () => {
vi.useFakeTimers();
navigate('/alice/Documents');
+4 -3
View File
@@ -17,8 +17,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { atPath } from './pathElements.js';
/**
* Whether a `getShares()` list means this item is shared.
*
@@ -35,7 +33,10 @@ export const has_direct_share = (shares) =>
* can be on the desktop and in any number of open windows.
*/
export const mark_item_shared = (path, is_shared) => {
const $items = atPath('.item', path);
if ( ! path ) return;
const $items = $('.item').filter(function() {
return ($(this).attr('data-path') || '').toLowerCase() === path.toLowerCase();
});
$items.attr('data-is_shared', is_shared ? 1 : 0);
$items
.find('.item-shared-marker')
-1
View File
@@ -38,7 +38,6 @@ export type {
AIMessageContent,
ChatMessage,
ChatOptions,
ServiceSpeed,
ChatResponse,
ChatResponseChunk,
ChatResponseStream,
-5
View File
@@ -20,7 +20,6 @@ const PARAMS_TO_PASS = [
'verbosity',
'provider',
'fallback',
'serviceSpeed',
'image_config',
'compaction',
'context_management',
@@ -236,10 +235,6 @@ export async function chat (
/** @type {ChatOptions & { stream?: boolean }} */
const userParams = extras.find(isPlainObject) ?? {};
if (userParams.serviceSpeed !== undefined && !['standard', 'fast', 'ultrafast'].includes(userParams.serviceSpeed)) {
throw { code: 'invalid_service_speed', message: 'serviceSpeed must be standard, fast, or ultrafast' };
}
// Copy relevant parameters from userParams to requestParams.
// Use `!== undefined` so legitimate zeros (temperature: 0, max_tokens: 0)
// are forwarded — truthy checks silently drop them.
-9
View File
@@ -54,12 +54,6 @@
* Present on responses only.
*/
/**
* Preferred service speed for a chat completion.
*
* @typedef {'standard' | 'fast' | 'ultrafast'} ServiceSpeed
*/
/**
* Options for a chat completion request.
*
@@ -73,8 +67,6 @@
* @property {boolean} [vision]
* @property {string} [driver]
* @property {string} [provider] The provider to route the request through.
* @property {ServiceSpeed} [serviceSpeed] Preferred service speed. Falls back to
* fast, then standard when the requested speed is unavailable. Faster service may cost more.
* @property {boolean} [fallback] Set to `false` to fail the request when the first provider tried
* fails, instead of retrying it on another provider that serves the same model.
* @property {Tool[]} [tools] Function/tool definitions the model can call. See Function Calling.
@@ -156,7 +148,6 @@
* @property {number} [webSearchRequests]
* @property {number} [webFetchRequests]
* @property {'fast' | 'standard'} [speed]
* @property {ServiceSpeed} [serviceSpeed]
* @property {string} [serviceTier]
* @property {Array<{ type: string, model?: string, inputTokens: number, outputTokens: number, cacheReadTokens?: number, cacheWrite5mTokens?: number, cacheWrite30mTokens?: number, cacheWrite1hTokens?: number, cacheWrite12hTokens?: number }>} [iterations]
* Per-pass breakdown when the upstream ran several passes (compaction, an advisor sub-call).
-17
View File
@@ -97,23 +97,6 @@ const textOf = (result: {
};
export default suite('ai', {
'service speed falls back on unsupported models without changing the normalized response': async t => {
useApiToken(t);
for (const serviceSpeed of ['standard', 'fast', 'ultrafast'] as const) {
const result = await t.puter.ai.chat('Hello', { model: 'fake', serviceSpeed, normalize: true });
t.assert.equal(typeof result.message.content, 'string');
t.assert.equal(result.usageDetails?.serviceSpeed, 'standard');
}
},
'invalid service speed returns a stable error': async t => {
const values = [null, false, true, 0, 1, -1, NaN, Infinity, '', 'invalid',
'FAST', 'fast ', ' fast', 'ultraFast', [], ['fast'], {}, { speed: 'fast' },
{ toString: 'fast' }];
for (const serviceSpeed of values) {
const error = await errorOf(t, () => t.puter.ai.chat('Hello', { model: 'fake', serviceSpeed: serviceSpeed as never }));
t.assert.equal(error.code, 'invalid_service_speed');
}
},
'chat with the fake model returns a message': async (t) => {
useApiToken(t);
const result = await t.puter.ai.chat('Hello there', {