mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
697fa58b97 | ||
|
|
ba9c0a8971 | ||
|
|
290e01fe18 | ||
|
|
5c2b4da9f0 | ||
|
|
07b19888a4 | ||
|
|
2394221745 | ||
|
|
ee2cdbfe1a | ||
|
|
9b6350beab | ||
|
|
b649d9ada7 | ||
|
|
50b0639d02 | ||
|
|
ab96702718 | ||
|
|
47f9b0bdf3 | ||
|
|
8c4b0eb1fc | ||
|
|
a88c1b9d65 | ||
|
|
9caaf7162c | ||
|
|
0191f2bfec | ||
|
|
7aadc48cca | ||
|
|
6b06793750 | ||
|
|
e441880f13 | ||
|
|
9a86803418 |
No files matched your search
@@ -213,6 +213,85 @@ the frame again, once per `ready`.
|
||||
before a picker's answer isn't adopted, since it is for the page's own user.
|
||||
Dismissing the loading scrim settles the picker as cancelled.
|
||||
|
||||
## Upgrade
|
||||
|
||||
`GET /embed/upgrade` is the deployment's upgrade dialog for a first-party page,
|
||||
served like the popups above: the GUI shell with the same framing rules, query
|
||||
redirect, exact-path redirect and `no-store`, naming the framing page as
|
||||
`gui_params.embed_host_origin`. It runs only when framed by an allowed page.
|
||||
|
||||
puter.js frames it on a signed-in first-party page (the same pages as the
|
||||
pickers) for `puter.ui.requestUpgrade(details)`, and for the Upgrade button of
|
||||
the usage limit dialog a refused call shows there. Elsewhere both keep their
|
||||
behavior: inside an app `requestUpgrade()` asks the desktop, and on any other
|
||||
page the usage limit dialog opens the dashboard's upgrade page in a new tab.
|
||||
|
||||
The frame covers the page with no box or close button, and the GUI page is
|
||||
transparent (`html.popup-frame-transparent`) so the dialog's own scrim covers
|
||||
the host. The frame allows `payment` as well as `otp-credentials`. Escape on the
|
||||
page is left to the dialog, since one there shouldn't drop a payment in
|
||||
progress. Its fragment names `user` (the page's signed-in user) and, when the
|
||||
refusal gave them, `reason` (`funds`, `subscription` or `storage`) and `method`.
|
||||
|
||||
The GUI reports `unavailable` unless its session belongs to `user`, the account
|
||||
has no verification or password change waiting (which the frame couldn't be
|
||||
closed from), and the deployment defines `window.UIUpgradeAccount` with
|
||||
`UIUpgradeAccount.embeddable === true`. Otherwise it runs:
|
||||
|
||||
```js
|
||||
new UIUpgradeAccount({ attribution: { appUid, reason, method }, onClose }).open_as_window();
|
||||
```
|
||||
|
||||
`appUid` is the app the framing page's origin resolves to, never one the
|
||||
fragment names. `open_as_window()` resolves once the dialog shows, and the GUI
|
||||
then posts `ready`. While the page still hides the frame, which Chromium and
|
||||
WebKit lay out at 0x0, the GUI posts `ready` first and opens the dialog once
|
||||
the frame has a viewport, so a window it opens doesn't center on 0x0.
|
||||
`onClose({ upgraded })` must run exactly once, when the
|
||||
dialog closes or when it declines to open for the account; the GUI then posts
|
||||
`{ msg: 'puterPopupFrame', state: 'closed', upgraded }` and the page removes
|
||||
the frame. If `open_as_window()` rejects, the GUI posts `unavailable` instead. `requestUpgrade()` resolves `{ upgraded }`. A call made while the
|
||||
frame shows settles with it rather than replacing it, and a call refused while
|
||||
it shows still rejects but opens no usage limit dialog over it.
|
||||
|
||||
When the frame can't show the upgrade (refused, never ready, `unavailable`, or
|
||||
no user to name), puter.js opens the dashboard's upgrade page in a new tab with
|
||||
what was refused, through a consent dialog when the click has expired or the
|
||||
tab is blocked, and `requestUpgrade()` resolves `{ upgraded: false }`.
|
||||
|
||||
## Email confirmation
|
||||
|
||||
`GET /embed/request-email-confirmation` walks a first-party page's user through
|
||||
confirming their email, served like the upgrade: the GUI shell with the same
|
||||
framing rules, redirects and `no-store`, run only when framed by an allowed
|
||||
page. puter.js frames it on a signed-in first-party page for
|
||||
`puter.ui.requestEmailConfirmation()`. Inside an app the desktop shows the same
|
||||
windows over the app instead.
|
||||
|
||||
Like the upgrade, the frame covers the page with no box or close button, over a
|
||||
transparent GUI page, and its fragment names `user`. Escape closes it, on the
|
||||
page or on the save-account or code window. On a window, Escape closes that
|
||||
window as its close button does, so a code already sent still answers. The GUI
|
||||
reports `unavailable` unless its session belongs to `user` and the account has
|
||||
no phone, card or password check waiting.
|
||||
An email check is fine: the boot skips its own email gate for this action, so
|
||||
the user gets one code window they can close.
|
||||
|
||||
The GUI then runs `ensureConfirmedEmail` (`src/gui/src/helpers/ensureConfirmedEmail.js`),
|
||||
which the desktop's handler uses too: a temporary account gets the save-account
|
||||
window first, then an unconfirmed email gets the code window, each at most
|
||||
once. When the page's whoami already reads the email as confirmed,
|
||||
`requestEmailConfirmation()` resolves `{ response: true }` without framing
|
||||
anything, and a confirmation in the frame marks that whoami confirmed for the
|
||||
calls after it. An email the GUI reads as confirmed shows nothing. Before
|
||||
opening a window the GUI waits for the frame to have a viewport, as the upgrade
|
||||
does. It then posts
|
||||
`{ msg: 'puterPopupFrame', state: 'closed', confirmed }`, and
|
||||
`requestEmailConfirmation()` resolves `{ response: confirmed }`, the field an
|
||||
app reads from the desktop's answer. A call made while the frame shows settles
|
||||
with it. A frame that is refused, never ready, or `unavailable` resolves
|
||||
`{ response: false }`, since there's no popup to fall back to.
|
||||
|
||||
## Checks
|
||||
|
||||
Run the GUI component/browser tests (with the SDK bundle built and Playwright
|
||||
@@ -220,7 +299,7 @@ Chromium installed), and the backend route tests:
|
||||
|
||||
```sh
|
||||
npx vitest run --config src/gui/vitest.config.js src/gui/src/UI/Dashboard/AppBrowser src/feedbackEmbed
|
||||
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js src/puter-js/src/modules/Auth.signInFrame.test.js src/puter-js/src/modules/UI.pickerFrame.test.js
|
||||
npx vitest run src/puter-js/src/lib/guiEmbed.test.js src/puter-js/src/modules/UI.showFeedbackDialog.test.js src/puter-js/src/modules/Auth.signInFrame.test.js src/puter-js/src/modules/UI.pickerFrame.test.js src/puter-js/src/modules/UI.requestUpgrade.test.js src/puter-js/src/lib/upgradePrompt.test.js src/puter-js/src/modules/UsageLimitDialog.upgrade.test.js
|
||||
npx vitest run --config src/gui/vitest.config.js src/gui/src/helpers/popupFrame.test.js
|
||||
npx vitest run --config src/backend/vitest.config.ts src/backend/server.test.ts
|
||||
```
|
||||
@@ -420,7 +420,11 @@ export type EventMap = {
|
||||
old_username: string;
|
||||
new_username: string;
|
||||
};
|
||||
'user.email-changed': { user_id: number; new_email: string };
|
||||
'user.email-changed': {
|
||||
user_id: number;
|
||||
old_email: string | null;
|
||||
new_email: string;
|
||||
};
|
||||
/** An account was suspended, or its suspension lifted. Local events. */
|
||||
'user.suspended': { userId: number };
|
||||
'user.unsuspended': { userId: number };
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
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';
|
||||
@@ -200,6 +202,77 @@ 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;
|
||||
|
||||
@@ -235,6 +235,9 @@ 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,
|
||||
|
||||
@@ -7468,6 +7468,11 @@ describe('AuthController user-protected mutations (validation paths)', () => {
|
||||
{ expiresIn: '1h' },
|
||||
);
|
||||
|
||||
const heard: Array<Record<string, unknown>> = [];
|
||||
eventClient.on('user.email-changed', (_k, data) => {
|
||||
heard.push(data as Record<string, unknown>);
|
||||
});
|
||||
|
||||
const req = makeReq({});
|
||||
(req as unknown as { query: Record<string, string> }).query = {
|
||||
token: linkJwt,
|
||||
@@ -7475,6 +7480,11 @@ describe('AuthController user-protected mutations (validation paths)', () => {
|
||||
const res = makeRes();
|
||||
await controller.handleChangeEmailConfirm(req, res);
|
||||
expect(res.sent).toMatch(/Email changed successfully/);
|
||||
expect(heard).toContainEqual({
|
||||
user_id: user.id,
|
||||
old_email: user.email ?? null,
|
||||
new_email: newEmail,
|
||||
});
|
||||
|
||||
const after = await server.stores.user.getById(user.id, {
|
||||
force: true,
|
||||
|
||||
@@ -2647,6 +2647,7 @@ export class AuthController extends PuterController {
|
||||
'user.email-changed' as never,
|
||||
{
|
||||
user_id: user.id,
|
||||
old_email: user.email ?? null,
|
||||
new_email: newEmail,
|
||||
} as never,
|
||||
{},
|
||||
|
||||
@@ -36,13 +36,17 @@ import type {
|
||||
|
||||
/**
|
||||
* GUI popups a first-party page can show in a frame at `/embed/<action>`,
|
||||
* instead of opening `/action/<action>` in a window.
|
||||
* instead of opening `/action/<action>` in a window, plus the upgrade dialog,
|
||||
* which it would otherwise open in a dashboard tab, and the email confirmation,
|
||||
* which it otherwise can't show.
|
||||
*/
|
||||
export const FRAMED_POPUP_ACTIONS = [
|
||||
'sign-in',
|
||||
'show-open-file-picker',
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'request-email-confirmation',
|
||||
] as const;
|
||||
|
||||
/** Longest description/og:description/twitter:description on `/app/<name>`. */
|
||||
|
||||
@@ -2222,3 +2222,166 @@ 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,6 +78,11 @@ 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,
|
||||
@@ -674,6 +679,16 @@ 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');
|
||||
@@ -700,15 +715,16 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
args.model = this.#providers[intendedProvider].getDefaultModel();
|
||||
}
|
||||
|
||||
let model = await this.#resolveLoadedModel(
|
||||
const initialModel = await this.#resolveLoadedModel(
|
||||
args.model,
|
||||
intendedProvider,
|
||||
);
|
||||
if (!model) {
|
||||
if (!initialModel) {
|
||||
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.
|
||||
@@ -840,18 +856,6 @@ 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!];
|
||||
@@ -899,24 +903,134 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
return attempt;
|
||||
};
|
||||
|
||||
let variantSpeed: ICompleteArguments['serviceSpeed'];
|
||||
let creditGateError: unknown;
|
||||
const runAttempt = async (
|
||||
attemptProvider: IChatProvider,
|
||||
attemptModel: IChatModel,
|
||||
baseModel: IChatModel,
|
||||
) => {
|
||||
if (!useFakeProvider) {
|
||||
await this.#resolvePuterPaths(attemptProvider, args, actor);
|
||||
}
|
||||
// Last point where a caller who left costs nothing.
|
||||
abortSignal?.throwIfAborted();
|
||||
return attemptProvider.complete(
|
||||
{
|
||||
...args,
|
||||
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,
|
||||
model: attemptModel.id,
|
||||
provider: attemptModel.provider,
|
||||
},
|
||||
this.#catalogEntries.get(attemptModel),
|
||||
pricingTime,
|
||||
);
|
||||
};
|
||||
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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -984,29 +1098,17 @@ 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();
|
||||
@@ -1162,6 +1264,20 @@ 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,
|
||||
@@ -1474,12 +1590,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
const metering = this.services.metering;
|
||||
const { requestedMaxTokens, pricingTime } = estimates;
|
||||
let { promptTokenEstimate } = estimates;
|
||||
// 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.
|
||||
// Providers reserve tool calls using the same caps they send upstream.
|
||||
const pricing =
|
||||
this.#providers[model.provider!]?.requestPricing?.(args, model, {
|
||||
promptTokenEstimate,
|
||||
|
||||
@@ -41,6 +41,7 @@ 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';
|
||||
@@ -232,6 +233,8 @@ 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();
|
||||
@@ -271,15 +274,29 @@ export class OpenAICompatProvider implements IChatProvider {
|
||||
source,
|
||||
model,
|
||||
);
|
||||
if (usageDetails) source.setUsageDetails?.(usageDetails);
|
||||
const serviceSpeed = servedServiceSpeed(source);
|
||||
if (usageDetails)
|
||||
source.setUsageDetails?.({
|
||||
...usageDetails,
|
||||
...(args.serviceSpeed !== undefined
|
||||
? { serviceSpeed }
|
||||
: {}),
|
||||
});
|
||||
const metered = meterChatUsage(
|
||||
this.#metering,
|
||||
actor,
|
||||
this.meteringModelKey(model.id),
|
||||
model,
|
||||
usage,
|
||||
{ ...meterOptions, pricingTime },
|
||||
{ ...meterOptions, pricingTime, serviceSpeed },
|
||||
);
|
||||
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,6 +174,9 @@ 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,6 +37,7 @@ 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,
|
||||
@@ -60,6 +61,7 @@ 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,
|
||||
@@ -81,6 +83,7 @@ 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,
|
||||
@@ -102,6 +105,7 @@ 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,
|
||||
@@ -123,6 +127,7 @@ 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,
|
||||
@@ -143,6 +148,7 @@ 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,
|
||||
@@ -164,6 +170,7 @@ 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,
|
||||
@@ -188,6 +195,7 @@ 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,
|
||||
@@ -210,6 +218,7 @@ 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,
|
||||
@@ -234,6 +243,7 @@ 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,
|
||||
@@ -257,6 +267,7 @@ 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,
|
||||
@@ -282,6 +293,7 @@ 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,
|
||||
@@ -305,6 +317,7 @@ 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,
|
||||
@@ -328,6 +341,7 @@ 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,
|
||||
@@ -351,6 +365,7 @@ 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,
|
||||
@@ -376,6 +391,7 @@ 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,
|
||||
@@ -399,6 +415,7 @@ 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,
|
||||
@@ -422,6 +439,7 @@ 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,
|
||||
@@ -447,6 +465,7 @@ 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,
|
||||
@@ -469,6 +488,7 @@ 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,
|
||||
@@ -491,6 +511,7 @@ 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,
|
||||
@@ -511,6 +532,7 @@ 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,
|
||||
@@ -531,6 +553,7 @@ 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,
|
||||
@@ -553,6 +576,7 @@ 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,
|
||||
@@ -574,6 +598,7 @@ 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,
|
||||
@@ -594,6 +619,7 @@ 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,
|
||||
@@ -614,6 +640,7 @@ 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,
|
||||
@@ -635,6 +662,7 @@ 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,
|
||||
@@ -657,6 +685,7 @@ 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,
|
||||
@@ -678,6 +707,7 @@ 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,
|
||||
@@ -700,6 +730,7 @@ 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,
|
||||
@@ -722,6 +753,7 @@ 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,
|
||||
@@ -743,6 +775,7 @@ 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,
|
||||
@@ -765,6 +798,7 @@ 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,
|
||||
@@ -785,6 +819,7 @@ 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,
|
||||
@@ -809,6 +844,7 @@ 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,
|
||||
@@ -832,6 +868,7 @@ 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,
|
||||
@@ -855,6 +892,7 @@ 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,
|
||||
@@ -877,6 +915,7 @@ 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,
|
||||
@@ -897,6 +936,7 @@ 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,
|
||||
@@ -919,6 +959,7 @@ 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,
|
||||
@@ -938,6 +979,7 @@ 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,
|
||||
@@ -958,6 +1000,7 @@ 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,
|
||||
@@ -981,6 +1024,7 @@ 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,
|
||||
@@ -1003,6 +1047,7 @@ 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,
|
||||
@@ -1023,6 +1068,7 @@ 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,
|
||||
@@ -1045,6 +1091,7 @@ 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,
|
||||
@@ -1065,6 +1112,7 @@ 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,6 +20,10 @@
|
||||
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';
|
||||
@@ -153,6 +157,7 @@ export class AzureChatProvider implements IChatProvider {
|
||||
|
||||
async complete(
|
||||
params: ICompleteArguments,
|
||||
resolved?: IChatModel,
|
||||
): ReturnType<IChatProvider['complete']> {
|
||||
const {
|
||||
max_tokens,
|
||||
@@ -180,7 +185,9 @@ export class AzureChatProvider implements IChatProvider {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return await this.#responsesProvider.complete(params);
|
||||
return await (resolved
|
||||
? this.#responsesProvider.complete(params, resolved)
|
||||
: this.#responsesProvider.complete(params));
|
||||
}
|
||||
// Inline compaction is a Responses-API feature; chat.completions can't
|
||||
// express `context_management` or a `compaction` content block.
|
||||
@@ -194,7 +201,9 @@ export class AzureChatProvider implements IChatProvider {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return await this.#responsesProvider.complete(params);
|
||||
return await (resolved
|
||||
? this.#responsesProvider.complete(params, resolved)
|
||||
: this.#responsesProvider.complete(params));
|
||||
}
|
||||
// Validate messages
|
||||
if (!Array.isArray(messages)) {
|
||||
@@ -207,9 +216,11 @@ export class AzureChatProvider implements IChatProvider {
|
||||
model = model ?? this.#defaultModel;
|
||||
|
||||
const modelUsed =
|
||||
this.models().find((m) =>
|
||||
resolved ??
|
||||
(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',
|
||||
@@ -279,6 +290,9 @@ 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 }
|
||||
@@ -315,7 +329,12 @@ export class AzureChatProvider implements IChatProvider {
|
||||
);
|
||||
|
||||
return OpenAiUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage }) => {
|
||||
usage_calculator: ({
|
||||
service_tier,
|
||||
usage,
|
||||
setUsageCosts,
|
||||
setUsageDetails,
|
||||
}) => {
|
||||
const cachedTokens =
|
||||
usage.prompt_tokens_details?.cached_tokens ?? 0;
|
||||
const trackedUsage = {
|
||||
@@ -335,9 +354,20 @@ export class AzureChatProvider implements IChatProvider {
|
||||
|
||||
const costsOverrideFromModel = buildCostsOverride(
|
||||
trackedUsage,
|
||||
modelUsed,
|
||||
serviceSpeedBillingModel(
|
||||
modelUsed,
|
||||
servedServiceSpeed({ service_tier }),
|
||||
),
|
||||
);
|
||||
|
||||
setUsageCosts(costsOverrideFromModel);
|
||||
if (params.serviceSpeed !== undefined)
|
||||
setUsageDetails({
|
||||
...OpenAiUtil.usageDetailsFromTrackedUsage(
|
||||
trackedUsage,
|
||||
)!,
|
||||
serviceSpeed: servedServiceSpeed({ service_tier }),
|
||||
});
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor!,
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
|
||||
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';
|
||||
@@ -152,37 +156,41 @@ 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,
|
||||
verbosity,
|
||||
stream,
|
||||
reasoning,
|
||||
reasoning_effort,
|
||||
temperature,
|
||||
text,
|
||||
outputFormat,
|
||||
}: ICompleteArguments): 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,
|
||||
serviceSpeed: requestedServiceSpeed,
|
||||
verbosity,
|
||||
stream,
|
||||
reasoning,
|
||||
reasoning_effort,
|
||||
temperature,
|
||||
text,
|
||||
outputFormat,
|
||||
}: ICompleteArguments,
|
||||
resolved?: IChatModel,
|
||||
): ReturnType<IChatProvider['complete']> {
|
||||
// Validate messages
|
||||
if (!Array.isArray(messages)) {
|
||||
throw new HttpError(400, '`messages` must be an array', {
|
||||
@@ -200,12 +208,13 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
model = model ?? this.#defaultModel;
|
||||
|
||||
const modelUsed =
|
||||
this.models({ no_restrictions: true }).find((m) =>
|
||||
resolved ??
|
||||
(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.
|
||||
@@ -335,9 +344,11 @@ 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
|
||||
@@ -363,7 +374,10 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
|
||||
const costsOverrideFromModel = buildCostsOverride(
|
||||
trackedUsage,
|
||||
modelUsed,
|
||||
serviceSpeedBillingModel(
|
||||
modelUsed,
|
||||
servedServiceSpeed({ service_tier }),
|
||||
),
|
||||
);
|
||||
if (numWebSearches) {
|
||||
costsOverrideFromModel.web_search_calls =
|
||||
@@ -374,6 +388,14 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
});
|
||||
}
|
||||
|
||||
setUsageCosts?.(costsOverrideFromModel);
|
||||
if (requestedServiceSpeed !== undefined)
|
||||
setUsageDetails?.({
|
||||
...OpenAiUtil.usageDetailsFromTrackedUsage(
|
||||
trackedUsage,
|
||||
)!,
|
||||
serviceSpeed: servedServiceSpeed({ service_tier }),
|
||||
});
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor,
|
||||
|
||||
@@ -40,7 +40,11 @@ const STANDALONE = [
|
||||
];
|
||||
|
||||
// Fields an Azure deployment genuinely differs from its source on, by puterId.
|
||||
const OVERRIDES: Record<string, string[]> = {};
|
||||
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 IDENTITY = ['id', 'puterId', 'aliases'];
|
||||
|
||||
@@ -65,6 +69,18 @@ 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,10 +22,8 @@ import { OPEN_AI_MODELS } from '../openai/models.js';
|
||||
import { XAI_MODELS } from '../xai/models.js';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Azure shares its source model's list prices, context tiers, and limits.
|
||||
* Identity and available service speeds belong to the deployment.
|
||||
*/
|
||||
const mirror = (
|
||||
source: readonly IChatModel[],
|
||||
@@ -41,6 +39,11 @@ 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,
|
||||
};
|
||||
@@ -127,6 +130,13 @@ 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,
|
||||
@@ -171,6 +181,13 @@ 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,6 +147,22 @@ 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,6 +1173,7 @@ describe('ClaudeProvider.complete request shape', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
'claude-fable-5-2',
|
||||
'claude-fable-5-1',
|
||||
'claude-haiku-5-5',
|
||||
'claude-opus-5-5',
|
||||
@@ -1198,6 +1199,7 @@ describe('ClaudeProvider.complete request shape', () => {
|
||||
);
|
||||
|
||||
it.each([
|
||||
'claude-fable-5-2',
|
||||
'claude-fable-5-1',
|
||||
'claude-haiku-5-5',
|
||||
'claude-opus-5-5',
|
||||
@@ -1303,6 +1305,73 @@ 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);
|
||||
@@ -2311,16 +2380,20 @@ describe('ClaudeProvider metering', () => {
|
||||
expect('thinking_tokens' in overrides).toBe(false);
|
||||
});
|
||||
|
||||
it('bills at the fast_* rates when the upstream reports usage.speed:"fast"', async () => {
|
||||
it.each(['claude-opus-5-5', 'claude-fable-5-2'])('bills at fast rates for %s when the upstream reports fast usage', async (model) => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
usage: { input_tokens: 10, output_tokens: 5, speed: 'fast' },
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'claude-opus-5-5',
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
speed: 'fast',
|
||||
}),
|
||||
@@ -2331,11 +2404,15 @@ describe('ClaudeProvider metering', () => {
|
||||
expect('input_tokens' in result.usage).toBe(false);
|
||||
expect(result.usageDetails.speed).toBe('fast');
|
||||
|
||||
const opus = CLAUDE_MODELS.find((m) => m.id === 'claude-opus-5-5')!;
|
||||
const selectedModel = CLAUDE_MODELS.find((m) => m.id === model)!;
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
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));
|
||||
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);
|
||||
});
|
||||
|
||||
it('bills at standard rates when fast mode was requested but not granted by the upstream', async () => {
|
||||
@@ -2357,13 +2434,15 @@ describe('ClaudeProvider metering', () => {
|
||||
expect('fast_input_tokens' in result.usage).toBe(false);
|
||||
});
|
||||
|
||||
it('retries at standard speed on Claude when fast mode is rate limited', async () => {
|
||||
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) => {
|
||||
const { provider } = makeProvider();
|
||||
const fastLimited = Object.assign(
|
||||
new Error(
|
||||
"This request would exceed your organization's rate limit of 0 fast mode input tokens per minute",
|
||||
),
|
||||
{ status: 429 },
|
||||
new Error(message),
|
||||
{ status },
|
||||
);
|
||||
messagesCreateMock.mockRejectedValueOnce(fastLimited).mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
@@ -2587,13 +2666,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('returns fast cost keys only when the model has fast rates and fast was requested', () => {
|
||||
it('leaves token reservation keys to the selected catalog rate card', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(
|
||||
provider.requestPricing({ speed: 'fast', tools: [] } as never, opus55, {
|
||||
promptTokenEstimate: 10,
|
||||
}),
|
||||
).toMatchObject({ inputKey: 'fast_input_tokens', outputKey: 'fast_output_tokens' });
|
||||
).toEqual({});
|
||||
expect(
|
||||
provider.requestPricing({ speed: 'fast', tools: [] } as never, haiku, {
|
||||
promptTokenEstimate: 10,
|
||||
|
||||
@@ -43,6 +43,7 @@ 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,
|
||||
@@ -107,20 +108,21 @@ const toAnthropicMediaPart = (part: any): any => {
|
||||
return part;
|
||||
};
|
||||
|
||||
/** A 429 from fast mode's separate quota, not the model's normal limits. */
|
||||
const isFastModeRateLimit = (e: unknown): boolean => {
|
||||
/** Fast quota or overload failures can be retried at standard speed. */
|
||||
const isFastModeUnavailable = (e: unknown): boolean => {
|
||||
const err = e as { status?: unknown; message?: unknown };
|
||||
return (
|
||||
err?.status === 429 &&
|
||||
typeof err.message === 'string' &&
|
||||
/fast mode/i.test(err.message)
|
||||
typeof err?.message === 'string' &&
|
||||
((err?.status === 429 && /fast mode/i.test(err.message)) ||
|
||||
(err?.status === 529 && /overload|fast mode/i.test(err.message)))
|
||||
);
|
||||
};
|
||||
|
||||
// Models whose current turn rejects a non-default temperature/top_p/top_k
|
||||
// outright. Fable 5/5.1, Sonnet 5/5.5, Haiku 5.5, and Opus 4.7+.
|
||||
// outright. Fable 5/5.1/5.2, 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',
|
||||
@@ -133,6 +135,7 @@ 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',
|
||||
@@ -298,20 +301,12 @@ export class ClaudeProvider implements IChatProvider {
|
||||
return `claude:${modelId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Reserves tools that can run several times before the model answers. */
|
||||
requestPricing(
|
||||
args: ICompleteArguments,
|
||||
model: IChatModel,
|
||||
est: { promptTokenEstimate: number },
|
||||
): { inputKey?: string; outputKey?: string; extraCost?: number } {
|
||||
const fast =
|
||||
args.speed === 'fast' &&
|
||||
model.costs?.fast_output_tokens !== undefined;
|
||||
|
||||
): { extraCost?: number } {
|
||||
let extraCost = 0;
|
||||
for (const raw of args.tools ?? []) {
|
||||
const tool = raw as Record<string, unknown>;
|
||||
@@ -350,12 +345,6 @@ export class ClaudeProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
return {
|
||||
...(fast
|
||||
? {
|
||||
inputKey: 'fast_input_tokens',
|
||||
outputKey: 'fast_output_tokens',
|
||||
}
|
||||
: {}),
|
||||
...(extraCost > 0 ? { extraCost } : {}),
|
||||
};
|
||||
}
|
||||
@@ -364,11 +353,9 @@ export class ClaudeProvider implements IChatProvider {
|
||||
try {
|
||||
return await this.#completeOnce(args);
|
||||
} catch (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)) {
|
||||
// Retry fast-capacity failures on Claude at standard speed before
|
||||
// considering a different provider.
|
||||
if (args.speed === 'fast' && isFastModeUnavailable(e)) {
|
||||
return await this.#completeOnce({ ...args, speed: undefined });
|
||||
}
|
||||
throw e;
|
||||
@@ -637,7 +624,7 @@ export class ClaudeProvider implements IChatProvider {
|
||||
: undefined;
|
||||
const fast =
|
||||
args.speed === 'fast' &&
|
||||
modelUsed.costs?.fast_output_tokens !== undefined;
|
||||
modelUsed.serviceSpeedRates?.fast !== undefined;
|
||||
const safeguardsPolicy = applySafeguardsPolicy(
|
||||
args.safeguards,
|
||||
args.anthropicBetas,
|
||||
@@ -1071,6 +1058,7 @@ 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' ||
|
||||
@@ -1505,7 +1493,8 @@ export class ClaudeProvider implements IChatProvider {
|
||||
|
||||
const fast =
|
||||
raw.speed === 'fast' &&
|
||||
modelUsed.costs?.fast_output_tokens !== undefined;
|
||||
modelUsed.serviceSpeedRates?.fast !== undefined;
|
||||
// Keep published usage keys while resolving rates from the common catalog shape.
|
||||
const executorUsage: Record<string, number> = fast
|
||||
? Object.fromEntries(
|
||||
Object.entries(exec).map(([k, v]) => [`fast_${k}`, v]),
|
||||
@@ -1517,7 +1506,24 @@ export class ClaudeProvider implements IChatProvider {
|
||||
const fetches = raw.server_tool_use?.web_fetch_requests ?? 0;
|
||||
if (fetches) executorUsage.web_fetch_requests = fetches;
|
||||
|
||||
const executorCosts = buildCostsOverride(executorUsage, modelUsed);
|
||||
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);
|
||||
|
||||
let advisorUsage: Record<string, number> | undefined;
|
||||
let advisorCosts: Record<string, number> | undefined;
|
||||
|
||||
@@ -55,6 +55,46 @@ 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',
|
||||
@@ -190,6 +230,15 @@ 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,
|
||||
@@ -199,12 +248,6 @@ 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,
|
||||
@@ -223,6 +266,15 @@ 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,
|
||||
@@ -231,11 +283,6 @@ 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,
|
||||
@@ -258,6 +305,15 @@ 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,
|
||||
@@ -266,11 +322,6 @@ 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,8 +84,14 @@ export class GeminiChatProvider extends OpenAICompatProvider {
|
||||
chat: {
|
||||
completions: {
|
||||
create: (params, options) => {
|
||||
const { model } = params as { model: string };
|
||||
if (!GEMINI_NATIVE_MODELS.has(model)) {
|
||||
const { model, service_tier } = params as {
|
||||
model: string;
|
||||
service_tier?: string;
|
||||
};
|
||||
if (
|
||||
!GEMINI_NATIVE_MODELS.has(model) &&
|
||||
(service_tier === undefined || !native)
|
||||
) {
|
||||
return compat.chat.completions.create(
|
||||
params,
|
||||
options,
|
||||
@@ -103,7 +109,12 @@ export class GeminiChatProvider extends OpenAICompatProvider {
|
||||
},
|
||||
},
|
||||
};
|
||||
const models = native ? GEMINI_MODELS : COMPAT_ONLY_MODELS;
|
||||
const models: IChatModel[] = native
|
||||
? GEMINI_MODELS
|
||||
: COMPAT_ONLY_MODELS.map((model) => ({
|
||||
...model,
|
||||
serviceSpeeds: ['standard'],
|
||||
}));
|
||||
super(meteringService, {
|
||||
client,
|
||||
defaultModel: 'gemini-2.5-flash',
|
||||
@@ -183,7 +194,10 @@ export class GeminiChatProvider extends OpenAICompatProvider {
|
||||
}
|
||||
|
||||
protected override meteredUsage(source: UsageSource, model: IChatModel) {
|
||||
if (GEMINI_NATIVE_MODELS.has(model.id)) {
|
||||
if (
|
||||
GEMINI_NATIVE_MODELS.has(model.id) ||
|
||||
'promptTokenCount' in (source.usage ?? {})
|
||||
) {
|
||||
return nativeMeteredUsage(
|
||||
source.usage as unknown as
|
||||
GenerateContentResponseUsageMetadata | undefined,
|
||||
|
||||
@@ -80,6 +80,7 @@ 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. */
|
||||
@@ -240,6 +241,12 @@ 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,6 +142,7 @@ 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',
|
||||
@@ -174,6 +175,7 @@ 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,
|
||||
},
|
||||
@@ -201,6 +203,7 @@ 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,
|
||||
@@ -228,6 +231,7 @@ 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,
|
||||
},
|
||||
@@ -250,6 +254,7 @@ 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,
|
||||
},
|
||||
@@ -270,6 +275,7 @@ 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,
|
||||
},
|
||||
@@ -291,6 +297,7 @@ 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,
|
||||
@@ -318,6 +325,7 @@ 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,
|
||||
@@ -344,6 +352,7 @@ 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,
|
||||
@@ -374,6 +383,7 @@ 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,
|
||||
@@ -402,6 +412,7 @@ 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,
|
||||
@@ -448,6 +459,7 @@ 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,6 +266,15 @@ 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,6 +43,8 @@ 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)
|
||||
@@ -70,6 +72,7 @@ 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
|
||||
@@ -122,6 +125,7 @@ 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
|
||||
@@ -174,6 +178,7 @@ 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
|
||||
@@ -222,6 +227,7 @@ 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,13 +20,12 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -147,7 +146,9 @@ describe('MistralAIProvider construction', () => {
|
||||
it('constructs the Mistral SDK with the configured API key', () => {
|
||||
makeProvider();
|
||||
expect(mistralCtor).toHaveBeenCalledTimes(1);
|
||||
expect(mistralCtor).toHaveBeenCalledWith({ apiKey: 'test-key' });
|
||||
expect(mistralCtor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ apiKey: 'test-key' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -606,7 +607,11 @@ describe('MistralAIProvider.complete non-stream output', () => {
|
||||
if (stream) {
|
||||
streamMock.mockResolvedValueOnce(
|
||||
asAsyncIterable([
|
||||
{ data: { choices: [{ delta: { content: 'hello' } }] } },
|
||||
{
|
||||
data: {
|
||||
choices: [{ delta: { content: 'hello' } }],
|
||||
},
|
||||
},
|
||||
{ data: { choices: [], usage } },
|
||||
]),
|
||||
);
|
||||
@@ -643,7 +648,11 @@ 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}`,
|
||||
{
|
||||
@@ -688,8 +697,7 @@ 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,
|
||||
@@ -718,15 +726,11 @@ 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' },
|
||||
],
|
||||
@@ -916,8 +920,7 @@ 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,
|
||||
@@ -1115,7 +1118,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);
|
||||
|
||||
@@ -1175,7 +1178,8 @@ describe('MistralAIProvider image_url coercion', () => {
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: 'https://example.com/already-flat.png',
|
||||
image_url:
|
||||
'https://example.com/already-flat.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1251,7 +1255,9 @@ 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1260,7 +1266,11 @@ 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?');
|
||||
@@ -1300,3 +1310,41 @@ 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,6 +17,8 @@
|
||||
* 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';
|
||||
@@ -26,6 +28,7 @@ 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,
|
||||
@@ -106,6 +109,7 @@ 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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -115,7 +119,24 @@ 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;
|
||||
@@ -181,17 +202,22 @@ export class MistralAIProvider implements IChatProvider {
|
||||
return `mistral:${modelId}`;
|
||||
}
|
||||
|
||||
async complete({
|
||||
messages,
|
||||
stream,
|
||||
model,
|
||||
tools,
|
||||
max_tokens,
|
||||
temperature,
|
||||
normalize,
|
||||
response,
|
||||
custom,
|
||||
}: ICompleteArguments): Promise<IChatCompleteResult> {
|
||||
async complete(
|
||||
{
|
||||
messages,
|
||||
stream,
|
||||
model,
|
||||
tools,
|
||||
max_tokens,
|
||||
temperature,
|
||||
normalize,
|
||||
response,
|
||||
custom,
|
||||
service_tier,
|
||||
serviceSpeed: requestedServiceSpeed,
|
||||
}: ICompleteArguments,
|
||||
resolved?: IChatModel,
|
||||
): 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`)
|
||||
@@ -217,10 +243,13 @@ export class MistralAIProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
const selectedModel =
|
||||
(await this.models()).find((m) =>
|
||||
resolved ??
|
||||
((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']>
|
||||
@@ -242,6 +271,9 @@ 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')),
|
||||
@@ -398,7 +430,8 @@ export class MistralAIProvider implements IChatProvider {
|
||||
},
|
||||
completion: completion as ChatCompletionResponse,
|
||||
stream,
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
usage_calculator: ({ usage, setUsageCosts, setUsageDetails }) => {
|
||||
const serviceSpeed = servedServiceSpeed({ usage });
|
||||
// `coerceMistralUsage` has already taken cached reads out of
|
||||
// `prompt_tokens`.
|
||||
const metered = meterChatUsage(
|
||||
@@ -407,8 +440,16 @@ 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,6 +42,7 @@ 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,
|
||||
@@ -75,6 +76,7 @@ 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,
|
||||
@@ -105,6 +107,7 @@ 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,
|
||||
@@ -135,6 +138,7 @@ 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,
|
||||
@@ -159,6 +163,7 @@ 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,
|
||||
@@ -188,6 +193,7 @@ 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,
|
||||
@@ -213,6 +219,7 @@ 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,
|
||||
@@ -238,6 +245,7 @@ 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,
|
||||
@@ -262,6 +270,7 @@ 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,6 +30,7 @@ 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
|
||||
@@ -77,6 +78,7 @@ 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
|
||||
@@ -104,6 +106,7 @@ 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
|
||||
@@ -126,6 +129,7 @@ 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,6 +20,7 @@
|
||||
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 {
|
||||
@@ -117,6 +118,7 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
|
||||
async complete(
|
||||
params: ICompleteArguments,
|
||||
resolved?: IChatModel,
|
||||
): ReturnType<IChatProvider['complete']> {
|
||||
const {
|
||||
max_tokens,
|
||||
@@ -134,6 +136,11 @@ 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
|
||||
@@ -147,7 +154,9 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return await this.#responsesProvider.complete(params);
|
||||
return await (resolved
|
||||
? this.#responsesProvider.complete(params, resolved)
|
||||
: this.#responsesProvider.complete(params));
|
||||
}
|
||||
// Inline compaction is a Responses-API feature; chat.completions can't
|
||||
// express `context_management` or a `compaction` content block.
|
||||
@@ -161,7 +170,9 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return await this.#responsesProvider.complete(params);
|
||||
return await (resolved
|
||||
? this.#responsesProvider.complete(params, resolved)
|
||||
: 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.
|
||||
@@ -176,7 +187,9 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return await this.#responsesProvider.complete(params);
|
||||
return await (resolved
|
||||
? this.#responsesProvider.complete(params, resolved)
|
||||
: this.#responsesProvider.complete(params));
|
||||
}
|
||||
// Validate messages
|
||||
if (!Array.isArray(messages)) {
|
||||
@@ -189,9 +202,11 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
model = model ?? this.#defaultModel;
|
||||
|
||||
const modelUsed =
|
||||
this.models().find((m) =>
|
||||
resolved ??
|
||||
(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',
|
||||
@@ -248,6 +263,9 @@ 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 }
|
||||
@@ -284,7 +302,13 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
);
|
||||
|
||||
return OpenAiUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage, setUsageCosts, setUsageDetails }) => {
|
||||
usage_calculator: ({
|
||||
usage,
|
||||
service_tier,
|
||||
setUsageCosts,
|
||||
setUsageDetails,
|
||||
}) => {
|
||||
const serviceSpeed = servedServiceSpeed({ service_tier });
|
||||
// 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.
|
||||
@@ -296,13 +320,17 @@ 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,6 +19,7 @@
|
||||
|
||||
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 {
|
||||
@@ -134,38 +135,42 @@ 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,
|
||||
verbosity,
|
||||
stream,
|
||||
reasoning,
|
||||
reasoning_effort,
|
||||
temperature,
|
||||
text,
|
||||
outputFormat,
|
||||
}: ICompleteArguments): 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,
|
||||
serviceSpeed: requestedServiceSpeed,
|
||||
verbosity,
|
||||
stream,
|
||||
reasoning,
|
||||
reasoning_effort,
|
||||
temperature,
|
||||
text,
|
||||
outputFormat,
|
||||
}: ICompleteArguments,
|
||||
resolved?: IChatModel,
|
||||
): ReturnType<IChatProvider['complete']> {
|
||||
// Validate messages
|
||||
if (!Array.isArray(messages)) {
|
||||
throw new HttpError(400, '`messages` must be an array', {
|
||||
@@ -183,12 +188,13 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
model = model ?? this.#defaultModel;
|
||||
|
||||
const modelUsed =
|
||||
this.models({ no_restrictions: true }).find((m) =>
|
||||
resolved ??
|
||||
(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',
|
||||
@@ -322,6 +328,7 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
return OpenAiUtil.handle_completion_output_responses_api({
|
||||
usage_calculator: ({
|
||||
usage,
|
||||
service_tier,
|
||||
webSearchCalls,
|
||||
setUsageCosts,
|
||||
setUsageDetails,
|
||||
@@ -347,23 +354,31 @@ 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.
|
||||
webSearchCalls
|
||||
? {
|
||||
costOverrides: {
|
||||
web_search_calls:
|
||||
webSearchCalls *
|
||||
(webSearchCallRate(tools, modelUsed.id) ??
|
||||
0),
|
||||
},
|
||||
}
|
||||
: {},
|
||||
{
|
||||
serviceSpeed: servedServiceSpeed({ service_tier }),
|
||||
...(webSearchCalls
|
||||
? {
|
||||
costOverrides: {
|
||||
web_search_calls:
|
||||
webSearchCalls *
|
||||
(webSearchCallRate(
|
||||
tools,
|
||||
modelUsed.id,
|
||||
) ?? 0),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
setUsageCosts?.(metered.costs);
|
||||
setUsageDetails?.(
|
||||
OpenAiUtil.usageDetailsFromTrackedUsage(metered.usage, {
|
||||
setUsageDetails?.({
|
||||
...(requestedServiceSpeed !== undefined
|
||||
? { serviceSpeed: servedServiceSpeed({ service_tier }) }
|
||||
: {}),
|
||||
...OpenAiUtil.usageDetailsFromTrackedUsage(metered.usage, {
|
||||
writeTtl: '30m',
|
||||
})!,
|
||||
);
|
||||
});
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
|
||||
@@ -46,6 +46,7 @@ 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,
|
||||
@@ -72,6 +73,7 @@ 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,
|
||||
@@ -98,6 +100,7 @@ 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,
|
||||
@@ -124,6 +127,7 @@ 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,
|
||||
@@ -150,6 +154,7 @@ 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,
|
||||
@@ -176,6 +181,7 @@ 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,
|
||||
@@ -201,6 +207,7 @@ 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,
|
||||
@@ -226,6 +233,7 @@ 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,
|
||||
@@ -251,6 +259,13 @@ 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,
|
||||
@@ -295,6 +310,13 @@ 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,
|
||||
@@ -338,6 +360,13 @@ 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,
|
||||
@@ -425,6 +454,13 @@ 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,
|
||||
@@ -446,6 +482,13 @@ 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,
|
||||
@@ -467,6 +510,13 @@ 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,
|
||||
@@ -490,6 +540,13 @@ 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,
|
||||
@@ -509,6 +566,13 @@ 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,
|
||||
@@ -530,6 +594,13 @@ 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,
|
||||
@@ -571,6 +642,13 @@ 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,15 +20,14 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -300,16 +299,20 @@ 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 () => {
|
||||
@@ -368,9 +371,12 @@ 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 ───────────────────────────────────────────
|
||||
@@ -868,11 +874,14 @@ 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);
|
||||
@@ -928,7 +937,10 @@ 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: [
|
||||
{
|
||||
@@ -1023,7 +1035,10 @@ 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' } }] },
|
||||
@@ -1126,31 +1141,180 @@ 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(() => {});
|
||||
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 () => {
|
||||
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);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -22,8 +22,10 @@ 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 } from '../../types.js';
|
||||
import type { IChatModel, ICompleteArguments, ModelCost } from '../../types.js';
|
||||
import { BoundedTtlMap } from '../../../../util/boundedTtlMap.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';
|
||||
@@ -64,6 +66,75 @@ 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, {
|
||||
@@ -202,8 +273,17 @@ export class OpenRouterProvider extends OpenAICompatProvider {
|
||||
return { ...params, usage: { include: true } };
|
||||
}
|
||||
|
||||
protected override meteredUsage(source: UsageSource) {
|
||||
protected override meteredUsage(source: UsageSource, model: IChatModel) {
|
||||
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,13 +20,12 @@
|
||||
/**
|
||||
* 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';
|
||||
@@ -933,8 +932,7 @@ 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,
|
||||
@@ -1022,8 +1020,7 @@ 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 });
|
||||
@@ -1081,8 +1078,7 @@ 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,
|
||||
@@ -1200,3 +1196,60 @@ 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,6 +19,8 @@
|
||||
|
||||
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';
|
||||
@@ -74,7 +76,15 @@ export class XAIProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
async complete(
|
||||
{ messages, stream, model, tools, max_tokens }: ICompleteArguments,
|
||||
{
|
||||
messages,
|
||||
stream,
|
||||
model,
|
||||
tools,
|
||||
max_tokens,
|
||||
service_tier,
|
||||
serviceSpeed: requestedServiceSpeed,
|
||||
}: ICompleteArguments,
|
||||
resolved?: IChatModel,
|
||||
): Promise<IChatCompleteResult> {
|
||||
const actor = Context.get('actor');
|
||||
@@ -88,6 +98,7 @@ export class XAIProvider implements IChatProvider {
|
||||
{
|
||||
messages,
|
||||
model: modelUsed.id,
|
||||
...(service_tier !== undefined ? { service_tier } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
...(max_tokens !== undefined ? { max_tokens } : {}),
|
||||
stream,
|
||||
@@ -107,18 +118,49 @@ export class XAIProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
usage_calculator: ({
|
||||
usage,
|
||||
service_tier: servedTier,
|
||||
setUsageCosts,
|
||||
setUsageDetails,
|
||||
}) => {
|
||||
const serviceSpeed = servedServiceSpeed({
|
||||
service_tier: servedTier,
|
||||
});
|
||||
// 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,
|
||||
OpenAIUtil.splitCachedPrompt(usage),
|
||||
trackedUsage,
|
||||
{
|
||||
serviceSpeed,
|
||||
authoritativeUsd:
|
||||
reportedTicks === undefined
|
||||
? undefined
|
||||
: reportedTicks / 10_000_000_000,
|
||||
},
|
||||
);
|
||||
setUsageCosts(costs);
|
||||
if (requestedServiceSpeed !== undefined)
|
||||
setUsageDetails?.({
|
||||
...OpenAIUtil.usageDetailsFromTrackedUsage(
|
||||
trackedUsage,
|
||||
)!,
|
||||
serviceSpeed,
|
||||
...(reasoningTokens ? { reasoningTokens } : {}),
|
||||
});
|
||||
return metered;
|
||||
},
|
||||
stream,
|
||||
|
||||
@@ -42,6 +42,7 @@ 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,
|
||||
@@ -65,6 +66,7 @@ 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,
|
||||
@@ -87,6 +89,7 @@ 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,
|
||||
@@ -109,6 +112,7 @@ 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,
|
||||
@@ -151,6 +155,7 @@ 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,
|
||||
@@ -186,6 +191,7 @@ 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,
|
||||
@@ -217,6 +223,7 @@ 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,
|
||||
@@ -246,6 +253,7 @@ 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,
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
|
||||
export type ModelCost = Record<string, number>;
|
||||
|
||||
export type ServiceSpeed = 'standard' | 'fast' | 'ultrafast';
|
||||
|
||||
export interface ModelModalities {
|
||||
input: string[];
|
||||
output: string[];
|
||||
@@ -67,6 +69,22 @@ 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;
|
||||
@@ -76,6 +94,8 @@ 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.
|
||||
@@ -163,6 +183,7 @@ export interface UsageDetails {
|
||||
webFetchRequests?: number;
|
||||
speed?: 'fast' | 'standard';
|
||||
serviceTier?: string;
|
||||
serviceSpeed?: ServiceSpeed;
|
||||
/**
|
||||
* Per-pass breakdown when the upstream ran several passes (compaction,
|
||||
* advisor).
|
||||
@@ -198,6 +219,7 @@ export interface ICompleteArguments {
|
||||
* request on another provider that serves the same model.
|
||||
*/
|
||||
fallback?: boolean;
|
||||
serviceSpeed?: ServiceSpeed;
|
||||
stream?: boolean;
|
||||
model: string;
|
||||
test_mode?: boolean;
|
||||
@@ -217,10 +239,7 @@ export interface ICompleteArguments {
|
||||
taskBudget?: { total: number; remaining?: number };
|
||||
/** Claude only: top-level automatic `cache_control`. */
|
||||
cacheControl?: CacheControl;
|
||||
/**
|
||||
* Claude only; forwarded only when the model's catalog entry has
|
||||
* `fast_output_tokens`.
|
||||
*/
|
||||
/** Claude only; fast is forwarded when the catalog declares fast rates. */
|
||||
speed?: 'fast' | 'standard';
|
||||
/** Claude only. */
|
||||
safeguards?: SafeguardRequest[];
|
||||
@@ -260,7 +279,15 @@ export interface ICompleteArguments {
|
||||
truncation?: 'auto' | 'disabled' | undefined;
|
||||
background?: boolean;
|
||||
service_tier?:
|
||||
'auto' | 'default' | 'flex' | 'scale' | 'priority' | undefined;
|
||||
| 'auto'
|
||||
| 'default'
|
||||
| 'flex'
|
||||
| 'scale'
|
||||
| 'priority'
|
||||
| 'fast'
|
||||
| 'ultrafast'
|
||||
| 'standard'
|
||||
| undefined;
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
reasoning?: { effort: ReasoningEffort } | undefined;
|
||||
@@ -328,6 +355,10 @@ 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,12 +650,15 @@ 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;
|
||||
@@ -746,6 +749,7 @@ 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) => {
|
||||
@@ -779,6 +783,7 @@ 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;
|
||||
@@ -787,6 +792,8 @@ 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 ??
|
||||
@@ -918,6 +925,7 @@ 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),
|
||||
@@ -972,6 +980,7 @@ 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: (
|
||||
@@ -1066,6 +1075,7 @@ 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,13 +19,15 @@
|
||||
|
||||
import type { Actor } from '../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../services/metering/MeteringService.js';
|
||||
import type { IChatModel } from '../types.js';
|
||||
import type { IChatModel, ServiceSpeed } from '../types.js';
|
||||
import { serviceSpeedBillingModel } from './serviceSpeed.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.
|
||||
@@ -77,10 +79,15 @@ export const meterChatUsage = (
|
||||
authoritativeUsd,
|
||||
costOverrides,
|
||||
pricingTime,
|
||||
serviceSpeed = 'standard',
|
||||
}: MeterChatUsageOptions = {},
|
||||
): { usage: Record<string, number>; costs: Record<string, number> } => {
|
||||
const priced = {
|
||||
...buildCostsOverride(usage, model, pricingTime),
|
||||
...buildCostsOverride(
|
||||
usage,
|
||||
serviceSpeedBillingModel(model, serviceSpeed),
|
||||
pricingTime,
|
||||
),
|
||||
...costOverrides,
|
||||
};
|
||||
if (authoritativeUsd !== undefined) {
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
/*
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
* 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,
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -413,6 +413,8 @@ describe('PuterServer host header validation', () => {
|
||||
'show-open-file-picker',
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'request-email-confirmation',
|
||||
])('lets a deployment subdomain frame the %s popup and names it to the GUI', async (action) => {
|
||||
const parent = `http://spreadsheet.puter.localhost:${port}`;
|
||||
const guiParams = (body: string) =>
|
||||
@@ -445,7 +447,7 @@ describe('PuterServer host header validation', () => {
|
||||
expect(redirected.headers['x-frame-options']).toBe('SAMEORIGIN');
|
||||
});
|
||||
|
||||
it.each(['/embed/SIGN-IN', '/embed/Show-Open-File-Picker', '/embed/sign-in/'])(
|
||||
it.each(['/embed/SIGN-IN', '/embed/Show-Open-File-Picker', '/embed/sign-in/', '/embed/Upgrade', '/embed/upgrade/', '/embed/Request-Email-Confirmation'])(
|
||||
'lets a first-party page frame only the exact spelling of %s',
|
||||
async (spelling) => {
|
||||
const res = await request(spelling, {
|
||||
|
||||
@@ -30,6 +30,7 @@ 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). |
|
||||
@@ -138,6 +139,8 @@ 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
|
||||
@@ -370,10 +373,20 @@ 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.
|
||||
@@ -418,6 +431,8 @@ 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.
|
||||
@@ -442,6 +457,8 @@ 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.
|
||||
@@ -543,6 +560,8 @@ 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.
|
||||
@@ -557,6 +576,30 @@ 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
|
||||
@@ -1092,3 +1135,23 @@ 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>
|
||||
```
|
||||
@@ -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`, 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`, `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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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`, 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`, `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.
|
||||
|
||||
#### `message` (String)
|
||||
|
||||
|
||||
@@ -49,6 +49,12 @@ 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.',
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
@@ -226,6 +226,11 @@ 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):
|
||||
|
||||
+7
-44
@@ -18,7 +18,9 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
@@ -31,11 +33,9 @@ import UIWindow from './UI/UIWindow.js';
|
||||
import UIWindowAppFeedback from './UI/UIWindowAppFeedback.js';
|
||||
import UIWindowAttachCustomDomain from './UI/UIWindowAttachCustomDomain.js';
|
||||
import UIWindowColorPicker from './UI/UIWindowColorPicker.js';
|
||||
import UIWindowEmailConfirmationRequired from './UI/UIWindowEmailConfirmationRequired.js';
|
||||
import UIWindowFontPicker from './UI/UIWindowFontPicker.js';
|
||||
import UIPermissionDialog from './UI/UIPermissionDialog.js';
|
||||
import UIEnablePushDialog from './UI/UIEnablePushDialog.js';
|
||||
import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js';
|
||||
import UIAuthSignup from './UI/Auth/UIAuthSignup.js';
|
||||
import UINotification from './UI/UINotification.js';
|
||||
|
||||
@@ -235,49 +235,12 @@ const ipc_listener = async (event, handled) => {
|
||||
// requestEmailConfirmation
|
||||
//--------------------------------------------------------
|
||||
else if ( event.data.msg === 'requestEmailConfirmation' ) {
|
||||
// If the user has an email and it is confirmed, respond with success
|
||||
if ( window.user.email && window.user.email_confirmed ) {
|
||||
target_iframe.contentWindow.postMessage({
|
||||
original_msg_id: msg_id,
|
||||
msg: 'requestEmailConfirmationResponded',
|
||||
response: true,
|
||||
}, '*');
|
||||
}
|
||||
|
||||
// If the user is a temporary user, show the save account window
|
||||
if ( window.user.is_temp &&
|
||||
!await UIWindowSaveAccount({
|
||||
send_confirmation_code: true,
|
||||
message: 'Please create an account to proceed.',
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
}) ) {
|
||||
target_iframe.contentWindow.postMessage({
|
||||
original_msg_id: msg_id,
|
||||
msg: 'requestEmailConfirmationResponded',
|
||||
response: false,
|
||||
}, '*');
|
||||
return;
|
||||
}
|
||||
else if ( !window.user.email_confirmed && !await UIWindowEmailConfirmationRequired() ) {
|
||||
target_iframe.contentWindow.postMessage({
|
||||
original_msg_id: msg_id,
|
||||
msg: 'requestEmailConfirmationResponded',
|
||||
response: false,
|
||||
}, '*');
|
||||
return;
|
||||
}
|
||||
|
||||
const email_confirm_resp = await UIWindowEmailConfirmationRequired({
|
||||
email: window.user.email,
|
||||
});
|
||||
|
||||
// Above the app's window, which stays on top in the dashboard.
|
||||
const confirmed = await ensureConfirmedEmail(i18n('save_account_to_proceed'), { stay_on_top: true });
|
||||
target_iframe.contentWindow.postMessage({
|
||||
original_msg_id: msg_id,
|
||||
msg: 'requestEmailConfirmationResponded',
|
||||
response: email_confirm_resp,
|
||||
response: confirmed,
|
||||
}, '*');
|
||||
}
|
||||
//--------------------------------------------------------
|
||||
@@ -1740,7 +1703,7 @@ const ipc_listener = async (event, handled) => {
|
||||
$(`.item[data-uid="${res.uid}"]`).removeItems();
|
||||
// now add new items
|
||||
UIItem({
|
||||
appendTo: $(`.item-container[data-path="${html_encode(path.dirname(target_path))}" i]`),
|
||||
appendTo: atPath('.item-container', path.dirname(target_path)),
|
||||
immutable: res.immutable || res.writable === false,
|
||||
associated_app_name: res.associated_app?.name,
|
||||
path: target_path,
|
||||
@@ -1754,7 +1717,7 @@ const ipc_listener = async (event, handled) => {
|
||||
suggested_apps: res.suggested_apps,
|
||||
});
|
||||
// sort each window
|
||||
$(`.item-container[data-path="${html_encode(path.dirname(target_path))}" i]`).each(function () {
|
||||
atPath('.item-container', path.dirname(target_path)).each(function () {
|
||||
window.sort_items(this, $(this).attr('data-sort_by'), $(this).attr('data-sort_order'));
|
||||
});
|
||||
$(el_filedialog_window).close();
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
// @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 { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from './lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
globalThis.def = globalThis.def ?? ((cls) => cls);
|
||||
globalThis.use = globalThis.use ?? (() => ({}));
|
||||
globalThis.$ = globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key) => key;
|
||||
|
||||
const { stub, confirmEmail, saveAccount } = vi.hoisted(() => ({
|
||||
stub: () => ({ default: () => {} }),
|
||||
confirmEmail: vi.fn(),
|
||||
saveAccount: vi.fn(),
|
||||
}));
|
||||
vi.mock('./helpers/download.js', stub);
|
||||
vi.mock('./helpers/itemIcon.js', stub);
|
||||
vi.mock('./helpers/socialLink.js', stub);
|
||||
vi.mock('./helpers/updateMousePosition.js', stub);
|
||||
vi.mock('./UI/UIAlert.js', stub);
|
||||
vi.mock('./UI/UIContextMenu.js', stub);
|
||||
vi.mock('./UI/UIItem.js', stub);
|
||||
vi.mock('./UI/UIPopover.js', stub);
|
||||
vi.mock('./UI/UIPrompt.js', stub);
|
||||
vi.mock('./UI/UIWindow.js', stub);
|
||||
vi.mock('./UI/UIWindowAppFeedback.js', stub);
|
||||
vi.mock('./UI/UIWindowColorPicker.js', stub);
|
||||
vi.mock('./UI/UIWindowEmailConfirmationRequired.js', () => ({ default: confirmEmail }));
|
||||
vi.mock('./UI/UIWindowFontPicker.js', stub);
|
||||
vi.mock('./UI/UIPermissionDialog.js', stub);
|
||||
vi.mock('./UI/UIEnablePushDialog.js', stub);
|
||||
vi.mock('./UI/UIWindowSaveAccount.js', () => ({ default: saveAccount }));
|
||||
vi.mock('./UI/Auth/UIAuthSignup.js', stub);
|
||||
vi.mock('./UI/UINotification.js', stub);
|
||||
vi.mock('./helpers/godmodeTokens.js', () => ({ renewGodmodeToken: vi.fn() }));
|
||||
vi.mock('./helpers/watchItems.js', () => ({ addItemWatcher: vi.fn() }));
|
||||
vi.mock('./helpers/verification_gates.js', () => ({
|
||||
openVerificationGateWindow: vi.fn(),
|
||||
}));
|
||||
|
||||
let iframe;
|
||||
let posted;
|
||||
|
||||
beforeAll(async () => {
|
||||
window.when_puter_happens = [];
|
||||
window.appCallbackFunctions = {};
|
||||
await import('./IPC.js');
|
||||
globalThis.puter = { services: undefined, util: { rpc: { send: vi.fn() } } };
|
||||
for (const fn of window.when_puter_happens) await fn();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
confirmEmail.mockReset();
|
||||
saveAccount.mockReset();
|
||||
document.body.innerHTML = '';
|
||||
const win = document.createElement('div');
|
||||
win.setAttribute('data-app_uuid', 'app-1');
|
||||
iframe = document.createElement('iframe');
|
||||
iframe.setAttribute('data-app', 'notes');
|
||||
win.append(iframe);
|
||||
document.body.append(win);
|
||||
posted = vi.spyOn(iframe.contentWindow, 'postMessage').mockImplementation(() => {});
|
||||
window.app_instance_ids = new Set(['inst-1']);
|
||||
window.iframe_for_app_instance = (id) => (id === 'inst-1' ? iframe : null);
|
||||
window.window_for_app_instance = () => win;
|
||||
window.is_auth = () => true;
|
||||
window.ipc_handlers = {};
|
||||
globalThis.services = { get: () => ({ get_by_uuid: () => ({ name: 'caller', uuid: 'inst-1' }) }) };
|
||||
});
|
||||
|
||||
const request = () =>
|
||||
window.dispatchEvent(
|
||||
new MessageEvent('message', {
|
||||
data: { appInstanceID: 'inst-1', env: 'app', msg: 'requestEmailConfirmation', uuid: 'cb-1' },
|
||||
source: iframe.contentWindow,
|
||||
origin: 'https://app.example',
|
||||
}),
|
||||
);
|
||||
|
||||
const settle = () => new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const replies = () => posted.mock.calls
|
||||
.map(([data]) => data)
|
||||
.filter((data) => data.msg === 'requestEmailConfirmationResponded');
|
||||
|
||||
describe('requestEmailConfirmation', () => {
|
||||
it('answers a confirmed email true once, without a dialog', async () => {
|
||||
window.user = { email: '[email protected]', email_confirmed: true };
|
||||
request();
|
||||
await settle();
|
||||
expect(replies()).toEqual([{ original_msg_id: 'cb-1', msg: 'requestEmailConfirmationResponded', response: true }]);
|
||||
expect(confirmEmail).not.toHaveBeenCalled();
|
||||
expect(saveAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([true, false])('shows an unconfirmed email one dialog and answers its result, %s', async (answer) => {
|
||||
window.user = { email: '[email protected]', email_confirmed: false };
|
||||
confirmEmail.mockResolvedValueOnce(answer);
|
||||
request();
|
||||
await settle();
|
||||
expect(confirmEmail).toHaveBeenCalledTimes(1);
|
||||
// The app's window stays on top in the dashboard, so the dialog has to as well.
|
||||
expect(confirmEmail).toHaveBeenCalledWith({ window_options: { stay_on_top: true } });
|
||||
expect(replies()).toEqual([{ original_msg_id: 'cb-1', msg: 'requestEmailConfirmationResponded', response: answer }]);
|
||||
});
|
||||
|
||||
it('answers false once when a temporary account isn\'t saved', async () => {
|
||||
window.user = { is_temp: true };
|
||||
saveAccount.mockResolvedValueOnce(false);
|
||||
request();
|
||||
await settle();
|
||||
expect(saveAccount).toHaveBeenCalledTimes(1);
|
||||
expect(saveAccount.mock.calls[0][0].window_options).toMatchObject({ stay_on_top: true });
|
||||
expect(confirmEmail).not.toHaveBeenCalled();
|
||||
expect(replies()).toEqual([{ original_msg_id: 'cb-1', msg: 'requestEmailConfirmationResponded', response: false }]);
|
||||
});
|
||||
|
||||
it('asks a temporary account that confirmed while saving for nothing more', async () => {
|
||||
window.user = { is_temp: true };
|
||||
saveAccount.mockImplementationOnce(async () => {
|
||||
window.user = { is_temp: false, email: '[email protected]', email_confirmed: true };
|
||||
return true;
|
||||
});
|
||||
request();
|
||||
await settle();
|
||||
expect(confirmEmail).not.toHaveBeenCalled();
|
||||
expect(replies()).toEqual([{ original_msg_id: 'cb-1', msg: 'requestEmailConfirmationResponded', response: true }]);
|
||||
});
|
||||
});
|
||||
@@ -111,8 +111,8 @@ const runVerificationGates = async (user, show_email, reload_on_success, page) =
|
||||
let email_verified = true;
|
||||
const emailGate = async () => {
|
||||
if ( ! show_email ) return;
|
||||
// Its ✕ would close it without an answer, leaving the caller
|
||||
// waiting on a card that is already gone.
|
||||
// No ✕: the signup card it replaced is already gone, so there's
|
||||
// nothing to go back to.
|
||||
email_verified = await UIWindowEmailConfirmationRequired({
|
||||
show_close_button: false,
|
||||
stay_on_top: true,
|
||||
|
||||
@@ -50,7 +50,7 @@ import { parent_path_for, parse_shared_path, shared_crumbs_for, shared_uids_from
|
||||
import resolve_shared_item from '../../helpers/resolveSharedItem.js';
|
||||
import { is_owned_by_me } from '../../helpers/pathOwner.js';
|
||||
import parse_item_metadata from '../../helpers/parseItemMetadata.js';
|
||||
import { compareSharers, filterBySharer, fitSharedByWidth, sharedByCellHtml, sharerFilterHtml, summarizeSharers } from './sharedBy.js';
|
||||
import { compareSharers, filterBySharer, fitSharedByWidth, sharedByCellHtml, sharerFilterHtml, sharerOf, summarizeSharers } from './sharedBy.js';
|
||||
import { paintAvatars } from '../../helpers/profilePictures.js';
|
||||
|
||||
const { html_encode, SelectionArea } = window;
|
||||
@@ -2983,7 +2983,7 @@ const TabFiles = {
|
||||
// Rebuilding the chips drops focus and scroll; carry both across.
|
||||
const focused = $bar.find('.sharer-chip:focus').attr('data-sharer');
|
||||
const scrollLeft = bar.scrollLeft;
|
||||
const html = sharerFilterHtml(sharers, this.sharerFilter);
|
||||
const html = sharerFilterHtml(sharers, this.sharerFilter, rows.filter((row) => sharerOf(row)).length);
|
||||
$bar.html(html).prop('hidden', ! html);
|
||||
paintAvatars(bar);
|
||||
bar.scrollLeft = scrollLeft;
|
||||
@@ -6011,8 +6011,10 @@ const TabFiles = {
|
||||
shared_with_me: true,
|
||||
share_mode: share.mode,
|
||||
shared_by: share.issuer,
|
||||
shared_by_all: share.issuers,
|
||||
owner: share.owner,
|
||||
holder_team: share.holderTeam,
|
||||
also_team_share: share.alsoHeldByTeam,
|
||||
}));
|
||||
|
||||
const linked = this.pendingSharedLinks ?? [];
|
||||
|
||||
@@ -1667,6 +1667,34 @@ describe('TabFiles listSharedView', () => {
|
||||
});
|
||||
expect(rows[1].holder_team).toEqual(team);
|
||||
});
|
||||
|
||||
it('merges the grants on one item into a single row', async () => {
|
||||
const team = { uid: 't-1', name: 'Acme', handle: 'acme' };
|
||||
const grant = { entryUid: 'u-1', name: 'a.txt', path: '/bob/u-1/a.txt', isDir: false, owner: 'bob' };
|
||||
window.puter = {
|
||||
fs: {
|
||||
listShared: vi.fn(async () => ({
|
||||
items: [
|
||||
{ ...grant, mode: 'read', issuer: 'bob', holderTeam: team },
|
||||
{ ...grant, mode: 'manage', issuer: 'bob' },
|
||||
{ ...grant, mode: 'read', issuer: 'carol' },
|
||||
],
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
const rows = await TabFiles.listSharedView();
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
uid: 'u-1',
|
||||
share_mode: 'manage',
|
||||
shared_by: 'bob',
|
||||
shared_by_all: ['bob', 'carol'],
|
||||
also_team_share: true,
|
||||
});
|
||||
expect(rows[0].holder_team).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles path field', () => {
|
||||
|
||||
@@ -22,9 +22,10 @@
|
||||
|
||||
import { avatarHue, avatarInitial } from '../../helpers/shareAvatar.js';
|
||||
import { avatarUserAttr } from '../../helpers/profilePictures.js';
|
||||
import { intlLocale } from '../../i18n/intlLocale.js';
|
||||
|
||||
/**
|
||||
* @typedef {{ shared_by?: string | null, owner?: string | null }} SharedRow
|
||||
* @typedef {{ shared_by?: string | null, shared_by_all?: string[], owner?: string | null }} SharedRow
|
||||
* @typedef {{ name: string, count: number }} Sharer
|
||||
*/
|
||||
|
||||
@@ -37,6 +38,19 @@ import { avatarUserAttr } from '../../helpers/profilePictures.js';
|
||||
*/
|
||||
export const sharerOf = (row) => row?.shared_by || row?.owner || '';
|
||||
|
||||
/**
|
||||
* Everyone who shared a row with the user, `sharerOf` first. More than one
|
||||
* when the item was granted by several people.
|
||||
*
|
||||
* @param {SharedRow} [row]
|
||||
* @returns {string[]} usernames
|
||||
*/
|
||||
export const sharersOf = (row) => {
|
||||
const first = sharerOf(row);
|
||||
if ( ! row?.shared_by_all?.length ) return first ? [first] : [];
|
||||
return [...new Set([first, ...row.shared_by_all])].filter(Boolean);
|
||||
};
|
||||
|
||||
/**
|
||||
* Everyone who shared something in `rows`, with how many items each, most
|
||||
* items first and ties by name.
|
||||
@@ -47,8 +61,7 @@ export const sharerOf = (row) => row?.shared_by || row?.owner || '';
|
||||
export const summarizeSharers = (rows) => {
|
||||
const counts = new Map();
|
||||
for ( const row of rows ) {
|
||||
const name = sharerOf(row);
|
||||
if ( name ) counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
for ( const name of sharersOf(row) ) counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
}
|
||||
return [...counts]
|
||||
.map(([name, count]) => ({ name, count }))
|
||||
@@ -56,7 +69,8 @@ export const summarizeSharers = (rows) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* The rows `sharer` shared; every row when `sharer` is empty.
|
||||
* The rows `sharer` shared, each shown as shared by them; every row when
|
||||
* `sharer` is empty.
|
||||
*
|
||||
* @template {SharedRow} T
|
||||
* @param {T[]} rows
|
||||
@@ -64,7 +78,9 @@ export const summarizeSharers = (rows) => {
|
||||
* @returns {T[]}
|
||||
*/
|
||||
export const filterBySharer = (rows, sharer) => (sharer
|
||||
? rows.filter((row) => sharerOf(row) === sharer)
|
||||
? rows
|
||||
.filter((row) => sharersOf(row).includes(sharer))
|
||||
.map((row) => (sharerOf(row) === sharer ? row : { ...row, shared_by: sharer }))
|
||||
: rows);
|
||||
|
||||
/**
|
||||
@@ -80,6 +96,21 @@ export const compareSharers = (a, b) => compareNames(sharerOf(a), sharerOf(b));
|
||||
// one per call, which made sorting a large Shared listing over 20x slower.
|
||||
const compareNames = new Intl.Collator(undefined, { sensitivity: 'base' }).compare;
|
||||
|
||||
/**
|
||||
* Names joined the way the user's language lists them ("ann and bob").
|
||||
*
|
||||
* @param {string[]} names
|
||||
* @returns {string}
|
||||
*/
|
||||
const listNames = (names) => {
|
||||
if ( names.length === 1 ) return names[0];
|
||||
try {
|
||||
return new Intl.ListFormat(intlLocale(window.locale ?? 'en'), { type: 'conjunction' }).format(names);
|
||||
} catch {
|
||||
return names.join(', ');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The "Shared by" column's width beside an auto-width name column: `width`,
|
||||
* held to 40% of the `room` the two split so the name keeps the larger part,
|
||||
@@ -102,15 +133,16 @@ export const fitSharedByWidth = (width, room) => Math.max(60, Math.min(width, Ma
|
||||
const avatarHtml = (name, className) => `<span class="${className}" style="--share-avatar-hue: ${avatarHue(name)}"${avatarUserAttr(name)} aria-hidden="true">${window.html_encode(avatarInitial(name))}</span>`;
|
||||
|
||||
/**
|
||||
* Tooltip for a row's sharer, naming the owner too when someone else re-shared it.
|
||||
* Tooltip for a row's sharers, naming the owner too when none of them owns it.
|
||||
*
|
||||
* @param {SharedRow} row
|
||||
* @returns {string} plain text
|
||||
*/
|
||||
export const sharedByTitle = (row) => {
|
||||
const username = sharerOf(row);
|
||||
if ( ! username ) return '';
|
||||
return row.owner && row.owner !== username
|
||||
const sharers = sharersOf(row);
|
||||
if ( sharers.length === 0 ) return '';
|
||||
const username = listNames(sharers);
|
||||
return row.owner && ! sharers.includes(row.owner)
|
||||
? i18n('shared_by_user_owned_by', { username, owner: row.owner }, false)
|
||||
: i18n('shared_by_user', { username }, false);
|
||||
};
|
||||
@@ -137,11 +169,11 @@ export const sharedByCellHtml = (row) => {
|
||||
*
|
||||
* @param {Sharer[]} sharers - From summarizeSharers
|
||||
* @param {string | null} active - The sharer filtered to, or null for everyone
|
||||
* @param {number} total - Items anyone shared; an item counts once however many shared it
|
||||
* @returns {string} markup
|
||||
*/
|
||||
export const sharerFilterHtml = (sharers, active) => {
|
||||
export const sharerFilterHtml = (sharers, active, total) => {
|
||||
if ( sharers.length < 2 ) return '';
|
||||
const total = sharers.reduce((sum, sharer) => sum + sharer.count, 0);
|
||||
const chip = (value, inner, count, title) => {
|
||||
const pressed = (active ?? '') === value;
|
||||
return `<button type="button" class="sharer-chip${pressed ? ' active' : ''}" data-sharer="${window.html_encode(value)}" aria-pressed="${pressed}"${title ? ` title="${window.html_encode(title)}"` : ''}>`
|
||||
|
||||
@@ -40,6 +40,13 @@ const {
|
||||
} = await import('./sharedBy.js');
|
||||
|
||||
const row = (shared_by, owner = shared_by, name = 'f') => ({ shared_by, owner, name });
|
||||
// An item granted by more than one person: `shared_by` holds the strongest grant's.
|
||||
const multiRow = (shared_by_all, owner = shared_by_all[0], name = 'f') => ({
|
||||
shared_by: shared_by_all[0],
|
||||
shared_by_all,
|
||||
owner,
|
||||
name,
|
||||
});
|
||||
|
||||
describe('sharerOf', () => {
|
||||
it('names whoever granted the share', () => {
|
||||
@@ -74,6 +81,13 @@ describe('summarizeSharers', () => {
|
||||
it('skips rows that name nobody', () => {
|
||||
expect(summarizeSharers([{}, row('ann')])).toEqual([{ name: 'ann', count: 1 }]);
|
||||
});
|
||||
|
||||
it('counts an item for everyone who shared it', () => {
|
||||
expect(summarizeSharers([multiRow(['bob', 'ann']), row('ann')])).toEqual([
|
||||
{ name: 'ann', count: 2 },
|
||||
{ name: 'bob', count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterBySharer', () => {
|
||||
@@ -83,6 +97,20 @@ describe('filterBySharer', () => {
|
||||
expect(filterBySharer(rows, 'ann').map((r) => r.name)).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('keeps an item for each person who shared it', () => {
|
||||
const shared = [multiRow(['bob', 'ann'], 'ann', 'both'), row('cy', 'cy', 'c')];
|
||||
expect(filterBySharer(shared, 'ann').map((r) => r.name)).toEqual(['both']);
|
||||
expect(filterBySharer(shared, 'bob').map((r) => r.name)).toEqual(['both']);
|
||||
});
|
||||
|
||||
it('shows a kept item as shared by the person filtered to', () => {
|
||||
const [both, own] = filterBySharer([multiRow(['bob', 'ann'], 'ann', 'both'), row('ann', 'ann', 'own')], 'ann');
|
||||
expect(sharerOf(both)).toBe('ann');
|
||||
expect(sharedByCellHtml(both)).toContain('<span class="item-shared-by-name">ann</span>');
|
||||
expect(sharedByTitle(both)).toBe('shared_by_user:ann and bob');
|
||||
expect(sharerOf(own)).toBe('ann');
|
||||
});
|
||||
|
||||
it('keeps everything without a filter', () => {
|
||||
expect(filterBySharer(rows, null)).toBe(rows);
|
||||
expect(filterBySharer(rows, '')).toBe(rows);
|
||||
@@ -123,6 +151,11 @@ describe('sharedByTitle', () => {
|
||||
it('names the owner too when someone else re-shared it', () => {
|
||||
expect(sharedByTitle(row('ann', 'bob'))).toBe('shared_by_user_owned_by:ann,bob');
|
||||
});
|
||||
|
||||
it('names everyone who shared the item', () => {
|
||||
expect(sharedByTitle(multiRow(['bob', 'ann'], 'ann'))).toBe('shared_by_user:bob and ann');
|
||||
expect(sharedByTitle(multiRow(['bob', 'cy'], 'ann'))).toBe('shared_by_user_owned_by:bob and cy,ann');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sharedByCellHtml', () => {
|
||||
@@ -155,7 +188,7 @@ describe('sharerFilterHtml', () => {
|
||||
});
|
||||
|
||||
it('leads with Everyone, counting every item', () => {
|
||||
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], null);
|
||||
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], null, 3);
|
||||
const chips = h.match(/<button[^>]*>/g);
|
||||
expect(chips).toHaveLength(3);
|
||||
expect(chips[0]).toContain('data-sharer=""');
|
||||
@@ -165,6 +198,11 @@ describe('sharerFilterHtml', () => {
|
||||
expect(h).toContain('data-avatar-user="bob"');
|
||||
});
|
||||
|
||||
it('counts an item shared by two people once under Everyone', () => {
|
||||
const h = sharerFilterHtml([{ name: 'ann', count: 2 }, { name: 'bob', count: 1 }], null, 2);
|
||||
expect(h).toContain('shared_filter_everyone:</span><span class="sharer-chip-count">2</span>');
|
||||
});
|
||||
|
||||
it('presses the active sharer only', () => {
|
||||
const h = sharerFilterHtml([{ name: 'bob', count: 2 }, { name: 'ann', count: 1 }], 'ann');
|
||||
const pressed = h.match(/<button[^>]*aria-pressed="true"[^>]*>/g);
|
||||
|
||||
+17
-42
@@ -34,6 +34,7 @@ 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';
|
||||
@@ -416,12 +417,12 @@ async function UIDesktop (options) {
|
||||
}
|
||||
|
||||
// hide all UIItems with matching uids
|
||||
$(`.item[data-path='${item.path}']`).fadeOut(150, function () {
|
||||
atPath('.item', 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 but the `i` selector doesn't work on ^=
|
||||
$(`.window[data-path^="${item.path}/"]`).close();
|
||||
// todo this has to be case-insensitive
|
||||
underPath('.window', item.path).close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -459,20 +460,7 @@ async function UIDesktop (options) {
|
||||
$(`.item[data-uid='${item.uid}']`).attr('data-path', new_path);
|
||||
$(`.window-${item.uid}`).attr('data-path', new_path);
|
||||
|
||||
// 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);
|
||||
});
|
||||
retargetPaths(item.old_path, new_path, item.name);
|
||||
|
||||
// Update all exact-matching windows
|
||||
$(`.window-${item.uid}`).each(function () {
|
||||
@@ -507,7 +495,7 @@ async function UIDesktop (options) {
|
||||
const metadata = parse_item_metadata(fsentry.metadata);
|
||||
|
||||
// update all shortcut_to_path
|
||||
$(`.item[data-shortcut_to_path="${html_encode(resp.old_path)}" i]`).attr('data-shortcut_to_path', html_encode(fsentry.path));
|
||||
atPath('.item', resp.old_path, 'data-shortcut_to_path').attr('data-shortcut_to_path', fsentry.path);
|
||||
|
||||
// remove all items with matching uids
|
||||
$(`.item[data-uid='${fsentry.uid}']`).fadeOut(150, function () {
|
||||
@@ -524,16 +512,16 @@ async function UIDesktop (options) {
|
||||
|
||||
// if trashing, close windows of trashed items and its descendants
|
||||
if ( dest_path === window.trash_path ) {
|
||||
$(`.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();
|
||||
atPath('.window', resp.old_path).close();
|
||||
// todo this has to be case-insensitive
|
||||
underPath('.window', resp.old_path).close();
|
||||
}
|
||||
|
||||
// update all paths of its and its descendants' open windows
|
||||
else {
|
||||
// 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));
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -560,7 +548,7 @@ async function UIDesktop (options) {
|
||||
|
||||
// create new item on matching containers
|
||||
UIItem({
|
||||
appendTo: $(`.item-container[data-path='${html_encode(dest_path)}' i]`),
|
||||
appendTo: atPath('.item-container', dest_path),
|
||||
immutable: fsentry.immutable || fsentry.writable === false,
|
||||
uid: fsentry.uid,
|
||||
path: fsentry.path,
|
||||
@@ -585,8 +573,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 = $(`.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 ) {
|
||||
let item_container = atPath('.item-container', path.dirname(dir.path));
|
||||
if ( item_container.length > 0 && atPath('.item', dir.path).length === 0 ) {
|
||||
UIItem({
|
||||
appendTo: item_container,
|
||||
immutable: false,
|
||||
@@ -606,7 +594,7 @@ async function UIDesktop (options) {
|
||||
});
|
||||
}
|
||||
//sort each container
|
||||
$(`.item-container[data-path='${html_encode(dest_path)}' i]`).each(function () {
|
||||
atPath('.item-container', dest_path).each(function () {
|
||||
window.sort_items(this, $(this).attr('data-sort_by'), $(this).attr('data-sort_order'));
|
||||
});
|
||||
});
|
||||
@@ -693,20 +681,7 @@ async function UIDesktop (options) {
|
||||
$(`.item[data-uid='${item.uid}']`).attr('data-path', new_path);
|
||||
$(`.window-${item.uid}`).attr('data-path', new_path);
|
||||
|
||||
// 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);
|
||||
});
|
||||
retargetPaths(item.old_path, new_path, item.name);
|
||||
|
||||
// Update all exact-matching windows
|
||||
$(`.window-${item.uid}`).each(function () {
|
||||
|
||||
+12
-20
@@ -18,9 +18,8 @@
|
||||
*/
|
||||
|
||||
import UIWindowItemProperties from './UIWindowItemProperties.js';
|
||||
import UIWindowSaveAccount from './UIWindowSaveAccount.js';
|
||||
import ensureConfirmedEmail from '../helpers/ensureConfirmedEmail.js';
|
||||
import UIPopover from './UIPopover.js';
|
||||
import UIWindowEmailConfirmationRequired from './UIWindowEmailConfirmationRequired.js';
|
||||
import UIContextMenu from './UIContextMenu.js';
|
||||
import UIAlert from './UIAlert.js';
|
||||
import UIWindowPublishWorker from './UIWindowPublishWorker.js';
|
||||
@@ -36,6 +35,7 @@ import { is_owned_by_me } from '../helpers/pathOwner.js';
|
||||
import { can_rename, can_restructure, invalidate_shared_roots, shared_mode_for } from '../helpers/sharedAccess.js';
|
||||
import { is_default_app } from '../helpers/defaultApp.js';
|
||||
import openWithAI from '../helpers/openWithAI.js';
|
||||
import refresh_item_container from '../helpers/refreshItemContainer.js';
|
||||
|
||||
async function UIItem (options) {
|
||||
const matching_appendto_count = $(options.appendTo).length;
|
||||
@@ -65,6 +65,7 @@ async function UIItem (options) {
|
||||
options.share_mode = options.share_mode ?? '';
|
||||
options.shared_by = options.shared_by ?? '';
|
||||
options.is_team_share = options.is_team_share ?? false;
|
||||
options.is_also_team_share = options.is_also_team_share ?? false;
|
||||
options.owner = options.owner ?? '';
|
||||
options.metadata = options.metadata ?? '';
|
||||
options.multiselectable = (options.multiselectable === undefined || options.multiselectable === true) ? true : false;
|
||||
@@ -103,6 +104,7 @@ async function UIItem (options) {
|
||||
data-share_mode="${html_encode(options.share_mode)}"
|
||||
data-shared_by="${html_encode(options.shared_by)}"
|
||||
data-is_team_share="${options.is_team_share ? 1 : 0}"
|
||||
data-is_also_team_share="${options.is_also_team_share ? 1 : 0}"
|
||||
data-owner="${html_encode(options.owner)}"
|
||||
data-has_website="${show_website_badge ? 1 : 0 }"
|
||||
data-website_url = "${website_url ? html_encode(website_url) : ''}"
|
||||
@@ -1255,23 +1257,7 @@ async function UIItem (options) {
|
||||
menu_items.push({
|
||||
html: i18n('publish_as_serverless_worker'),
|
||||
onClick: async function () {
|
||||
if ( window.user.is_temp &&
|
||||
!await UIWindowSaveAccount({
|
||||
send_confirmation_code: true,
|
||||
message: 'Please create an account to proceed.',
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
}) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
else if ( !window.user.email_confirmed && !await UIWindowEmailConfirmationRequired() )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! await ensureConfirmedEmail(i18n('save_account_to_publish')) ) return;
|
||||
UIWindowPublishWorker(options.uid, $(el_item).attr('data-name'), $(el_item).attr('data-path'));
|
||||
},
|
||||
});
|
||||
@@ -1546,7 +1532,13 @@ async function UIItem (options) {
|
||||
// Or mode lookups keep answering for a share we
|
||||
// just walked away from.
|
||||
invalidate_shared_roots();
|
||||
$(el_item).removeItems();
|
||||
// A team that still shares it keeps it listed,
|
||||
// as the team's share.
|
||||
if ( $(el_item).attr('data-is_also_team_share') === '1' ) {
|
||||
refresh_item_container($(el_item).closest('.item-container'));
|
||||
} else {
|
||||
$(el_item).removeItems();
|
||||
}
|
||||
} catch (e) {
|
||||
UIAlert({ message: e?.message ?? i18n('error_unknown_cause') });
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock('../helpers/launchApp.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/openItem.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/publishAsWebsite.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/openWithAI.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../helpers/refreshItemContainer.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
@@ -67,6 +68,8 @@ Object.assign(window, {
|
||||
|
||||
const { default: UIItem } = await import('./UIItem.js');
|
||||
const { default: UIContextMenu } = await import('./UIContextMenu.js');
|
||||
const { default: refresh_item_container } =
|
||||
await import('../helpers/refreshItemContainer.js');
|
||||
|
||||
const menuLabels = async (options) => {
|
||||
document.body.innerHTML =
|
||||
@@ -90,14 +93,25 @@ const menuLabels = async (options) => {
|
||||
return UIContextMenu.mock.calls[0][0].items.map((item) => item?.html);
|
||||
};
|
||||
|
||||
const removeFromShared = async (options) => {
|
||||
await menuLabels(options);
|
||||
await UIContextMenu.mock.calls[0][0].items
|
||||
.find((item) => item?.html === 'share_remove_from_shared')
|
||||
.onClick();
|
||||
};
|
||||
|
||||
describe('UIItem Remove from Shared', () => {
|
||||
beforeEach(() => {
|
||||
window.user = { username: 'alice' };
|
||||
window.trash_path = '/alice/Trash';
|
||||
window.puter = {
|
||||
fs: { listShared: vi.fn(async () => ({ items: [] })) },
|
||||
fs: {
|
||||
listShared: vi.fn(async () => ({ items: [] })),
|
||||
unshare: vi.fn(async () => ({ revoked: 1 })),
|
||||
},
|
||||
};
|
||||
globalThis.puter = window.puter;
|
||||
refresh_item_container.mockClear();
|
||||
});
|
||||
|
||||
it('is offered on an item shared with the user directly', async () => {
|
||||
@@ -109,4 +123,19 @@ describe('UIItem Remove from Shared', () => {
|
||||
expect($('.item').attr('data-is_team_share')).toBe('1');
|
||||
expect(labels).not.toContain('share_remove_from_shared');
|
||||
});
|
||||
|
||||
it('takes the item away once the user has left it', async () => {
|
||||
await removeFromShared({});
|
||||
expect(puter.fs.unshare).toHaveBeenCalledWith('/bob/u-1/Photos', 'alice');
|
||||
expect($('.item')).toHaveLength(0);
|
||||
expect(refresh_item_container).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists the item again when a team still shares it', async () => {
|
||||
await removeFromShared({ is_also_team_share: true });
|
||||
expect(puter.fs.unshare).toHaveBeenCalledWith('/bob/u-1/Photos', 'alice');
|
||||
expect($('.item')).toHaveLength(1);
|
||||
expect(refresh_item_container).toHaveBeenCalledTimes(1);
|
||||
expect(refresh_item_container.mock.calls[0][0][0]).toBe($('.item-container')[0]);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ 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';
|
||||
@@ -934,7 +935,7 @@ async function UIWindow (options) {
|
||||
// LEGACY SUPPORT, remove this in the future when Polotno uses the new SDK
|
||||
// this is literally put in here to support Polotno's legacy code
|
||||
...(selected_files.length === 1 && selected_files[0]),
|
||||
}, '*');
|
||||
}, window.openerOrigin);
|
||||
|
||||
window.close();
|
||||
window.open('', '_self').close();
|
||||
@@ -1018,7 +1019,7 @@ async function UIWindow (options) {
|
||||
// LEGACY SUPPORT, remove this in the future when Polotno uses the new SDK
|
||||
// this is literally put in here to support Polotno's legacy code
|
||||
...(selected_dirs.length === 1 && selected_dirs[0]),
|
||||
}, '*');
|
||||
}, window.openerOrigin);
|
||||
|
||||
window.close();
|
||||
window.open('', '_self').close();
|
||||
@@ -3348,7 +3349,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');
|
||||
$(el_window).find(`.window-sidebar-item[data-path="${html_encode(target_path)}"]`).addClass('window-sidebar-item-active');
|
||||
atPath($(el_window).find('.window-sidebar-item'), target_path).addClass('window-sidebar-item-active');
|
||||
|
||||
// clean
|
||||
$(el_window).find('.explore-table-headers-th > .header-sort-icon').html('');
|
||||
@@ -3432,8 +3433,8 @@ window.update_window_path = async function (el_window, target_path) {
|
||||
}
|
||||
}
|
||||
|
||||
$(el_window).attr('data-path', html_encode(target_path));
|
||||
$(el_window).attr('data-name', html_encode(path.basename(target_path)));
|
||||
$(el_window).attr('data-path', target_path);
|
||||
$(el_window).attr('data-name', path.basename(target_path));
|
||||
|
||||
// /stat
|
||||
if ( target_path === window.shared_path ) {
|
||||
@@ -3447,7 +3448,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') !== html_encode(target_path) ) {
|
||||
if ( $(el_window).attr('data-path') !== target_path ) {
|
||||
return;
|
||||
}
|
||||
$(el_window).removeClass(`window-${ $(el_window).attr('data-uid')}`);
|
||||
@@ -3491,9 +3492,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', html_encode(fsentry.name));
|
||||
$(el_window).attr('data-name', fsentry.name);
|
||||
// data-path
|
||||
$(el_window).attr('data-path', html_encode(target_path));
|
||||
$(el_window).attr('data-path', target_path);
|
||||
$(el_window_navbar_path_input).val(target_path);
|
||||
$(el_window_navbar_path_input).attr('data-path', target_path);
|
||||
// update layout
|
||||
|
||||
@@ -20,12 +20,24 @@
|
||||
import UIWindow from './UIWindow.js';
|
||||
import UIAlert from './UIAlert.js';
|
||||
|
||||
/**
|
||||
* Asks for the code sent to the user's email.
|
||||
*
|
||||
* @returns {Promise<boolean>} True once the email is confirmed, false when the
|
||||
* window closes without it. A logout, from its footer or anywhere else,
|
||||
* leaves it unsettled, so a gate that reopens it on false doesn't race the
|
||||
* logout.
|
||||
*/
|
||||
function UIWindowEmailConfirmationRequired (options) {
|
||||
return new Promise(async (resolve) => {
|
||||
options = options ?? {};
|
||||
options.window_options = options.window_options ?? {};
|
||||
let final_code = '';
|
||||
let is_checking_code = false;
|
||||
let logging_out = false;
|
||||
let submit_timer = null;
|
||||
let code_sent = false;
|
||||
let close_after_check = false;
|
||||
|
||||
const submit_btn_txt = 'Confirm Email';
|
||||
|
||||
@@ -92,6 +104,20 @@ function UIWindowEmailConfirmationRequired (options) {
|
||||
window_css: {
|
||||
height: 'initial',
|
||||
},
|
||||
// A code on its way to the server answers for the window, so the
|
||||
// window waits for it. One not sent yet isn't sent.
|
||||
on_before_exit: () => {
|
||||
if ( code_sent && ! logging_out ) {
|
||||
close_after_check = true;
|
||||
return false;
|
||||
}
|
||||
clearTimeout(submit_timer);
|
||||
return true;
|
||||
},
|
||||
// A no-op after a confirmation, which resolves first.
|
||||
on_close: () => {
|
||||
if ( ! logging_out && window.auth_token ) resolve(false);
|
||||
},
|
||||
body_css: {
|
||||
padding: '30px',
|
||||
width: 'initial',
|
||||
@@ -122,7 +148,8 @@ function UIWindowEmailConfirmationRequired (options) {
|
||||
// set animation
|
||||
$(el_window).find('.email-confirm-btn').html('<svg style="width:20px; margin-top: 5px;" xmlns="http://www.w3.org/2000/svg" height="24" width="24" viewBox="0 0 24 24"><title>circle anim</title><g fill="#fff" class="nc-icon-wrapper"><g class="nc-loop-circle-24-icon-f"><path d="M12 24a12 12 0 1 1 12-12 12.013 12.013 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10.011 10.011 0 0 0 12 2z" fill="#eee" opacity=".4"></path><path d="M24 12h-2A10.011 10.011 0 0 0 12 2V0a12.013 12.013 0 0 1 12 12z" data-color="color-2"></path></g><style>.nc-loop-circle-24-icon-f{--animation-duration:0.5s;transform-origin:12px 12px;animation:nc-loop-circle-anim var(--animation-duration) infinite linear}@keyframes nc-loop-circle-anim{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}</style></g></svg>');
|
||||
|
||||
setTimeout(() => {
|
||||
submit_timer = setTimeout(() => {
|
||||
code_sent = true;
|
||||
$.ajax({
|
||||
url: `${window.api_origin }/confirm-email`,
|
||||
type: 'POST',
|
||||
@@ -141,9 +168,13 @@ function UIWindowEmailConfirmationRequired (options) {
|
||||
},
|
||||
success: function (res) {
|
||||
if ( res.email_confirmed ) {
|
||||
// Callers check window.user next, before the refresh lands.
|
||||
if ( window.user ) window.user.email_confirmed = true;
|
||||
resolve(true);
|
||||
code_sent = false;
|
||||
close_after_check = false;
|
||||
$(el_window).close();
|
||||
window.refresh_user_data(window.auth_token);
|
||||
resolve(true);
|
||||
} else {
|
||||
$(el_window).find('.error').html('Invalid confirmation code.');
|
||||
$(el_window).find('.error').fadeIn();
|
||||
@@ -155,7 +186,8 @@ function UIWindowEmailConfirmationRequired (options) {
|
||||
}
|
||||
},
|
||||
error: function (res) {
|
||||
$(el_window).find('.error').html(html_encode(res.responseJSON.error));
|
||||
// A network failure has no body to read.
|
||||
$(el_window).find('.error').html(html_encode(res.responseJSON?.error ?? i18n('error_unknown_cause')));
|
||||
$(el_window).find('.error').fadeIn();
|
||||
$(el_window).find('.digit-input').val('');
|
||||
$(el_window).find('.digit-input').first().focus();
|
||||
@@ -165,6 +197,8 @@ function UIWindowEmailConfirmationRequired (options) {
|
||||
},
|
||||
complete: function () {
|
||||
is_checking_code = false;
|
||||
code_sent = false;
|
||||
if ( close_after_check ) $(el_window).close();
|
||||
},
|
||||
});
|
||||
}, 1000);
|
||||
@@ -172,6 +206,7 @@ function UIWindowEmailConfirmationRequired (options) {
|
||||
|
||||
// send email confirmation
|
||||
$(el_window).find('.send-conf-email').on('click', function (e) {
|
||||
$(el_window).find('.error').hide();
|
||||
$.ajax({
|
||||
url: `${window.api_origin }/send-confirm-email`,
|
||||
type: 'POST',
|
||||
@@ -194,19 +229,22 @@ function UIWindowEmailConfirmationRequired (options) {
|
||||
});
|
||||
$(el_window).find('.digit-input').first().focus();
|
||||
},
|
||||
complete: function () {
|
||||
error: function (res) {
|
||||
$(el_window).find('.error').html(html_encode(res.responseJSON?.error ?? i18n('error_unknown_cause')));
|
||||
$(el_window).find('.error').fadeIn();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// logout
|
||||
$(el_window).find('.conf-email-log-out').on('click', function (e) {
|
||||
logging_out = true;
|
||||
window.logout();
|
||||
$(el_window).close();
|
||||
});
|
||||
|
||||
// Elements
|
||||
const numberCodeForm = document.querySelector('[data-number-code-form]');
|
||||
const numberCodeForm = $(el_window).find('[data-number-code-form]').get(0);
|
||||
const numberCodeInputs = [...numberCodeForm.querySelectorAll('[data-number-code-input]')];
|
||||
|
||||
// Event listeners
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
// Renders the body and, as the real window does, asks on_before_exit before
|
||||
// closing and runs on_close once it's removed.
|
||||
vi.mock('./UIWindow.js', () => ({
|
||||
default: vi.fn(async (opts) => {
|
||||
const el = document.createElement('div');
|
||||
el.className = `window ${opts.window_class}`;
|
||||
el.innerHTML = opts.body_content;
|
||||
el.onClose = opts.on_close;
|
||||
el.onBeforeExit = opts.on_before_exit;
|
||||
document.body.append(el);
|
||||
return el;
|
||||
}),
|
||||
}));
|
||||
vi.mock('./UIAlert.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.def = globalThis.def ?? (value => value);
|
||||
|
||||
const { default: UIWindowEmailConfirmationRequired } = await import('./UIWindowEmailConfirmationRequired.js');
|
||||
|
||||
const PENDING = Symbol('pending');
|
||||
const settled = (promise) => Promise.race([promise, new Promise((r) => setTimeout(() => r(PENDING), 0))]);
|
||||
const win = () => jQuery('.window-confirm-email-using-code');
|
||||
|
||||
/** Opens the window and waits for it to render. Returns its answer, still pending. */
|
||||
const open = async (options) => {
|
||||
const result = UIWindowEmailConfirmationRequired(options);
|
||||
await vi.waitFor(() => expect(win()).toHaveLength(1));
|
||||
return { result };
|
||||
};
|
||||
|
||||
const enterCode = () => {
|
||||
const first = document.querySelector('[data-number-code-input="0"]');
|
||||
first.value = '123456';
|
||||
first.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
globalThis.$ = jQuery;
|
||||
jQuery.fn.close = function () {
|
||||
this.each((_, el) => {
|
||||
if ( ! el.isConnected || el.onBeforeExit?.() === false ) return;
|
||||
el.remove();
|
||||
el.onClose?.();
|
||||
});
|
||||
return this;
|
||||
};
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => String(value);
|
||||
window.icons = {};
|
||||
window.api_origin = 'https://api.puter.test';
|
||||
window.auth_token = 'token-1';
|
||||
window.user = { email: '[email protected]', email_confirmed: false };
|
||||
window.refresh_user_data = vi.fn();
|
||||
window.logout = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
delete jQuery.fn.close;
|
||||
});
|
||||
|
||||
describe('UIWindowEmailConfirmationRequired', () => {
|
||||
it('resolves true once the code confirms the email, and says so on window.user straight away', async () => {
|
||||
const ajax = vi.spyOn(jQuery, 'ajax').mockImplementation((opts) => {
|
||||
opts.success({ email_confirmed: true });
|
||||
opts.complete?.();
|
||||
});
|
||||
const { result } = await open();
|
||||
vi.useFakeTimers();
|
||||
enterCode();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
vi.useRealTimers();
|
||||
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(ajax).toHaveBeenCalledWith(expect.objectContaining({
|
||||
url: 'https://api.puter.test/confirm-email',
|
||||
data: JSON.stringify({ code: '123456' }),
|
||||
}));
|
||||
// Callers read it next, before the refresh lands.
|
||||
expect(window.user.email_confirmed).toBe(true);
|
||||
expect(window.refresh_user_data).toHaveBeenCalledWith('token-1');
|
||||
expect(win()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('stays open on a wrong code', async () => {
|
||||
vi.spyOn(jQuery, 'ajax').mockImplementation((opts) => {
|
||||
opts.success({ email_confirmed: false });
|
||||
opts.complete?.();
|
||||
});
|
||||
const { result } = await open();
|
||||
vi.useFakeTimers();
|
||||
enterCode();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
expect(win()).toHaveLength(1);
|
||||
expect(window.user.email_confirmed).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the server\'s error', { status: 429, responseJSON: { error: 'Too many requests.' } }, 'Too many requests.'],
|
||||
['an unknown error on a network failure', { status: 0 }, 'error_unknown_cause'],
|
||||
])('shows %s and lets the user try again', async (_label, xhr, shown) => {
|
||||
vi.spyOn(jQuery, 'ajax').mockImplementation((opts) => {
|
||||
opts.error(xhr);
|
||||
opts.complete?.();
|
||||
});
|
||||
const { result } = await open();
|
||||
vi.useFakeTimers();
|
||||
enterCode();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(win().find('.error').text()).toBe(shown);
|
||||
expect(win().find('.digit-input').prop('disabled')).toBe(false);
|
||||
expect(win().find('.email-confirm-btn').text()).toBe('Confirm Email');
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
});
|
||||
|
||||
it('takes the code typed in a second window open over another', async () => {
|
||||
const ajax = vi.spyOn(jQuery, 'ajax').mockImplementation((opts) => {
|
||||
opts.success({ email_confirmed: true });
|
||||
opts.complete?.();
|
||||
});
|
||||
const { result: under } = await open();
|
||||
const over = UIWindowEmailConfirmationRequired();
|
||||
await vi.waitFor(() => expect(win()).toHaveLength(2));
|
||||
vi.useFakeTimers();
|
||||
const first = win().get(1).querySelector('[data-number-code-input="0"]');
|
||||
first.value = '123456';
|
||||
first.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
vi.useRealTimers();
|
||||
|
||||
await expect(over).resolves.toBe(true);
|
||||
expect(ajax).toHaveBeenCalledWith(expect.objectContaining({ data: JSON.stringify({ code: '123456' }) }));
|
||||
expect(win()).toHaveLength(1);
|
||||
expect(await settled(under)).toBe(PENDING);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['the server\'s error', { status: 429, responseJSON: { error: 'Too many requests.' } }, 'Too many requests.'],
|
||||
['an unknown error on a network failure', { status: 0 }, 'error_unknown_cause'],
|
||||
])('shows %s when a new code can\'t be sent', async (_label, xhr, shown) => {
|
||||
const ajax = vi.spyOn(jQuery, 'ajax').mockImplementation((opts) => opts.error(xhr));
|
||||
await open();
|
||||
win().find('.send-conf-email').trigger('click');
|
||||
|
||||
expect(ajax).toHaveBeenCalledWith(expect.objectContaining({ url: 'https://api.puter.test/send-confirm-email' }));
|
||||
expect(win().find('.error').text()).toBe(shown);
|
||||
});
|
||||
|
||||
it('resolves false when it closes without a confirmation', async () => {
|
||||
const { result } = await open();
|
||||
win().close();
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('sends no code when it closes before the code is sent', async () => {
|
||||
const ajax = vi.spyOn(jQuery, 'ajax').mockImplementation(() => {});
|
||||
const { result } = await open();
|
||||
vi.useFakeTimers();
|
||||
enterCode();
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
win().close();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
vi.useRealTimers();
|
||||
|
||||
await expect(result).resolves.toBe(false);
|
||||
expect(ajax).not.toHaveBeenCalled();
|
||||
expect(window.user.email_confirmed).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['true when the code confirms the email', { email_confirmed: true }, true],
|
||||
['false when it doesn\'t', { email_confirmed: false }, false],
|
||||
])('waits for a code already sent when asked to close, and resolves %s', async (_label, answer, expected) => {
|
||||
let respond;
|
||||
vi.spyOn(jQuery, 'ajax').mockImplementation((opts) => {
|
||||
respond = () => {
|
||||
opts.success(answer);
|
||||
opts.complete();
|
||||
};
|
||||
});
|
||||
const { result } = await open();
|
||||
vi.useFakeTimers();
|
||||
enterCode();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
vi.useRealTimers();
|
||||
win().close();
|
||||
expect(win()).toHaveLength(1);
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
|
||||
respond();
|
||||
await expect(result).resolves.toBe(expected);
|
||||
expect(win()).toHaveLength(0);
|
||||
expect(window.user.email_confirmed).toBe(expected);
|
||||
});
|
||||
|
||||
it('closes once a code already sent fails, when asked to close meanwhile', async () => {
|
||||
let fail;
|
||||
vi.spyOn(jQuery, 'ajax').mockImplementation((opts) => {
|
||||
fail = () => {
|
||||
opts.error({ status: 0 });
|
||||
opts.complete();
|
||||
};
|
||||
});
|
||||
const { result } = await open();
|
||||
vi.useFakeTimers();
|
||||
enterCode();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
vi.useRealTimers();
|
||||
win().close();
|
||||
fail();
|
||||
|
||||
await expect(result).resolves.toBe(false);
|
||||
expect(win()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('closes for a logout from its footer while a code is being checked', async () => {
|
||||
vi.spyOn(jQuery, 'ajax').mockImplementation(() => {});
|
||||
const { result } = await open({ logout_in_footer: true });
|
||||
vi.useFakeTimers();
|
||||
enterCode();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
vi.useRealTimers();
|
||||
win().find('.conf-email-log-out').trigger('click');
|
||||
|
||||
expect(win()).toHaveLength(0);
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
});
|
||||
|
||||
it('stays unsettled when a logout elsewhere closes it, so a gate doesn\'t reopen it', async () => {
|
||||
const { result } = await open();
|
||||
// As the logout handler leaves things when it closes every window.
|
||||
window.auth_token = null;
|
||||
win().close();
|
||||
expect(win()).toHaveLength(0);
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
});
|
||||
|
||||
it('stays unsettled on a logout from its footer, so a gate doesn\'t reopen it', async () => {
|
||||
const { result } = await open({ logout_in_footer: true });
|
||||
win().find('.conf-email-log-out').trigger('click');
|
||||
expect(window.logout).toHaveBeenCalledTimes(1);
|
||||
expect(win()).toHaveLength(0);
|
||||
expect(await settled(result)).toBe(PENDING);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
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 = '';
|
||||
@@ -110,7 +111,7 @@ async function UIWindowPublishWebsite (target_dir_uid, target_dir_name, target_d
|
||||
});
|
||||
|
||||
// find all items whose path starts with target_dir_path
|
||||
$(`.item[data-path^="${target_dir_path}/"]`).each(function () {
|
||||
underPath('.item', target_dir_path).each(function () {
|
||||
// show the link badge
|
||||
$(this).find('.item-has-website-url-badge').show();
|
||||
// update item's website_url attribute
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
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 = '';
|
||||
@@ -117,7 +118,7 @@ async function UIWindowPublishWorker (target_dir_uid, target_dir_name, target_di
|
||||
});
|
||||
|
||||
// find all items whose path starts with target_dir_path
|
||||
$(`.item[data-path^="${target_dir_path}/"]`).each(function () {
|
||||
underPath('.item', target_dir_path).each(function () {
|
||||
// show the link badge
|
||||
// $(this).find('.item-has-website-url-badge').show();
|
||||
// update item's website_url attribute
|
||||
|
||||
@@ -117,6 +117,12 @@ async function UIWindowSaveAccount (options) {
|
||||
},
|
||||
});
|
||||
|
||||
// One is already open, which UIWindow focused instead of opening this.
|
||||
if ( ! el_window ) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
$(el_window).find('.signup-btn').on('click', function (e) {
|
||||
// todo do some basic validation client-side
|
||||
|
||||
@@ -164,10 +170,15 @@ async function UIWindowSaveAccount (options) {
|
||||
|
||||
//close this window
|
||||
if ( data.user.requires_email_confirmation && !data.user.email_confirmed ) {
|
||||
// The code window brings its own backdrop; this
|
||||
// one under it would dim the page twice.
|
||||
const $backdrop = $(el_window).closest('.window-backdrop');
|
||||
$backdrop.css('visibility', 'hidden');
|
||||
let is_verified = await UIWindowEmailConfirmationRequired({
|
||||
stay_on_top: true,
|
||||
has_head: true,
|
||||
});
|
||||
$backdrop.css('visibility', '');
|
||||
resolve(is_verified);
|
||||
} else {
|
||||
resolve(true);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
// As the real window does, opens once: a second one focuses the first and returns nothing.
|
||||
vi.mock('./UIWindow.js', () => ({
|
||||
default: vi.fn(async (opts) => {
|
||||
if ( document.querySelector('.window-save-account') ) return undefined;
|
||||
const el = document.createElement('div');
|
||||
el.className = `window ${opts.window_class}`;
|
||||
el.innerHTML = opts.body_content;
|
||||
el.onClose = opts.on_close;
|
||||
document.body.append(el);
|
||||
return el;
|
||||
}),
|
||||
}));
|
||||
vi.mock('./UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.def = globalThis.def ?? (value => value);
|
||||
|
||||
const { default: UIWindowSaveAccount } = await import('./UIWindowSaveAccount.js');
|
||||
|
||||
const PENDING = Symbol('pending');
|
||||
const settled = (promise) => Promise.race([promise, new Promise((r) => setTimeout(() => r(PENDING), 0))]);
|
||||
const win = () => jQuery('.window-save-account');
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
globalThis.$ = jQuery;
|
||||
jQuery.fn.close = function () {
|
||||
this.each((_, el) => {
|
||||
el.remove();
|
||||
el.onClose?.();
|
||||
});
|
||||
return this;
|
||||
};
|
||||
globalThis.i18n = (key) => key;
|
||||
globalThis.html_encode = (value) => String(value);
|
||||
window.icons = {};
|
||||
window.uuidv4 = () => 'id-1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete jQuery.fn.close;
|
||||
});
|
||||
|
||||
describe('UIWindowSaveAccount', () => {
|
||||
it('resolves false when it closes without saving', async () => {
|
||||
const result = UIWindowSaveAccount();
|
||||
await vi.waitFor(() => expect(win()).toHaveLength(1));
|
||||
win().close();
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('resolves false when one is already open, and leaves that one waiting', async () => {
|
||||
const first = UIWindowSaveAccount();
|
||||
await vi.waitFor(() => expect(win()).toHaveLength(1));
|
||||
|
||||
await expect(UIWindowSaveAccount()).resolves.toBe(false);
|
||||
expect(win()).toHaveLength(1);
|
||||
expect(await settled(first)).toBe(PENDING);
|
||||
});
|
||||
});
|
||||
@@ -140,6 +140,21 @@ body {
|
||||
background-color: #ccccccbe !important;
|
||||
}
|
||||
|
||||
/* A popup framed over its whole page, such as the upgrade, shows the page
|
||||
around its own dialog. The root's color scheme matches the frame's, or the
|
||||
browser paints the frame opaque; the GUI inside keeps the light scheme it
|
||||
has everywhere else, so its form fields don't turn dark. */
|
||||
html.popup-frame-transparent,
|
||||
html.popup-frame-transparent body {
|
||||
background: transparent !important;
|
||||
}
|
||||
html.popup-frame-transparent {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
html.popup-frame-transparent body {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.disable-user-select {
|
||||
cursor: default;
|
||||
-webkit-touch-callout: none !important;
|
||||
|
||||
+26
-46
@@ -23,6 +23,7 @@ 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';
|
||||
@@ -1178,7 +1179,7 @@ window.create_folder = async (basedir, appendto_element) => {
|
||||
rename: true,
|
||||
overwrite: false,
|
||||
success: function (data) {
|
||||
const el_created_dir = $(appendto_element).find(`.item[data-path="${html_encode(dirname)}/${html_encode(data.name)}"]`);
|
||||
const el_created_dir = atPath($(appendto_element).find('.item'), `${dirname}/${data.name}`);
|
||||
if ( el_created_dir.length > 0 ) {
|
||||
window.activate_item_name_editor(el_created_dir);
|
||||
|
||||
@@ -1211,7 +1212,7 @@ window.create_file = async (options) => {
|
||||
generateThumbnails: true,
|
||||
thumbnailGenerator: createUploadThumbnailGenerator(),
|
||||
success: async function (data) {
|
||||
const created_file = $(appendto_element).find(`.item[data-path="${html_encode(dirname)}/${html_encode(data.name)}"]`);
|
||||
const created_file = atPath($(appendto_element).find('.item'), `${dirname}/${data.name}`);
|
||||
if ( created_file.length > 0 ) {
|
||||
window.activate_item_name_editor(created_file);
|
||||
|
||||
@@ -1583,8 +1584,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 but the `i` selector doesn't work on ^=
|
||||
$(`.window[data-path^="${$(el_item).attr('data-path')}/"]`).close();
|
||||
// todo this has to be case-insensitive
|
||||
underPath('.window', $(el_item).attr('data-path')).close();
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -1605,7 +1606,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
|
||||
$(`.item[data-shortcut_to_path="${html_encode($(el_item).attr('data-path'))}" i]`).attr('data-shortcut_to_path', '');
|
||||
atPath('.item', $(el_item).attr('data-path'), 'data-shortcut_to_path').attr('data-shortcut_to_path', '');
|
||||
});
|
||||
} catch ( err ) {
|
||||
// Nothing was deleted: bring back what was faded out above.
|
||||
@@ -1619,7 +1620,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($(`.item[data-path="${html_encode(window.clipboard[i])}" i]`));
|
||||
el_items.push(atPath('.item', window.clipboard[i]));
|
||||
}
|
||||
if ( el_items.length > 0 )
|
||||
{
|
||||
@@ -1961,24 +1962,18 @@ 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;
|
||||
|
||||
// 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);
|
||||
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);
|
||||
|
||||
// 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.
|
||||
// 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 () {
|
||||
const $moved_items = $(`.item[data-uid='${$(el_item).attr('data-uid')}']`);
|
||||
$moved_items.not(atPath($moved_items, fsentry.path)).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
|
||||
@@ -1992,23 +1987,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 ) {
|
||||
$(`.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();
|
||||
atPath('.window', moved_from_path).close();
|
||||
// todo this has to be case-insensitive
|
||||
underPath('.window', moved_from_path).close();
|
||||
}
|
||||
|
||||
// update all paths of its and its descendants' open windows
|
||||
else {
|
||||
// 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)));
|
||||
// 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));
|
||||
});
|
||||
}
|
||||
|
||||
// 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?.($(el_item).attr('data-path'), is_trashing ? null : fsentry.path);
|
||||
window.dashboard_object?.folderMoved?.(moved_from_path, is_trashing ? null : fsentry.path);
|
||||
}
|
||||
|
||||
if ( is_trashing ) {
|
||||
@@ -2032,7 +2027,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
|
||||
// create new item on matching containers
|
||||
const options = {
|
||||
appendTo: $(`.item-container[data-path="${html_encode(item_dest_path)}" i]`),
|
||||
appendTo: atPath('.item-container', item_dest_path),
|
||||
immutable: fsentry.immutable || (fsentry.writable === false),
|
||||
associated_app_name: fsentry.associated_app?.name,
|
||||
uid: fsentry.uid,
|
||||
@@ -2063,8 +2058,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 = $(`.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 ) {
|
||||
let item_container = atPath('.item-container', path.dirname(dir.path));
|
||||
if ( item_container.length > 0 && atPath('.item', dir.path).length === 0 ) {
|
||||
UIItem({
|
||||
appendTo: item_container,
|
||||
immutable: false,
|
||||
@@ -2090,7 +2085,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
});
|
||||
|
||||
//sort each container
|
||||
$(`.item-container[data-path="${html_encode(item_dest_path)}" i]`).each(function () {
|
||||
atPath('.item-container', item_dest_path).each(function () {
|
||||
window.sort_items(this, $(this).attr('data-sort_by'), $(this).attr('data-sort_order'));
|
||||
});
|
||||
} catch ( err ) {
|
||||
@@ -2285,9 +2280,7 @@ window.updateSubdomainsForItems = async function (fsentries, container) {
|
||||
|
||||
// If not found by uid, try path-based search
|
||||
if ( $item.length === 0 && fsentry.path ) {
|
||||
// Escape special characters in path for jQuery selector
|
||||
const escapedPath = fsentry.path.replace(/[!"#$%&'()*+,.\/:;<=>?@[\\\]^`{|}~]/g, '\\$&');
|
||||
$item = $(document).find(`.item[data-path="${escapedPath}"]`);
|
||||
$item = atPath('.item', fsentry.path);
|
||||
}
|
||||
|
||||
if ( $item.length > 0 ) {
|
||||
@@ -3301,20 +3294,7 @@ 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);
|
||||
|
||||
// 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);
|
||||
});
|
||||
retargetPaths(old_path, new_path, new_name);
|
||||
|
||||
// Update the 'Sites Cache'
|
||||
if ( $(el_item).attr('data-has_website') === '1' )
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// @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,6 +19,7 @@
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -37,7 +38,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 = $(`.item-container[data-path='${html_encode(item.dirpath)}' i]`);
|
||||
const $containers = atPath('.item-container', item.dirpath);
|
||||
if ( $containers.length === 0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import UIWindowSaveAccount from '../UI/UIWindowSaveAccount.js';
|
||||
import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js';
|
||||
import ensureConfirmedEmail from './ensureConfirmedEmail.js';
|
||||
|
||||
/**
|
||||
* Walks a temporary account through saving itself, and an unconfirmed one
|
||||
@@ -30,22 +29,7 @@ import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRe
|
||||
*/
|
||||
const ensure_can_publish = async function (required) {
|
||||
if ( ! required ) return true;
|
||||
if ( window.user.is_temp && ! await UIWindowSaveAccount({
|
||||
send_confirmation_code: true,
|
||||
message: i18n('save_account_to_publish'),
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
}) ) {
|
||||
return false;
|
||||
}
|
||||
// Saving refreshes window.user; a deployment that doesn't confirm at
|
||||
// signup leaves the new account unconfirmed, and it still has to confirm.
|
||||
if ( ! window.user.email_confirmed ) {
|
||||
return !! await UIWindowEmailConfirmationRequired();
|
||||
}
|
||||
return true;
|
||||
return ensureConfirmedEmail(i18n('save_account_to_publish'));
|
||||
};
|
||||
|
||||
export default ensure_can_publish;
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* 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 UIWindowSaveAccount from '../UI/UIWindowSaveAccount.js';
|
||||
import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js';
|
||||
|
||||
// The walk-through showing, which a call made meanwhile joins.
|
||||
let showing = null;
|
||||
|
||||
const walkThrough = async (message, window_options) => {
|
||||
if ( window.user.is_temp && ! await UIWindowSaveAccount({
|
||||
send_confirmation_code: true,
|
||||
message,
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
...window_options,
|
||||
},
|
||||
}) ) {
|
||||
return false;
|
||||
}
|
||||
// Saving refreshes window.user; a deployment that doesn't confirm at
|
||||
// signup leaves the new account unconfirmed, and it still has to confirm.
|
||||
if ( ! window.user.email_confirmed ) {
|
||||
return !! await UIWindowEmailConfirmationRequired({ window_options });
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Walks a temporary account through saving itself, and an unconfirmed one
|
||||
* through confirming its email, showing each window at most once. A call made
|
||||
* while the windows show settles with them, without opening its own.
|
||||
*
|
||||
* @param {string} message - What the save-account window says to a temporary account.
|
||||
* @param {Record<string, unknown>} [window_options] - Passed to both windows, such as
|
||||
* `stay_on_top` to show them above an app.
|
||||
* @returns {Promise<boolean>} Whether the email is confirmed; false if the user backed out of either step.
|
||||
*/
|
||||
const ensureConfirmedEmail = (message, window_options = {}) => {
|
||||
showing ??= walkThrough(message, window_options).finally(() => {
|
||||
showing = null;
|
||||
});
|
||||
return showing;
|
||||
};
|
||||
|
||||
export default ensureConfirmedEmail;
|
||||
@@ -0,0 +1,133 @@
|
||||
// @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';
|
||||
|
||||
vi.mock('../UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UI/UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() }));
|
||||
|
||||
const { default: ensureConfirmedEmail } = await import('./ensureConfirmedEmail.js');
|
||||
const { default: UIWindowSaveAccount } = await import('../UI/UIWindowSaveAccount.js');
|
||||
const { default: UIWindowEmailConfirmationRequired } = await import('../UI/UIWindowEmailConfirmationRequired.js');
|
||||
|
||||
beforeEach(() => {
|
||||
UIWindowSaveAccount.mockReset();
|
||||
UIWindowEmailConfirmationRequired.mockReset();
|
||||
});
|
||||
|
||||
describe('ensureConfirmedEmail', () => {
|
||||
it('shows nothing for a confirmed email', async () => {
|
||||
window.user = { is_temp: false, email_confirmed: true };
|
||||
expect(await ensureConfirmedEmail('save it')).toBe(true);
|
||||
expect(UIWindowSaveAccount).not.toHaveBeenCalled();
|
||||
expect(UIWindowEmailConfirmationRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([true, false])('asks an unconfirmed email for its code once, and answers %s as it does', async (answer) => {
|
||||
window.user = { is_temp: false, email_confirmed: false };
|
||||
UIWindowEmailConfirmationRequired.mockResolvedValueOnce(answer);
|
||||
expect(await ensureConfirmedEmail('save it')).toBe(answer);
|
||||
expect(UIWindowEmailConfirmationRequired).toHaveBeenCalledTimes(1);
|
||||
expect(UIWindowSaveAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('has a temporary account saved first, with the message given', async () => {
|
||||
window.user = { is_temp: true };
|
||||
UIWindowSaveAccount.mockResolvedValueOnce(false);
|
||||
expect(await ensureConfirmedEmail('save it')).toBe(false);
|
||||
expect(UIWindowSaveAccount).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: 'save it',
|
||||
send_confirmation_code: true,
|
||||
}));
|
||||
expect(UIWindowEmailConfirmationRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the window options to both windows', async () => {
|
||||
window.user = { is_temp: true };
|
||||
UIWindowSaveAccount.mockImplementationOnce(async () => {
|
||||
window.user = { is_temp: false, email_confirmed: false };
|
||||
return true;
|
||||
});
|
||||
UIWindowEmailConfirmationRequired.mockResolvedValueOnce(true);
|
||||
await ensureConfirmedEmail('save it', { stay_on_top: true });
|
||||
expect(UIWindowSaveAccount.mock.calls[0][0].window_options).toEqual({
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
stay_on_top: true,
|
||||
});
|
||||
expect(UIWindowEmailConfirmationRequired).toHaveBeenCalledWith({ window_options: { stay_on_top: true } });
|
||||
});
|
||||
|
||||
it('asks no code again from an account that confirmed it while saving', async () => {
|
||||
window.user = { is_temp: true };
|
||||
// Saving asked for the code, which marks window.user confirmed.
|
||||
UIWindowSaveAccount.mockImplementationOnce(async () => {
|
||||
window.user = { is_temp: false, email_confirmed: true };
|
||||
return true;
|
||||
});
|
||||
expect(await ensureConfirmedEmail('save it')).toBe(true);
|
||||
expect(UIWindowEmailConfirmationRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks a just-saved account for its code when saving left it unconfirmed', async () => {
|
||||
window.user = { is_temp: true };
|
||||
UIWindowSaveAccount.mockImplementationOnce(async () => {
|
||||
window.user = { is_temp: false, email_confirmed: false };
|
||||
return true;
|
||||
});
|
||||
UIWindowEmailConfirmationRequired.mockResolvedValueOnce(true);
|
||||
expect(await ensureConfirmedEmail('save it')).toBe(true);
|
||||
expect(UIWindowEmailConfirmationRequired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('answers a call made while the code window shows with that window, without a second one', async () => {
|
||||
window.user = { is_temp: false, email_confirmed: false };
|
||||
let answer;
|
||||
UIWindowEmailConfirmationRequired.mockReturnValueOnce(new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}));
|
||||
const first = ensureConfirmedEmail('save it');
|
||||
const second = ensureConfirmedEmail('save it');
|
||||
await Promise.resolve();
|
||||
expect(UIWindowEmailConfirmationRequired).toHaveBeenCalledTimes(1);
|
||||
answer(true);
|
||||
expect(await Promise.all([first, second])).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it('answers a call made while the save-account window shows with that window', async () => {
|
||||
window.user = { is_temp: true };
|
||||
let answer;
|
||||
UIWindowSaveAccount.mockReturnValueOnce(new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
}));
|
||||
const first = ensureConfirmedEmail('save it');
|
||||
const second = ensureConfirmedEmail('save it');
|
||||
expect(UIWindowSaveAccount).toHaveBeenCalledTimes(1);
|
||||
answer(false);
|
||||
expect(await Promise.all([first, second])).toEqual([false, false]);
|
||||
});
|
||||
|
||||
it('opens a new window for a call made after the last one answered', async () => {
|
||||
window.user = { is_temp: false, email_confirmed: false };
|
||||
UIWindowEmailConfirmationRequired.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
|
||||
expect(await ensureConfirmedEmail('save it')).toBe(false);
|
||||
expect(await ensureConfirmedEmail('save it')).toBe(true);
|
||||
expect(UIWindowEmailConfirmationRequired).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,7 @@ import { is_default_app } from './defaultApp.js';
|
||||
* @param {Function} options.onPublishWebsite - Optional custom publish-as-website handler (used by Dashboard); receives {uid, name, path}
|
||||
* @param {Function} options.onPublishWorker - Optional custom publish-as-worker handler (used by Dashboard); receives {uid, name, path}
|
||||
* @param {Function} options.onPasteInto - Optional custom paste handler (used by Dashboard); receives the folder's path
|
||||
* @param {Function} options.onRemovedFromShared - Optional, called once Remove from Shared has taken the item's row away (used by Dashboard)
|
||||
* @param {Function} options.onRemovedFromShared - Optional, called once Remove from Shared has dropped the user's own access (used by Dashboard)
|
||||
* @returns {Promise<Array>} Array of context menu items
|
||||
*/
|
||||
const generate_file_context_menu = async function (options) {
|
||||
@@ -389,7 +389,9 @@ const generate_file_context_menu = async function (options) {
|
||||
// Or mode lookups keep answering for a share we just
|
||||
// walked away from.
|
||||
invalidate_shared_roots();
|
||||
$(el_item).remove();
|
||||
// A team that still shares it keeps it listed, as the
|
||||
// team's share; the refreshed listing shows it that way.
|
||||
if ( fsentry.also_team_share !== true ) $(el_item).remove();
|
||||
options.onRemovedFromShared?.();
|
||||
} catch (e) {
|
||||
UIAlert({ message: e?.message ?? i18n('error_unknown_cause') });
|
||||
|
||||
@@ -89,6 +89,38 @@ it('does not offer it on a share held through a team', async () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('Remove from Shared', () => {
|
||||
const remove = async (fsentry) => {
|
||||
const row = sharedRow();
|
||||
document.body.append(row);
|
||||
const onRemovedFromShared = vi.fn();
|
||||
window.user = { username: 'me' };
|
||||
globalThis.puter = { fs: { unshare: vi.fn(async () => ({ revoked: 1 })) } };
|
||||
const items = await generate_file_context_menu({
|
||||
element: row,
|
||||
fsentry: { name: 'notes.txt', path: '/ann/uid-1/notes.txt', ...fsentry },
|
||||
associated_app_name: 'editor',
|
||||
onRemovedFromShared,
|
||||
});
|
||||
await items.find((item) => item.html === 'share_remove_from_shared').onClick();
|
||||
expect(puter.fs.unshare).toHaveBeenCalledWith('/ann/uid-1/notes.txt', 'me');
|
||||
expect(onRemovedFromShared).toHaveBeenCalledTimes(1);
|
||||
return row;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('takes the row away once the user has left it', async () => {
|
||||
expect((await remove({})).isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the row for the listing to update when a team still shares it', async () => {
|
||||
expect((await remove({ also_team_share: true })).isConnected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Open With', () => {
|
||||
const editor = { name: 'editor', title: 'Editor', uuid: 'app-editor' };
|
||||
const viewer = { name: 'viewer', title: 'Viewer', uuid: 'app-viewer' };
|
||||
|
||||
@@ -17,13 +17,50 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { strongerMode } from './shareModes.js';
|
||||
|
||||
// Largest page the backend will serve (ShareStore.MAX_HOLDER_PAGE_SIZE).
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
/**
|
||||
* Every share the current user holds, across all pages. A page can be short
|
||||
* once unreachable items are filtered out, so it pages on `cursor` rather than
|
||||
* on the item count.
|
||||
* One row per shared item. The API lists a row per grant and grants add up, so
|
||||
* an item granted twice keeps its strongest row. It stays a team share only
|
||||
* when no grant names the user directly, since a direct grant is theirs to leave.
|
||||
* A merged row lists everyone who granted it in `issuers`, strongest grant first,
|
||||
* and sets `alsoHeldByTeam` when leaving the direct grant leaves a team's in place.
|
||||
*
|
||||
* @param {Array<Object>} shares
|
||||
* @returns {Array<Object>}
|
||||
*/
|
||||
const issuersOf = (share) => share.issuers ?? [share.issuer];
|
||||
const heldByTeam = (share) => Boolean(share.holderTeam) || share.alsoHeldByTeam === true;
|
||||
|
||||
export const mergeGrants = (shares) => {
|
||||
const merged = new Map();
|
||||
for ( const share of shares ) {
|
||||
const key = share.entryUid ?? share.path;
|
||||
const held = merged.get(key);
|
||||
if ( ! held ) {
|
||||
merged.set(key, share);
|
||||
continue;
|
||||
}
|
||||
const [best, other] = strongerMode(held.mode, share.mode) === held.mode ? [held, share] : [share, held];
|
||||
const teamOnly = held.holderTeam && share.holderTeam;
|
||||
const issuers = [...issuersOf(best), ...issuersOf(other)].filter(Boolean);
|
||||
merged.set(key, {
|
||||
...best,
|
||||
holderTeam: teamOnly ? best.holderTeam : undefined,
|
||||
alsoHeldByTeam: ! teamOnly && (heldByTeam(held) || heldByTeam(share)),
|
||||
issuers: [...new Set(issuers)],
|
||||
});
|
||||
}
|
||||
return [...merged.values()];
|
||||
};
|
||||
|
||||
/**
|
||||
* Every item shared with the current user, across all pages. A page can be
|
||||
* short once unreachable items are filtered out, so it pages on `cursor`
|
||||
* rather than on the item count.
|
||||
*
|
||||
* @returns {Promise<Array<Object>>}
|
||||
*/
|
||||
@@ -40,7 +77,7 @@ const list_all_shared = async () => {
|
||||
cursor = page.cursor;
|
||||
} while ( cursor );
|
||||
|
||||
return shares;
|
||||
return mergeGrants(shares);
|
||||
};
|
||||
|
||||
export default list_all_shared;
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* 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 list_all_shared, { mergeGrants } from './listAllShared.js';
|
||||
|
||||
const BUDGET = '33333333-3333-3333-3333-333333333333';
|
||||
const REPORT = '44444444-4444-4444-4444-444444444444';
|
||||
const TEAM = { uid: 'team-1', name: 'Finance', handle: 'finance' };
|
||||
|
||||
const grant = (entryUid, name, extra) => ({
|
||||
entryUid,
|
||||
path: `/jf/${entryUid}/${name}`,
|
||||
name,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('mergeGrants', () => {
|
||||
it('lists an item once, with its strongest grant', () => {
|
||||
const merged = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'manage', issuer: 'jf' }),
|
||||
grant(REPORT, 'report.pdf', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'delegate' }),
|
||||
grant(REPORT, 'report.pdf', { mode: 'write', issuer: 'delegate' }),
|
||||
]);
|
||||
expect(merged.map((share) => [share.entryUid, share.mode, share.issuer])).toEqual([
|
||||
[BUDGET, 'manage', 'jf'],
|
||||
[REPORT, 'write', 'delegate'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('names everyone who granted the item, strongest grant first', () => {
|
||||
const [share] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'delegate' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf', holderTeam: TEAM }),
|
||||
]);
|
||||
expect(share.issuer).toBe('delegate');
|
||||
expect(share.issuers).toEqual(['delegate', 'jf']);
|
||||
});
|
||||
|
||||
it('keeps the first of two equal grants', () => {
|
||||
const [share] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'delegate' }),
|
||||
]);
|
||||
expect(share.issuer).toBe('jf');
|
||||
});
|
||||
|
||||
it('is not a team share when one grant names the user directly', () => {
|
||||
const [share] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'jf', holderTeam: TEAM }),
|
||||
]);
|
||||
expect(share.mode).toBe('write');
|
||||
expect(share.holderTeam).toBeUndefined();
|
||||
expect(share.alsoHeldByTeam).toBe(true);
|
||||
});
|
||||
|
||||
it('marks only an item held both ways as also held by a team', () => {
|
||||
const [direct, team] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'delegate' }),
|
||||
grant(REPORT, 'report.pdf', { mode: 'read', holderTeam: TEAM }),
|
||||
grant(REPORT, 'report.pdf', { mode: 'write', holderTeam: TEAM }),
|
||||
]);
|
||||
expect(direct.alsoHeldByTeam).toBe(false);
|
||||
expect(team.alsoHeldByTeam).toBe(false);
|
||||
});
|
||||
|
||||
it('still marks it when a later grant is direct again', () => {
|
||||
const [share] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', holderTeam: TEAM }),
|
||||
grant(BUDGET, 'Budget', { mode: 'read', issuer: 'jf' }),
|
||||
grant(BUDGET, 'Budget', { mode: 'write', issuer: 'delegate' }),
|
||||
]);
|
||||
expect(share.holderTeam).toBeUndefined();
|
||||
expect(share.alsoHeldByTeam).toBe(true);
|
||||
});
|
||||
|
||||
it('stays a team share when every grant is to a team', () => {
|
||||
const [share] = mergeGrants([
|
||||
grant(BUDGET, 'Budget', { mode: 'read', holderTeam: { ...TEAM, uid: 'team-2' } }),
|
||||
grant(BUDGET, 'Budget', { mode: 'write', holderTeam: TEAM }),
|
||||
]);
|
||||
expect(share.holderTeam).toEqual(TEAM);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list_all_shared', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.window = { puter: { fs: { listShared: vi.fn() } } };
|
||||
});
|
||||
|
||||
it('merges grants for one item that arrive on different pages', async () => {
|
||||
window.puter.fs.listShared
|
||||
.mockResolvedValueOnce({
|
||||
items: [grant(BUDGET, 'Budget', { mode: 'manage' })],
|
||||
cursor: 'next',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [grant(BUDGET, 'Budget', { mode: 'read' })],
|
||||
});
|
||||
|
||||
const shares = await list_all_shared();
|
||||
|
||||
expect(shares).toHaveLength(1);
|
||||
expect(shares[0].mode).toBe('manage');
|
||||
expect(window.puter.fs.listShared).toHaveBeenLastCalledWith({ limit: 200, cursor: 'next' });
|
||||
});
|
||||
});
|
||||
@@ -232,7 +232,7 @@ Please try recreating the link.`);
|
||||
// LEGACY SUPPORT, remove this in the future when Polotno uses the new SDK
|
||||
// this is literally put in here to support Polotno's legacy code
|
||||
...(!Array.isArray(res) && res),
|
||||
}, '*');
|
||||
}, window.openerOrigin);
|
||||
|
||||
window.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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));
|
||||
});
|
||||
};
|
||||
@@ -29,17 +29,47 @@ export const FRAMED_POPUP_ACTIONS = new Set([
|
||||
'show-open-file-picker',
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'request-email-confirmation',
|
||||
]);
|
||||
|
||||
// Actions a frame shows only for the user the framing page is signed in as.
|
||||
const HOST_USER_ACTIONS = new Set([
|
||||
'show-open-file-picker',
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'request-email-confirmation',
|
||||
]);
|
||||
|
||||
// Actions framed over the whole page with no box, which their own dialog's
|
||||
// scrim covers.
|
||||
const TRANSPARENT_ACTIONS = new Set(['upgrade', 'request-email-confirmation']);
|
||||
|
||||
const UPGRADE_REASONS = new Set(['funds', 'subscription', 'storage']);
|
||||
const UPGRADE_METHOD_MAX = 200;
|
||||
|
||||
// Modal UI the upgrade dialog and the GUI's windows show. Once some has shown
|
||||
// and all of it has been gone this long, the dialog closed without reporting
|
||||
// it, and the frame mustn't be left covering the page.
|
||||
const UPGRADE_MODAL_UI = '.window, [aria-modal="true"]';
|
||||
const UPGRADE_GONE_MS = 500;
|
||||
// How long a hidden frame waits for the page to show it before opening anyway.
|
||||
const FRAME_SIZE_TIMEOUT_MS = 1000;
|
||||
// A focus() the frame ignored is retried this often, this many times.
|
||||
const FOCUS_RETRY_MS = 50;
|
||||
const FOCUS_RETRIES = 20;
|
||||
|
||||
/** Lifecycle messages to the framing page carry this as `msg`. */
|
||||
export const POPUP_FRAME_MSG = 'puterPopupFrame';
|
||||
|
||||
// The popup's query parameters a framing page may pass, in the fragment.
|
||||
const FRAGMENT_PARAMS = ['msg_id', 'options', 'request_auth'];
|
||||
|
||||
// The account checks a sign-in can show (email, phone, card), which are
|
||||
// windows rather than auth screens.
|
||||
const ACCOUNT_CHECK_WINDOWS = '.window-confirm-email-using-code, .window-confirm-phone-using-code, .window-card-verification';
|
||||
// The account checks a sign-in can show (email, phone, card), and the
|
||||
// save-account window an email confirmation shows a temporary account first,
|
||||
// which are windows rather than auth screens.
|
||||
const ACCOUNT_CHECK_WINDOWS = '.window-confirm-email-using-code, .window-confirm-phone-using-code, .window-card-verification, .window-save-account';
|
||||
|
||||
// What a framed popup shows first; until one is on the page the framing page
|
||||
// keeps the frame hidden behind its loading scrim.
|
||||
@@ -56,6 +86,24 @@ export const framedPopupAction = (pathname) => {
|
||||
return match && FRAMED_POPUP_ACTIONS.has(match[1]) ? match[1] : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a framed `action` serves only the framing page's own user, named in
|
||||
* the fragment as `user`.
|
||||
*
|
||||
* @param {string | null | undefined} action
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const framesHostUser = (action) => HOST_USER_ACTIONS.has(action);
|
||||
|
||||
/**
|
||||
* Whether a framed `action` lets the framing page show through around its
|
||||
* dialog.
|
||||
*
|
||||
* @param {string | null | undefined} action
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const isTransparentPopupFrame = (action) => TRANSPARENT_ACTIONS.has(action);
|
||||
|
||||
/**
|
||||
* The popup parameters the framing page put in the fragment, and no others.
|
||||
* The route refuses a query string, so nothing else reaches the boot.
|
||||
@@ -82,11 +130,14 @@ export const framedPopupParams = (hash) => {
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {string | null | undefined} hostOrigin - `gui_params.embed_host_origin`
|
||||
* @returns {((state: 'loading' | 'ready' | 'unavailable' | 'closed' | 'provider', fields?: Record<string, string>) => void) | null}
|
||||
* @returns {((state: 'loading' | 'ready' | 'unavailable' | 'closed' | 'provider', fields?: Record<string, unknown>) => void) | null}
|
||||
*/
|
||||
export const startPopupFrame = (win, hostOrigin) => {
|
||||
if ( ! hostOrigin || win.parent === win ) return null;
|
||||
const parent = win.parent;
|
||||
if ( isTransparentPopupFrame(framedPopupAction(win.location?.pathname)) ) {
|
||||
win.document.documentElement.classList.add('popup-frame-transparent');
|
||||
}
|
||||
const tell = (state, fields = {}) =>
|
||||
parent.postMessage({ ...fields, msg: POPUP_FRAME_MSG, state }, hostOrigin);
|
||||
|
||||
@@ -117,42 +168,225 @@ export const startPopupFrame = (win, hostOrigin) => {
|
||||
* with nothing focused, until the user presses a key or the pointer in it. A
|
||||
* cross-origin frame can't take focus until the framing page focuses it, which
|
||||
* also resets it, so a save picker's file name would start unfocused.
|
||||
* Firefox and WebKit also ignore a focus() for a moment after the page shows
|
||||
* the frame, without a `focus` event after it, so one that didn't take is
|
||||
* retried for a while.
|
||||
*
|
||||
* @param {Window} win
|
||||
*/
|
||||
export const focusOnFrameFocus = (win) => {
|
||||
const focus = win.HTMLElement.prototype.focus;
|
||||
let latest = null;
|
||||
let retryTimer = null;
|
||||
let retries = 0;
|
||||
// Whether the latest focus() still needs giving back.
|
||||
const pending = () => {
|
||||
const active = win.document.activeElement;
|
||||
return !!latest?.el.isConnected && (!active || active === win.document.body);
|
||||
};
|
||||
const giveBack = () => {
|
||||
if ( pending() ) focus.apply(latest.el, latest.args);
|
||||
};
|
||||
const retry = () => {
|
||||
retryTimer = null;
|
||||
giveBack();
|
||||
if ( pending() && ++retries < FOCUS_RETRIES ) retryTimer = setTimeout(retry, FOCUS_RETRY_MS);
|
||||
};
|
||||
win.HTMLElement.prototype.focus = function (...args) {
|
||||
latest = { el: this, args };
|
||||
return focus.apply(this, args);
|
||||
const result = focus.apply(this, args);
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
retries = 0;
|
||||
if ( pending() ) retryTimer = setTimeout(retry, FOCUS_RETRY_MS);
|
||||
return result;
|
||||
};
|
||||
const forget = () => {
|
||||
latest = null;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
win.HTMLElement.prototype.focus = focus;
|
||||
};
|
||||
win.addEventListener('pointerdown', forget, { capture: true, once: true });
|
||||
win.addEventListener('keydown', forget, { capture: true, once: true });
|
||||
win.addEventListener('focus', () => {
|
||||
const active = win.document.activeElement;
|
||||
if ( ! latest?.el.isConnected || (active && active !== win.document.body) ) return;
|
||||
focus.apply(latest.el, latest.args);
|
||||
});
|
||||
win.addEventListener('focus', giveBack);
|
||||
};
|
||||
|
||||
/**
|
||||
* Closes a framed sign-in on Escape in one of its account checks, as Escape
|
||||
* on its auth screens does. Those screens handle Escape themselves; the
|
||||
* account checks are windows, which don't.
|
||||
* Closes a framed sign-in or email confirmation on Escape in one of its
|
||||
* account windows, as Escape on the sign-in's auth screens does. Those
|
||||
* screens handle Escape themselves; the account windows don't.
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {(accountWindow: Element) => void} [close] - closes the account window
|
||||
* Escape was pressed in, when its own closing is what answers the frame;
|
||||
* without it the frame closes
|
||||
*/
|
||||
export const closeAccountChecksOnEscape = (win) => {
|
||||
export const closeAccountChecksOnEscape = (win, close = () => win.close()) => {
|
||||
win.document.addEventListener('keydown', (e) => {
|
||||
// Safari sends the Escape that ends an IME conversion as keyCode 229
|
||||
// with isComposing false.
|
||||
if ( e.key !== 'Escape' || e.defaultPrevented || e.isComposing || e.keyCode === 229 ) return;
|
||||
const el = e.target === win.document.body ? win.document.querySelector('.window-active') : e.target;
|
||||
if ( el?.closest?.(ACCOUNT_CHECK_WINDOWS) ) win.close();
|
||||
const accountWindow = el?.closest?.(ACCOUNT_CHECK_WINDOWS);
|
||||
if ( accountWindow ) close(accountWindow);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a framed upgrade can show for the GUI session `session` (its
|
||||
* `/whoami`): the deployment has an upgrade dialog that reports when it
|
||||
* closes, and no account check is waiting, which a frame couldn't be closed
|
||||
* from.
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {Record<string, unknown> | null} session
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const canShowUpgradeFrame = (win, session) =>
|
||||
win.UIUpgradeAccount?.embeddable === true &&
|
||||
!!session &&
|
||||
!session.requires_email_confirmation &&
|
||||
!session.requires_phone_verification &&
|
||||
!session.requires_card_verification &&
|
||||
!session.requires_password_change;
|
||||
|
||||
/**
|
||||
* Whether a framed email confirmation can show for the GUI session `session`
|
||||
* (its `/whoami`): no check other than the email is waiting, since those show
|
||||
* at boot with no way to close the frame.
|
||||
*
|
||||
* @param {Record<string, unknown> | null} session
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export const canShowEmailConfirmationFrame = (session) =>
|
||||
!!session &&
|
||||
!session.requires_phone_verification &&
|
||||
!session.requires_card_verification &&
|
||||
!session.requires_password_change;
|
||||
|
||||
/**
|
||||
* Confirms the email of the framed user with `confirm`, which shows the GUI's
|
||||
* windows for it. An email already confirmed resolves true without showing
|
||||
* anything, so the page never shows the frame.
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {() => Promise<boolean>} confirm
|
||||
* @param {(state: 'ready') => void} tell
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const confirmEmailInFrame = async (win, confirm, tell) => {
|
||||
if ( win.user?.email_confirmed === true ) return true;
|
||||
// The windows center themselves when they open.
|
||||
await whenFrameSized(win, () => tell('ready'));
|
||||
return (await confirm()) === true;
|
||||
};
|
||||
|
||||
/**
|
||||
* What prompted a framed upgrade, from its `reason` and `method` fragment
|
||||
* parameters, keeping only well-formed values.
|
||||
*
|
||||
* @param {string} hash - `location.hash`
|
||||
* @returns {{ reason?: string, method?: string }}
|
||||
*/
|
||||
export const framedUpgradeDetails = (hash) => {
|
||||
const given = new URLSearchParams(String(hash ?? '').replace(/^#/, ''));
|
||||
const details = {};
|
||||
const reason = given.get('reason');
|
||||
if ( UPGRADE_REASONS.has(reason) ) details.reason = reason;
|
||||
const method = given.get('method');
|
||||
if ( method && method.length <= UPGRADE_METHOD_MAX ) details.method = method;
|
||||
return details;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the deployment's upgrade dialog in a framed popup and tells the
|
||||
* framing page it's ready once it's open, or before it opens when the page
|
||||
* still hides the frame. Resolves whether the user upgraded,
|
||||
* once the dialog closes, or false once every dialog and window it showed has
|
||||
* gone without it reporting a close.
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {{ appUid?: string, reason?: string, method?: string }} attribution - what prompted it;
|
||||
* an empty one is passed as null, so the dialog credits the prompt it remembers
|
||||
* @param {(state: 'ready') => void} tell
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export const showFramedUpgrade = (win, attribution, tell) => new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let seen = false;
|
||||
let goneTimer = null;
|
||||
const settle = (finish) => {
|
||||
if ( settled ) return;
|
||||
settled = true;
|
||||
observer.disconnect();
|
||||
clearTimeout(goneTimer);
|
||||
finish();
|
||||
};
|
||||
const shown = () => !!win.document.querySelector(UPGRADE_MODAL_UI);
|
||||
const check = () => {
|
||||
if ( shown() ) {
|
||||
seen = true;
|
||||
clearTimeout(goneTimer);
|
||||
goneTimer = null;
|
||||
} else if ( seen && ! goneTimer ) {
|
||||
goneTimer = setTimeout(() => {
|
||||
goneTimer = null;
|
||||
if ( ! shown() ) settle(() => resolve(false));
|
||||
}, UPGRADE_GONE_MS);
|
||||
}
|
||||
};
|
||||
const observer = new win.MutationObserver(check);
|
||||
observer.observe(win.document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['aria-modal'] });
|
||||
|
||||
let toldReady = false;
|
||||
const ready = () => {
|
||||
if ( toldReady || settled ) return;
|
||||
toldReady = true;
|
||||
tell('ready');
|
||||
};
|
||||
const open = () => {
|
||||
const dialog = new win.UIUpgradeAccount({
|
||||
attribution: Object.keys(attribution).length ? attribution : null,
|
||||
onClose: (result) => settle(() => resolve(result?.upgraded === true)),
|
||||
});
|
||||
return dialog.open_as_window();
|
||||
};
|
||||
whenFrameSized(win, ready)
|
||||
.then(open)
|
||||
.then(() => {
|
||||
check();
|
||||
ready();
|
||||
}, (e) => settle(() => reject(e)));
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolves once the frame has a viewport. The page keeps a loading frame
|
||||
* hidden, which Chromium and WebKit lay out at 0x0, and a GUI window opened
|
||||
* then (such as an alert) centers itself on that. Without one, `reveal` asks
|
||||
* the page to show the frame first.
|
||||
*
|
||||
* @param {Window} win
|
||||
* @param {() => void} reveal
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const whenFrameSized = (win, reveal) => new Promise((resolve) => {
|
||||
const sized = () => win.innerWidth > 0 && win.innerHeight > 0;
|
||||
if ( sized() ) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
let timer = null;
|
||||
const done = () => {
|
||||
win.removeEventListener('resize', onResize);
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const onResize = () => {
|
||||
if ( sized() ) done();
|
||||
};
|
||||
win.addEventListener('resize', onResize);
|
||||
// Opening on a 0x0 viewport is better than never opening.
|
||||
timer = setTimeout(done, FRAME_SIZE_TIMEOUT_MS);
|
||||
reveal();
|
||||
});
|
||||
@@ -2,10 +2,16 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
POPUP_FRAME_MSG,
|
||||
canShowEmailConfirmationFrame,
|
||||
canShowUpgradeFrame,
|
||||
closeAccountChecksOnEscape,
|
||||
confirmEmailInFrame,
|
||||
focusOnFrameFocus,
|
||||
framedPopupAction,
|
||||
framedPopupParams,
|
||||
framedUpgradeDetails,
|
||||
framesHostUser,
|
||||
showFramedUpgrade,
|
||||
startPopupFrame,
|
||||
} from './popupFrame.js';
|
||||
|
||||
@@ -33,6 +39,7 @@ const statesSent = (win) => win.parent.postMessage.mock.calls.map(([data, origin
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.documentElement.classList.remove('popup-frame-transparent');
|
||||
});
|
||||
|
||||
describe('framedPopupAction', () => {
|
||||
@@ -42,6 +49,8 @@ describe('framedPopupAction', () => {
|
||||
['/embed/show-open-file-picker', 'show-open-file-picker'],
|
||||
['/embed/show-save-file-picker', 'show-save-file-picker'],
|
||||
['/embed/show-directory-picker', 'show-directory-picker'],
|
||||
['/embed/upgrade', 'upgrade'],
|
||||
['/embed/request-email-confirmation', 'request-email-confirmation'],
|
||||
['/embed/apps', null],
|
||||
['/embed/feedback', null],
|
||||
['/embed/request-permission', null],
|
||||
@@ -70,6 +79,288 @@ describe('framedPopupParams', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('framesHostUser', () => {
|
||||
it.each([
|
||||
['show-open-file-picker', true],
|
||||
['show-save-file-picker', true],
|
||||
['show-directory-picker', true],
|
||||
['upgrade', true],
|
||||
['request-email-confirmation', true],
|
||||
['sign-in', false],
|
||||
[null, false],
|
||||
])('%s serves only the framing page\'s user: %s', (action, expected) => {
|
||||
expect(framesHostUser(action)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('framedUpgradeDetails', () => {
|
||||
it('keeps a known reason and the method', () => {
|
||||
expect(framedUpgradeDetails('#user=abc&reason=storage&method=puter.fs.upload&appUid=app-x'))
|
||||
.toEqual({ reason: 'storage', method: 'puter.fs.upload' });
|
||||
});
|
||||
|
||||
it('drops an unknown reason and an overlong method', () => {
|
||||
expect(framedUpgradeDetails(`#reason=free-money&method=${'m'.repeat(201)}`)).toEqual({});
|
||||
expect(framedUpgradeDetails('')).toEqual({});
|
||||
expect(framedUpgradeDetails(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('canShowUpgradeFrame', () => {
|
||||
const win = { UIUpgradeAccount: Object.assign(function () {}, { embeddable: true }) };
|
||||
const session = { uuid: 'u-1' };
|
||||
|
||||
it('shows for a session with nothing to verify, when the dialog reports its close', () => {
|
||||
expect(canShowUpgradeFrame(win, session)).toBe(true);
|
||||
});
|
||||
|
||||
it('needs a dialog that reports its close', () => {
|
||||
expect(canShowUpgradeFrame({}, session)).toBe(false);
|
||||
expect(canShowUpgradeFrame({ UIUpgradeAccount: function () {} }, session)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'requires_email_confirmation',
|
||||
'requires_phone_verification',
|
||||
'requires_card_verification',
|
||||
'requires_password_change',
|
||||
])('leaves an account with %s to the tab', (check) => {
|
||||
expect(canShowUpgradeFrame(win, { ...session, [check]: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('needs a session', () => {
|
||||
expect(canShowUpgradeFrame(win, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canShowEmailConfirmationFrame', () => {
|
||||
const session = { uuid: 'u-1' };
|
||||
|
||||
it('shows for a session with nothing to verify, or only the email', () => {
|
||||
expect(canShowEmailConfirmationFrame(session)).toBe(true);
|
||||
expect(canShowEmailConfirmationFrame({ ...session, requires_email_confirmation: true })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'requires_phone_verification',
|
||||
'requires_card_verification',
|
||||
'requires_password_change',
|
||||
])('leaves an account with %s unavailable', (check) => {
|
||||
expect(canShowEmailConfirmationFrame({ ...session, [check]: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('needs a session', () => {
|
||||
expect(canShowEmailConfirmationFrame(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmEmailInFrame', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const confirmWindow = ({ user, width = 1280, height = 800 } = {}) =>
|
||||
Object.assign(new EventTarget(), { user, innerWidth: width, innerHeight: height });
|
||||
|
||||
it('resolves true for a confirmed email without showing anything', async () => {
|
||||
const confirm = vi.fn();
|
||||
const tell = vi.fn();
|
||||
await expect(confirmEmailInFrame(confirmWindow({ user: { email_confirmed: true } }), confirm, tell))
|
||||
.resolves.toBe(true);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
expect(tell).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['confirms', true, true],
|
||||
['backs out', false, false],
|
||||
['gets no answer', undefined, false],
|
||||
])('resolves whether the email is confirmed when the user %s', async (_label, answer, expected) => {
|
||||
const confirm = vi.fn(async () => answer);
|
||||
await expect(confirmEmailInFrame(confirmWindow({ user: { email_confirmed: false } }), confirm, vi.fn()))
|
||||
.resolves.toBe(expected);
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('walks a temporary account through it', async () => {
|
||||
const confirm = vi.fn(async () => true);
|
||||
await expect(confirmEmailInFrame(confirmWindow({ user: { is_temp: true } }), confirm, vi.fn()))
|
||||
.resolves.toBe(true);
|
||||
expect(confirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('has a hidden frame shown before its windows open', async () => {
|
||||
vi.useFakeTimers();
|
||||
const win = confirmWindow({ user: {}, width: 0, height: 0 });
|
||||
const sizes = [];
|
||||
const tell = vi.fn();
|
||||
const result = confirmEmailInFrame(win, async () => {
|
||||
sizes.push([win.innerWidth, win.innerHeight]);
|
||||
return true;
|
||||
}, tell);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(tell).toHaveBeenCalledWith('ready');
|
||||
expect(sizes).toEqual([]);
|
||||
|
||||
win.innerWidth = 1280;
|
||||
win.innerHeight = 800;
|
||||
win.dispatchEvent(new Event('resize'));
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(sizes).toEqual([[1280, 800]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showFramedUpgrade', () => {
|
||||
const upgradeWindow = (openAsWindow, { width = 1280, height = 800 } = {}) => {
|
||||
const created = [];
|
||||
const events = new EventTarget();
|
||||
const win = {
|
||||
document,
|
||||
innerWidth: width,
|
||||
innerHeight: height,
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
MutationObserver: window.MutationObserver,
|
||||
UIUpgradeAccount: function (options) {
|
||||
created.push(options);
|
||||
this.open_as_window = () => openAsWindow(options);
|
||||
},
|
||||
};
|
||||
return { win, created };
|
||||
};
|
||||
/** Stands in for the dialog's own modal root. */
|
||||
const showModal = () => {
|
||||
const root = document.createElement('div');
|
||||
root.setAttribute('aria-modal', 'true');
|
||||
document.body.append(root);
|
||||
return root;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('passes what prompted it, tells the page it is ready once open, and resolves the close', async () => {
|
||||
const tell = vi.fn();
|
||||
let options;
|
||||
const { win, created } = upgradeWindow(async (o) => {
|
||||
options = o;
|
||||
});
|
||||
const result = showFramedUpgrade(win, { appUid: 'app-1', reason: 'funds' }, tell);
|
||||
await vi.waitFor(() => expect(tell).toHaveBeenCalledWith('ready'));
|
||||
expect(created[0].attribution).toEqual({ appUid: 'app-1', reason: 'funds' });
|
||||
expect(tell).toHaveBeenCalledTimes(1);
|
||||
|
||||
options.onClose({ upgraded: true });
|
||||
await expect(result).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('passes no attribution when nothing is known, so the dialog credits the prompt it remembers', async () => {
|
||||
const { win, created } = upgradeWindow(async (o) => o.onClose());
|
||||
await showFramedUpgrade(win, {}, vi.fn());
|
||||
expect(created[0].attribution).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves false for a close without an upgrade, even one before it opened', async () => {
|
||||
const { win } = upgradeWindow(async (o) => o.onClose({ upgraded: false }));
|
||||
await expect(showFramedUpgrade(win, {}, vi.fn())).resolves.toBe(false);
|
||||
const { win: bare } = upgradeWindow(async (o) => o.onClose());
|
||||
await expect(showFramedUpgrade(bare, {}, vi.fn())).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('resolves false once the dialog is gone without reporting a close', async () => {
|
||||
vi.useFakeTimers();
|
||||
const tell = vi.fn();
|
||||
let root;
|
||||
const { win } = upgradeWindow(async () => {
|
||||
root = showModal();
|
||||
});
|
||||
const result = showFramedUpgrade(win, {}, tell);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(tell).toHaveBeenCalledWith('ready');
|
||||
|
||||
root.remove();
|
||||
await vi.advanceTimersByTimeAsync(499);
|
||||
await expect(Promise.race([result, 'pending'])).resolves.toBe('pending');
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('keeps waiting while one window replaces another', async () => {
|
||||
vi.useFakeTimers();
|
||||
let options;
|
||||
const { win } = upgradeWindow(async (o) => {
|
||||
options = o;
|
||||
// An alert, which is a GUI window, rather than the dialog itself.
|
||||
document.body.append(Object.assign(document.createElement('div'), { className: 'window' }));
|
||||
});
|
||||
const result = showFramedUpgrade(win, {}, vi.fn());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
document.querySelector('.window').remove();
|
||||
showModal();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await expect(Promise.race([result, 'pending'])).resolves.toBe('pending');
|
||||
|
||||
options.onClose({ upgraded: true });
|
||||
await expect(result).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('waits for the dialog to show before watching for it to go', async () => {
|
||||
vi.useFakeTimers();
|
||||
let options;
|
||||
const { win } = upgradeWindow(async (o) => {
|
||||
options = o;
|
||||
});
|
||||
const result = showFramedUpgrade(win, {}, vi.fn());
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await expect(Promise.race([result, 'pending'])).resolves.toBe('pending');
|
||||
options.onClose({ upgraded: false });
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('has a hidden frame shown before opening the dialog, so it lays out on a real viewport', async () => {
|
||||
vi.useFakeTimers();
|
||||
const tell = vi.fn();
|
||||
const sizes = [];
|
||||
const { win, created } = upgradeWindow(async () => {
|
||||
sizes.push([win.innerWidth, win.innerHeight]);
|
||||
}, { width: 0, height: 0 });
|
||||
const result = showFramedUpgrade(win, {}, tell);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(tell).toHaveBeenCalledWith('ready');
|
||||
expect(created).toHaveLength(0);
|
||||
|
||||
win.innerWidth = 1280;
|
||||
win.innerHeight = 800;
|
||||
win.dispatchEvent(new Event('resize'));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sizes).toEqual([[1280, 800]]);
|
||||
// Told once, not again once it opens.
|
||||
expect(tell).toHaveBeenCalledTimes(1);
|
||||
|
||||
created[0].onClose({ upgraded: false });
|
||||
await expect(result).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('opens the dialog anyway when a hidden frame never gets a viewport', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { win, created } = upgradeWindow(async () => {}, { width: 0, height: 0 });
|
||||
showFramedUpgrade(win, {}, vi.fn());
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(created).toHaveLength(0);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(created).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects when the dialog fails to open', async () => {
|
||||
const { win } = upgradeWindow(async () => {
|
||||
throw new Error('broken');
|
||||
});
|
||||
await expect(showFramedUpgrade(win, {}, vi.fn())).rejects.toThrow('broken');
|
||||
});
|
||||
});
|
||||
|
||||
describe('startPopupFrame', () => {
|
||||
it('does nothing without a framing page to answer', () => {
|
||||
const win = frameWindow();
|
||||
@@ -105,6 +396,28 @@ describe('startPopupFrame', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the framing page show through an upgrade or email confirmation frame only', () => {
|
||||
startPopupFrame({ ...frameWindow(), location: { pathname: '/embed/sign-in' } }, HOST);
|
||||
expect(document.documentElement.classList.contains('popup-frame-transparent')).toBe(false);
|
||||
|
||||
startPopupFrame({ ...frameWindow(), location: { pathname: '/embed/upgrade' } }, HOST);
|
||||
expect(document.documentElement.classList.contains('popup-frame-transparent')).toBe(true);
|
||||
|
||||
document.documentElement.classList.remove('popup-frame-transparent');
|
||||
startPopupFrame({ ...frameWindow(), location: { pathname: '/embed/request-email-confirmation' } }, HOST);
|
||||
expect(document.documentElement.classList.contains('popup-frame-transparent')).toBe(true);
|
||||
});
|
||||
|
||||
it('sends a boolean field with a state', () => {
|
||||
const win = frameWindow();
|
||||
const tell = startPopupFrame(win, HOST);
|
||||
tell('closed', { upgraded: true });
|
||||
expect(win.parent.postMessage).toHaveBeenLastCalledWith(
|
||||
{ upgraded: true, msg: POPUP_FRAME_MSG, state: 'closed' },
|
||||
HOST,
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves other window.open calls alone', () => {
|
||||
const win = frameWindow();
|
||||
const open = win.open;
|
||||
@@ -169,6 +482,57 @@ describe('focusOnFrameFocus', () => {
|
||||
}
|
||||
});
|
||||
|
||||
/** A window whose focus() is ignored while `blocked`, as a frame just shown is. */
|
||||
const blockableWindow = () => {
|
||||
const { win, input } = ownWindow();
|
||||
const nativeFocus = win.HTMLElement.prototype.focus;
|
||||
const state = { blocked: true };
|
||||
win.HTMLElement.prototype.focus = function (...args) {
|
||||
if ( ! state.blocked ) nativeFocus.apply(this, args);
|
||||
};
|
||||
return { win, input, state };
|
||||
};
|
||||
|
||||
it('retries a focus() the frame ignored until it takes', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { win, input, state } = blockableWindow();
|
||||
focusOnFrameFocus(win);
|
||||
input.focus();
|
||||
expect(win.document.activeElement).toBe(win.document.body);
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
expect(win.document.activeElement).toBe(win.document.body);
|
||||
state.blocked = false;
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(win.document.activeElement).toBe(input);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('stops retrying after a while, and once the user presses a key', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { win, input, state } = blockableWindow();
|
||||
focusOnFrameFocus(win);
|
||||
input.focus();
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
state.blocked = false;
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(win.document.activeElement).toBe(win.document.body);
|
||||
|
||||
const other = blockableWindow();
|
||||
focusOnFrameFocus(other.win);
|
||||
other.input.focus();
|
||||
other.win.document.body.dispatchEvent(new other.win.Event('keydown', { bubbles: true }));
|
||||
other.state.blocked = false;
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(other.win.document.activeElement).toBe(other.win.document.body);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves an element that has focus, and one that has left the page', () => {
|
||||
const { win, input } = ownWindow();
|
||||
const nativeFocus = win.HTMLElement.prototype.focus;
|
||||
@@ -207,6 +571,7 @@ describe('closeAccountChecksOnEscape', () => {
|
||||
'window-confirm-email-using-code',
|
||||
'window-confirm-phone-using-code',
|
||||
'window-card-verification',
|
||||
'window-save-account',
|
||||
])('closes on Escape in the %s window', (cls) => {
|
||||
const win = signInWindow();
|
||||
escapeOn(showWindow(win, `window window-active ${cls}`).querySelector('input'));
|
||||
@@ -232,4 +597,15 @@ describe('closeAccountChecksOnEscape', () => {
|
||||
escapeOn(showWindow(win, 'window window-alert').querySelector('input'));
|
||||
expect(win.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hands the account window to the close it is given, leaving the frame open', () => {
|
||||
const win = { document: document.implementation.createHTMLDocument(), close: vi.fn() };
|
||||
const close = vi.fn();
|
||||
closeAccountChecksOnEscape(win, close);
|
||||
const el = showWindow(win);
|
||||
escapeOn(el.querySelector('input'));
|
||||
escapeOn(win.document.body);
|
||||
expect(close.mock.calls).toEqual([[el], [el]]);
|
||||
expect(win.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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', html_encode(fsentry.name));
|
||||
$(el_window).attr('data-name', fsentry.name);
|
||||
// data-path
|
||||
$(el_window).attr('data-path', html_encode(container_path));
|
||||
$(el_window).attr('data-path', container_path);
|
||||
$(el_window).find('.window-navbar-path-input').val(container_path);
|
||||
$(el_window).find('.window-navbar-path-input').attr('data-path', container_path);
|
||||
}
|
||||
@@ -159,6 +159,7 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
shared_by: share.issuer,
|
||||
owner: share.owner,
|
||||
holder_team: share.holderTeam,
|
||||
also_team_share: share.alsoHeldByTeam,
|
||||
metadata: '',
|
||||
})))
|
||||
: puter.fs.readdir({ path: container_path, consistency: options.consistency ?? 'eventual' });
|
||||
@@ -264,6 +265,7 @@ const refresh_item_container = function (el_item_container, options) {
|
||||
shared_by: fsentry.shared_by,
|
||||
is_shared: fsentry.is_shared,
|
||||
is_team_share: !!fsentry.holder_team,
|
||||
is_also_team_share: fsentry.also_team_share === true,
|
||||
owner: fsentry.owner?.username ?? fsentry.owner,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,6 +107,31 @@ describe('refresh_item_container in the Shared view', () => {
|
||||
is_team_share: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('flags a direct share the team holds too', async () => {
|
||||
list_all_shared.mockResolvedValue([
|
||||
{
|
||||
entryUid: 'u-1',
|
||||
name: 'a.txt',
|
||||
path: '/bob/u-1/a.txt',
|
||||
isDir: false,
|
||||
mode: 'read',
|
||||
issuer: 'bob',
|
||||
owner: 'bob',
|
||||
alsoHeldByTeam: true,
|
||||
},
|
||||
]);
|
||||
document.body.innerHTML =
|
||||
'<div class="window"><div class="item-container" data-path="/alice/Shared"></div></div>';
|
||||
|
||||
refresh_item_container($('.item-container'));
|
||||
|
||||
await vi.waitFor(() => expect(UIItem).toHaveBeenCalledTimes(1));
|
||||
expect(UIItem.mock.calls[0][0]).toMatchObject({
|
||||
is_team_share: false,
|
||||
is_also_team_share: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const deferred = () => {
|
||||
@@ -167,6 +192,15 @@ 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');
|
||||
|
||||
@@ -23,6 +23,20 @@
|
||||
// place in these dialogs; a row already set to one is shown as-is.
|
||||
export const MODES = ['read', 'write', 'manage'];
|
||||
|
||||
// Every mode the API grants, weakest first. Each one implies those before it.
|
||||
const STRENGTH = ['see', 'list', 'read', 'write', 'manage'];
|
||||
|
||||
/**
|
||||
* The stronger of two access modes. `held` wins a tie, and a mode outside the
|
||||
* API's five never beats a known one.
|
||||
*
|
||||
* @param {string} held
|
||||
* @param {string} other
|
||||
* @returns {string}
|
||||
*/
|
||||
export const strongerMode = (held, other) =>
|
||||
STRENGTH.indexOf(other) > STRENGTH.indexOf(held) ? other : held;
|
||||
|
||||
/**
|
||||
* Human-readable label for an access mode, ready to drop into HTML.
|
||||
*
|
||||
|
||||
@@ -6,12 +6,12 @@ import { decode, encode } from 'html-entities';
|
||||
globalThis.window = globalThis.window ?? {};
|
||||
globalThis.html_encode = (str) => encode(str);
|
||||
|
||||
let MODES, mode_label, options_for;
|
||||
let MODES, mode_label, options_for, strongerMode;
|
||||
|
||||
beforeAll(async () => {
|
||||
await import('../i18n/i18n.js'); // installs window.i18n
|
||||
globalThis.i18n = window.i18n;
|
||||
({ MODES, mode_label, options_for } = await import('./shareModes.js'));
|
||||
({ MODES, mode_label, options_for, strongerMode } = await import('./shareModes.js'));
|
||||
});
|
||||
|
||||
// What the browser renders for a fragment of HTML-safe markup.
|
||||
@@ -41,6 +41,21 @@ describe('mode_label', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('strongerMode', () => {
|
||||
it('ranks all five API modes', () => {
|
||||
const modes = ['see', 'list', 'read', 'write', 'manage'];
|
||||
for ( let i = 1; i < modes.length; i++ ) {
|
||||
expect(strongerMode(modes[i - 1], modes[i])).toBe(modes[i]);
|
||||
expect(strongerMode(modes[i], modes[i - 1])).toBe(modes[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it('never lets an unrecognized mode beat a known one', () => {
|
||||
expect(strongerMode('see', 'owner')).toBe('see');
|
||||
expect(strongerMode('owner', 'see')).toBe('see');
|
||||
});
|
||||
});
|
||||
|
||||
describe('options_for', () => {
|
||||
const values = (html) => [...html.matchAll(/value="([^"]*)"/g)].map((m) => m[1]);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import list_all_shared from './listAllShared.js';
|
||||
import { is_owned_by_me } from './pathOwner.js';
|
||||
import { strongerMode } from './shareModes.js';
|
||||
import { is_share_root } from './sharePaths.js';
|
||||
|
||||
// What we hold on each shared root, by path: `{ mode, name }`. A `readdir`
|
||||
@@ -29,13 +30,16 @@ let inflight = null;
|
||||
|
||||
/**
|
||||
* Record what a Shared listing reported. Replaces rather than merges, so a
|
||||
* share withdrawn elsewhere does not linger.
|
||||
* share withdrawn elsewhere does not linger. Grants add up, so a path listed
|
||||
* more than once keeps its strongest mode.
|
||||
*/
|
||||
export const remember_shared_roots = (shares) => {
|
||||
roots.clear();
|
||||
for ( const share of shares ) {
|
||||
if ( ! share?.path || ! share?.mode ) continue;
|
||||
roots.set(share.path, { mode: share.mode, name: share.name });
|
||||
const held = roots.get(share.path);
|
||||
const mode = held ? strongerMode(held.mode, share.mode) : share.mode;
|
||||
roots.set(share.path, { mode, name: share.name });
|
||||
}
|
||||
loaded = true;
|
||||
};
|
||||
|
||||
@@ -64,6 +64,19 @@ describe('shared_access', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the strongest mode when a path is granted more than once', async () => {
|
||||
remember_shared_roots([
|
||||
{ path: `/jf/${BUDGET}/Budget`, mode: 'manage' },
|
||||
{ path: `/jf/${BUDGET}/Budget`, mode: 'read' },
|
||||
{ path: `/jf/${REPORT}/report.pdf`, mode: 'read' },
|
||||
{ path: `/jf/${REPORT}/report.pdf`, mode: 'write' },
|
||||
]);
|
||||
expect(await shared_mode_for(`/jf/${BUDGET}/Budget`)).toBe('manage');
|
||||
expect(await shared_mode_for(`/jf/${REPORT}/report.pdf`)).toBe('write');
|
||||
expect(await can_share(`/jf/${BUDGET}/Budget/q1.xlsx`)).toBe(true);
|
||||
expect(await can_rename(`/jf/${REPORT}/report.pdf`)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports nothing outside every shared root', async () => {
|
||||
expect(await shared_mode_for(`/jf/${CONTENTS}/Private/a.txt`)).toBe(null);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* 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.
|
||||
*
|
||||
@@ -33,10 +35,7 @@ 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) => {
|
||||
if ( ! path ) return;
|
||||
const $items = $('.item').filter(function() {
|
||||
return ($(this).attr('data-path') || '').toLowerCase() === path.toLowerCase();
|
||||
});
|
||||
const $items = atPath('.item', path);
|
||||
$items.attr('data-is_shared', is_shared ? 1 : 0);
|
||||
$items
|
||||
.find('.item-shared-marker')
|
||||
|
||||
@@ -557,6 +557,7 @@ const en = {
|
||||
saturation: 'Saturation',
|
||||
save_account: 'Save account',
|
||||
save_account_to_get_copy_link: 'Please create an account to proceed.',
|
||||
save_account_to_proceed: 'Please create an account to proceed.',
|
||||
save_account_to_publish: 'Please create an account to proceed.',
|
||||
save_session: 'Save session',
|
||||
save_session_c2a: 'Create an account to save your current session and avoid losing your work.',
|
||||
|
||||
+81
-15
@@ -47,8 +47,21 @@ import UIAuthRecoverPassword from './UI/Auth/UIAuthRecoverPassword.js';
|
||||
import { PROCESS_RUNNING } from './definitions.js';
|
||||
import { isAttestedOrigin } from './helpers/attestedOrigin.js';
|
||||
import { embedHostUser } from './helpers/embedHostUser.js';
|
||||
import { closeAccountChecksOnEscape, focusOnFrameFocus, framedPopupAction, framedPopupParams, startPopupFrame } from './helpers/popupFrame.js';
|
||||
import {
|
||||
canShowEmailConfirmationFrame,
|
||||
canShowUpgradeFrame,
|
||||
closeAccountChecksOnEscape,
|
||||
confirmEmailInFrame,
|
||||
focusOnFrameFocus,
|
||||
framedPopupAction,
|
||||
framedPopupParams,
|
||||
framedUpgradeDetails,
|
||||
framesHostUser,
|
||||
showFramedUpgrade,
|
||||
startPopupFrame,
|
||||
} from './helpers/popupFrame.js';
|
||||
import confirm_before_unload from './helpers/confirmBeforeUnload.js';
|
||||
import ensureConfirmedEmail from './helpers/ensureConfirmedEmail.js';
|
||||
import create_access_token from './helpers/createAccessToken.js';
|
||||
import create_gui_token from './helpers/createGuiToken.js';
|
||||
import {
|
||||
@@ -95,7 +108,6 @@ import { ThemeService } from './services/ThemeService.js';
|
||||
import { privacy_aware_path as privacy_aware_path_factory } from './util/desktop.js';
|
||||
import { resolveAPIOrigin } from './util/apiOrigin.js';
|
||||
import {
|
||||
defersTokenToOpener,
|
||||
deliversTokenAtBoot,
|
||||
relationshipIsConsent,
|
||||
relaysTokenAtBoot,
|
||||
@@ -568,7 +580,7 @@ const postAuthActions = async (action) => {
|
||||
window.opener?.postMessage({
|
||||
msg,
|
||||
original_msg_id: msg_id,
|
||||
}, '*');
|
||||
}, window.openerOrigin);
|
||||
// Escape closes only the picker window, which would leave the
|
||||
// page's frame open over an empty desktop.
|
||||
if ( window.embedded_in_frame ) window.close();
|
||||
@@ -636,7 +648,7 @@ const postAuthActions = async (action) => {
|
||||
// send 'sendMeFileData' event to parent
|
||||
window.opener.postMessage({
|
||||
msg: 'sendMeFileData',
|
||||
}, '*');
|
||||
}, window.openerOrigin);
|
||||
|
||||
// listen for 'showSaveFilePickerPopup' event from parent. Only the
|
||||
// page that asked supplies the file, once; any other window that
|
||||
@@ -711,7 +723,7 @@ const postAuthActions = async (action) => {
|
||||
uid: file_signature.uid,
|
||||
path: window.privacy_aware_path(res.path),
|
||||
},
|
||||
}, '*');
|
||||
}, window.openerOrigin);
|
||||
|
||||
window.close();
|
||||
window.open('', '_self').close();
|
||||
@@ -992,6 +1004,46 @@ const postAuthActions = async (action) => {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Action: upgrade — the deployment's upgrade dialog for the first-party page framing
|
||||
// it, which hears whether the user upgraded once the dialog closes. The app it credits
|
||||
// is the one the framing page's origin resolves to, never one the fragment names.
|
||||
// -------------------------------------------------------------------------------------
|
||||
if ( action === 'upgrade' && window.embedded_in_frame ) {
|
||||
let upgraded;
|
||||
try {
|
||||
upgraded = await showFramedUpgrade(window, {
|
||||
...(window.host_app_uid ? { appUid: window.host_app_uid } : {}),
|
||||
...framedUpgradeDetails(window.location.hash),
|
||||
}, window.tell_popup_frame);
|
||||
} catch (e) {
|
||||
// A dialog that failed to open leaves the page to open the upgrade in a tab.
|
||||
console.error('upgrade action failed', e);
|
||||
window.tell_popup_frame('unavailable');
|
||||
return;
|
||||
}
|
||||
window.tell_popup_frame('closed', { upgraded });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Action: request-email-confirmation — puter.ui.requestEmailConfirmation() on the
|
||||
// first-party page framing it, which hears whether the email is confirmed once the
|
||||
// user is done.
|
||||
// -------------------------------------------------------------------------------------
|
||||
if ( action === 'request-email-confirmation' && window.embedded_in_frame ) {
|
||||
let confirmed = false;
|
||||
try {
|
||||
confirmed = await confirmEmailInFrame(
|
||||
window,
|
||||
() => ensureConfirmedEmail(i18n('save_account_to_proceed')),
|
||||
window.tell_popup_frame,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('request-email-confirmation action failed', e);
|
||||
}
|
||||
window.tell_popup_frame('closed', { confirmed });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Action: attach-custom-domain — puter.hosting.attach() on a website. Hosts the custom
|
||||
// domain dialog for `subdomain` and posts the chosen domain back to the opener.
|
||||
@@ -1558,6 +1610,10 @@ window.initgui = async function (options) {
|
||||
window.gui_action = action;
|
||||
|
||||
if (window.embedded_in_frame && action === 'sign-in') closeAccountChecksOnEscape(window);
|
||||
// Closing the window answers the frame, after any code it already sent.
|
||||
if (window.embedded_in_frame && action === 'request-email-confirmation') {
|
||||
closeAccountChecksOnEscape(window, (accountWindow) => $(accountWindow).close());
|
||||
}
|
||||
|
||||
if (action === 'oidc-2fa') {
|
||||
const result = oidcChallenge?.token && await UIAuth2FA(oidcChallenge.token);
|
||||
@@ -1741,21 +1797,27 @@ window.initgui = async function (options) {
|
||||
// this is the referrer in terms of user acquisition
|
||||
window.referrerStr = window.openerOrigin;
|
||||
|
||||
// A framed picker shows only the Puter session of the user the
|
||||
// framing page is signed in as; for anyone else the page uses the
|
||||
// popup, which can sign in and switch accounts.
|
||||
if (window.embedded_in_frame && defersTokenToOpener(action)) {
|
||||
// A framed picker or upgrade shows only the Puter session of the user
|
||||
// the framing page is signed in as; for anyone else the page uses the
|
||||
// popup or tab, which can sign in and switch accounts.
|
||||
if (window.embedded_in_frame && framesHostUser(action)) {
|
||||
const host_user = embedHostUser(window.location.hash);
|
||||
let session = null;
|
||||
let session_user = null;
|
||||
if (host_user && window.is_auth()) {
|
||||
try {
|
||||
const session = await puter.os.user({ query: 'icon_size=64' });
|
||||
session = await puter.os.user({ query: 'icon_size=64' });
|
||||
session_user = String(session?.uuid ?? '').toLowerCase();
|
||||
} catch (e) {
|
||||
session_user = null;
|
||||
}
|
||||
}
|
||||
if (!host_user || session_user !== host_user) {
|
||||
if (
|
||||
!host_user ||
|
||||
session_user !== host_user ||
|
||||
(action === 'upgrade' && !canShowUpgradeFrame(window, session)) ||
|
||||
(action === 'request-email-confirmation' && !canShowEmailConfirmationFrame(session))
|
||||
) {
|
||||
window.tell_popup_frame('unavailable');
|
||||
return;
|
||||
}
|
||||
@@ -1993,11 +2055,11 @@ window.initgui = async function (options) {
|
||||
);
|
||||
window.userAppToken = response?.token;
|
||||
|
||||
// A framed picker already holds the framing page's user, so it
|
||||
// doesn't offer to switch.
|
||||
// A framed picker or upgrade already holds the framing page's user,
|
||||
// so it doesn't offer to switch.
|
||||
if (
|
||||
!picked_a_user_for_sdk_login &&
|
||||
!(window.embedded_in_frame && defersTokenToOpener(action)) &&
|
||||
!(window.embedded_in_frame && framesHostUser(action)) &&
|
||||
window.logged_in_users.length > 1 &&
|
||||
(!window.userAppToken ||
|
||||
window.url_query_params.get('request_auth'))
|
||||
@@ -2374,8 +2436,12 @@ window.initgui = async function (options) {
|
||||
// assertVerifiedAccount): email → phone (SMS) → card, or card →
|
||||
// phone → email when the account clears email last.
|
||||
let card_gate_cleared_by_fallback = false;
|
||||
// A framed email confirmation asks for the code itself, in a
|
||||
// window the user can close.
|
||||
const confirmsEmailInAction =
|
||||
window.embedded_in_frame && action === 'request-email-confirmation';
|
||||
const emailGate = async () => {
|
||||
if (whoami.requires_email_confirmation) {
|
||||
if (whoami.requires_email_confirmation && !confirmsEmailInAction) {
|
||||
let is_verified;
|
||||
do {
|
||||
is_verified = await UIWindowEmailConfirmationRequired({
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* A popup's opener can be navigated to another origin while a picker is open,
|
||||
* so messages to it go to the origin attested at boot, not to '*'.
|
||||
*/
|
||||
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||
|
||||
// Sent before the opener's origin is known, or to report that it is unusable.
|
||||
const unattestedMessages = ['requestOrigin', 'permissionGranted', 'pushEnabled'];
|
||||
|
||||
const openerPosts = (src) => {
|
||||
const posts = [];
|
||||
for (const match of src.matchAll(/window\.opener\??\.postMessage\(/g)) {
|
||||
let depth = 1;
|
||||
let i = match.index + match[0].length;
|
||||
while (depth > 0 && i < src.length) {
|
||||
if (src[i] === '(') depth++;
|
||||
else if (src[i] === ')') depth--;
|
||||
i++;
|
||||
}
|
||||
posts.push(src.slice(match.index, i));
|
||||
}
|
||||
return posts;
|
||||
};
|
||||
|
||||
describe('messages to a popup opener', () => {
|
||||
for (const file of ['./initgui.js', './UI/UIWindow.js', './helpers/openItem.js']) {
|
||||
it(`target the attested opener origin in ${file}`, () => {
|
||||
const posts = openerPosts(read(file));
|
||||
expect(posts.length).toBeGreaterThan(0);
|
||||
for (const post of posts) {
|
||||
if (!/'\*'\s*,?\s*\)$/.test(post)) continue;
|
||||
const msg = post.match(/msg:\s*'([^']+)'/)?.[1];
|
||||
expect(unattestedMessages, post).toContain(msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Vendored
+3
@@ -18,6 +18,7 @@ declare global {
|
||||
// -- Shared --
|
||||
export type {
|
||||
APILoggingConfig,
|
||||
EmailConfirmationResult,
|
||||
ListPage,
|
||||
ListPaginationOptions,
|
||||
ListStreamOptions,
|
||||
@@ -28,6 +29,7 @@ export type {
|
||||
ToolSchema,
|
||||
UpgradeReason,
|
||||
UpgradeRequestDetails,
|
||||
UpgradeResult,
|
||||
} from './types/lib/types.js';
|
||||
export type { default as APICallLogger } from './types/lib/APICallLogger.js';
|
||||
|
||||
@@ -36,6 +38,7 @@ export type {
|
||||
AIMessageContent,
|
||||
ChatMessage,
|
||||
ChatOptions,
|
||||
ServiceSpeed,
|
||||
ChatResponse,
|
||||
ChatResponseChunk,
|
||||
ChatResponseStream,
|
||||
|
||||
@@ -61,6 +61,82 @@ const RESERVED_LABELS = new Set(['www', 'api', 'js', 'dav', 'about', 'developer'
|
||||
// Dismisses the single-instance embed showing at each path.
|
||||
const showingEmbeds = new Map();
|
||||
|
||||
// Embed dialogs showing over the page, and the page's own styles to put back
|
||||
// once none is.
|
||||
const scrollLockers = new Set();
|
||||
let pageStyle = null;
|
||||
|
||||
/**
|
||||
* The element whose overflow the page's viewport uses: the body's while the
|
||||
* root's is `visible`. Hiding the root's then would make the body a scroller
|
||||
* of its own, which starts at the top.
|
||||
*
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
const viewportOverflowElement = () => {
|
||||
const root = document.documentElement;
|
||||
const body = document.body;
|
||||
const visible = (el) => {
|
||||
const style = getComputedStyle(el);
|
||||
return style.overflowX === 'visible' && style.overflowY === 'visible';
|
||||
};
|
||||
return body && visible(root) && ! visible(body) ? body : root;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops the page scrolling behind `dialog` while it shows: Firefox and WebKit
|
||||
* pass a scroll over the frame on to the page, whatever the dialog's
|
||||
* `overscroll-behavior`. The root takes the width its scrollbar gave back as
|
||||
* padding, so the page doesn't shift. Returns the function that lets it scroll again.
|
||||
*
|
||||
* @param {HTMLDialogElement} dialog
|
||||
* @returns {() => void}
|
||||
*/
|
||||
const lockPageScroll = (dialog) => {
|
||||
const root = document.documentElement;
|
||||
const unlockPage = () => {
|
||||
const { scroller, overflow, overflowX, overflowY } = pageStyle;
|
||||
scroller.style.overflow = overflow;
|
||||
// A shorthand reads empty when the page set only one axis.
|
||||
if ( ! overflow ) {
|
||||
scroller.style.overflowX = overflowX;
|
||||
scroller.style.overflowY = overflowY;
|
||||
}
|
||||
root.style.paddingRight = pageStyle.paddingRight;
|
||||
pageStyle = null;
|
||||
};
|
||||
// One the page removed itself never unlocks, so the page's own styles
|
||||
// go back before they're read again.
|
||||
for ( const shown of scrollLockers ) if ( ! shown.isConnected ) scrollLockers.delete(shown);
|
||||
if ( scrollLockers.size === 0 && pageStyle ) unlockPage();
|
||||
if ( scrollLockers.size === 0 ) {
|
||||
const scroller = viewportOverflowElement();
|
||||
pageStyle = {
|
||||
scroller,
|
||||
overflow: scroller.style.overflow,
|
||||
overflowX: scroller.style.overflowX,
|
||||
overflowY: scroller.style.overflowY,
|
||||
paddingRight: root.style.paddingRight,
|
||||
};
|
||||
const hasScrollbar = window.innerWidth > root.clientWidth;
|
||||
const width = hasScrollbar ? root.getBoundingClientRect().width : 0;
|
||||
scroller.style.overflow = 'hidden';
|
||||
// Measured, since a gutter the page keeps (`scrollbar-gutter: stable`)
|
||||
// gives nothing back.
|
||||
const released = hasScrollbar ? root.getBoundingClientRect().width - width : 0;
|
||||
if ( released > 0 ) {
|
||||
const padding = parseFloat(getComputedStyle(root).paddingRight) || 0;
|
||||
root.style.paddingRight = `${padding + released}px`;
|
||||
}
|
||||
}
|
||||
scrollLockers.add(dialog);
|
||||
return () => {
|
||||
if ( ! scrollLockers.delete(dialog) ) return;
|
||||
for ( const shown of scrollLockers ) if ( ! shown.isConnected ) scrollLockers.delete(shown);
|
||||
if ( scrollLockers.size === 0 && pageStyle ) unlockPage();
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a page at `pageUrl` may frame an embed served by `guiOrigin`:
|
||||
* exactly one label under its host, on the same scheme and port, and not one
|
||||
@@ -215,6 +291,12 @@ const mountGuiEmbed = ({ guiOrigin, path, fragment, className, title, stateMsg,
|
||||
let wasReady = false;
|
||||
let restarted = false;
|
||||
let restartTimer = null;
|
||||
let unlockScroll = null;
|
||||
// A dialog the page removes itself, such as by re-rendering its body,
|
||||
// counts as dismissed, so whoever awaits it isn't left waiting.
|
||||
const removal = new MutationObserver(() => {
|
||||
if ( ! dialog.isConnected ) dismiss();
|
||||
});
|
||||
const restoreFocus = document.activeElement;
|
||||
const wait = (ms) => {
|
||||
clearTimeout(timer);
|
||||
@@ -226,9 +308,11 @@ const mountGuiEmbed = ({ guiOrigin, path, fragment, className, title, stateMsg,
|
||||
clearTimeout(timer);
|
||||
clearTimeout(loadingTimer);
|
||||
clearTimeout(restartTimer);
|
||||
removal.disconnect();
|
||||
window.removeEventListener('message', onWindowMessage);
|
||||
if ( showingEmbeds.get(path) === dismiss ) showingEmbeds.delete(path);
|
||||
dialog.remove();
|
||||
unlockScroll?.();
|
||||
if ( shown ) restoreFocus?.focus?.({ preventScroll: true });
|
||||
resolve(result);
|
||||
};
|
||||
@@ -244,6 +328,7 @@ const mountGuiEmbed = ({ guiOrigin, path, fragment, className, title, stateMsg,
|
||||
if ( dialog.open ) return;
|
||||
shown = true;
|
||||
dialog.showModal();
|
||||
unlockScroll = lockPageScroll(dialog);
|
||||
};
|
||||
const onWindowMessage = (event) => {
|
||||
if ( event.origin !== origin || event.source !== frame.contentWindow ) return;
|
||||
@@ -320,6 +405,7 @@ const mountGuiEmbed = ({ guiOrigin, path, fragment, className, title, stateMsg,
|
||||
frame.src = url.href;
|
||||
// Hidden at first, so a quickly refused session shows nothing.
|
||||
document.body.append(dialog);
|
||||
removal.observe(document.documentElement, { childList: true, subtree: true });
|
||||
onFrame?.(frame.contentWindow);
|
||||
wait(READY_TIMEOUT_MS);
|
||||
loadingTimer = setTimeout(() => {
|
||||
@@ -357,7 +443,10 @@ export const showFeedbackEmbed = ({ guiOrigin, userUuid }) => mountGuiEmbed({
|
||||
},
|
||||
});
|
||||
|
||||
/** Popup actions a first-party page shows framed, each in a box its popup window's size. */
|
||||
/**
|
||||
* Popup actions a first-party page shows framed, each in a box its popup
|
||||
* window's size. Any other covers the page, and its dialog draws its own scrim.
|
||||
*/
|
||||
const POPUP_FRAME_BOXES = {
|
||||
'sign-in': { width: 600, height: 700 },
|
||||
'show-open-file-picker': { width: 700, height: 400 },
|
||||
@@ -365,24 +454,33 @@ const POPUP_FRAME_BOXES = {
|
||||
'show-directory-picker': { width: 700, height: 400 },
|
||||
};
|
||||
|
||||
// The upgrade has no box: it covers the page and its dialog draws its own
|
||||
// scrim. A payment in it may use the Payment Request API, which a frame can
|
||||
// use only when allowed, and Escape is left to the dialog, which doesn't close
|
||||
// mid-entry.
|
||||
const UNBOXED_POPUP_FRAMES = {
|
||||
upgrade: { allow: 'payment; otp-credentials', escapeDismisses: false },
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the GUI popup for `action` in a frame over the page instead of a
|
||||
* window, from `/embed/<action>`. The GUI runs the popup unchanged and posts
|
||||
* its answers to this page as it would to an opener; `onMessage` sees each of
|
||||
* them and finishes with the result. The frame closing on its own, as the
|
||||
* popup would, finishes with `closed()`, and so does the user dismissing it.
|
||||
* Resolves null when the frame can't serve the request (refused, never
|
||||
* answering, or reporting itself unavailable), so the caller can open the
|
||||
* popup instead. Never rejects.
|
||||
* popup would, finishes with `closed(data)`, `data` being its closing
|
||||
* message, and the user dismissing it finishes with `closed()`. Resolves null
|
||||
* when the frame can't serve the request (refused, never answering, or
|
||||
* reporting itself unavailable), so the caller can open the popup instead.
|
||||
* Never rejects.
|
||||
*
|
||||
* @template T
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.guiOrigin
|
||||
* @param {'sign-in' | 'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker'} opts.action
|
||||
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for a picker
|
||||
* @param {'sign-in' | 'show-open-file-picker' | 'show-save-file-picker' | 'show-directory-picker' | 'upgrade' | 'request-email-confirmation'} opts.action
|
||||
* @param {Record<string, string>} opts.fragment - the popup's parameters, and `user` for a picker, the upgrade or the email confirmation
|
||||
* @param {string} opts.title - the frame's accessible name
|
||||
* @param {string} [opts.closeLabel] - puts a close button on the frame
|
||||
* @param {() => T} opts.closed
|
||||
* @param {(data?: Record<string, unknown>) => T} opts.closed
|
||||
* @param {(data: Record<string, unknown>, finish: (result: T) => void) => void} opts.onMessage
|
||||
* @param {(frame: Window) => void} [opts.onFrame]
|
||||
* @returns {Promise<T | null>}
|
||||
@@ -400,13 +498,14 @@ export const showPopupFrame = ({ guiOrigin, action, fragment, title, closeLabel,
|
||||
allow: 'otp-credentials',
|
||||
// Escape inside the frame closes it as well.
|
||||
escapeDismisses: true,
|
||||
...UNBOXED_POPUP_FRAMES[action],
|
||||
// A second request for this popup, such as from a double click, replaces the
|
||||
// first. Each popup has its own window name, so other popups stay open.
|
||||
single: true,
|
||||
dismissed: closed,
|
||||
dismissed: () => closed(),
|
||||
onFrame,
|
||||
onMessage: (data, finish) => {
|
||||
if ( data.msg === 'puterPopupFrame' && data.state === 'closed' ) finish(closed());
|
||||
if ( data.msg === 'puterPopupFrame' && data.state === 'closed' ) finish(closed(data));
|
||||
else onMessage(data, finish);
|
||||
},
|
||||
});
|
||||
@@ -459,6 +459,15 @@ describe('showPopupFrame', () => {
|
||||
await expect(picker).resolves.toBe('closed');
|
||||
});
|
||||
|
||||
it('finishes as closed when the page removes it, letting the page scroll again', async () => {
|
||||
const result = show({ action: 'upgrade', fragment: { user: USER } });
|
||||
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
expect(document.documentElement.style.overflow).toBe('hidden');
|
||||
document.body.innerHTML = '<main>re-rendered</main>';
|
||||
await expect(result).resolves.toBe('closed');
|
||||
expect(document.documentElement.style.overflow).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the scrim behind a ready frame from dismissing it', () => {
|
||||
show();
|
||||
fromPopup({ msg: 'puterPopupFrame', state: 'ready' });
|
||||
@@ -475,3 +484,139 @@ describe('showPopupFrame', () => {
|
||||
expect(popupFrame().closest('dialog').isConnected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('page scroll behind an embed', () => {
|
||||
const root = document.documentElement;
|
||||
const popupFrame = (action) => document.querySelector(`dialog.puter-popup-frame iframe[src*="/embed/${action}"]`);
|
||||
const show = (action, closed = () => 'closed') => showPopupFrame({
|
||||
guiOrigin: GUI_ORIGIN,
|
||||
action,
|
||||
fragment: { user: USER },
|
||||
title: action,
|
||||
closed,
|
||||
onMessage: () => {},
|
||||
});
|
||||
const tell = (action, state) => window.dispatchEvent(new MessageEvent('message', {
|
||||
data: { msg: 'puterPopupFrame', state },
|
||||
origin: GUI_ORIGIN,
|
||||
source: popupFrame(action).contentWindow,
|
||||
}));
|
||||
/**
|
||||
* Gives the page a classic scrollbar this wide, or none, whose space comes
|
||||
* back once the root stops scrolling unless the page keeps its gutter.
|
||||
*/
|
||||
const scrollbar = (width, { gutterKept = false } = {}) => {
|
||||
vi.spyOn(root, 'clientWidth', 'get').mockReturnValue(window.innerWidth - width);
|
||||
vi.spyOn(root, 'getBoundingClientRect').mockImplementation(() => ({
|
||||
width: window.innerWidth - (root.style.overflow === 'hidden' && ! gutterKept ? 0 : width),
|
||||
}));
|
||||
};
|
||||
|
||||
// Earlier tests leave dialogs that never closed, which locked the page.
|
||||
beforeEach(() => {
|
||||
root.removeAttribute('style');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
root.removeAttribute('style');
|
||||
document.body.removeAttribute('style');
|
||||
});
|
||||
|
||||
it('stops while the embed shows and comes back as the page had it once it closes', async () => {
|
||||
scrollbar(0);
|
||||
root.style.overflow = 'clip';
|
||||
const result = show('upgrade');
|
||||
tell('upgrade', 'loading');
|
||||
expect(root.style.overflow).toBe('clip');
|
||||
|
||||
tell('upgrade', 'ready');
|
||||
expect(root.style.overflow).toBe('hidden');
|
||||
expect(root.style.paddingRight).toBe('');
|
||||
|
||||
tell('upgrade', 'closed');
|
||||
await expect(result).resolves.toBe('closed');
|
||||
expect(root.style.overflow).toBe('clip');
|
||||
});
|
||||
|
||||
it('stops the body instead when the viewport scrolls by the body\'s overflow', async () => {
|
||||
scrollbar(0);
|
||||
root.style.overflow = 'visible';
|
||||
document.body.style.overflowX = 'hidden';
|
||||
const result = show('upgrade');
|
||||
tell('upgrade', 'ready');
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
expect(root.style.overflow).toBe('visible');
|
||||
|
||||
tell('upgrade', 'closed');
|
||||
await result;
|
||||
expect(document.body.style.overflowX).toBe('hidden');
|
||||
expect(document.body.style.overflowY).toBe('');
|
||||
});
|
||||
|
||||
it('keeps the space of a classic scrollbar, so the page doesn\'t shift', async () => {
|
||||
scrollbar(15);
|
||||
root.style.paddingRight = '8px';
|
||||
const result = show('upgrade');
|
||||
tell('upgrade', 'ready');
|
||||
expect(root.style.overflow).toBe('hidden');
|
||||
expect(root.style.paddingRight).toBe('23px');
|
||||
tell('upgrade', 'closed');
|
||||
await result;
|
||||
expect(root.style.paddingRight).toBe('8px');
|
||||
expect(root.style.overflow).toBe('');
|
||||
});
|
||||
|
||||
it('adds no space for a scrollbar whose gutter the page keeps', async () => {
|
||||
scrollbar(15, { gutterKept: true });
|
||||
const result = show('upgrade');
|
||||
tell('upgrade', 'ready');
|
||||
expect(root.style.overflow).toBe('hidden');
|
||||
expect(root.style.paddingRight).toBe('');
|
||||
tell('upgrade', 'closed');
|
||||
await result;
|
||||
});
|
||||
|
||||
it('stops behind the loading scrim too, and stays stopped until the last embed closes', async () => {
|
||||
vi.useFakeTimers();
|
||||
scrollbar(0);
|
||||
const upgrade = show('upgrade');
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(root.style.overflow).toBe('hidden');
|
||||
const signIn = show('sign-in');
|
||||
tell('sign-in', 'ready');
|
||||
|
||||
tell('sign-in', 'closed');
|
||||
await signIn;
|
||||
expect(root.style.overflow).toBe('hidden');
|
||||
tell('upgrade', 'closed');
|
||||
await upgrade;
|
||||
expect(root.style.overflow).toBe('');
|
||||
});
|
||||
|
||||
it('gives the page its own styles back after an embed the page removed itself', async () => {
|
||||
scrollbar(15);
|
||||
root.style.paddingRight = '8px';
|
||||
show('upgrade');
|
||||
tell('upgrade', 'ready');
|
||||
document.querySelector('dialog.puter-popup-frame').remove();
|
||||
|
||||
const signIn = show('sign-in');
|
||||
tell('sign-in', 'ready');
|
||||
expect(root.style.overflow).toBe('hidden');
|
||||
expect(root.style.paddingRight).toBe('23px');
|
||||
tell('sign-in', 'closed');
|
||||
await signIn;
|
||||
|
||||
expect(root.style.overflow).toBe('');
|
||||
expect(root.style.paddingRight).toBe('8px');
|
||||
});
|
||||
|
||||
it('leaves the page alone for an embed that never showed', async () => {
|
||||
scrollbar(0);
|
||||
const result = show('upgrade');
|
||||
tell('upgrade', 'unavailable');
|
||||
await expect(result).resolves.toBeNull();
|
||||
expect(root.style.overflow).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,23 @@
|
||||
* @property {string} [message] One sentence saying what was refused and why.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How a framed upgrade on a first-party page ended.
|
||||
*
|
||||
* @typedef {Object} UpgradeResult
|
||||
* @property {boolean} upgraded `true` when the user subscribed to a plan or upgraded to a higher
|
||||
* one before closing the dialog. `false` otherwise, including after a downgrade or cancellation,
|
||||
* or when the upgrade opened in a new tab instead.
|
||||
*/
|
||||
|
||||
/**
|
||||
* How `puter.ui.requestEmailConfirmation()` ended.
|
||||
*
|
||||
* @typedef {Object} EmailConfirmationResult
|
||||
* @property {boolean} response `true` when the user's email is confirmed, including when it already
|
||||
* was. `false` when the user closed the dialog first, or when a website couldn't show it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The legacy positional callbacks most methods accept alongside the promise
|
||||
* they return.
|
||||
|
||||
@@ -26,7 +26,7 @@ import { showUsageLimitDialog } from '../modules/UsageLimitDialog.js';
|
||||
*
|
||||
* @typedef {{
|
||||
* env?: string,
|
||||
* ui?: { requestUpgrade: Function },
|
||||
* ui?: { requestUpgrade: Function, canShowUpgradeFrame_?: () => boolean, upgradeFrameShowing_?: () => boolean },
|
||||
* authToken?: string,
|
||||
* appID?: string,
|
||||
* getAppIDFromAuthToken?: (token: unknown) => string | null,
|
||||
@@ -109,7 +109,8 @@ const messageFor = (reason, error, context) => {
|
||||
* Inside an app the desktop's upgrade flow is asked for (and not awaited: it
|
||||
* never answers). The SDK's own dialog covers storage refusals everywhere
|
||||
* else - the desktop leans on it for those - and funding / plan refusals on
|
||||
* third-party sites only. The error is not consumed; callers still reject.
|
||||
* websites only. On a first-party page its Upgrade button shows the upgrade
|
||||
* in a frame over the page. The error is not consumed; callers still reject.
|
||||
*
|
||||
* @param {unknown} error The rejection, in whatever shape it arrived.
|
||||
* @param {UpgradePromptContext} [context] Which SDK method was refused and,
|
||||
@@ -127,11 +128,26 @@ export const promptIfUpgradeRequired = (error, context = {}, puter = globalThis.
|
||||
if ( puter?.env === 'app' ) {
|
||||
puter.ui.requestUpgrade({ reason, method, message });
|
||||
} else if ( puter?.env === 'web' || reason === 'storage' ) {
|
||||
// The upgrade a first-party page already shows answers this refusal
|
||||
// too, and a dialog over it would block it.
|
||||
if ( puter.ui?.upgradeFrameShowing_?.() === true ) return;
|
||||
// A first-party page shows the upgrade in a frame over itself
|
||||
// instead of a dashboard tab.
|
||||
const framed = puter?.env === 'web' && puter.ui?.canShowUpgradeFrame_?.() === true;
|
||||
showUsageLimitDialog(message, {
|
||||
title: TITLES[reason],
|
||||
method,
|
||||
reason,
|
||||
appID: appIDOf(puter),
|
||||
...(framed ? {
|
||||
// The page may have stopped being able to frame it, such as
|
||||
// by signing out, since the dialog opened.
|
||||
onUpgrade: () => {
|
||||
if ( puter.ui.canShowUpgradeFrame_?.() !== true ) return false;
|
||||
puter.ui.requestUpgrade({ reason, method, message });
|
||||
return true;
|
||||
},
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -143,5 +159,5 @@ export const promptIfUpgradeRequired = (error, context = {}, puter = globalThis.
|
||||
* @param {PromptingSdk | undefined} puter
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
const appIDOf = (puter) =>
|
||||
export const appIDOf = (puter) =>
|
||||
puter?.getAppIDFromAuthToken?.(puter.authToken) || puter?.appID || undefined;
|
||||
@@ -179,6 +179,57 @@ describe('promptIfUpgradeRequired', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('on a first-party page that can frame the upgrade', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.puter = {
|
||||
env: 'web',
|
||||
ui: { requestUpgrade: vi.fn(), canShowUpgradeFrame_: () => true },
|
||||
};
|
||||
});
|
||||
|
||||
it('has the dialog ask for the framed upgrade instead of opening a tab', () => {
|
||||
promptIfUpgradeRequired({ code: 'insufficient_funds', status: 402 }, { method: 'puter.ai.chat' });
|
||||
expect(globalThis.puter.ui.requestUpgrade).not.toHaveBeenCalled();
|
||||
const [message, options] = showUsageLimitDialog.mock.calls[0];
|
||||
expect(message).toBe('Your account does not have enough funding to complete this request.');
|
||||
expect(options).toMatchObject({ title: 'Low Balance', method: 'puter.ai.chat', reason: 'funds' });
|
||||
|
||||
expect(options.onUpgrade()).toBe(true);
|
||||
expect(globalThis.puter.ui.requestUpgrade).toHaveBeenCalledWith({
|
||||
reason: 'funds',
|
||||
method: 'puter.ai.chat',
|
||||
message: 'Your account does not have enough funding to complete this request.',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves the tab to the dialog when the page can no longer frame it by the click', () => {
|
||||
promptIfUpgradeRequired({ code: 'insufficient_funds', status: 402 }, { method: 'puter.ai.chat' });
|
||||
globalThis.puter.ui.canShowUpgradeFrame_ = () => false;
|
||||
expect(showUsageLimitDialog.mock.calls[0][1].onUpgrade()).toBe(false);
|
||||
expect(globalThis.puter.ui.requestUpgrade).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows no dialog over an upgrade the page already shows', () => {
|
||||
globalThis.puter.ui.upgradeFrameShowing_ = () => true;
|
||||
promptIfUpgradeRequired({ code: 'insufficient_funds', status: 402 }, { method: 'puter.ai.chat' });
|
||||
promptIfUpgradeRequired({ code: 'storage_limit_reached' }, { method: 'puter.fs.upload' });
|
||||
expect(showUsageLimitDialog).not.toHaveBeenCalled();
|
||||
expect(globalThis.puter.ui.requestUpgrade).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the tab when the page cannot frame it', () => {
|
||||
globalThis.puter.ui.canShowUpgradeFrame_ = () => false;
|
||||
promptIfUpgradeRequired({ code: 'storage_limit_reached' }, { method: 'puter.fs.upload' });
|
||||
expect(showUsageLimitDialog.mock.calls[0][1]).not.toHaveProperty('onUpgrade');
|
||||
});
|
||||
|
||||
it('keeps the tab for a storage refusal outside a website', () => {
|
||||
globalThis.puter.env = 'gui';
|
||||
promptIfUpgradeRequired({ code: 'storage_limit_reached' }, { method: 'puter.fs.upload' });
|
||||
expect(showUsageLimitDialog.mock.calls[0][1]).not.toHaveProperty('onUpgrade');
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing for a rejection an upgrade would not clear', () => {
|
||||
promptIfUpgradeRequired({ status: 403 });
|
||||
promptIfUpgradeRequired(new Error('offline'));
|
||||
|
||||
+170
-14
@@ -4,6 +4,8 @@ import FSItem from './FSItem.js';
|
||||
import PuterDialog from './PuterDialog.js';
|
||||
import { ancestorsAllowGuiEmbed, canFrameGuiEmbed, showFeedbackEmbed, showPopupFrame } from '../lib/guiEmbed.js';
|
||||
import { hasUserActivation } from '../lib/auth-popup.js';
|
||||
import { appIDOf } from '../lib/upgradePrompt.js';
|
||||
import { upgradeUrl } from './UsageLimitDialog.js';
|
||||
import { checkPermissions } from './perms/lib/holds.js';
|
||||
|
||||
|
||||
@@ -533,6 +535,12 @@ export class UIModule extends EventListener {
|
||||
// Set while the framed feedback dialog is open on a first-party page.
|
||||
#feedbackEmbedOpen = false;
|
||||
|
||||
// The framed upgrade showing, which a call made while it's open joins.
|
||||
#upgradeFrame = null;
|
||||
|
||||
// The framed email confirmation showing, which a call made while it's open joins.
|
||||
#emailConfirmationFrame = null;
|
||||
|
||||
// Holds the callback functions for the various events
|
||||
// that are triggered when a watched item has changed.
|
||||
itemWatchCallbackFunctions = [];
|
||||
@@ -620,7 +628,7 @@ export class UIModule extends EventListener {
|
||||
* @param {(win: Window | null, how?: { framed: boolean }) => (() => void) | void} [opts.onWindow]
|
||||
*/
|
||||
#showWebPicker ({ action, msgId, url, title, fragment = {}, onWindow }) {
|
||||
if ( ! this.#canFramePicker() ) {
|
||||
if ( ! this.#canFrameForUser() ) {
|
||||
// window.open() returns synchronously and the popup cannot post
|
||||
// back until this function yields, so there is no race.
|
||||
const popup = this.#openPickerPopup(url, title);
|
||||
@@ -662,8 +670,8 @@ export class UIModule extends EventListener {
|
||||
})();
|
||||
}
|
||||
|
||||
/** Whether a picker can show framed: a signed-in first-party page, not in a frame of another site. */
|
||||
#canFramePicker () {
|
||||
/** Whether a picker or upgrade can show framed: a signed-in first-party page, not in a frame of another site. */
|
||||
#canFrameForUser () {
|
||||
return this.env === 'web' &&
|
||||
!globalThis.crossOriginIsolated &&
|
||||
!!globalThis.document?.body &&
|
||||
@@ -680,9 +688,8 @@ export class UIModule extends EventListener {
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async #showPickerFrame ({ action, msgId, title, fragment, onWindow }) {
|
||||
const known = await Promise.resolve(puter.whoami ?? puter.whoamiCache_).catch(() => null);
|
||||
const user = known?.uuid ? known : await puter.auth.getUser().catch(() => null);
|
||||
if ( typeof user?.uuid !== 'string' || ! user.uuid ) return false;
|
||||
const user = await this.#frameUser();
|
||||
if ( ! user ) return false;
|
||||
let frame = null;
|
||||
let stopListening;
|
||||
// The frame only serves this page's own user, so the token it sends
|
||||
@@ -708,6 +715,18 @@ export class UIModule extends EventListener {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's signed-in user that a frame is shown for, from the cached
|
||||
* whoami when there is one, or null without a uuid.
|
||||
*
|
||||
* @returns {Promise<{ uuid: string, email_confirmed?: boolean } | null>}
|
||||
*/
|
||||
async #frameUser () {
|
||||
const known = await Promise.resolve(puter.whoami ?? puter.whoamiCache_).catch(() => null);
|
||||
const user = known?.uuid ? known : await puter.auth.getUser().catch(() => null);
|
||||
return typeof user?.uuid === 'string' && user.uuid ? user : null;
|
||||
}
|
||||
|
||||
// Whether a message on the window may drive this module.
|
||||
#isTrustedMessageSource (e) {
|
||||
if ( this.messageTarget ) {
|
||||
@@ -1318,17 +1337,59 @@ export class UIModule extends EventListener {
|
||||
};
|
||||
|
||||
/**
|
||||
* Asks the desktop to walk the user through confirming their email.
|
||||
* Resolves once the dialog closes.
|
||||
* Walks the user through confirming their email: a temporary account is
|
||||
* saved first, then the user enters the code sent to their address.
|
||||
* Resolves once they're done, or right away when the email is already
|
||||
* confirmed.
|
||||
*
|
||||
* @returns {Promise<unknown>}
|
||||
* Inside an app the desktop shows it. On a signed-in first-party page one
|
||||
* label under the Puter origin, it shows in a frame over the page, and
|
||||
* `response` is false when the frame can't show it. Anywhere else the
|
||||
* promise never settles, since a website has no desktop to ask.
|
||||
*
|
||||
* @returns {Promise<import('../lib/types.js').EmailConfirmationResult>}
|
||||
*/
|
||||
requestEmailConfirmation () {
|
||||
return new Promise((resolve, reject) => {
|
||||
if ( this.#canFrameForUser() ) return this.#showEmailConfirmationFrame();
|
||||
return new Promise((resolve) => {
|
||||
this.#postMessageWithCallback('requestEmailConfirmation', resolve, { });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The email confirmation in a frame over the page, for the page's
|
||||
* signed-in user. A call made while one is showing settles with it, and
|
||||
* an email the page already knows is confirmed resolves without one.
|
||||
*
|
||||
* @returns {Promise<import('../lib/types.js').EmailConfirmationResult>}
|
||||
*/
|
||||
#showEmailConfirmationFrame () {
|
||||
if ( this.#emailConfirmationFrame ) return this.#emailConfirmationFrame;
|
||||
this.#emailConfirmationFrame = (async () => {
|
||||
try {
|
||||
const user = await this.#frameUser();
|
||||
// A confirmed email never goes back to unconfirmed, so this
|
||||
// skips the loading scrim the frame would show while it boots.
|
||||
if ( user?.email_confirmed === true ) return { response: true };
|
||||
const framed = user && await showPopupFrame({
|
||||
guiOrigin: puter.defaultGUIOrigin,
|
||||
action: 'request-email-confirmation',
|
||||
fragment: { user: user.uuid },
|
||||
title: 'Confirm your email',
|
||||
closed: (data) => ({ response: data?.confirmed === true }),
|
||||
onMessage: () => {},
|
||||
});
|
||||
// The page's cached whoami still reads it as unconfirmed,
|
||||
// and would frame the next call too.
|
||||
if ( framed?.response ) user.email_confirmed = true;
|
||||
return framed || { response: false };
|
||||
} finally {
|
||||
this.#emailConfirmationFrame = null;
|
||||
}
|
||||
})();
|
||||
return this.#emailConfirmationFrame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the desktop to walk the user through clearing an account
|
||||
* verification gate (a 403 `*_required` code — email confirmation, phone
|
||||
@@ -1700,16 +1761,112 @@ export class UIModule extends EventListener {
|
||||
* fills them in when a call is refused for want of credit, a plan, or
|
||||
* storage.
|
||||
*
|
||||
* On a signed-in first-party page one label under the Puter origin, the
|
||||
* upgrade shows in a frame over the page instead, and the promise
|
||||
* resolves once it closes. When the frame can't show it, the upgrade
|
||||
* opens in a new tab and the promise resolves `{ upgraded: false }`.
|
||||
* Anywhere else the promise never settles: the desktop doesn't answer an
|
||||
* app, and a website has no desktop to ask.
|
||||
*
|
||||
* @param {import('../lib/types.js').UpgradeRequestDetails} [details]
|
||||
* @returns {Promise<unknown>}
|
||||
* @returns {Promise<import('../lib/types.js').UpgradeResult>}
|
||||
*/
|
||||
requestUpgrade (details) {
|
||||
const { reason, method, message } = details ?? {};
|
||||
if ( this.canShowUpgradeFrame_() ) return this.#showUpgradeFrame({ reason, method });
|
||||
return new Promise((resolve) => {
|
||||
this.#postMessageWithCallback('requestUpgrade', resolve, { reason, method, message });
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether {@link requestUpgrade} shows the upgrade in a frame over this page.
|
||||
*
|
||||
* @internal
|
||||
* @returns {boolean}
|
||||
*/
|
||||
canShowUpgradeFrame_ () {
|
||||
return this.#canFrameForUser();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@link requestUpgrade} is showing the upgrade in a frame, about
|
||||
* to, or asking to open it in a tab instead.
|
||||
*
|
||||
* @internal
|
||||
* @returns {boolean}
|
||||
*/
|
||||
upgradeFrameShowing_ () {
|
||||
return this.#upgradeFrame !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The upgrade in a frame over the page, for the page's signed-in user, or
|
||||
* in a new tab when the frame can't show it. A call made while one is
|
||||
* showing settles with it, so a payment in progress isn't replaced.
|
||||
*
|
||||
* @param {{ reason?: string, method?: string }} details
|
||||
* @returns {Promise<import('../lib/types.js').UpgradeResult>}
|
||||
*/
|
||||
#showUpgradeFrame ({ reason, method }) {
|
||||
if ( this.#upgradeFrame ) return this.#upgradeFrame;
|
||||
this.#upgradeFrame = (async () => {
|
||||
try {
|
||||
const user = await this.#frameUser();
|
||||
const framed = user && await showPopupFrame({
|
||||
guiOrigin: puter.defaultGUIOrigin,
|
||||
action: 'upgrade',
|
||||
fragment: {
|
||||
user: user.uuid,
|
||||
...(reason ? { reason } : {}),
|
||||
...(method ? { method } : {}),
|
||||
},
|
||||
title: 'Upgrade',
|
||||
closed: (data) => ({ upgraded: data?.upgraded === true }),
|
||||
onMessage: () => {},
|
||||
});
|
||||
if ( framed ) return framed;
|
||||
await this.#openUpgradeTab(upgradeUrl({ reason, method, appID: appIDOf(puter) }));
|
||||
return { upgraded: false };
|
||||
} finally {
|
||||
this.#upgradeFrame = null;
|
||||
}
|
||||
})();
|
||||
return this.#upgradeFrame;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the upgrade page in a new tab. The click that asked for it may
|
||||
* have expired by now, so without it the user opens it from a consent
|
||||
* dialog. Resolves once the tab opens or the user answers the dialog.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
#openUpgradeTab (url) {
|
||||
if ( hasUserActivation() && window.open(url, '_blank') ) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
let consent = null;
|
||||
const answered = () => {
|
||||
consent?.remove();
|
||||
resolve();
|
||||
};
|
||||
try {
|
||||
consent = PuterDialog.show({
|
||||
message: 'This website wants to open the Puter upgrade page.',
|
||||
onContinue: () => {
|
||||
window.open(url, '_blank');
|
||||
answered();
|
||||
},
|
||||
onCancel: answered,
|
||||
});
|
||||
} catch {
|
||||
// No dialog either; as with a blocked tab, the page never opens.
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a picker for choosing where to save a file, and saves `content`
|
||||
* there. Resolves to the saved `FSItem`; if the user cancels, the promise
|
||||
@@ -2423,9 +2580,8 @@ export class UIModule extends EventListener {
|
||||
if ( this.#feedbackEmbedOpen ) return { sent: false };
|
||||
this.#feedbackEmbedOpen = true;
|
||||
try {
|
||||
const known = await Promise.resolve(puter.whoami ?? puter.whoamiCache_).catch(() => null);
|
||||
const user = known?.uuid ? known : await puter.auth.getUser().catch(() => null);
|
||||
if ( typeof user?.uuid !== 'string' || ! user.uuid ) return null;
|
||||
const user = await this.#frameUser();
|
||||
if ( ! user ) return null;
|
||||
return await showFeedbackEmbed({ guiOrigin: puter.defaultGUIOrigin, userUuid: user.uuid });
|
||||
} finally {
|
||||
this.#feedbackEmbedOpen = false;
|
||||
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user