mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2225f1a90 |
No files matched your search
@@ -53,7 +53,7 @@ jobs:
|
||||
# step above fails; the tests skip themselves without the key.
|
||||
- name: Run live realtime conversation tests
|
||||
if: ${{ !cancelled() }}
|
||||
run: npm run setupExtensions && (cd src/puter-js && npm run build) && npx vitest run --config src/puter-js/tests/api/vitest.config.ts src/puter-js/tests/api/realtime/realtimeLive.test.ts
|
||||
run: npm run setupExtensions && (cd src/puter-js && npm run build) && npx vitest run --config src/puter-js/tests/api/vitest.config.ts src/puter-js/tests/api/realtime/convoLive.test.ts
|
||||
env:
|
||||
CI: 'true'
|
||||
PUTER_TEST_AI_OPENAI_API_KEY: ${{ secrets.PUTER_TEST_AI_OPENAI_API_KEY }}
|
||||
+10
-255
@@ -14,25 +14,11 @@ to its normal sign-in. Apps that need the desktop (built-in and godmode apps,
|
||||
private apps the user can't open, or a failed token request) open `/app/<name>`
|
||||
on the GUI origin, cut off from the embed. Website shortcuts open their external
|
||||
destination with no opener.
|
||||
The embed follows the OS's light or dark setting. A host with a fixed theme adds
|
||||
`theme=dark` or `theme=light` to the fragment (`#user=<uuid>&theme=dark`), since
|
||||
Chromium doesn't pass an iframe's `color-scheme` on to the page inside it.
|
||||
Apps opened from the embed carry `utm_source=puter.com&utm_medium=referral&utm_campaign=app_browser_embed`;
|
||||
the desktop and dashboard send `utm_campaign=desktop` and `utm_campaign=dashboard`.
|
||||
|
||||
## Session and framing
|
||||
|
||||
The embed shows the GUI session only to the user the host page is signed in as.
|
||||
The host names that user's `uuid` (from `puter.auth.getUser()`) in the iframe
|
||||
URL's fragment, as `/embed/apps#user=<uuid>`. The embed reads the session, asks
|
||||
`/whoami` which user it belongs to, and shows the signed-out view unless the two
|
||||
match. So a host that names no user, a malformed one, or a user other than the
|
||||
session's, gets the signed-out view, and so does a session the API refuses. A
|
||||
failed `/whoami` check shows the error state. The fragment never reaches the
|
||||
server and can only narrow what the embed shows: naming the session's own user
|
||||
gives the same view as a direct visit to the GUI. Changing the fragment of a
|
||||
loaded embed reloads it.
|
||||
|
||||
The embed reads the existing GUI session from local storage on its own origin.
|
||||
If that storage holds no session, the embed gets the session from the GUI origin's session cookie through
|
||||
`/get-gui-token`. This matters in Safari, which gives a framed origin separate
|
||||
@@ -43,7 +29,7 @@ does not create accounts, show a login dialog, or accept a token from its parent
|
||||
Being signed in on a subdomain alone does not create a session on the main GUI
|
||||
origin.
|
||||
|
||||
Without a session it can show, the embed shows the recommended apps (`RecommendedAppsService`)
|
||||
Without a session, the embed shows the recommended apps (`RecommendedAppsService`)
|
||||
that the server put in the page, and nothing else: no installed apps, folders, or
|
||||
saved order, and no puter.js. Selecting one opens it at the same URL a signed-in
|
||||
launch uses, with no opener and no token on offer, so the app signs in on its own.
|
||||
@@ -72,18 +58,11 @@ and popups to escape the sandbox.
|
||||
|
||||
## Host integration
|
||||
|
||||
A first-party app that signs in with puter.js can place `<puter-user-menu>` from
|
||||
the SDK in its header instead (see `src/puter-js/src/ui/components.md`). It opens
|
||||
this embed with the signed-in user named and closes it when the app's sign-in
|
||||
changes. The rest of this section is for a host that builds the iframe itself.
|
||||
|
||||
The iframe can be shown whether or not the user is signed in. Create it when
|
||||
“More apps” is opened, naming the signed-in user in its fragment, and remove it
|
||||
on close so reopening loads current apps and preferences. Remove it when the
|
||||
host's own sign-in changes too, such as on sign-out, so an open embed doesn't
|
||||
keep the previous user. The host owns the modal and close control;
|
||||
“More apps” is opened, and remove it on close so reopening loads current apps
|
||||
and preferences. The host owns the modal and close control;
|
||||
there is no parent messaging API and no app list or credential is sent to it.
|
||||
The app browser fills its iframe and responds to its size. It shows a spinner until its apps load.
|
||||
The app browser fills its iframe and responds to its size.
|
||||
|
||||
This example assumes `puterOrigin` comes from the host app's deployment
|
||||
settings. Translate the button and frame labels using the host app's
|
||||
@@ -105,14 +84,10 @@ localization system.
|
||||
const container = document.querySelector('#apps-frame');
|
||||
const trigger = document.querySelector('#more-apps');
|
||||
|
||||
trigger.addEventListener('click', async () => {
|
||||
const url = new URL('/embed/apps', puterOrigin);
|
||||
// Signed out, the embed shows the recommended apps.
|
||||
const user = puter.auth.isSignedIn() ? await puter.auth.getUser().catch(() => null) : null;
|
||||
if (user) url.hash = `user=${encodeURIComponent(user.uuid)}`;
|
||||
trigger.addEventListener('click', () => {
|
||||
const frame = document.createElement('iframe');
|
||||
frame.title = 'Your apps';
|
||||
frame.src = url.href;
|
||||
frame.src = new URL('/embed/apps', puterOrigin).href;
|
||||
container.replaceChildren(frame);
|
||||
dialog.showModal();
|
||||
});
|
||||
@@ -124,242 +99,22 @@ localization system.
|
||||
</script>
|
||||
```
|
||||
|
||||
## Implementation
|
||||
## Implementation and checks
|
||||
|
||||
`createAppBrowser` in `src/gui/src/UI/Dashboard/AppBrowser.js` owns the shared UI
|
||||
and data behavior; its `guestApps` option is the signed-out list. `TabApps.js`
|
||||
injects desktop actions. `appsEmbed.js` supplies new-tab launching through
|
||||
`launchAppInTab` (signed in) and `guestAppLaunchUrl` (signed out) in
|
||||
`src/gui/src/helpers/appTabLaunch.js`, which build the same app URL as
|
||||
`launch_app`, and loads only the embed entry, its SDK, and shared styles. `embedHostUser` in
|
||||
`src/gui/src/helpers/embedHostUser.js` reads the host's user from the fragment, and
|
||||
`embedHostTheme.js` beside it applies the host's theme.
|
||||
The normal GUI webpack build emits `bundle.min.js`, `apps-embed.min.js`, and `feedback-embed.min.js`.
|
||||
`launch_app`, and loads only the embed entry, its SDK, and shared styles.
|
||||
The normal GUI webpack build emits `bundle.min.js` and `apps-embed.min.js`.
|
||||
The `/embed/apps` route puts the recommended list in the page's config as
|
||||
`guestApps`. No puter.js API or app-list endpoint changes are needed.
|
||||
|
||||
## Feedback dialog
|
||||
|
||||
`GET /embed/feedback` is the app feedback dialog (`puter.ui.showFeedbackDialog()`)
|
||||
for a first-party page, with the same framing rules, query redirect, and
|
||||
host-user binding as `/embed/apps`. It sends the feedback for the framing
|
||||
page's origin, which the server reads from the referrer and pins with
|
||||
`frame-ancestors`; opened top-level, it does nothing. Its page is transparent
|
||||
so the dialog's own scrim covers the host.
|
||||
|
||||
puter.js uses it on a page one label under the GUI origin, other than the
|
||||
subdomains the server refuses, when the page is signed in, framing it in a
|
||||
modal `<dialog>` that stays hidden until the embed is ready. A session check that takes longer than 200ms shows a loading scrim
|
||||
with a spinner in its place; Escape or a click on it cancels. The embed posts `{ msg: 'feedbackEmbed', state }` to its parent
|
||||
(`loading`, then `ready` or `unavailable`) and finally
|
||||
`{ msg: 'feedbackDialogClosed', sent }`. It reports `unavailable` without the
|
||||
named user's session, or while that user's email still needs confirming, and
|
||||
puter.js then uses the `/action/send-feedback` popup, which can sign in. So does
|
||||
a frame that never answers, such as one the host's CSP blocks. `feedbackEmbed.js`
|
||||
is the entry, sharing `embedSession` in `src/gui/src/helpers/embedSession.js`
|
||||
with the app browser; `showFeedbackEmbed` in `src/puter-js/src/lib/guiEmbed.js`
|
||||
is the host side.
|
||||
|
||||
## Sign-in and file pickers
|
||||
|
||||
`GET /embed/sign-in`, `/embed/show-open-file-picker`, `/embed/show-save-file-picker`
|
||||
and `/embed/show-directory-picker` serve the same GUI popups as `/action/<action>`
|
||||
for a first-party page, with the same framing rules, query redirect, and
|
||||
`no-store` as `/embed/apps`. Any other spelling of the path (another case, a
|
||||
trailing slash) redirects to it, since the shell boots the desktop for it. They render the normal GUI shell, which names the
|
||||
framing page (read from the referrer, pinned with `frame-ancestors`) as
|
||||
`gui_params.embed_host_origin`. The GUI then runs the popup boot unchanged, with
|
||||
the framing page as `window.opener`, and `window.close()` asks the page to
|
||||
remove the frame (`src/gui/src/helpers/popupFrame.js`). Without a framing page
|
||||
to answer (opened top-level, or framed by a refused page) it does nothing. The
|
||||
popup's parameters come from the fragment instead of the query: `msg_id`,
|
||||
`options` and `request_auth`, plus `user` for a picker.
|
||||
|
||||
The frame posts `{ msg: 'puterPopupFrame', state }` to its parent: `loading` as
|
||||
soon as the shell boots, `ready` once it shows a window or dialog, `unavailable`
|
||||
when the page has to use the popup, `provider` when the user picks a sign-in
|
||||
provider (below), and `closed` where a popup would close. Its
|
||||
answers (`puter.token`, `fileOpenPicked`, `directoryPicked`, `fileSaved`, the
|
||||
cancel messages, `sendMeFileData`) are the popup's.
|
||||
|
||||
puter.js uses them on a page one label under the GUI origin, other than the
|
||||
subdomains the server refuses, unless the page is cross-origin isolated or in a
|
||||
frame of another site (which `frame-ancestors` refuses too).
|
||||
`showPopupFrame` in `src/puter-js/src/lib/guiEmbed.js` shows the frame in a box
|
||||
the size of the popup window, over a scrim, hidden until `ready` and behind the
|
||||
loading spinner after 200ms. Escape closes it, on the page or in the frame. One shows per popup, as each
|
||||
popup has its own window name: a second picker request of the same kind
|
||||
replaces the one showing, and a `signIn()` call made while a framed sign-in is
|
||||
open settles with it. On
|
||||
screens up to 600px wide the box fills the screen. A frame that is refused,
|
||||
never answers, or reports `unavailable` falls back to the popup, opened through
|
||||
a consent dialog when the click that asked for it has expired. A reload or navigation the frame
|
||||
starts itself (logging out, a sign-out in another tab) names the frame as its
|
||||
referrer, so the server refuses it; the page then shows its spinner and opens
|
||||
the frame again, once per `ready`.
|
||||
|
||||
- **Sign-in** (`puter.auth.signIn()`) has no host-user binding: it is how the
|
||||
page gets a user. The box has a close button, which rejects with
|
||||
`auth_window_closed` as closing the popup does. Providers refuse to be
|
||||
framed, so choosing one posts `{ state: 'provider', url }` with the
|
||||
provider's start URL for this sign-in's `msg_id`, then `unavailable`. The
|
||||
page opens the popup straight at that URL, once it checks the URL is the
|
||||
GUI's `/auth/oidc/<provider>/start` for this `msg_id`, and the provider
|
||||
returns it as this sign-in's popup. `attempt_temp_user_creation` keeps the popup, since
|
||||
only the popup creates a temporary account.
|
||||
- **Pickers** frame only for a signed-in page. The GUI reports `unavailable`
|
||||
unless its session belongs to the user named in `user`, and the page then uses
|
||||
the popup, which can sign in and switch accounts. The token the GUI sends
|
||||
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 }`.
|
||||
|
||||
## Top-up
|
||||
|
||||
`GET /embed/top-up` is the deployment's top-up dialog, where a user with a plan
|
||||
buys add-on credit, served and framed like the upgrade: a transparent GUI page
|
||||
covering the host with no box or close button, allowing `payment` and
|
||||
`otp-credentials`, with Escape left to the dialog. Its fragment names `user`.
|
||||
|
||||
puter.js frames it for the **Add credits** item of `<puter-user-menu>`, which
|
||||
shows only when whoami reports `subscribed: true` and no `team` paying for the
|
||||
account. On a page that can't frame the GUI, the item opens the dashboard with
|
||||
`?topup=` in a new tab instead.
|
||||
|
||||
The GUI reports `unavailable` unless its session belongs to `user`, the account
|
||||
has no verification or password change waiting, and the deployment defines
|
||||
`window.UITopUp` with `UITopUp.embeddable === true`. Otherwise it runs:
|
||||
|
||||
```js
|
||||
new UITopUp({ onClose }).open();
|
||||
```
|
||||
|
||||
`open()` resolves once the dialog has loaded the account and shows what it can
|
||||
buy, and `onClose({ toppedUp })` 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', toppedUp }`
|
||||
and the page removes the frame. When the frame can't show the top-up, puter.js
|
||||
opens the dashboard's top-up in a new tab, as it does the upgrade.
|
||||
|
||||
While the top-up shows, a call refused for funds still rejects but opens no
|
||||
usage limit dialog over it, and `requestUpgrade()` frames nothing over it: it
|
||||
resolves `{ upgraded: false }` once the top-up closes.
|
||||
|
||||
## 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.
|
||||
|
||||
## Custom domains
|
||||
|
||||
`GET /embed/attach-custom-domain` is the custom domain dialog of
|
||||
`puter.hosting.attach()`, the popup at `/action/attach-custom-domain`, 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 (the same pages as the pickers).
|
||||
|
||||
The frame covers the page with no box or close button, over a transparent GUI
|
||||
page, so the dialog floats over the host with its own scrim as it does on the
|
||||
desktop. The frame allows `clipboard-write`, for the dialog's copy buttons, as
|
||||
well as `otp-credentials`. Escape on the page is left to the dialog, which
|
||||
doesn't close while a request is in flight. Its fragment names `user` (the
|
||||
page's signed-in user), `subdomain` (the site to attach a domain to) and
|
||||
`msg_id`.
|
||||
|
||||
The GUI reports `unavailable` unless its session belongs to `user` and the
|
||||
account has no verification or password change waiting. It then runs the
|
||||
popup's action unchanged, with the framing page as the requester the dialog
|
||||
names: it posts `ready` once the dialog is on the page, answers
|
||||
`{ msg: 'customDomainAttached', domain, original_msg_id }` once the dialog has
|
||||
closed and its exit has played, and then posts `closed`. `domain` is the domain the user chose, or
|
||||
null. `attach()` resolves it. A frame that closes without an answer, or a
|
||||
loading scrim the user dismisses, resolves null.
|
||||
|
||||
A call made while the frame shows resolves null without replacing it, as a
|
||||
second call does on the desktop. A frame that is refused, never ready, or
|
||||
`unavailable` falls back to the popup, which can sign in, through a consent
|
||||
dialog when the click has expired.
|
||||
|
||||
## Checks
|
||||
|
||||
Run the GUI component/browser tests (with the SDK bundle built and Playwright
|
||||
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 src/puter-js/src/modules/UI.requestUpgrade.test.js src/puter-js/src/modules/UI.showTopUp.test.js src/puter-js/src/modules/UI.attachCustomDomainFrame.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/gui/vitest.config.js src/gui/src/UI/Dashboard/AppBrowser
|
||||
npx vitest run --config src/backend/vitest.config.ts src/backend/server.test.ts
|
||||
```
|
||||
@@ -261,26 +261,6 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body).toHaveProperty('taskbar_items');
|
||||
});
|
||||
|
||||
it('leaves out a desktop background saved on the account', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.update(user.id as number, {
|
||||
desktop_bg_url: 'https://cdn.test/wallpaper.png',
|
||||
desktop_bg_color: '#000000',
|
||||
desktop_bg_fit: 'cover',
|
||||
});
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body).not.toHaveProperty('desktop_bg_url');
|
||||
expect(body).not.toHaveProperty('desktop_bg_color');
|
||||
expect(body).not.toHaveProperty('desktop_bg_fit');
|
||||
});
|
||||
|
||||
it('reports the SMS-to-card fallback only once a send has opened it', async () => {
|
||||
const user = await server.stores.user.create({
|
||||
username: `wuser_${Math.random().toString(36).slice(2, 8)}`,
|
||||
@@ -342,7 +322,7 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(flags.payment_bypass).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips created_ts and human_readable_age fields for app actors', async () => {
|
||||
it('strips desktop_bg_*, created_ts and human_readable_age fields for app actors', async () => {
|
||||
const user = await seedUser();
|
||||
const { res, captured } = makeRes();
|
||||
|
||||
@@ -360,6 +340,9 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(body.app_name).toBe('app-test-actor');
|
||||
// is_user_token is stripped for app actors.
|
||||
expect(body.is_user_token).toBeUndefined();
|
||||
expect(body.desktop_bg_url).toBeUndefined();
|
||||
expect(body.desktop_bg_color).toBeUndefined();
|
||||
expect(body.desktop_bg_fit).toBeUndefined();
|
||||
expect(body.human_readable_age).toBeUndefined();
|
||||
// Account age, in either form, is not exposed to apps.
|
||||
expect(body.created_ts).toBeUndefined();
|
||||
|
||||
@@ -81,6 +81,9 @@ export const handleWhoami = async (
|
||||
delete details.email;
|
||||
delete details.unconfirmed_email;
|
||||
}
|
||||
delete details.desktop_bg_url;
|
||||
delete details.desktop_bg_color;
|
||||
delete details.desktop_bg_fit;
|
||||
delete details.human_readable_age;
|
||||
delete details.created_ts;
|
||||
delete details.is_user_token;
|
||||
@@ -118,7 +121,8 @@ export const handleWhoami = async (
|
||||
}
|
||||
|
||||
const subscription = details.subscription as
|
||||
{ offering?: Record<string, unknown> } | undefined;
|
||||
| { offering?: Record<string, unknown> }
|
||||
| undefined;
|
||||
if (subscription?.offering) {
|
||||
delete subscription.offering.group;
|
||||
delete subscription.offering.benefits;
|
||||
|
||||
@@ -420,11 +420,7 @@ export type EventMap = {
|
||||
old_username: string;
|
||||
new_username: string;
|
||||
};
|
||||
'user.email-changed': {
|
||||
user_id: number;
|
||||
old_email: string | null;
|
||||
new_email: string;
|
||||
};
|
||||
'user.email-changed': { user_id: number; new_email: string };
|
||||
/** An account was suspended, or its suspension lifted. Local events. */
|
||||
'user.suspended': { userId: number };
|
||||
'user.unsuspended': { userId: number };
|
||||
|
||||
@@ -21,8 +21,6 @@
|
||||
import { GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -202,77 +200,6 @@ describe('S3Client — remote endpoint configuration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3Client — bucket in another region', () => {
|
||||
let server: Server;
|
||||
let client: S3Client;
|
||||
let signedRegions: string[];
|
||||
|
||||
// Answers like a bucket that lives in us-west-2: a request signed for any
|
||||
// other region gets S3's 301 naming the bucket's region.
|
||||
beforeEach(async () => {
|
||||
signedRegions = [];
|
||||
server = createServer((req, res) => {
|
||||
const region =
|
||||
/Credential=[^/]+\/\d{8}\/([^/]+)\//.exec(
|
||||
req.headers.authorization ?? '',
|
||||
)?.[1] ?? '';
|
||||
signedRegions.push(region);
|
||||
if (region !== 'us-west-2') {
|
||||
res.writeHead(301, {
|
||||
'content-type': 'application/xml',
|
||||
'x-amz-bucket-region': 'us-west-2',
|
||||
});
|
||||
res.end(
|
||||
'<?xml version="1.0" encoding="UTF-8"?><Error><Code>PermanentRedirect</Code><Message>The bucket you are attempting to access must be addressed using the specified endpoint.</Message></Error>',
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/plain',
|
||||
'content-length': '5',
|
||||
});
|
||||
res.end('hello');
|
||||
});
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve),
|
||||
);
|
||||
const { port } = server.address() as AddressInfo;
|
||||
client = new S3Client({
|
||||
port: 0,
|
||||
extensions: [],
|
||||
s3: {
|
||||
s3Config: {
|
||||
endpoint: `http://127.0.0.1:${port}`,
|
||||
accessKeyId: 'fakeAccessKeyId',
|
||||
secretAccessKey: 'fakeSecretAccessKey',
|
||||
forcePathStyle: true,
|
||||
},
|
||||
},
|
||||
} as unknown as IConfig);
|
||||
await client.onServerStart();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await client.onServerShutdown();
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it("retries a read in the bucket's region when S3 redirects it", async () => {
|
||||
const result = await client
|
||||
.get('ap-northeast-2')
|
||||
.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: 'other-bucket',
|
||||
Key: 'index.html',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await result.Body?.transformToString()).toBe('hello');
|
||||
expect(signedRegions).toEqual(['ap-northeast-2', 'us-west-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('S3Client — legacy storage migration', () => {
|
||||
let client: S3Client;
|
||||
let dir: string;
|
||||
|
||||
@@ -235,9 +235,6 @@ export class S3Client extends PuterClient {
|
||||
|
||||
const client = new AwsS3Client({
|
||||
region,
|
||||
// Rows without a stored region are read with the server's own, so
|
||||
// retry where S3's 301 says the bucket actually lives.
|
||||
followRegionRedirects: true,
|
||||
requestStreamBufferSize: 32 * 1024,
|
||||
requestHandler: new NodeHttpHandler({
|
||||
socketTimeout: 5000,
|
||||
|
||||
@@ -7468,11 +7468,6 @@ 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,
|
||||
@@ -7480,11 +7475,6 @@ 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,7 +2647,6 @@ export class AuthController extends PuterController {
|
||||
'user.email-changed' as never,
|
||||
{
|
||||
user_id: user.id,
|
||||
old_email: user.email ?? null,
|
||||
new_email: newEmail,
|
||||
} as never,
|
||||
{},
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface OidcLoginContinuation {
|
||||
embeddedInPopup: boolean;
|
||||
openerOrigin: string | null;
|
||||
messageId: string | null;
|
||||
switchedToLogin: boolean;
|
||||
}
|
||||
|
||||
interface OidcReturnDeps {
|
||||
@@ -55,6 +56,7 @@ export function completeOidcLoginReturn(
|
||||
data.userUuid !== user.uuid ||
|
||||
typeof data.redirectUri !== 'string' ||
|
||||
typeof data.embeddedInPopup !== 'boolean' ||
|
||||
typeof data.switchedToLogin !== 'boolean' ||
|
||||
(data.openerOrigin !== null && typeof data.openerOrigin !== 'string') ||
|
||||
(data.messageId !== null && typeof data.messageId !== 'string')
|
||||
) {
|
||||
@@ -89,5 +91,6 @@ export function completeOidcLoginReturn(
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.switchedToLogin) append('oidc_switched', 'login');
|
||||
return target;
|
||||
}
|
||||
@@ -29,13 +29,14 @@ const ALLOWED_SORT_ORDER = ['asc', 'desc'];
|
||||
*
|
||||
* Two categories:
|
||||
*
|
||||
* - User-level: taskbar items (UserStore)
|
||||
* - User-level: desktop background, taskbar items (UserStore)
|
||||
* - Folder-level: layout, sort_by/sort_order (fsentries table)
|
||||
*/
|
||||
/**
|
||||
* Desktop preference writes — taskbar, layout, sort order. Each persists on
|
||||
* every call, and the GUI fires them on direct user action, so a per-minute
|
||||
* ceiling well above human speed is enough to catch a stuck client.
|
||||
* Desktop preference writes — background, taskbar, layout, sort order. All four
|
||||
* persist to the user row on every call, and the GUI fires them on direct user
|
||||
* action, so a per-minute ceiling well above human speed is enough to catch a
|
||||
* stuck client.
|
||||
*/
|
||||
const PREFERENCE_WRITE_LIMIT = {
|
||||
scope: 'desktop-preference',
|
||||
@@ -46,6 +47,62 @@ const PREFERENCE_WRITE_LIMIT = {
|
||||
|
||||
export class DesktopController extends PuterController {
|
||||
registerRoutes(router) {
|
||||
// -- Desktop background --------------------------------------
|
||||
|
||||
router.post(
|
||||
'/set-desktop-bg',
|
||||
{
|
||||
subdomain: 'api',
|
||||
requireUserActor: true,
|
||||
allowFullAccessToken: true,
|
||||
rateLimit: PREFERENCE_WRITE_LIMIT,
|
||||
},
|
||||
async (req, res) => {
|
||||
const { url, color, fit } = req.body ?? {};
|
||||
|
||||
const patch = {};
|
||||
if (url !== undefined) {
|
||||
if (url !== null && typeof url !== 'string') {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`url` must be a string or null',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
patch.desktop_bg_url = url;
|
||||
}
|
||||
if (color !== undefined) {
|
||||
if (color !== null && typeof color !== 'string') {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`color` must be a string or null',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
patch.desktop_bg_color = color;
|
||||
}
|
||||
if (fit !== undefined) {
|
||||
if (fit !== null && typeof fit !== 'string') {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'`fit` must be a string or null',
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
patch.desktop_bg_fit = fit;
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
throw new HttpError(400, 'No fields provided', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
|
||||
await this.stores.user.update(req.actor.user.id, patch);
|
||||
res.json({});
|
||||
},
|
||||
);
|
||||
|
||||
// -- Taskbar items -------------------------------------------
|
||||
|
||||
router.post(
|
||||
|
||||
@@ -130,11 +130,102 @@ const callRoute = async (
|
||||
});
|
||||
};
|
||||
|
||||
describe('DesktopController routes', () => {
|
||||
it('has no desktop background route', () => {
|
||||
expect(
|
||||
router.routes.some((r) => r.path === '/set-desktop-bg'),
|
||||
).toBe(false);
|
||||
// ── /set-desktop-bg ─────────────────────────────────────────────────
|
||||
|
||||
describe('DesktopController POST /set-desktop-bg', () => {
|
||||
it('persists url/color/fit on the user row', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const { res, captured } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({
|
||||
body: {
|
||||
url: 'https://cdn.test/wallpaper.png',
|
||||
color: '#000000',
|
||||
fit: 'cover',
|
||||
},
|
||||
actor,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
expect(captured.body).toEqual({});
|
||||
|
||||
const refreshed = await server.stores.user.getById(userId);
|
||||
expect(refreshed?.desktop_bg_url).toBe(
|
||||
'https://cdn.test/wallpaper.png',
|
||||
);
|
||||
expect(refreshed?.desktop_bg_color).toBe('#000000');
|
||||
expect(refreshed?.desktop_bg_fit).toBe('cover');
|
||||
});
|
||||
|
||||
it('persists only the supplied fields (partial update)', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({ body: { color: '#ffffff' }, actor }),
|
||||
res,
|
||||
);
|
||||
const refreshed = await server.stores.user.getById(userId);
|
||||
expect(refreshed?.desktop_bg_color).toBe('#ffffff');
|
||||
// Untouched fields remain at their column default.
|
||||
expect(refreshed?.desktop_bg_url).toBeFalsy();
|
||||
expect(refreshed?.desktop_bg_fit).toBeFalsy();
|
||||
});
|
||||
|
||||
it('passes through `null` to clear a field', async () => {
|
||||
const { actor, userId } = await makeUser();
|
||||
// First populate, then clear.
|
||||
const populate = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({
|
||||
body: { url: 'https://cdn.test/x.png' },
|
||||
actor,
|
||||
}),
|
||||
populate.res,
|
||||
);
|
||||
const before = await server.stores.user.getById(userId);
|
||||
expect(before?.desktop_bg_url).toBe('https://cdn.test/x.png');
|
||||
|
||||
const clear = makeRes();
|
||||
await callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({ body: { url: null }, actor }),
|
||||
clear.res,
|
||||
);
|
||||
const after = await server.stores.user.getById(userId);
|
||||
expect(after?.desktop_bg_url).toBeNull();
|
||||
});
|
||||
|
||||
it('throws 400 when url is not a string or null', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({ body: { url: 123 }, actor }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('throws 400 when no fields are provided', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const { res } = makeRes();
|
||||
await expect(
|
||||
callRoute(
|
||||
'post',
|
||||
'/set-desktop-bg',
|
||||
makeReq({ body: {}, actor }),
|
||||
res,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -501,29 +501,6 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
expect(body.service.name).toBe('puter-kvstore');
|
||||
});
|
||||
|
||||
it('treats null `args` as no arguments', async () => {
|
||||
const actor = await makeUserActor();
|
||||
await server.stores.permission.setFlatUserPerm(
|
||||
actor.user!.id!,
|
||||
'service:puter-subdomains:ii:puter-subdomains',
|
||||
{
|
||||
permission: 'service:puter-subdomains:ii:puter-subdomains',
|
||||
deleted: false,
|
||||
issuer_user_id: actor.user!.id!,
|
||||
} as never,
|
||||
);
|
||||
|
||||
const res = makeRes();
|
||||
const req = makeReq(
|
||||
{ interface: 'puter-subdomains', method: 'select', args: null },
|
||||
actor,
|
||||
);
|
||||
await runWithContext({ actor }, () =>
|
||||
routes['POST /call'](req, res as unknown as Response, () => {}),
|
||||
);
|
||||
expect((res.body as { success: boolean }).success).toBe(true);
|
||||
});
|
||||
|
||||
it('pipes a stream-shaped result instead of JSON', async () => {
|
||||
// Use an ephemeral driver registered onto the controller's bag.
|
||||
const streamPayload = Buffer.from('hello-world');
|
||||
|
||||
@@ -276,10 +276,8 @@ export class DriverController extends PuterController {
|
||||
interface: ifaceName,
|
||||
method,
|
||||
driver: driverName,
|
||||
args: rawArgs,
|
||||
args = {},
|
||||
} = (req.body ?? {}) as Record<string, unknown>;
|
||||
// Drivers read fields off `args`, so a null one counts as absent.
|
||||
const args = rawArgs ?? {};
|
||||
|
||||
if (!ifaceName || typeof ifaceName !== 'string') {
|
||||
throw new HttpError(400, 'Missing or invalid `interface`', {
|
||||
|
||||
@@ -27,7 +27,7 @@ import type { Actor } from '../../core/actor.js';
|
||||
import { PuterRouter } from '../../core/http/PuterRouter.js';
|
||||
import { PuterServer } from '../../server.js';
|
||||
import { setupTestServer } from '../../testUtil.js';
|
||||
import { embedFrameAncestors, embedHostOrigin } from './HomepageController.js';
|
||||
import { embedFrameAncestors } from './HomepageController.js';
|
||||
|
||||
// ── Test harness ────────────────────────────────────────────────────
|
||||
//
|
||||
@@ -550,26 +550,3 @@ describe('embedFrameAncestors', () => {
|
||||
).toBe("'self'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('embedHostOrigin', () => {
|
||||
const config = {
|
||||
origin: 'https://example.test',
|
||||
domain: 'example.test',
|
||||
api_base_url: 'https://backend.example.test',
|
||||
static_hosting_domain: 'site.example.test',
|
||||
};
|
||||
|
||||
it.each([
|
||||
['https://sheets.example.test/doc/1', 'https://sheets.example.test'],
|
||||
['https://backend.example.test/x', null],
|
||||
['https://api.example.test/', null],
|
||||
['https://someone.site.example.test/', null],
|
||||
['https://a.sheets.example.test/', null],
|
||||
['http://sheets.example.test/', null],
|
||||
['https://example.test/', null],
|
||||
['not a url', null],
|
||||
[undefined, null],
|
||||
])('%s frames as %s', (referer, expected) => {
|
||||
expect(embedHostOrigin(config, referer)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -31,26 +31,8 @@ import type {
|
||||
PuterHomepageService,
|
||||
PageMeta,
|
||||
LaunchOptions,
|
||||
PopupFrame,
|
||||
} from '../../services/homepage/PuterHomepageService';
|
||||
|
||||
/**
|
||||
* GUI popups a first-party page can show in a frame at `/embed/<action>`,
|
||||
* instead of opening `/action/<action>` in a window, plus the upgrade and
|
||||
* top-up dialogs, 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',
|
||||
'top-up',
|
||||
'request-email-confirmation',
|
||||
'attach-custom-domain',
|
||||
] as const;
|
||||
|
||||
/** Longest description/og:description/twitter:description on `/app/<name>`. */
|
||||
export const APP_META_DESCRIPTION_MAX = 150;
|
||||
|
||||
@@ -79,34 +61,32 @@ export function appMetaDescription(text: string): string {
|
||||
return `${cut.replace(/[\s,;:.!?…—-]+$/, '')}…`;
|
||||
}
|
||||
|
||||
type EmbedFramingConfig = Pick<
|
||||
IConfig,
|
||||
| 'origin'
|
||||
| 'domain'
|
||||
| 'api_base_url'
|
||||
| 'static_hosting_domain'
|
||||
| 'static_hosting_domain_alt'
|
||||
| 'private_app_hosting_domain'
|
||||
| 'private_app_hosting_domain_alt'
|
||||
>;
|
||||
|
||||
/**
|
||||
* The origin of a first-party page that may frame an `/embed/*` page, named
|
||||
* exactly from the request's referrer, or null. A wildcard over `*.<domain>`
|
||||
* `frame-ancestors` for the app browser embed. A wildcard over `*.<domain>`
|
||||
* would also admit user-hosted sites whenever the hosting domains sit under it,
|
||||
* so only a single label under `<domain>` qualifies, and not one this server
|
||||
* answers itself (the API, WebDAV, SDK and native-app hosts).
|
||||
* so the embedding page is named exactly, from the request's referrer. Only a
|
||||
* single label under `<domain>` qualifies, and not one this server answers
|
||||
* itself (the API, WebDAV, SDK and native-app hosts).
|
||||
*/
|
||||
export function embedHostOrigin(
|
||||
config: EmbedFramingConfig,
|
||||
export function embedFrameAncestors(
|
||||
config: Pick<
|
||||
IConfig,
|
||||
| 'origin'
|
||||
| 'domain'
|
||||
| 'api_base_url'
|
||||
| 'static_hosting_domain'
|
||||
| 'static_hosting_domain_alt'
|
||||
| 'private_app_hosting_domain'
|
||||
| 'private_app_hosting_domain_alt'
|
||||
>,
|
||||
referer: string | undefined,
|
||||
): string | null {
|
||||
): string {
|
||||
const gui = new URL(config.origin ?? `https://${config.domain}`);
|
||||
let parent: URL;
|
||||
try {
|
||||
parent = new URL(referer ?? '');
|
||||
} catch {
|
||||
return null;
|
||||
return "'self'";
|
||||
}
|
||||
const host = parent.hostname.toLowerCase();
|
||||
const label = subdomainLabelOf(host, config.domain);
|
||||
@@ -125,16 +105,7 @@ export function embedHostOrigin(
|
||||
!isReservedSubdomain(label) &&
|
||||
host !== new URL(config.api_base_url).hostname &&
|
||||
!hostingDomains.some((d) => host === d || host.endsWith(`.${d}`));
|
||||
return allowed ? parent.origin : null;
|
||||
}
|
||||
|
||||
/** `frame-ancestors` for an `/embed/*` page: the GUI and {@link embedHostOrigin}. */
|
||||
export function embedFrameAncestors(
|
||||
config: EmbedFramingConfig,
|
||||
referer: string | undefined,
|
||||
): string {
|
||||
const host = embedHostOrigin(config, referer);
|
||||
return host ? `'self' ${host}` : "'self'";
|
||||
return allowed ? `'self' ${parent.origin}` : "'self'";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,7 +142,6 @@ export class HomepageController extends PuterController {
|
||||
res: express.Response,
|
||||
metaOverrides: Partial<PageMeta> = {},
|
||||
launch: LaunchOptions = {},
|
||||
popupFrame?: PopupFrame,
|
||||
) => {
|
||||
const meta = { ...defaultMeta(req), ...metaOverrides };
|
||||
const actor =
|
||||
@@ -180,27 +150,19 @@ export class HomepageController extends PuterController {
|
||||
actor?: Parameters<typeof homepage.send>[0]['actor'];
|
||||
}
|
||||
).actor ?? null;
|
||||
await homepage.send({ req, res, actor }, meta, launch, popupFrame);
|
||||
await homepage.send({ req, res, actor }, meta, launch);
|
||||
};
|
||||
|
||||
// -- Root + path-aliased shell routes ------------------------
|
||||
|
||||
router.get('/', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
/**
|
||||
* Headers shared by the `/embed/*` pages; false when the request was
|
||||
* redirected instead. SDK bootstrap parameters must not change an
|
||||
* embed's account or API origin, so a query string is dropped. Checked
|
||||
* on the raw URL; `req.query` drops pairs past the parser's limit.
|
||||
*/
|
||||
const prepareEmbed = (
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
path: string,
|
||||
): boolean => {
|
||||
router.get('/embed/apps', {}, async (req, res) => {
|
||||
// SDK bootstrap parameters must not change this page's account or API origin.
|
||||
// Checked on the raw URL; `req.query` drops pairs past the parser's limit.
|
||||
if (req.originalUrl.includes('?')) {
|
||||
res.redirect(path);
|
||||
return false;
|
||||
res.redirect('/embed/apps');
|
||||
return;
|
||||
}
|
||||
res.removeHeader('X-Frame-Options');
|
||||
res.setHeader(
|
||||
@@ -208,11 +170,6 @@ export class HomepageController extends PuterController {
|
||||
`frame-ancestors ${embedFrameAncestors(this.config, req.get('referer'))}`,
|
||||
);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return true;
|
||||
};
|
||||
|
||||
router.get('/embed/apps', {}, async (req, res) => {
|
||||
if (!prepareEmbed(req, res, '/embed/apps')) return;
|
||||
// Shown to visitors without a session; a signed-in embed fetches
|
||||
// its own list, so a failure here only costs signed-out visitors.
|
||||
const guestApps = await this.services.recommendedApps
|
||||
@@ -221,48 +178,6 @@ export class HomepageController extends PuterController {
|
||||
res.send(homepage.renderAppBrowserEmbed({ guestApps }));
|
||||
});
|
||||
|
||||
router.get('/embed/feedback', {}, (req, res) => {
|
||||
if (!prepareEmbed(req, res, '/embed/feedback')) return;
|
||||
// The feedback is recorded against this origin, which
|
||||
// frame-ancestors has just pinned to the actual framing page.
|
||||
res.send(
|
||||
homepage.renderFeedbackEmbed({
|
||||
hostOrigin: embedHostOrigin(
|
||||
this.config,
|
||||
req.get('referer'),
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// The GUI runs the popup's own boot, answering the framing page
|
||||
// instead of an opener.
|
||||
for (const action of FRAMED_POPUP_ACTIONS) {
|
||||
const embedPath = `/embed/${action}`;
|
||||
router.get(embedPath, {}, async (req, res) => {
|
||||
// Routes match case-insensitively and with a trailing slash,
|
||||
// and the shell boots the desktop for any other spelling, so
|
||||
// only the exact one may be framed.
|
||||
if (req.originalUrl !== embedPath) {
|
||||
res.redirect(embedPath);
|
||||
return;
|
||||
}
|
||||
if (!prepareEmbed(req, res, embedPath)) return;
|
||||
await sendShell(
|
||||
req,
|
||||
res,
|
||||
{},
|
||||
{},
|
||||
{
|
||||
hostOrigin: embedHostOrigin(
|
||||
this.config,
|
||||
req.get('referer'),
|
||||
),
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/settings', {}, (req, res) => sendShell(req, res));
|
||||
router.get('/settings/*splat', {}, (req, res) => sendShell(req, res));
|
||||
|
||||
|
||||
@@ -1620,7 +1620,7 @@ describe('OIDCController signup callback', () => {
|
||||
expect(captured.redirectUrl).toContain('action=signup');
|
||||
});
|
||||
|
||||
it('creates a fresh user on first signup', async () => {
|
||||
it('creates a fresh user without oidc_switched on first signup', async () => {
|
||||
const state = oidc().signState({
|
||||
provider: 'custom',
|
||||
redirect_uri: TEST_ORIGIN + '/',
|
||||
@@ -1646,9 +1646,11 @@ describe('OIDCController signup callback', () => {
|
||||
);
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
|
||||
// Fresh creation — must NOT advertise "you've actually been signed in".
|
||||
expect(captured.redirectUrl).not.toContain('oidc_switched=login');
|
||||
});
|
||||
|
||||
it('signs in to an already-linked account on signup, returning as a login would', async () => {
|
||||
it('adds oidc_switched=login when signup hits an already-linked account', async () => {
|
||||
// Seed an account linked on (provider, sub) so the signup callback
|
||||
// resolves to `linked-sub` rather than creating a new user.
|
||||
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
|
||||
@@ -1684,7 +1686,7 @@ describe('OIDCController signup callback', () => {
|
||||
res,
|
||||
);
|
||||
expect(captured.cookies).toHaveLength(1);
|
||||
expect(captured.redirectUrl).toBe(TEST_ORIGIN + '/');
|
||||
expect(captured.redirectUrl).toContain('oidc_switched=login');
|
||||
});
|
||||
|
||||
it('redirects to action=signup with auth_error when user resolution fails (unconfirmed email)', async () => {
|
||||
|
||||
@@ -688,7 +688,14 @@ export class OIDCController extends PuterController {
|
||||
);
|
||||
}
|
||||
|
||||
await this.#finishLogin(req, res, user, stateDecoded);
|
||||
// If we landed on an existing account (either via the
|
||||
// provider_sub or via email match), signal the GUI so it can
|
||||
// render a "signed in" flow rather than "account created".
|
||||
const extra =
|
||||
resolved.origin === 'created'
|
||||
? undefined
|
||||
: { oidc_switched: 'login' };
|
||||
await this.#finishLogin(req, res, user, stateDecoded, extra);
|
||||
};
|
||||
router.get('/auth/oidc/callback/signup', cbOpts, signupCb);
|
||||
router.post('/auth/oidc/callback/signup', cbOpts, signupCb);
|
||||
@@ -827,14 +834,17 @@ if (window.opener) {
|
||||
attempt = 0,
|
||||
): Promise<
|
||||
| { error: string; code?: string; requestCode?: string }
|
||||
| { user: import('../../stores/user/UserStore.js').UserRow }
|
||||
| {
|
||||
user: import('../../stores/user/UserStore.js').UserRow;
|
||||
origin: 'linked-sub' | 'linked-email' | 'created';
|
||||
}
|
||||
> {
|
||||
// 1. Existing provider/sub link.
|
||||
const linked = await this.services.oidc.findUserByProviderSub(
|
||||
provider,
|
||||
userinfo.sub,
|
||||
);
|
||||
if (linked) return { user: linked };
|
||||
if (linked) return { user: linked, origin: 'linked-sub' };
|
||||
|
||||
// 2/3. Email match branch.
|
||||
const claimedEmail =
|
||||
@@ -871,7 +881,7 @@ if (window.opener) {
|
||||
error: outcome.error ?? 'Failed to link provider.',
|
||||
};
|
||||
}
|
||||
return { user: byEmail };
|
||||
return { user: byEmail, origin: 'linked-email' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -901,7 +911,7 @@ if (window.opener) {
|
||||
requestCode: outcome.requestCode,
|
||||
};
|
||||
}
|
||||
return { user: outcome.user };
|
||||
return { user: outcome.user, origin: 'created' };
|
||||
}
|
||||
|
||||
async #processCallback(
|
||||
@@ -990,6 +1000,7 @@ if (window.opener) {
|
||||
res: Response,
|
||||
user: UserRow,
|
||||
stateDecoded: Record<string, unknown>,
|
||||
extraQueryParams?: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const origin = (this.config.origin ?? '').replace(/\/$/, '');
|
||||
const continuation: OidcLoginContinuation = {
|
||||
@@ -1007,6 +1018,7 @@ if (window.opener) {
|
||||
stateDecoded.msg_id == null
|
||||
? null
|
||||
: String(stateDecoded.msg_id),
|
||||
switchedToLogin: extraQueryParams?.oidc_switched === 'login',
|
||||
};
|
||||
if (user.otp_enabled) {
|
||||
const challenge = this.services.token.sign(
|
||||
|
||||
@@ -139,7 +139,7 @@ describe('OIDC account second-factor login', () => {
|
||||
expect(await response.json()).toMatchObject({
|
||||
proceed: true,
|
||||
next_step: 'complete',
|
||||
redirectUrl: `${env.origin}/dashboard`,
|
||||
redirectUrl: `${env.origin}/dashboard${flow === 'signup' ? '?oidc_switched=login' : ''}`,
|
||||
});
|
||||
expect(
|
||||
response.headers
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
setupPuterTestEnv,
|
||||
TEST_USER_CREDENTIALS,
|
||||
type PuterTestEnv,
|
||||
} from '../../testUtil.js';
|
||||
|
||||
/** A PUT body is the file, whatever content type the client labels it with. */
|
||||
describe('PUT stores the body as sent', () => {
|
||||
let env: PuterTestEnv;
|
||||
|
||||
beforeAll(async () => {
|
||||
env = await setupPuterTestEnv();
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await env?.shutdown();
|
||||
});
|
||||
|
||||
const dav = (path: string, init: RequestInit) => {
|
||||
const port = new URL(env.apiOrigin).port;
|
||||
const auth = Buffer.from(
|
||||
`${TEST_USER_CREDENTIALS.username}:${TEST_USER_CREDENTIALS.password}`,
|
||||
).toString('base64');
|
||||
return fetch(`http://dav.puter.localhost:${port}${encodeURI(path)}`, {
|
||||
...init,
|
||||
headers: { Authorization: `Basic ${auth}`, ...init.headers },
|
||||
});
|
||||
};
|
||||
|
||||
it.each([
|
||||
['application/json', '{"name":"report","rows":[1,2,3]}'],
|
||||
// Not JSON at all: the label alone must not get the body rejected.
|
||||
['application/json', 'not json'],
|
||||
['text/plain;actually=json', '{"a":1}'],
|
||||
['application/x-www-form-urlencoded', 'a=1&b=two'],
|
||||
['application/octet-stream', 'plain bytes'],
|
||||
])(
|
||||
'with Content-Type %s',
|
||||
async (contentType, content) => {
|
||||
const path = `/${env.users.user.username}/Documents/put-${randomUUID().slice(0, 8)}.bin`;
|
||||
const put = await dav(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': contentType },
|
||||
body: content,
|
||||
});
|
||||
expect(put.status).toBe(201);
|
||||
|
||||
const get = await dav(path, { method: 'GET' });
|
||||
expect(get.status).toBe(200);
|
||||
expect(await get.text()).toBe(content);
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
|
||||
it('with a form body over the form parser limit', async () => {
|
||||
const path = `/${env.users.user.username}/Documents/put-${randomUUID().slice(0, 8)}.bin`;
|
||||
const content = `a=${'x'.repeat(200 * 1024)}`;
|
||||
const put = await dav(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: content,
|
||||
});
|
||||
expect(put.status).toBe(201);
|
||||
|
||||
const get = await dav(path, { method: 'GET' });
|
||||
expect(await get.text()).toBe(content);
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -224,7 +224,7 @@ const extractToken = (
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Query string (used by e.g. asset URLs).
|
||||
// 5. Query string (used by e.g. QR login, asset URLs).
|
||||
const queryToken = (req.query as { auth_token?: unknown } | undefined)
|
||||
?.auth_token;
|
||||
if (typeof queryToken === 'string' && queryToken.length > 0) {
|
||||
|
||||
@@ -187,9 +187,9 @@ describe('createUserSubdomainNotFound', () => {
|
||||
});
|
||||
|
||||
it('passes through reserved subdomains (api, js, native apps, etc.)', () => {
|
||||
// `api`, `js`, `dav`, `docs`, `developer`, `about`, `apps`,
|
||||
// `api`, `js`, `dav`, `docs`, `developer`, `editor`, `pdf`,
|
||||
// `puter-app-icons`, etc. all pass through.
|
||||
for (const sub of ['api', 'js', 'docs', 'about', 'puter-app-icons']) {
|
||||
for (const sub of ['api', 'js', 'docs', 'editor', 'puter-app-icons']) {
|
||||
const { out, next } = run(
|
||||
createUserSubdomainNotFound(config),
|
||||
makeReq({
|
||||
@@ -865,13 +865,13 @@ describe('createNativeAppStatic', () => {
|
||||
|
||||
beforeAll(() => {
|
||||
root = mkdtempSync(nodePath.join(tmpdir(), 'native-apps-'));
|
||||
mkdirSync(nodePath.join(root, 'about', 'assets'), { recursive: true });
|
||||
mkdirSync(nodePath.join(root, 'editor', 'assets'), { recursive: true });
|
||||
writeFileSync(
|
||||
nodePath.join(root, 'about', 'index.html'),
|
||||
'<h1>about</h1>',
|
||||
nodePath.join(root, 'editor', 'index.html'),
|
||||
'<h1>editor</h1>',
|
||||
);
|
||||
writeFileSync(
|
||||
nodePath.join(root, 'about', 'assets', 'app.js'),
|
||||
nodePath.join(root, 'editor', 'assets', 'app.js'),
|
||||
'console.log(1);',
|
||||
);
|
||||
mkdirSync(nodePath.join(root, 'docs', 'dist'), { recursive: true });
|
||||
@@ -893,7 +893,7 @@ describe('createNativeAppStatic', () => {
|
||||
|
||||
it('leaves a native app name alone on a host outside our domains', async () => {
|
||||
const req = staticReq({
|
||||
subdomains: ['com', 'example', 'about'],
|
||||
subdomains: ['com', 'example', 'editor'],
|
||||
path: '/index.html',
|
||||
});
|
||||
(req as Request & { is_custom_domain?: boolean }).is_custom_domain =
|
||||
@@ -907,7 +907,7 @@ describe('createNativeAppStatic', () => {
|
||||
const { out, next } = await runStatic(
|
||||
createNativeAppStatic(config),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
path: '/index.html',
|
||||
}),
|
||||
);
|
||||
@@ -915,24 +915,17 @@ describe('createNativeAppStatic', () => {
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// `editor`, `markus` and `pdf` are web apps on standard host routing,
|
||||
// even with a folder of their own under the native apps root.
|
||||
it.each(['api', 'editor', 'markus', 'pdf'])(
|
||||
'passes %s through, as it is not a native app',
|
||||
async (sub) => {
|
||||
mkdirSync(nodePath.join(root, sub), { recursive: true });
|
||||
writeFileSync(nodePath.join(root, sub, 'index.html'), sub);
|
||||
const { out, next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', sub],
|
||||
path: '/index.html',
|
||||
}),
|
||||
);
|
||||
expect(out.sent).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
it('passes through subdomains that are not native apps', async () => {
|
||||
const { out, next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'api'],
|
||||
path: '/index.html',
|
||||
}),
|
||||
);
|
||||
expect(out.sent).toBeUndefined();
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes through when there is no subdomain at all', async () => {
|
||||
const { next } = await runStatic(
|
||||
@@ -946,13 +939,13 @@ describe('createNativeAppStatic', () => {
|
||||
const { out, next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
path: '/index.html',
|
||||
}),
|
||||
);
|
||||
expect(out.sent).toEqual({
|
||||
path: '/index.html',
|
||||
root: nodePath.join(root, 'about'),
|
||||
root: nodePath.join(root, 'editor'),
|
||||
});
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -961,11 +954,11 @@ describe('createNativeAppStatic', () => {
|
||||
const { out } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'ABOUT'],
|
||||
subdomains: ['localhost', 'puter', 'EDITOR'],
|
||||
path: '/index.html',
|
||||
}),
|
||||
);
|
||||
expect(out.sent?.root).toBe(nodePath.join(root, 'about'));
|
||||
expect(out.sent?.root).toBe(nodePath.join(root, 'editor'));
|
||||
});
|
||||
|
||||
it('serves docs out of its dist/ subdirectory, not the app root', async () => {
|
||||
@@ -986,7 +979,7 @@ describe('createNativeAppStatic', () => {
|
||||
const { out, next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
path: '/assets',
|
||||
originalUrl: '/assets?v=2',
|
||||
}),
|
||||
@@ -1002,7 +995,7 @@ describe('createNativeAppStatic', () => {
|
||||
const { out } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
path: '/assets/',
|
||||
}),
|
||||
);
|
||||
@@ -1014,7 +1007,7 @@ describe('createNativeAppStatic', () => {
|
||||
const { out, next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
path: '/nope.html',
|
||||
}),
|
||||
);
|
||||
@@ -1026,7 +1019,7 @@ describe('createNativeAppStatic', () => {
|
||||
const { next } = await runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
path: '/index.html',
|
||||
}),
|
||||
new Error('send failed'),
|
||||
@@ -1039,7 +1032,7 @@ describe('createNativeAppStatic', () => {
|
||||
runStatic(
|
||||
withRoot(),
|
||||
staticReq({
|
||||
subdomains: ['localhost', 'puter', 'about'],
|
||||
subdomains: ['localhost', 'puter', 'editor'],
|
||||
path: '/../../etc/passwd',
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -27,7 +27,15 @@ import { HttpError, isHttpError } from '../HttpError';
|
||||
import { isLocalWorkerHost } from './localWorkerProxy';
|
||||
|
||||
/** Native-app subdomains served via `nativeAppStatic`. */
|
||||
const NATIVE_APP_SUBDOMAINS = ['about', 'developer', 'docs', 'apps'] as const;
|
||||
const NATIVE_APP_SUBDOMAINS = [
|
||||
'about',
|
||||
'developer',
|
||||
'docs',
|
||||
'editor',
|
||||
'markus',
|
||||
'pdf',
|
||||
'apps',
|
||||
] as const;
|
||||
|
||||
/** Subset served out of a `dist/` subdirectory rather than the app root. */
|
||||
const NATIVE_APPS_WITH_DIST = new Set(['docs', 'developer']);
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { brotliCompressSync, deflateSync, gzipSync } from 'node:zlib';
|
||||
import express, {
|
||||
type ErrorRequestHandler,
|
||||
type RequestHandler,
|
||||
} from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { SLICE_BYTES } from '../../../util/slicedJson';
|
||||
import { createJsonBodyParser } from './jsonBody';
|
||||
|
||||
const LIMIT = 4 * 1024 * 1024;
|
||||
const TEXT_AS_JSON = 'text/plain;actually=json';
|
||||
|
||||
type Verify = NonNullable<Parameters<typeof express.json>[0]>['verify'];
|
||||
const captureRawBody: Verify = (req, _res, buf) => {
|
||||
(req as { rawBody?: Buffer }).rawBody = buf;
|
||||
};
|
||||
|
||||
/** An app that answers with what its parsers left on the request. */
|
||||
const listen = (parsers: RequestHandler[]): Promise<Server> => {
|
||||
const app = express();
|
||||
app.use(...parsers);
|
||||
app.post('/', (req, res) => {
|
||||
const rawBody = (req as { rawBody?: Buffer }).rawBody;
|
||||
res.json({
|
||||
body: req.body,
|
||||
hasBody: req.body !== undefined,
|
||||
rawBody: rawBody?.toString('base64'),
|
||||
});
|
||||
});
|
||||
const onError: ErrorRequestHandler = (err, _req, res, _next) => {
|
||||
res.status(err.statusCode ?? 500).json({ type: err.type });
|
||||
};
|
||||
app.use(onError);
|
||||
return new Promise((resolve) => {
|
||||
const server = app.listen(0, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
};
|
||||
|
||||
describe('createJsonBodyParser', () => {
|
||||
let sliced: Server;
|
||||
let framework: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
sliced = await listen([
|
||||
createJsonBodyParser({ limit: LIMIT, verify: captureRawBody }),
|
||||
]);
|
||||
// What the server ran before: one parser per content type.
|
||||
framework = await listen([
|
||||
express.json({ limit: LIMIT, verify: captureRawBody }),
|
||||
express.json({
|
||||
limit: LIMIT,
|
||||
type: (req) => req.headers['content-type'] === TEXT_AS_JSON,
|
||||
verify: captureRawBody,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
sliced?.close();
|
||||
framework?.close();
|
||||
});
|
||||
|
||||
const post = async (
|
||||
server: Server,
|
||||
body: string | Buffer | undefined,
|
||||
headers: Record<string, string>,
|
||||
) => {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
const response = await fetch(`http://127.0.0.1:${port}/`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
return { status: response.status, answer: await response.json() };
|
||||
};
|
||||
|
||||
const large = `${JSON.stringify({
|
||||
text: 'é😀 "quoted" \\ line\n'.repeat(SLICE_BYTES / 8),
|
||||
list: Array.from({ length: 20_000 }, (_, i) => ({
|
||||
i,
|
||||
on: i % 2 === 0,
|
||||
})),
|
||||
}).slice(0, -1)},"__proto__":{"nested":true}}`;
|
||||
|
||||
it.each<[string, string | Buffer | undefined, Record<string, string>]>([
|
||||
['an object', '{"a":1}', { 'Content-Type': 'application/json' }],
|
||||
['an array', '[1,2]', { 'Content-Type': 'application/json' }],
|
||||
[
|
||||
'a body sent as text',
|
||||
'{"interface":"x","auth_token":"t"}',
|
||||
{ 'Content-Type': TEXT_AS_JSON },
|
||||
],
|
||||
['a large body', large, { 'Content-Type': 'application/json' }],
|
||||
['a large body sent as text', large, { 'Content-Type': TEXT_AS_JSON }],
|
||||
[
|
||||
'a charset parameter',
|
||||
large,
|
||||
{ 'Content-Type': 'application/json; charset=UTF-8' },
|
||||
],
|
||||
[
|
||||
'a quoted charset parameter',
|
||||
'{"a":"é"}',
|
||||
{ 'Content-Type': 'application/json;charset="utf-8"' },
|
||||
],
|
||||
[
|
||||
'another parameter',
|
||||
'{"a":1}',
|
||||
{ 'Content-Type': 'application/json; profile=x' },
|
||||
],
|
||||
['an empty body', '', { 'Content-Type': 'application/json' }],
|
||||
['no body', undefined, { 'Content-Type': 'application/json' }],
|
||||
[
|
||||
'a byte order mark',
|
||||
`\ufeff${large}`,
|
||||
{ 'Content-Type': 'application/json' },
|
||||
],
|
||||
[
|
||||
'only a byte order mark',
|
||||
'\ufeff',
|
||||
{ 'Content-Type': 'application/json' },
|
||||
],
|
||||
['only whitespace', ' \n', { 'Content-Type': 'application/json' }],
|
||||
['a bare string', '"text"', { 'Content-Type': 'application/json' }],
|
||||
['a bare number', ' 12', { 'Content-Type': 'application/json' }],
|
||||
['null', 'null', { 'Content-Type': 'application/json' }],
|
||||
['malformed JSON', '{"a":', { 'Content-Type': 'application/json' }],
|
||||
[
|
||||
'malformed large JSON',
|
||||
`${large.slice(0, -1)},]`,
|
||||
{ 'Content-Type': 'application/json' },
|
||||
],
|
||||
[
|
||||
'a large bare string',
|
||||
JSON.stringify('x'.repeat(4 * SLICE_BYTES)),
|
||||
{ 'Content-Type': 'application/json' },
|
||||
],
|
||||
[
|
||||
'invalid UTF-8',
|
||||
Buffer.concat([
|
||||
Buffer.from('{"a":"'),
|
||||
Buffer.from([0xff, 0xe2, 0x82]),
|
||||
Buffer.from('"}'),
|
||||
]),
|
||||
{ 'Content-Type': 'application/json' },
|
||||
],
|
||||
[
|
||||
'a body over the limit',
|
||||
`["${'x'.repeat(LIMIT)}"]`,
|
||||
{ 'Content-Type': 'application/json' },
|
||||
],
|
||||
[
|
||||
'a gzipped body',
|
||||
gzipSync(large),
|
||||
{ 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' },
|
||||
],
|
||||
[
|
||||
'a deflated body',
|
||||
deflateSync(large),
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Encoding': 'deflate',
|
||||
},
|
||||
],
|
||||
[
|
||||
'a brotli body',
|
||||
brotliCompressSync(large),
|
||||
{ 'Content-Type': 'application/json', 'Content-Encoding': 'br' },
|
||||
],
|
||||
[
|
||||
'a body that inflates past the limit',
|
||||
gzipSync(`["${'x'.repeat(LIMIT)}"]`),
|
||||
{ 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' },
|
||||
],
|
||||
[
|
||||
'an unknown content encoding',
|
||||
'{"a":1}',
|
||||
{ 'Content-Type': 'application/json', 'Content-Encoding': 'lzw' },
|
||||
],
|
||||
['plain text', '{"a":1}', { 'Content-Type': 'text/plain' }],
|
||||
[
|
||||
'text with the marker and a charset',
|
||||
'{"a":1}',
|
||||
{ 'Content-Type': `${TEXT_AS_JSON}; charset=utf-8` },
|
||||
],
|
||||
[
|
||||
'a JSON-suffixed type',
|
||||
'{"a":1}',
|
||||
{ 'Content-Type': 'application/vnd.api+json' },
|
||||
],
|
||||
[
|
||||
'form data',
|
||||
'a=1',
|
||||
{ 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
],
|
||||
['no content type', '{"a":1}', { 'Content-Type': '' }],
|
||||
])('answers %s the way express.json does', async (_name, body, headers) => {
|
||||
const got = await post(sliced, body, headers);
|
||||
const want = await post(framework, body, headers);
|
||||
expect(got).toEqual(want);
|
||||
});
|
||||
|
||||
it('leaves a body in another charset unread', async () => {
|
||||
for (const charset of ['utf-16', 'utf8', 'latin1']) {
|
||||
const { status, answer } = await post(
|
||||
sliced,
|
||||
Buffer.from('{"a":1}', 'utf16le'),
|
||||
{ 'Content-Type': `application/json; charset=${charset}` },
|
||||
);
|
||||
expect(status).toBe(200);
|
||||
expect(answer.hasBody).toBe(false);
|
||||
}
|
||||
// The last charset named is the one that counts.
|
||||
const { answer } = await post(sliced, '{"a":1}', {
|
||||
'Content-Type': 'application/json; charset=utf-8; charset=utf-16',
|
||||
});
|
||||
expect(answer.hasBody).toBe(false);
|
||||
});
|
||||
|
||||
it('parses bodies that arrive together, each to its own result', async () => {
|
||||
const bodies = Array.from({ length: 6 }, (_, n) =>
|
||||
JSON.stringify({ n, pad: 'x'.repeat((n + 1) * SLICE_BYTES) }),
|
||||
);
|
||||
const answers = await Promise.all(
|
||||
bodies.map((body) =>
|
||||
post(sliced, body, { 'Content-Type': 'application/json' }),
|
||||
),
|
||||
);
|
||||
answers.forEach(({ status, answer }, n) => {
|
||||
expect(status).toBe(200);
|
||||
expect(answer.body).toEqual(JSON.parse(bodies[n]!));
|
||||
});
|
||||
// A failed parse hands its turn on like a finished one.
|
||||
const mixed = await Promise.all([
|
||||
post(sliced, `${bodies[5]!},`, {
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
post(sliced, bodies[4], { 'Content-Type': 'application/json' }),
|
||||
post(sliced, bodies[3], { 'Content-Type': 'application/json' }),
|
||||
]);
|
||||
expect(mixed.map(({ status }) => status)).toEqual([400, 200, 200]);
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import express, { type Request, type RequestHandler } from 'express';
|
||||
import { parseJsonInSlices, SLICE_BYTES } from '../../../util/slicedJson';
|
||||
|
||||
/** What puter.js labels a JSON driver call, to keep it a simple CORS request. */
|
||||
const TEXT_AS_JSON = 'text/plain;actually=json';
|
||||
|
||||
const UTF8_CHARSET = /;\s*charset=("?)utf-8\1\s*(?:;|$)/i;
|
||||
|
||||
/** No charset, or exactly UTF-8. Anything else is left to `express.json`. */
|
||||
const declaresUtf8 = (contentType: string): boolean => {
|
||||
const mentions = contentType.match(/charset/gi)?.length ?? 0;
|
||||
return mentions === 0 || (mentions === 1 && UTF8_CHARSET.test(contentType));
|
||||
};
|
||||
|
||||
const isUtf8Json = (req: Request): boolean => {
|
||||
const contentType = req.headers['content-type'];
|
||||
if (contentType === TEXT_AS_JSON) return true;
|
||||
return (
|
||||
typeof contentType === 'string' &&
|
||||
Boolean(req.is('application/json')) &&
|
||||
declaresUtf8(contentType)
|
||||
);
|
||||
};
|
||||
|
||||
/** The error `express.json` raises, which the error handler answers with a 400. */
|
||||
const parseFailed = (message: string): Error =>
|
||||
Object.assign(new SyntaxError(message), {
|
||||
status: 400,
|
||||
statusCode: 400,
|
||||
expose: true,
|
||||
type: 'entity.parse.failed',
|
||||
});
|
||||
|
||||
const waiting: Array<() => void> = [];
|
||||
let parsing = false;
|
||||
|
||||
/**
|
||||
* Bodies too large for one `JSON.parse` call parse one at a time, so a single
|
||||
* tree is under construction however many arrive together.
|
||||
*/
|
||||
const parseInTurn = async (body: Buffer): Promise<unknown> => {
|
||||
if (body.length <= SLICE_BYTES) return parseJsonInSlices(body);
|
||||
if (parsing) await new Promise<void>((resolve) => waiting.push(resolve));
|
||||
parsing = true;
|
||||
try {
|
||||
return await parseJsonInSlices(body);
|
||||
} finally {
|
||||
// Handing over keeps `parsing` set, so a new arrival can't cut in.
|
||||
const next = waiting.shift();
|
||||
if (next) next();
|
||||
else parsing = false;
|
||||
}
|
||||
};
|
||||
|
||||
interface JsonBodyOptions {
|
||||
limit: number;
|
||||
verify?: NonNullable<Parameters<typeof express.raw>[0]>['verify'];
|
||||
}
|
||||
|
||||
/**
|
||||
* `express.json` for UTF-8 bodies, with the parse done in slices so a large
|
||||
* body can't hold the event loop. Reading, limits, decompression and `verify`
|
||||
* are the framework's; the result and the errors match `express.json`.
|
||||
*/
|
||||
export const createJsonBodyParser = ({
|
||||
limit,
|
||||
verify,
|
||||
}: JsonBodyOptions): RequestHandler => {
|
||||
const read = express.raw({
|
||||
limit,
|
||||
verify,
|
||||
type: (req) => isUtf8Json(req as Request),
|
||||
});
|
||||
|
||||
return (req, res, next) => {
|
||||
read(req, res, async (err?: unknown) => {
|
||||
// Not a Buffer: the reader skipped this request.
|
||||
if (err || !Buffer.isBuffer(req.body)) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
let body: Buffer = req.body;
|
||||
req.body = undefined;
|
||||
// The framework's decoder drops a byte order mark.
|
||||
if (body[0] === 0xef && body[1] === 0xbb && body[2] === 0xbf) {
|
||||
body = body.subarray(3);
|
||||
}
|
||||
if (body.length === 0) {
|
||||
req.body = {};
|
||||
next();
|
||||
return;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = await parseInTurn(body);
|
||||
} catch (e) {
|
||||
next(parseFailed((e as Error).message));
|
||||
return;
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
next(parseFailed('JSON body must be an object or an array'));
|
||||
return;
|
||||
}
|
||||
req.body = parsed;
|
||||
next();
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -1,184 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { request, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { setImmediate as nextTurn } from 'node:timers/promises';
|
||||
import express, {
|
||||
type ErrorRequestHandler,
|
||||
type RequestHandler,
|
||||
} from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createJsonBodyParser } from './jsonBody';
|
||||
import {
|
||||
createLargeJsonBodyParser,
|
||||
DEFAULT_JSON_LIMIT_BYTES,
|
||||
} from './largeJsonBody';
|
||||
|
||||
const MiB = 1024 * 1024;
|
||||
const TOKEN = 'valid-token';
|
||||
const PAD = 'x'.repeat(DEFAULT_JSON_LIMIT_BYTES);
|
||||
|
||||
/** Stands in for the auth probe: one token is valid, wherever it is sent. */
|
||||
const authProbe: RequestHandler = (req, _res, next) => {
|
||||
const token =
|
||||
(req.body as { auth_token?: string } | undefined)?.auth_token ??
|
||||
req.headers.authorization;
|
||||
if (!req.actor && token === TOKEN) {
|
||||
req.actor = { user: { uuid: 'u1' } } as typeof req.actor;
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
describe('a JSON body over the default limit', () => {
|
||||
let server: Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.post(
|
||||
'/call',
|
||||
createLargeJsonBodyParser({
|
||||
authProbe,
|
||||
limitBytes: 128 * MiB,
|
||||
budgetBytes: 256 * MiB,
|
||||
perUserBytes: 128 * MiB,
|
||||
}),
|
||||
);
|
||||
app.use(createJsonBodyParser({ limit: DEFAULT_JSON_LIMIT_BYTES }));
|
||||
app.use(authProbe);
|
||||
app.post('/call', (req, res) => {
|
||||
const body = req.body as { auth_token?: string; pad: string };
|
||||
res.json({
|
||||
user: req.actor?.user.uuid ?? null,
|
||||
token: body.auth_token ?? null,
|
||||
padLength: body.pad.length,
|
||||
});
|
||||
});
|
||||
const onError: ErrorRequestHandler = (err, _req, res, _next) => {
|
||||
res.status(err.statusCode ?? 500).json({});
|
||||
};
|
||||
app.use(onError);
|
||||
await new Promise<void>((resolve) => {
|
||||
server = app.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server?.close();
|
||||
});
|
||||
|
||||
/** Sends `chunks` one write at a time, so the server sees them apart. */
|
||||
const post = (chunks: string[], headers: Record<string, string> = {}) =>
|
||||
new Promise<{ status: number; answer: Record<string, unknown> }>(
|
||||
(resolve, reject) => {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
const req = request(
|
||||
{
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
path: '/call',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain;actually=json',
|
||||
'Content-Length': chunks.reduce(
|
||||
(total, chunk) =>
|
||||
total + Buffer.byteLength(chunk),
|
||||
0,
|
||||
),
|
||||
...headers,
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const parts: Buffer[] = [];
|
||||
res.on('data', (part: Buffer) => parts.push(part));
|
||||
res.on('end', () =>
|
||||
resolve({
|
||||
status: res.statusCode!,
|
||||
answer: JSON.parse(
|
||||
Buffer.concat(parts).toString(),
|
||||
),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
req.on('error', reject);
|
||||
void (async () => {
|
||||
for (const chunk of chunks) {
|
||||
req.write(chunk);
|
||||
await nextTurn();
|
||||
}
|
||||
req.end();
|
||||
})();
|
||||
},
|
||||
);
|
||||
|
||||
it('is accepted when it opens with a valid auth_token', async () => {
|
||||
const { status, answer } = await post([
|
||||
`{"auth_token":"${TOKEN}","pad":"${PAD}"}`,
|
||||
]);
|
||||
expect(status).toBe(200);
|
||||
expect(answer).toEqual({
|
||||
user: 'u1',
|
||||
token: TOKEN,
|
||||
padLength: PAD.length,
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it('is accepted when the token arrives in pieces, with spacing', async () => {
|
||||
const opening = ` {\n "auth_token" : "${TOKEN}" ,`;
|
||||
const { status, answer } = await post([...opening, `"pad":"${PAD}"}`]);
|
||||
expect(status).toBe(200);
|
||||
expect(answer.user).toBe('u1');
|
||||
}, 60_000);
|
||||
|
||||
it('is accepted with a header credential, as before', async () => {
|
||||
const { status, answer } = await post([`{"pad":"${PAD}"}`], {
|
||||
Authorization: TOKEN,
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(answer).toEqual({
|
||||
user: 'u1',
|
||||
token: null,
|
||||
padLength: PAD.length,
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it.each([
|
||||
[
|
||||
'its token comes after other fields',
|
||||
`{"pad":"${PAD}","auth_token":"${TOKEN}"}`,
|
||||
],
|
||||
['its token is not valid', `{"auth_token":"expired","pad":"${PAD}"}`],
|
||||
['it has no token', `{"pad":"${PAD}"}`],
|
||||
[
|
||||
'a later field replaces the token it opens with',
|
||||
`{"auth_token":"${TOKEN}","pad":"${PAD}","auth_token":"other"}`,
|
||||
],
|
||||
[
|
||||
'the token it opens with is nested',
|
||||
`{"args":{"auth_token":"${TOKEN}"},"pad":"${PAD}"}`,
|
||||
],
|
||||
])(
|
||||
'is refused with 413 when %s',
|
||||
async (_name, body) => {
|
||||
expect((await post([body])).status).toBe(413);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Readable } from 'node:stream';
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { isHttpError } from '../HttpError';
|
||||
@@ -36,7 +35,7 @@ const makeReq = (
|
||||
user: string | null = 'u1',
|
||||
body?: unknown,
|
||||
) =>
|
||||
Object.assign(Readable.from([]), {
|
||||
({
|
||||
complete: true,
|
||||
body,
|
||||
headers: {
|
||||
|
||||
@@ -17,54 +17,13 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { Request, RequestHandler, Response } from 'express';
|
||||
import express, { type Request, type RequestHandler } from 'express';
|
||||
import { actorUid } from '../../actor';
|
||||
import { HttpError } from '../HttpError';
|
||||
import { createJsonBodyParser } from './jsonBody';
|
||||
|
||||
/** The global JSON parser's limit, which applies before auth. */
|
||||
export const DEFAULT_JSON_LIMIT_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
/** How much of a body is read to find a token it opens with. */
|
||||
const LEADING_TOKEN_BYTES = 8 * 1024;
|
||||
const LEADING_TOKEN =
|
||||
/^[ \t\n\r]*\{[ \t\n\r]*"auth_token"[ \t\n\r]*:[ \t\n\r]*"([^"\\]+)"/;
|
||||
|
||||
/**
|
||||
* The first `bytes` of the body, put back for the parser to read. Empty when
|
||||
* the body ends or fails before that many arrive.
|
||||
*/
|
||||
const peekBody = (req: Request, bytes: number): Promise<Buffer> =>
|
||||
new Promise((resolve) => {
|
||||
const settle = (head: Buffer | null) => {
|
||||
req.off('readable', onReadable);
|
||||
req.off('end', onGone);
|
||||
req.off('close', onGone);
|
||||
req.off('error', onGone);
|
||||
if (head) req.unshift(head);
|
||||
resolve(head ?? Buffer.alloc(0));
|
||||
};
|
||||
const onReadable = () => {
|
||||
// Null until `bytes` are buffered.
|
||||
const head = req.read(bytes) as Buffer | null;
|
||||
if (head) settle(head);
|
||||
};
|
||||
const onGone = () => settle(null);
|
||||
req.on('readable', onReadable);
|
||||
req.on('end', onGone);
|
||||
req.on('close', onGone);
|
||||
req.on('error', onGone);
|
||||
});
|
||||
|
||||
/**
|
||||
* The `auth_token` a JSON body opens with, which puter.js sends first so it can
|
||||
* be read ahead of the body.
|
||||
*/
|
||||
const leadingBodyToken = async (req: Request): Promise<string | undefined> => {
|
||||
const head = await peekBody(req, LEADING_TOKEN_BYTES);
|
||||
return LEADING_TOKEN.exec(head.toString('latin1'))?.[1];
|
||||
};
|
||||
|
||||
interface LargeJsonBodyOptions {
|
||||
/** The global auth probe; it skips requests that already carry an actor. */
|
||||
authProbe: RequestHandler;
|
||||
@@ -79,9 +38,9 @@ interface LargeJsonBodyOptions {
|
||||
|
||||
/**
|
||||
* Parses JSON bodies over the default limit, up to `limitBytes`, once the
|
||||
* caller's credential resolves: a header, a cookie, or an `auth_token` the body
|
||||
* opens with. Unauthenticated requests and requests without a Content-Length
|
||||
* fall through to the global parser and its limit.
|
||||
* caller's header or cookie credential resolves. Unauthenticated requests and
|
||||
* requests without a Content-Length fall through to the global parser and its
|
||||
* limit.
|
||||
*
|
||||
* The parsed body lives as long as the request, so a body's bytes count against
|
||||
* both budgets until its response closes. Over the user's budget gets a 429,
|
||||
@@ -94,91 +53,82 @@ export const createLargeJsonBodyParser = ({
|
||||
perUserBytes,
|
||||
accepts,
|
||||
}: LargeJsonBodyOptions): RequestHandler => {
|
||||
const json = createJsonBodyParser({ limit: limitBytes });
|
||||
const json = express.json({ limit: limitBytes });
|
||||
const textAsJson = express.json({
|
||||
limit: limitBytes,
|
||||
type: (req) =>
|
||||
req.headers['content-type'] === 'text/plain;actually=json',
|
||||
});
|
||||
let inFlightBytes = 0;
|
||||
const inFlightByUser = new Map<string, number>();
|
||||
|
||||
const probe = (req: Request, res: Response) =>
|
||||
new Promise<void>((resolve) => {
|
||||
void authProbe(req, res, () => resolve());
|
||||
});
|
||||
|
||||
return async (req, res, next) => {
|
||||
return (req, res, next) => {
|
||||
const declared = Number(req.headers['content-length']);
|
||||
if (!(declared > DEFAULT_JSON_LIMIT_BYTES && declared <= limitBytes)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
await probe(req, res);
|
||||
let bodyToken: string | undefined;
|
||||
if (!req.actor && !res.closed) {
|
||||
bodyToken = await leadingBodyToken(req);
|
||||
if (bodyToken) {
|
||||
// Where the probe looks for a body token.
|
||||
req.body = { auth_token: bodyToken };
|
||||
await probe(req, res);
|
||||
req.body = undefined;
|
||||
}
|
||||
}
|
||||
// `res.closed`: the client left during the probe, so `close` has
|
||||
// already fired and would never release a charge.
|
||||
if (!req.actor || res.closed) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const user = req.actor.user.uuid ?? actorUid(req.actor);
|
||||
const userBytes = inFlightByUser.get(user) ?? 0;
|
||||
if (userBytes + declared > perUserBytes) {
|
||||
res.setHeader('Retry-After', '5');
|
||||
next(
|
||||
new HttpError(
|
||||
429,
|
||||
'Too many large requests in progress for this account',
|
||||
{ legacyCode: 'too_many_requests' },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (inFlightBytes + declared > budgetBytes) {
|
||||
res.setHeader('Retry-After', '5');
|
||||
next(
|
||||
new HttpError(503, 'Too many large requests in progress', {
|
||||
code: 'large_body_capacity',
|
||||
// Load shedding, not a fault: record it, don't page.
|
||||
alarmSeverity: 'info',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
inFlightBytes += declared;
|
||||
inFlightByUser.set(user, userBytes + declared);
|
||||
res.once('close', () => {
|
||||
inFlightBytes -= declared;
|
||||
const left = (inFlightByUser.get(user) ?? 0) - declared;
|
||||
if (left > 0) inFlightByUser.set(user, left);
|
||||
else inFlightByUser.delete(user);
|
||||
});
|
||||
json(req, res, (err?: unknown) => {
|
||||
if (err) {
|
||||
next(err);
|
||||
void authProbe(req, res, () => {
|
||||
// `res.closed`: the client left during the probe, so `close` has
|
||||
// already fired and would never release a charge.
|
||||
if (!req.actor || res.closed) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
// The caller was resolved from the token the body opens with,
|
||||
// so the parsed body has to name the same one.
|
||||
const tokenMoved =
|
||||
bodyToken !== undefined &&
|
||||
(req.body as { auth_token?: unknown } | undefined)
|
||||
?.auth_token !== bodyToken;
|
||||
if (tokenMoved || (accepts && !accepts(req))) {
|
||||
// The same answer the default limit gives.
|
||||
const user = req.actor.user.uuid ?? actorUid(req.actor);
|
||||
const userBytes = inFlightByUser.get(user) ?? 0;
|
||||
if (userBytes + declared > perUserBytes) {
|
||||
res.setHeader('Retry-After', '5');
|
||||
next(
|
||||
new HttpError(413, 'request entity too large', {
|
||||
legacyCode: 'client_bad_request',
|
||||
new HttpError(
|
||||
429,
|
||||
'Too many large requests in progress for this account',
|
||||
{ legacyCode: 'too_many_requests' },
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (inFlightBytes + declared > budgetBytes) {
|
||||
res.setHeader('Retry-After', '5');
|
||||
next(
|
||||
new HttpError(503, 'Too many large requests in progress', {
|
||||
code: 'large_body_capacity',
|
||||
// Load shedding, not a fault: record it, don't page.
|
||||
alarmSeverity: 'info',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
inFlightBytes += declared;
|
||||
inFlightByUser.set(user, userBytes + declared);
|
||||
res.once('close', () => {
|
||||
inFlightBytes -= declared;
|
||||
const left = (inFlightByUser.get(user) ?? 0) - declared;
|
||||
if (left > 0) inFlightByUser.set(user, left);
|
||||
else inFlightByUser.delete(user);
|
||||
});
|
||||
json(req, res, (err?: unknown) => {
|
||||
if (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
textAsJson(req, res, (err?: unknown) => {
|
||||
if (err) {
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
if (accepts && !accepts(req)) {
|
||||
// The same answer the default limit gives.
|
||||
next(
|
||||
new HttpError(413, 'request entity too large', {
|
||||
legacyCode: 'client_bad_request',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -355,17 +355,6 @@ describe('ChatCompletionDriver.complete auth and model resolution', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('rejects a model that is not a string with 400', async () => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
driver.complete({
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
model: { id: 'gpt' },
|
||||
} as unknown as ICompleteArguments),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it('routes by alias when a provider exposes one (puterId is auto-aliased on the bucket)', async () => {
|
||||
// Inject a model that carries a `puterId` so we can verify the
|
||||
// alias bucket resolves back to the canonical id. Spy on
|
||||
@@ -2233,166 +2222,3 @@ describe('ChatCompletionDriver.countTokens', () => {
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatCompletionDriver service speed', () => {
|
||||
it.each([
|
||||
[{ serviceSpeed: 'ultrafast' }, 'fast', 0.028],
|
||||
[{ speed: 'fast' }, 'fast', 0.028],
|
||||
[{ serviceSpeed: 'standard', speed: 'fast' }, 'standard', 0.014],
|
||||
] as const)('uses the Claude catalog rate card for %j', async (options, speed, cents) => {
|
||||
const sent: Record<string, unknown>[] = [];
|
||||
vi.stubGlobal('fetch', async (input: Request | string, init?: RequestInit) => {
|
||||
const body = input instanceof Request
|
||||
? await input.clone().json()
|
||||
: JSON.parse(String(init?.body));
|
||||
sent.push(body);
|
||||
return new Response(JSON.stringify({
|
||||
id: 'claude-speed-test', type: 'message', role: 'assistant',
|
||||
model: body.model, stop_reason: 'end_turn',
|
||||
content: [{ type: 'text', text: 'OK' }],
|
||||
usage: { input_tokens: 10, output_tokens: 5, speed },
|
||||
}), { headers: { 'content-type': 'application/json' } });
|
||||
});
|
||||
const d = new ChatCompletionDriver(
|
||||
{ providers: { claude: { apiKey: 'test-key' }, ollama: { enabled: false } } } as never,
|
||||
server.clients, server.stores, server.services,
|
||||
);
|
||||
try {
|
||||
await d.onServerStart();
|
||||
const result = await withTestActor(() => d.complete({
|
||||
model: 'claude-opus-5-5', provider: 'claude',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
fallback: false, max_tokens: 32, normalize: true, ...options,
|
||||
}));
|
||||
expect(sent).toHaveLength(1);
|
||||
expect(sent[0]!.speed).toBe(speed === 'fast' ? 'fast' : undefined);
|
||||
expect(result).toMatchObject({
|
||||
message: { content: 'OK' }, usage: { usd_cents: cents },
|
||||
});
|
||||
} finally {
|
||||
d.onServerShutdown();
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
null, false, true, 0, 1, -1, NaN, Infinity, '', 'invalid', 'FAST',
|
||||
'fast ', ' fast', 'ultraFast', [], ['fast'], {}, { speed: 'fast' },
|
||||
{ toString: 'fast' },
|
||||
])('rejects invalid speed %j before sending a request', async (serviceSpeed) => {
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
driver.complete({
|
||||
model: 'fake',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
serviceSpeed: serviceSpeed as never,
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'invalid_service_speed',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['openai-responses', true],
|
||||
['openai-responses', false],
|
||||
['openai-completion', false],
|
||||
] as const)(
|
||||
'settles unavailable tiers at standard rates on %s with provider fallback disabled (admission rejection: %s)',
|
||||
async (provider, rejectFast) => {
|
||||
const sent: Record<string, unknown>[] = [];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
async (_input: unknown, init?: RequestInit) => {
|
||||
const body =
|
||||
_input instanceof Request
|
||||
? await _input.clone().json()
|
||||
: JSON.parse(String(init?.body));
|
||||
sent.push(body);
|
||||
if (rejectFast && body.service_tier !== 'default') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: `${body.service_tier} service_tier capacity unavailable`,
|
||||
type: 'invalid_request_error',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
},
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: 'response-test',
|
||||
object: 'response',
|
||||
model: body.model,
|
||||
status: 'completed',
|
||||
service_tier: 'default',
|
||||
output: [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'output_text', text: 'OK' },
|
||||
],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 2,
|
||||
input_tokens_details: { cached_tokens: 0 },
|
||||
},
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
},
|
||||
);
|
||||
const d = new ChatCompletionDriver(
|
||||
{
|
||||
providers: {
|
||||
'openai-completion': { apiKey: 'test-key' },
|
||||
ollama: { enabled: false },
|
||||
},
|
||||
} as never,
|
||||
server.clients,
|
||||
server.stores,
|
||||
server.services,
|
||||
);
|
||||
try {
|
||||
await d.onServerStart();
|
||||
const result = (await withTestActor(() =>
|
||||
d.complete({
|
||||
model: 'gpt-6.1-sol',
|
||||
provider,
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
serviceSpeed: 'ultrafast',
|
||||
fallback: false,
|
||||
max_tokens: 32,
|
||||
normalize: true,
|
||||
}),
|
||||
)) as {
|
||||
message: { content: string };
|
||||
usageDetails: { serviceSpeed: string };
|
||||
usage: { usd_cents: number };
|
||||
};
|
||||
expect(sent.map((body) => body.service_tier)).toEqual(
|
||||
rejectFast
|
||||
? ['ultrafast', 'priority', 'default']
|
||||
: ['ultrafast'],
|
||||
);
|
||||
expect(sent.every((body) => body.model === 'gpt-6.1-sol')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result.message.content).toBe('OK');
|
||||
expect(result.usageDetails.serviceSpeed).toBe('standard');
|
||||
expect(result.usage.usd_cents).toBeCloseTo(0.004, 8);
|
||||
} finally {
|
||||
d.onServerShutdown();
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -78,12 +78,6 @@ import type {
|
||||
UsageDetails,
|
||||
} from './types.js';
|
||||
import { normalize_tools_object } from './utils/FunctionCalling.js';
|
||||
import {
|
||||
requestedServiceSpeed,
|
||||
selectServiceSpeed,
|
||||
lowerServiceSpeed,
|
||||
isServiceSpeedUnavailable,
|
||||
} from './utils/serviceSpeed.js';
|
||||
import {
|
||||
messagesHavePuterPaths,
|
||||
modelSupportsModality,
|
||||
@@ -680,16 +674,6 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
async complete(args: ICompleteArguments): Promise<IChatCompleteResult> {
|
||||
if (
|
||||
args.serviceSpeed !== undefined &&
|
||||
!['standard', 'fast', 'ultrafast'].includes(args.serviceSpeed)
|
||||
) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
'serviceSpeed must be standard, fast, or ultrafast',
|
||||
{ legacyCode: 'invalid_service_speed' },
|
||||
);
|
||||
}
|
||||
const abortSignal = Context.get('abortSignal');
|
||||
abortSignal?.throwIfAborted();
|
||||
const actor = Context.get('actor');
|
||||
@@ -716,16 +700,15 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
args.model = this.#providers[intendedProvider].getDefaultModel();
|
||||
}
|
||||
|
||||
const initialModel = await this.#resolveLoadedModel(
|
||||
let model = await this.#resolveLoadedModel(
|
||||
args.model,
|
||||
intendedProvider,
|
||||
);
|
||||
if (!initialModel) {
|
||||
if (!model) {
|
||||
throw new HttpError(400, `Model not found: ${args.model}`, {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
}
|
||||
let model: IChatModel = initialModel;
|
||||
// A pinned provider without a route for the model normally resolves
|
||||
// to another vendor; with `fallback: false` that is the one thing the
|
||||
// caller asked us not to do.
|
||||
@@ -857,6 +840,18 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
// The instant the current attempt is priced at, from its hold to its
|
||||
// charge, so a rate change mid-request can't split the two.
|
||||
let pricingTime = new Date();
|
||||
if (!useFakeProvider) {
|
||||
({
|
||||
hold,
|
||||
fundsCap,
|
||||
costFactor,
|
||||
promptTokenEstimate: attemptPromptTokenEstimate,
|
||||
} = await this.#applyCreditGate(actor, model, args, {
|
||||
promptTokenEstimate,
|
||||
requestedMaxTokens,
|
||||
pricingTime,
|
||||
}));
|
||||
}
|
||||
|
||||
// First attempt
|
||||
const provider = this.#providers[model.provider!];
|
||||
@@ -904,132 +899,24 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
return attempt;
|
||||
};
|
||||
|
||||
let variantSpeed: ICompleteArguments['serviceSpeed'];
|
||||
let creditGateError: unknown;
|
||||
const runAttempt = async (
|
||||
attemptProvider: IChatProvider,
|
||||
baseModel: IChatModel,
|
||||
attemptModel: IChatModel,
|
||||
) => {
|
||||
let requestedSpeed = requestedServiceSpeed(
|
||||
args,
|
||||
baseModel.provider,
|
||||
);
|
||||
const offers =
|
||||
requestedSpeed !== undefined && requestedSpeed !== 'standard'
|
||||
? ((await attemptProvider.serviceSpeedModels?.(
|
||||
baseModel,
|
||||
)) ?? {})
|
||||
: {};
|
||||
for (;;) {
|
||||
const selection = selectServiceSpeed(
|
||||
baseModel,
|
||||
{ ...args, serviceSpeed: requestedSpeed },
|
||||
(id) => {
|
||||
const variant = this.#resolveModel(
|
||||
id,
|
||||
baseModel.provider!,
|
||||
);
|
||||
return variant &&
|
||||
variant.provider === baseModel.provider
|
||||
? variant
|
||||
: undefined;
|
||||
},
|
||||
offers,
|
||||
);
|
||||
const attemptModel = selection.model;
|
||||
const requestArgs = {
|
||||
...selection.args,
|
||||
if (!useFakeProvider) {
|
||||
await this.#resolvePuterPaths(attemptProvider, args, actor);
|
||||
}
|
||||
// Last point where a caller who left costs nothing.
|
||||
abortSignal?.throwIfAborted();
|
||||
return attemptProvider.complete(
|
||||
{
|
||||
...args,
|
||||
model: attemptModel.id,
|
||||
provider: attemptModel.provider,
|
||||
};
|
||||
const catalogModel = this.#resolveModel(
|
||||
attemptModel.id,
|
||||
attemptModel.provider!,
|
||||
);
|
||||
const rawModel = this.#catalogEntries.get(
|
||||
catalogModel ?? baseModel,
|
||||
);
|
||||
const snapshot = rawModel
|
||||
? {
|
||||
...rawModel,
|
||||
costs: attemptModel.costs,
|
||||
pricingOverrides: attemptModel.pricingOverrides,
|
||||
serviceSpeedPricing: attemptModel.serviceSpeedPricing,
|
||||
}
|
||||
: attemptModel;
|
||||
if (!useFakeProvider) {
|
||||
try {
|
||||
({
|
||||
hold,
|
||||
fundsCap,
|
||||
costFactor,
|
||||
promptTokenEstimate: attemptPromptTokenEstimate,
|
||||
} = await this.#applyCreditGate(
|
||||
actor,
|
||||
attemptModel,
|
||||
requestArgs,
|
||||
{
|
||||
promptTokenEstimate,
|
||||
requestedMaxTokens,
|
||||
pricingTime,
|
||||
},
|
||||
));
|
||||
} catch (error) {
|
||||
creditGateError = error;
|
||||
throw error;
|
||||
}
|
||||
await this.#resolvePuterPaths(
|
||||
attemptProvider,
|
||||
requestArgs,
|
||||
actor,
|
||||
);
|
||||
}
|
||||
abortSignal?.throwIfAborted();
|
||||
try {
|
||||
const result = await attemptProvider.complete(
|
||||
requestArgs,
|
||||
snapshot,
|
||||
pricingTime,
|
||||
);
|
||||
model = attemptModel;
|
||||
variantSpeed =
|
||||
attemptModel.id !== baseModel.id
|
||||
? selection.speed
|
||||
: undefined;
|
||||
if (
|
||||
args.serviceSpeed !== undefined &&
|
||||
'message' in result
|
||||
) {
|
||||
const messageResult = result as IChatMessageResult;
|
||||
const details =
|
||||
messageResult.usageDetails ??
|
||||
usageDetailsFromUsage(messageResult.usage, model);
|
||||
messageResult.usageDetails = {
|
||||
...details,
|
||||
serviceSpeed:
|
||||
variantSpeed ??
|
||||
details?.serviceSpeed ??
|
||||
details?.speed ??
|
||||
'standard',
|
||||
};
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
await hold.release();
|
||||
hold = NO_CREDIT_HOLD;
|
||||
if (
|
||||
args.serviceSpeed === undefined ||
|
||||
selection.speed === 'standard' ||
|
||||
!isServiceSpeedUnavailable(
|
||||
error,
|
||||
attemptModel.id !== baseModel.id,
|
||||
)
|
||||
)
|
||||
throw error;
|
||||
requestedSpeed = lowerServiceSpeed(selection.speed);
|
||||
pricingTime = new Date();
|
||||
}
|
||||
}
|
||||
},
|
||||
this.#catalogEntries.get(attemptModel),
|
||||
pricingTime,
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1097,17 +984,29 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
// The previous attempt released its hold when it failed, so
|
||||
// this one starts from nothing held.
|
||||
pricingTime = new Date();
|
||||
if (!useFakeProvider) {
|
||||
({
|
||||
hold,
|
||||
fundsCap,
|
||||
costFactor,
|
||||
promptTokenEstimate: attemptPromptTokenEstimate,
|
||||
} = await this.#applyCreditGate(actor, fallback, args, {
|
||||
promptTokenEstimate,
|
||||
requestedMaxTokens,
|
||||
pricingTime,
|
||||
}));
|
||||
}
|
||||
|
||||
tried.add(routeId(fallback.provider!, fallback.id));
|
||||
|
||||
try {
|
||||
res = await runAttempt(fbProvider, fallback);
|
||||
model = fallback;
|
||||
lastError = null;
|
||||
console.warn(
|
||||
`[ai-chat] fallback served (${completionId}, ${first.provider}:${first.model} -> ${fallback.provider}:${fallback.id}, first ${first.status ?? 'none'}${first.upstreamType ? `/${first.upstreamType}` : ''})`,
|
||||
);
|
||||
} catch (fbErr) {
|
||||
if (fbErr === creditGateError) throw fbErr;
|
||||
await hold.release();
|
||||
hold = NO_CREDIT_HOLD;
|
||||
abortSignal?.throwIfAborted();
|
||||
@@ -1263,20 +1162,6 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
usageDetailsFromUsage(usage, model),
|
||||
);
|
||||
}
|
||||
if (
|
||||
args.serviceSpeed !== undefined &&
|
||||
chatStream.usageDetails
|
||||
) {
|
||||
const details = chatStream.usageDetails;
|
||||
chatStream.setUsageDetails({
|
||||
...details,
|
||||
serviceSpeed:
|
||||
variantSpeed ??
|
||||
details.serviceSpeed ??
|
||||
details.speed ??
|
||||
'standard',
|
||||
});
|
||||
}
|
||||
withClaudeThinkingTokens(
|
||||
enrichedUsage,
|
||||
model,
|
||||
@@ -1589,7 +1474,12 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
const metering = this.services.metering;
|
||||
const { requestedMaxTokens, pricingTime } = estimates;
|
||||
let { promptTokenEstimate } = estimates;
|
||||
// Providers reserve tool calls using the same caps they send upstream.
|
||||
// A provider can name its own cost keys for this request (Claude's
|
||||
// fast-mode rate card), cap its output, and size an extra reservation
|
||||
// the per-token math can't see (a web-search/advisor tool that could
|
||||
// run several times before the model answers) — the same clamps it
|
||||
// applies when building the request, so the hold and the request
|
||||
// agree.
|
||||
const pricing =
|
||||
this.#providers[model.provider!]?.requestPricing?.(args, model, {
|
||||
promptTokenEstimate,
|
||||
@@ -2129,9 +2019,7 @@ export class ChatCompletionDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
#resolveModel(modelId: string, provider?: string): IChatModel | null {
|
||||
// `model` comes straight from the caller and may not be a string.
|
||||
if (typeof modelId !== 'string') return null;
|
||||
const models = this.#modelIdMap[normalizeModelKey(modelId)];
|
||||
const models = this.#modelIdMap[normalizeModelKey(modelId ?? '')];
|
||||
if (!models || models.length === 0) return null;
|
||||
// An explicitly requested provider is honoured even if its route is
|
||||
// marked — the caller asked for that one, not for the cheapest hop.
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
meterChatUsage,
|
||||
type MeterChatUsageOptions,
|
||||
} from '../utils/meterChatUsage.js';
|
||||
import { servedServiceSpeed } from '../utils/serviceSpeed.js';
|
||||
import { catalogPrice, costKeys } from '../utils/pricing.js';
|
||||
import { normalizeModelKey } from '../utils/modelRouting.js';
|
||||
import * as OpenAIUtil from '../utils/OpenAIUtil.js';
|
||||
@@ -233,8 +232,6 @@ export class OpenAICompatProvider implements IChatProvider {
|
||||
)
|
||||
: {}),
|
||||
};
|
||||
if (args.serviceSpeed !== undefined && args.service_tier !== undefined)
|
||||
shared.service_tier = args.service_tier;
|
||||
const params = this.vendorParams(shared, args, model, actor);
|
||||
|
||||
let pricingTime = attemptPricingTime ?? new Date();
|
||||
@@ -274,29 +271,15 @@ export class OpenAICompatProvider implements IChatProvider {
|
||||
source,
|
||||
model,
|
||||
);
|
||||
const serviceSpeed = servedServiceSpeed(source);
|
||||
if (usageDetails)
|
||||
source.setUsageDetails?.({
|
||||
...usageDetails,
|
||||
...(args.serviceSpeed !== undefined
|
||||
? { serviceSpeed }
|
||||
: {}),
|
||||
});
|
||||
if (usageDetails) source.setUsageDetails?.(usageDetails);
|
||||
const metered = meterChatUsage(
|
||||
this.#metering,
|
||||
actor,
|
||||
this.meteringModelKey(model.id),
|
||||
model,
|
||||
usage,
|
||||
{ ...meterOptions, pricingTime, serviceSpeed },
|
||||
{ ...meterOptions, pricingTime },
|
||||
);
|
||||
if (args.serviceSpeed !== undefined && !usageDetails) {
|
||||
const details = OpenAIUtil.usageDetailsFromTrackedUsage(
|
||||
metered.usage,
|
||||
);
|
||||
if (details)
|
||||
source.setUsageDetails?.({ ...details, serviceSpeed });
|
||||
}
|
||||
source.setUsageCosts(metered.costs);
|
||||
return this.reportedUsage(metered.usage);
|
||||
},
|
||||
|
||||
@@ -174,9 +174,6 @@ export const mapAlibabaCatalogModel = (
|
||||
: undefined;
|
||||
return {
|
||||
...existing,
|
||||
serviceSpeedVariants: {
|
||||
fast: id === 'glm-5.2' ? 'glm-5.2-fast-preview' : `${id}-prime`,
|
||||
},
|
||||
id,
|
||||
puterId,
|
||||
name: raw.name ?? existing?.name ?? id,
|
||||
|
||||
@@ -37,7 +37,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen-max-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 160,
|
||||
@@ -61,7 +60,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen-plus-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
@@ -83,7 +81,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.5-plus-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
@@ -105,7 +102,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.6-plus-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 50,
|
||||
@@ -127,7 +123,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.6-flash-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 19,
|
||||
@@ -148,7 +143,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.7-plus-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 50,
|
||||
@@ -170,7 +164,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.7-max-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 250,
|
||||
@@ -195,7 +188,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.8-max-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
@@ -218,7 +210,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.8-max-0902-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
@@ -243,7 +234,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen-flash-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 5,
|
||||
@@ -267,7 +257,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3-coder-flash-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30,
|
||||
@@ -293,7 +282,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.5-397b-a17b-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 60,
|
||||
@@ -317,7 +305,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.5-122b-a10b-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
@@ -341,7 +328,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.5-35b-a3b-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 25,
|
||||
@@ -365,7 +351,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.5-27b-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30,
|
||||
@@ -391,7 +376,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.6-35b-a3b-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 24.8,
|
||||
@@ -415,7 +399,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.6-27b-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 60,
|
||||
@@ -439,7 +422,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3-vl-plus-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 20,
|
||||
@@ -465,7 +447,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3-omni-flash-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 43,
|
||||
@@ -488,7 +469,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen-mt-plus-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 246,
|
||||
@@ -511,7 +491,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen-plus-2025-01-25-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
@@ -532,7 +511,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen-plus-2025-04-28-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
@@ -553,7 +531,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen-plus-2025-07-14-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
@@ -576,7 +553,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen-plus-2025-12-01-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
@@ -598,7 +574,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.7-max-2026-05-20-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 250,
|
||||
@@ -619,7 +594,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.7-max-2026-06-08-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 250,
|
||||
@@ -640,7 +614,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.7-max-2026-05-17-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 250,
|
||||
@@ -662,7 +635,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.7-max-preview-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 250,
|
||||
@@ -685,7 +657,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.5-plus-2026-02-15-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
@@ -707,7 +678,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.5-plus-2026-04-20-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
@@ -730,7 +700,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.5-flash-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 10,
|
||||
@@ -753,7 +722,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.7-flash-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 3,
|
||||
@@ -775,7 +743,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.8-flash-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 15,
|
||||
@@ -798,7 +765,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.8-27b-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 50,
|
||||
@@ -819,7 +785,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.8-2.4t-a95b-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
@@ -844,7 +809,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.5-omni-flash-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
@@ -868,7 +832,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.5-omni-plus-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 140,
|
||||
@@ -892,7 +855,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen3.8-omni-flash-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 15,
|
||||
@@ -915,7 +877,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen-mt-flash-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 16,
|
||||
@@ -936,7 +897,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'qwen-mt-lite-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 12,
|
||||
@@ -959,7 +919,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'glm-5.1-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 140,
|
||||
@@ -979,7 +938,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'glm-5.2-fast-preview' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 140,
|
||||
@@ -1000,7 +958,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'glm-5.2-fast-preview-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 280,
|
||||
@@ -1024,7 +981,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'deepseek-v4-flash-0731-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 44,
|
||||
@@ -1047,7 +1003,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'deepseek-v4.1-flash-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30,
|
||||
@@ -1068,7 +1023,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'deepseek-v4-pro-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 240,
|
||||
@@ -1091,7 +1045,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'deepseek-v4-pro-0813-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 132,
|
||||
@@ -1112,7 +1065,6 @@ export const ALIBABA_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'kimi-k2.7-code-prime' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 95,
|
||||
|
||||
@@ -20,10 +20,6 @@
|
||||
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
|
||||
import { OpenAI } from 'openai';
|
||||
import { ChatCompletionCreateParams } from 'openai/resources/index.js';
|
||||
import {
|
||||
serviceSpeedBillingModel,
|
||||
servedServiceSpeed,
|
||||
} from '../../utils/serviceSpeed.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { FSService } from '../../../../services/fs/FSService.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
@@ -157,7 +153,6 @@ export class AzureChatProvider implements IChatProvider {
|
||||
|
||||
async complete(
|
||||
params: ICompleteArguments,
|
||||
resolved?: IChatModel,
|
||||
): ReturnType<IChatProvider['complete']> {
|
||||
const {
|
||||
max_tokens,
|
||||
@@ -185,9 +180,7 @@ export class AzureChatProvider implements IChatProvider {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return await (resolved
|
||||
? this.#responsesProvider.complete(params, resolved)
|
||||
: this.#responsesProvider.complete(params));
|
||||
return await this.#responsesProvider.complete(params);
|
||||
}
|
||||
// Inline compaction is a Responses-API feature; chat.completions can't
|
||||
// express `context_management` or a `compaction` content block.
|
||||
@@ -201,9 +194,7 @@ export class AzureChatProvider implements IChatProvider {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return await (resolved
|
||||
? this.#responsesProvider.complete(params, resolved)
|
||||
: this.#responsesProvider.complete(params));
|
||||
return await this.#responsesProvider.complete(params);
|
||||
}
|
||||
// Validate messages
|
||||
if (!Array.isArray(messages)) {
|
||||
@@ -216,11 +207,9 @@ export class AzureChatProvider implements IChatProvider {
|
||||
model = model ?? this.#defaultModel;
|
||||
|
||||
const modelUsed =
|
||||
resolved ??
|
||||
(this.models().find((m) =>
|
||||
this.models().find((m) =>
|
||||
[m.id, ...(m.aliases || [])].includes(model),
|
||||
) ||
|
||||
this.models().find((m) => m.id === this.getDefaultModel())!);
|
||||
) || this.models().find((m) => m.id === this.getDefaultModel())!;
|
||||
|
||||
// messages.unshift({
|
||||
// role: 'system',
|
||||
@@ -290,9 +279,6 @@ export class AzureChatProvider implements IChatProvider {
|
||||
}),
|
||||
messages: messages,
|
||||
model: modelUsed.id,
|
||||
...(params.service_tier !== undefined
|
||||
? { service_tier: params.service_tier }
|
||||
: {}),
|
||||
...(mappedTools?.length ? { tools: mappedTools } : {}),
|
||||
...(max_tokens !== undefined
|
||||
? { max_completion_tokens: max_tokens }
|
||||
@@ -329,12 +315,7 @@ export class AzureChatProvider implements IChatProvider {
|
||||
);
|
||||
|
||||
return OpenAiUtil.handle_completion_output({
|
||||
usage_calculator: ({
|
||||
service_tier,
|
||||
usage,
|
||||
setUsageCosts,
|
||||
setUsageDetails,
|
||||
}) => {
|
||||
usage_calculator: ({ usage }) => {
|
||||
const cachedTokens =
|
||||
usage.prompt_tokens_details?.cached_tokens ?? 0;
|
||||
const trackedUsage = {
|
||||
@@ -354,20 +335,9 @@ export class AzureChatProvider implements IChatProvider {
|
||||
|
||||
const costsOverrideFromModel = buildCostsOverride(
|
||||
trackedUsage,
|
||||
serviceSpeedBillingModel(
|
||||
modelUsed,
|
||||
servedServiceSpeed({ service_tier }),
|
||||
),
|
||||
modelUsed,
|
||||
);
|
||||
|
||||
setUsageCosts(costsOverrideFromModel);
|
||||
if (params.serviceSpeed !== undefined)
|
||||
setUsageDetails({
|
||||
...OpenAiUtil.usageDetailsFromTrackedUsage(
|
||||
trackedUsage,
|
||||
)!,
|
||||
serviceSpeed: servedServiceSpeed({ service_tier }),
|
||||
});
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor!,
|
||||
|
||||
@@ -19,10 +19,6 @@
|
||||
|
||||
import { OpenAI } from 'openai';
|
||||
import { ResponseCreateParams } from 'openai/resources/responses/responses.mjs';
|
||||
import {
|
||||
serviceSpeedBillingModel,
|
||||
servedServiceSpeed,
|
||||
} from '../../utils/serviceSpeed.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { FSService } from '../../../../services/fs/FSService.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
@@ -156,41 +152,37 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
: {};
|
||||
}
|
||||
|
||||
async complete(
|
||||
{
|
||||
messages,
|
||||
model,
|
||||
max_tokens,
|
||||
moderation,
|
||||
tools,
|
||||
tool_choice,
|
||||
parallel_tool_calls,
|
||||
include,
|
||||
conversation,
|
||||
compaction,
|
||||
context_management,
|
||||
previous_response_id,
|
||||
instructions,
|
||||
metadata,
|
||||
prompt,
|
||||
prompt_cache_key,
|
||||
prompt_cache_retention,
|
||||
store,
|
||||
top_p,
|
||||
truncation,
|
||||
background,
|
||||
service_tier,
|
||||
serviceSpeed: requestedServiceSpeed,
|
||||
verbosity,
|
||||
stream,
|
||||
reasoning,
|
||||
reasoning_effort,
|
||||
temperature,
|
||||
text,
|
||||
outputFormat,
|
||||
}: ICompleteArguments,
|
||||
resolved?: IChatModel,
|
||||
): ReturnType<IChatProvider['complete']> {
|
||||
async complete({
|
||||
messages,
|
||||
model,
|
||||
max_tokens,
|
||||
moderation,
|
||||
tools,
|
||||
tool_choice,
|
||||
parallel_tool_calls,
|
||||
include,
|
||||
conversation,
|
||||
compaction,
|
||||
context_management,
|
||||
previous_response_id,
|
||||
instructions,
|
||||
metadata,
|
||||
prompt,
|
||||
prompt_cache_key,
|
||||
prompt_cache_retention,
|
||||
store,
|
||||
top_p,
|
||||
truncation,
|
||||
background,
|
||||
service_tier,
|
||||
verbosity,
|
||||
stream,
|
||||
reasoning,
|
||||
reasoning_effort,
|
||||
temperature,
|
||||
text,
|
||||
outputFormat,
|
||||
}: ICompleteArguments): ReturnType<IChatProvider['complete']> {
|
||||
// Validate messages
|
||||
if (!Array.isArray(messages)) {
|
||||
throw new HttpError(400, '`messages` must be an array', {
|
||||
@@ -208,13 +200,12 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
model = model ?? this.#defaultModel;
|
||||
|
||||
const modelUsed =
|
||||
resolved ??
|
||||
(this.models({ no_restrictions: true }).find((m) =>
|
||||
this.models({ no_restrictions: true }).find((m) =>
|
||||
[m.id, ...(m.aliases || [])].includes(model),
|
||||
) ||
|
||||
this.models({ no_restrictions: true }).find(
|
||||
(m) => m.id === this.getDefaultModel(),
|
||||
)!);
|
||||
this.models({ no_restrictions: true }).find(
|
||||
(m) => m.id === this.getDefaultModel(),
|
||||
)!;
|
||||
|
||||
const userIdentifier = upstreamUserIdentifier(actor);
|
||||
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
|
||||
@@ -344,11 +335,9 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
return OpenAiUtil.handle_completion_output_responses_api({
|
||||
usage_calculator: ({
|
||||
usage,
|
||||
service_tier,
|
||||
webSearchCalls,
|
||||
tool_usage,
|
||||
setUsageCosts,
|
||||
setUsageDetails,
|
||||
}) => {
|
||||
const numWebSearches: number =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -374,10 +363,7 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
|
||||
const costsOverrideFromModel = buildCostsOverride(
|
||||
trackedUsage,
|
||||
serviceSpeedBillingModel(
|
||||
modelUsed,
|
||||
servedServiceSpeed({ service_tier }),
|
||||
),
|
||||
modelUsed,
|
||||
);
|
||||
if (numWebSearches) {
|
||||
costsOverrideFromModel.web_search_calls =
|
||||
@@ -388,14 +374,6 @@ export class AzureResponsesProvider implements IChatProvider {
|
||||
});
|
||||
}
|
||||
|
||||
setUsageCosts?.(costsOverrideFromModel);
|
||||
if (requestedServiceSpeed !== undefined)
|
||||
setUsageDetails?.({
|
||||
...OpenAiUtil.usageDetailsFromTrackedUsage(
|
||||
trackedUsage,
|
||||
)!,
|
||||
serviceSpeed: servedServiceSpeed({ service_tier }),
|
||||
});
|
||||
this.#meteringService.utilRecordUsageObject(
|
||||
trackedUsage,
|
||||
actor,
|
||||
|
||||
@@ -40,11 +40,7 @@ const STANDALONE = [
|
||||
];
|
||||
|
||||
// Fields an Azure deployment genuinely differs from its source on, by puterId.
|
||||
const OVERRIDES: Record<string, string[]> = {
|
||||
'azure:x-ai/grok-4.3': ['serviceSpeedRates'],
|
||||
'azure:x-ai/grok-4-20-non-reasoning': ['serviceSpeedRates'],
|
||||
'azure:x-ai/grok-4-20-reasoning': ['serviceSpeedRates'],
|
||||
};
|
||||
const OVERRIDES: Record<string, string[]> = {};
|
||||
|
||||
const IDENTITY = ['id', 'puterId', 'aliases'];
|
||||
|
||||
@@ -69,18 +65,6 @@ const mirrored = AZURE_MODELS.filter(
|
||||
).map((m) => [m.puterId!, m] as const);
|
||||
|
||||
describe('AZURE_MODELS mirroring', () => {
|
||||
it('inherits OpenAI fast rates without enabling direct-only speed tiers', () => {
|
||||
for (const model of AZURE_MODELS) {
|
||||
expect(model.serviceSpeedRates?.ultrafast).toBeUndefined();
|
||||
if (model.puterId?.startsWith('azure:x-ai/')) {
|
||||
expect(model.serviceSpeedRates?.fast).toBeUndefined();
|
||||
}
|
||||
}
|
||||
const azure = AZURE_MODELS.find((model) => model.id === 'gpt-5.4')!;
|
||||
expect(azure.serviceSpeedRates).toEqual({
|
||||
fast: sourceOf(azure)!.serviceSpeedRates!.fast,
|
||||
});
|
||||
});
|
||||
it('has a direct-provider source for every entry not listed as standalone', () => {
|
||||
const orphans = mirrored
|
||||
.filter(([, m]) => !sourceOf(m))
|
||||
|
||||
@@ -22,8 +22,10 @@ import { OPEN_AI_MODELS } from '../openai/models.js';
|
||||
import { XAI_MODELS } from '../xai/models.js';
|
||||
|
||||
/**
|
||||
* Azure shares its source model's list prices, context tiers, and limits.
|
||||
* Identity and available service speeds belong to the deployment.
|
||||
* An Azure deployment of a model we also serve direct. Every field but the
|
||||
* identity comes from the source entry, so prices, long-context tiers and
|
||||
* limits can't drift from it. `name` is the vendor-qualified name both catalogs
|
||||
* share (`openai/gpt-5.4`); extra fields override the source.
|
||||
*/
|
||||
const mirror = (
|
||||
source: readonly IChatModel[],
|
||||
@@ -39,11 +41,6 @@ const mirror = (
|
||||
puterId: `azure:${name}`,
|
||||
id: azure.id,
|
||||
...shared,
|
||||
serviceSpeedRates:
|
||||
source === OPEN_AI_MODELS &&
|
||||
shared.serviceSpeedRates?.fast !== undefined
|
||||
? { fast: shared.serviceSpeedRates.fast }
|
||||
: undefined,
|
||||
aliases: [name],
|
||||
...azure,
|
||||
};
|
||||
@@ -130,13 +127,6 @@ export const AZURE_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: {
|
||||
fast: {
|
||||
prompt_tokens: 250,
|
||||
cached_tokens: 25,
|
||||
completion_tokens: 2000,
|
||||
},
|
||||
},
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 125,
|
||||
@@ -181,13 +171,6 @@ export const AZURE_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: {
|
||||
fast: {
|
||||
prompt_tokens: 45,
|
||||
cached_tokens: 4.5,
|
||||
completion_tokens: 360,
|
||||
},
|
||||
},
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 25,
|
||||
|
||||
@@ -147,22 +147,6 @@ describe.skipIf(skipUnlessEnv(ENV_VAR))('ClaudeProvider (integration)', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('returns a completion from claude-fable-5-2', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
|
||||
const provider = buildProvider();
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'claude-fable-5-2',
|
||||
messages: [{ role: 'user', content: 'Reply with OK.' }],
|
||||
reasoning_effort: 'low',
|
||||
max_tokens: 128,
|
||||
}),
|
||||
);
|
||||
const content = (result as {
|
||||
message: { content: Array<{ type: string; text?: string }> };
|
||||
}).message.content;
|
||||
expect(content.find((block) => block.type === 'text')?.text?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// (a) Claude Code-shaped system blocks, attribution header first — the
|
||||
// pipeline must never merge or reorder system blocks: the header is
|
||||
// only recognized by Anthropic when it is the sole content of an
|
||||
|
||||
@@ -1173,7 +1173,6 @@ describe('ClaudeProvider.complete request shape', () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
'claude-fable-5-2',
|
||||
'claude-fable-5-1',
|
||||
'claude-haiku-5-5',
|
||||
'claude-opus-5-5',
|
||||
@@ -1199,7 +1198,6 @@ describe('ClaudeProvider.complete request shape', () => {
|
||||
);
|
||||
|
||||
it.each([
|
||||
'claude-fable-5-2',
|
||||
'claude-fable-5-1',
|
||||
'claude-haiku-5-5',
|
||||
'claude-opus-5-5',
|
||||
@@ -1305,73 +1303,6 @@ describe('ClaudeProvider model resolution', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'claude-fable-5-2',
|
||||
'claude-fable-5-2-latest',
|
||||
'claude-fable-5.2',
|
||||
'anthropic/claude-fable-5-2',
|
||||
])(
|
||||
'routes %s to Fable 5.2 with published rates',
|
||||
async (model) => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_read_input_tokens: 1000,
|
||||
cache_creation_input_tokens: 200,
|
||||
cache_creation: {
|
||||
ephemeral_5m_input_tokens: 150,
|
||||
ephemeral_1h_input_tokens: 50,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await withTestActor(() =>
|
||||
provider.complete({
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(messagesCreateMock.mock.calls[0]![0].model).toBe(
|
||||
'claude-fable-5-2',
|
||||
);
|
||||
const fable52 = CLAUDE_MODELS.find(
|
||||
(m) => m.id === 'claude-fable-5-2',
|
||||
)!;
|
||||
expect(fable52.costs).toMatchObject({
|
||||
input_tokens: 800,
|
||||
output_tokens: 4000,
|
||||
cache_read_input_tokens: 20,
|
||||
ephemeral_5m_input_tokens: 1000,
|
||||
ephemeral_1h_input_tokens: 1600,
|
||||
});
|
||||
expect(fable52.serviceSpeedRates?.fast).toMatchObject({
|
||||
input_tokens: 1600,
|
||||
output_tokens: 8000,
|
||||
cache_read_input_tokens: 40,
|
||||
ephemeral_5m_input_tokens: 2000,
|
||||
ephemeral_1h_input_tokens: 3200,
|
||||
});
|
||||
expect(fable52.context).toBe(1_000_000);
|
||||
expect(fable52.max_tokens).toBe(128_000);
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('claude:claude-fable-5-2');
|
||||
expect(overrides).toMatchObject({
|
||||
input_tokens: 100 * Number(fable52.costs.input_tokens),
|
||||
output_tokens: 50 * Number(fable52.costs.output_tokens),
|
||||
cache_read_input_tokens:
|
||||
1000 * Number(fable52.costs.cache_read_input_tokens),
|
||||
ephemeral_5m_input_tokens:
|
||||
150 * Number(fable52.costs.ephemeral_5m_input_tokens),
|
||||
ephemeral_1h_input_tokens:
|
||||
50 * Number(fable52.costs.ephemeral_1h_input_tokens),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('routes the bare claude-fable alias to fable 5.1 rather than fable 5', async () => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce(baseResponse);
|
||||
@@ -2380,20 +2311,16 @@ describe('ClaudeProvider metering', () => {
|
||||
expect('thinking_tokens' in overrides).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['claude-opus-5-5', 'claude-fable-5-2'])('bills at fast rates for %s when the upstream reports fast usage', async (model) => {
|
||||
it('bills at the fast_* rates when the upstream reports usage.speed:"fast"', async () => {
|
||||
const { provider } = makeProvider();
|
||||
messagesCreateMock.mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
usage: {
|
||||
input_tokens: 10, output_tokens: 5, speed: 'fast',
|
||||
cache_creation: { ephemeral_5m_input_tokens: 7, ephemeral_1h_input_tokens: 3 },
|
||||
cache_read_input_tokens: 20,
|
||||
},
|
||||
usage: { input_tokens: 10, output_tokens: 5, speed: 'fast' },
|
||||
});
|
||||
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete({
|
||||
model,
|
||||
model: 'claude-opus-5-5',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
speed: 'fast',
|
||||
}),
|
||||
@@ -2404,15 +2331,11 @@ describe('ClaudeProvider metering', () => {
|
||||
expect('input_tokens' in result.usage).toBe(false);
|
||||
expect(result.usageDetails.speed).toBe('fast');
|
||||
|
||||
const selectedModel = CLAUDE_MODELS.find((m) => m.id === model)!;
|
||||
const opus = CLAUDE_MODELS.find((m) => m.id === 'claude-opus-5-5')!;
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe(`claude:${model}`);
|
||||
const fastRates = selectedModel.serviceSpeedRates!.fast as Record<string, number>;
|
||||
expect(overrides.fast_input_tokens).toBe(10 * fastRates.input_tokens);
|
||||
expect(overrides.fast_output_tokens).toBe(5 * fastRates.output_tokens);
|
||||
expect(overrides.fast_ephemeral_5m_input_tokens).toBe(7 * fastRates.ephemeral_5m_input_tokens);
|
||||
expect(overrides.fast_ephemeral_1h_input_tokens).toBe(3 * fastRates.ephemeral_1h_input_tokens);
|
||||
expect(overrides.fast_cache_read_input_tokens).toBe(20 * fastRates.cache_read_input_tokens);
|
||||
expect(prefix).toBe('claude:claude-opus-5-5');
|
||||
expect(overrides.fast_input_tokens).toBe(10 * Number(opus.costs.fast_input_tokens));
|
||||
expect(overrides.fast_output_tokens).toBe(5 * Number(opus.costs.fast_output_tokens));
|
||||
});
|
||||
|
||||
it('bills at standard rates when fast mode was requested but not granted by the upstream', async () => {
|
||||
@@ -2434,15 +2357,13 @@ describe('ClaudeProvider metering', () => {
|
||||
expect('fast_input_tokens' in result.usage).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[429, "This request would exceed your organization's rate limit of 0 fast mode input tokens per minute"],
|
||||
[529, 'Overloaded'],
|
||||
[529, 'Fast mode capacity unavailable'],
|
||||
])('retries at standard speed on Claude after fast capacity failure %s', async (status, message) => {
|
||||
it('retries at standard speed on Claude when fast mode is rate limited', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const fastLimited = Object.assign(
|
||||
new Error(message),
|
||||
{ status },
|
||||
new Error(
|
||||
"This request would exceed your organization's rate limit of 0 fast mode input tokens per minute",
|
||||
),
|
||||
{ status: 429 },
|
||||
);
|
||||
messagesCreateMock.mockRejectedValueOnce(fastLimited).mockResolvedValueOnce({
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
@@ -2666,13 +2587,13 @@ describe('ClaudeProvider.requestPricing', () => {
|
||||
const haiku = CLAUDE_MODELS.find((m) => m.id === 'claude-haiku-4-5-20251001')!;
|
||||
const opus55 = CLAUDE_MODELS.find((m) => m.id === 'claude-opus-5-5')!;
|
||||
|
||||
it('leaves token reservation keys to the selected catalog rate card', () => {
|
||||
it('returns fast cost keys only when the model has fast rates and fast was requested', () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(
|
||||
provider.requestPricing({ speed: 'fast', tools: [] } as never, opus55, {
|
||||
promptTokenEstimate: 10,
|
||||
}),
|
||||
).toEqual({});
|
||||
).toMatchObject({ inputKey: 'fast_input_tokens', outputKey: 'fast_output_tokens' });
|
||||
expect(
|
||||
provider.requestPricing({ speed: 'fast', tools: [] } as never, haiku, {
|
||||
promptTokenEstimate: 10,
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
} from '../../utils/mediaParts.js';
|
||||
import { isToolChoice, toolChoiceFromWire } from '../../utils/openaiParams.js';
|
||||
import { buildCostsOverride } from '../../utils/pricing.js';
|
||||
import { serviceSpeedCosts } from '../../utils/serviceSpeed.js';
|
||||
import type {
|
||||
AIChatStream,
|
||||
AIChatTextStream,
|
||||
@@ -108,21 +107,20 @@ const toAnthropicMediaPart = (part: any): any => {
|
||||
return part;
|
||||
};
|
||||
|
||||
/** Fast quota or overload failures can be retried at standard speed. */
|
||||
const isFastModeUnavailable = (e: unknown): boolean => {
|
||||
/** A 429 from fast mode's separate quota, not the model's normal limits. */
|
||||
const isFastModeRateLimit = (e: unknown): boolean => {
|
||||
const err = e as { status?: unknown; message?: unknown };
|
||||
return (
|
||||
typeof err?.message === 'string' &&
|
||||
((err?.status === 429 && /fast mode/i.test(err.message)) ||
|
||||
(err?.status === 529 && /overload|fast mode/i.test(err.message)))
|
||||
err?.status === 429 &&
|
||||
typeof err.message === 'string' &&
|
||||
/fast mode/i.test(err.message)
|
||||
);
|
||||
};
|
||||
|
||||
// Models whose current turn rejects a non-default temperature/top_p/top_k
|
||||
// outright. Fable 5/5.1/5.2, Sonnet 5/5.5, Haiku 5.5, and Opus 4.7+.
|
||||
// outright. Fable 5/5.1, Sonnet 5/5.5, Haiku 5.5, and Opus 4.7+.
|
||||
const OMITS_SAMPLING_PARAMS = new Set([
|
||||
'claude-haiku-5-5',
|
||||
'claude-fable-5-2',
|
||||
'claude-fable-5-1',
|
||||
'claude-fable-5',
|
||||
'claude-sonnet-5-5',
|
||||
@@ -135,7 +133,6 @@ const OMITS_SAMPLING_PARAMS = new Set([
|
||||
|
||||
const SUPPORTS_EFFORT = new Set([
|
||||
'claude-haiku-5-5',
|
||||
'claude-fable-5-2',
|
||||
'claude-fable-5-1',
|
||||
'claude-fable-5',
|
||||
'claude-sonnet-5-5',
|
||||
@@ -301,12 +298,20 @@ export class ClaudeProvider implements IChatProvider {
|
||||
return `claude:${modelId}`;
|
||||
}
|
||||
|
||||
/** Reserves tools that can run several times before the model answers. */
|
||||
/**
|
||||
* Sizes the credit hold for Claude-only extras the generic per-token math
|
||||
* can't see: fast mode's own rate card, and a web-search/advisor tool that
|
||||
* could run several times before the model answers.
|
||||
*/
|
||||
requestPricing(
|
||||
args: ICompleteArguments,
|
||||
model: IChatModel,
|
||||
est: { promptTokenEstimate: number },
|
||||
): { extraCost?: number } {
|
||||
): { inputKey?: string; outputKey?: string; extraCost?: number } {
|
||||
const fast =
|
||||
args.speed === 'fast' &&
|
||||
model.costs?.fast_output_tokens !== undefined;
|
||||
|
||||
let extraCost = 0;
|
||||
for (const raw of args.tools ?? []) {
|
||||
const tool = raw as Record<string, unknown>;
|
||||
@@ -345,6 +350,12 @@ export class ClaudeProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
return {
|
||||
...(fast
|
||||
? {
|
||||
inputKey: 'fast_input_tokens',
|
||||
outputKey: 'fast_output_tokens',
|
||||
}
|
||||
: {}),
|
||||
...(extraCost > 0 ? { extraCost } : {}),
|
||||
};
|
||||
}
|
||||
@@ -353,9 +364,11 @@ export class ClaudeProvider implements IChatProvider {
|
||||
try {
|
||||
return await this.#completeOnce(args);
|
||||
} catch (e) {
|
||||
// Retry fast-capacity failures on Claude at standard speed before
|
||||
// considering a different provider.
|
||||
if (args.speed === 'fast' && isFastModeUnavailable(e)) {
|
||||
// Fast mode has its own quota. Anthropic's advice on that 429 is to
|
||||
// drop `speed` and run at standard speed; doing it here keeps the
|
||||
// request on Claude instead of marking the route unhealthy and
|
||||
// replaying it on a reseller.
|
||||
if (args.speed === 'fast' && isFastModeRateLimit(e)) {
|
||||
return await this.#completeOnce({ ...args, speed: undefined });
|
||||
}
|
||||
throw e;
|
||||
@@ -624,7 +637,7 @@ export class ClaudeProvider implements IChatProvider {
|
||||
: undefined;
|
||||
const fast =
|
||||
args.speed === 'fast' &&
|
||||
modelUsed.serviceSpeedRates?.fast !== undefined;
|
||||
modelUsed.costs?.fast_output_tokens !== undefined;
|
||||
const safeguardsPolicy = applySafeguardsPolicy(
|
||||
args.safeguards,
|
||||
args.anthropicBetas,
|
||||
@@ -1058,7 +1071,6 @@ export class ClaudeProvider implements IChatProvider {
|
||||
// keeps reasoning visible in the stream.
|
||||
if (
|
||||
modelId === 'claude-haiku-5-5' ||
|
||||
modelId === 'claude-fable-5-2' ||
|
||||
modelId === 'claude-fable-5-1' ||
|
||||
modelId === 'claude-fable-5' ||
|
||||
modelId === 'claude-sonnet-5-5' ||
|
||||
@@ -1493,8 +1505,7 @@ export class ClaudeProvider implements IChatProvider {
|
||||
|
||||
const fast =
|
||||
raw.speed === 'fast' &&
|
||||
modelUsed.serviceSpeedRates?.fast !== undefined;
|
||||
// Keep published usage keys while resolving rates from the common catalog shape.
|
||||
modelUsed.costs?.fast_output_tokens !== undefined;
|
||||
const executorUsage: Record<string, number> = fast
|
||||
? Object.fromEntries(
|
||||
Object.entries(exec).map(([k, v]) => [`fast_${k}`, v]),
|
||||
@@ -1506,24 +1517,7 @@ export class ClaudeProvider implements IChatProvider {
|
||||
const fetches = raw.server_tool_use?.web_fetch_requests ?? 0;
|
||||
if (fetches) executorUsage.web_fetch_requests = fetches;
|
||||
|
||||
const fastRates = fast
|
||||
? serviceSpeedCosts(modelUsed, 'fast')
|
||||
: undefined;
|
||||
const billingModel = fastRates
|
||||
? {
|
||||
...modelUsed,
|
||||
costs: {
|
||||
...modelUsed.costs,
|
||||
...Object.fromEntries(
|
||||
Object.keys(exec).map((key) => [
|
||||
`fast_${key}`,
|
||||
fastRates[key],
|
||||
]),
|
||||
),
|
||||
},
|
||||
}
|
||||
: modelUsed;
|
||||
const executorCosts = buildCostsOverride(executorUsage, billingModel);
|
||||
const executorCosts = buildCostsOverride(executorUsage, modelUsed);
|
||||
|
||||
let advisorUsage: Record<string, number> | undefined;
|
||||
let advisorCosts: Record<string, number> | undefined;
|
||||
|
||||
@@ -55,46 +55,6 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
context: 1_000_000,
|
||||
max_tokens: 128_000,
|
||||
},
|
||||
{
|
||||
puterId: 'anthropic:anthropic/claude-fable-5-2',
|
||||
id: 'claude-fable-5-2',
|
||||
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
||||
open_weights: false,
|
||||
tool_call: true,
|
||||
release_date: '2026-11-09',
|
||||
aliases: [
|
||||
'claude-fable-5-2-latest',
|
||||
'claude-fable-5.2',
|
||||
'anthropic/claude-fable-5-2',
|
||||
],
|
||||
name: 'Claude Fable 5.2',
|
||||
midConversationSystem: true,
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'input_tokens',
|
||||
output_cost_key: 'output_tokens',
|
||||
// Source: https://www.anthropic.com/claude-fable-and-mythos-5-2
|
||||
serviceSpeedRates: {
|
||||
fast: {
|
||||
input_tokens: 1600,
|
||||
output_tokens: 8000,
|
||||
ephemeral_5m_input_tokens: 1600 * 1.25,
|
||||
ephemeral_1h_input_tokens: 1600 * 2,
|
||||
cache_read_input_tokens: 1600 * 0.025,
|
||||
},
|
||||
},
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
input_tokens: 800,
|
||||
ephemeral_5m_input_tokens: 800 * 1.25,
|
||||
ephemeral_1h_input_tokens: 800 * 2,
|
||||
// 0.025x input, not the usual 0.1x.
|
||||
cache_read_input_tokens: 800 * 0.025,
|
||||
output_tokens: 4000,
|
||||
web_search_requests: 1_000_000, // $10/1k requests
|
||||
},
|
||||
context: 1000000,
|
||||
max_tokens: 128000,
|
||||
},
|
||||
{
|
||||
puterId: 'anthropic:anthropic/claude-fable-5-1',
|
||||
id: 'claude-fable-5-1',
|
||||
@@ -230,15 +190,6 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'input_tokens',
|
||||
output_cost_key: 'output_tokens',
|
||||
serviceSpeedRates: {
|
||||
fast: {
|
||||
input_tokens: 800,
|
||||
output_tokens: 4000,
|
||||
ephemeral_5m_input_tokens: 1000,
|
||||
ephemeral_1h_input_tokens: 1600,
|
||||
cache_read_input_tokens: 40,
|
||||
},
|
||||
},
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
input_tokens: 400,
|
||||
@@ -248,6 +199,12 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
cache_read_input_tokens: 400 * 0.05,
|
||||
output_tokens: 2000,
|
||||
web_search_requests: 1_000_000,
|
||||
// Fast mode (`speed:'fast'`), live-verified.
|
||||
fast_input_tokens: 800,
|
||||
fast_output_tokens: 4000,
|
||||
fast_ephemeral_5m_input_tokens: 1000,
|
||||
fast_ephemeral_1h_input_tokens: 1600,
|
||||
fast_cache_read_input_tokens: 40, // 0.05x fast_input_tokens
|
||||
},
|
||||
context: 1000000,
|
||||
max_tokens: 128000,
|
||||
@@ -266,15 +223,6 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'input_tokens',
|
||||
output_cost_key: 'output_tokens',
|
||||
serviceSpeedRates: {
|
||||
fast: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 5000,
|
||||
ephemeral_5m_input_tokens: 1250,
|
||||
ephemeral_1h_input_tokens: 2000,
|
||||
cache_read_input_tokens: 100,
|
||||
},
|
||||
},
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
input_tokens: 500,
|
||||
@@ -283,6 +231,11 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
cache_read_input_tokens: 500 * 0.1,
|
||||
output_tokens: 2500,
|
||||
web_search_requests: 1_000_000,
|
||||
fast_input_tokens: 1000,
|
||||
fast_output_tokens: 5000,
|
||||
fast_ephemeral_5m_input_tokens: 1250,
|
||||
fast_ephemeral_1h_input_tokens: 2000,
|
||||
fast_cache_read_input_tokens: 100,
|
||||
},
|
||||
context: 1000000,
|
||||
max_tokens: 128000,
|
||||
@@ -305,15 +258,6 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'input_tokens',
|
||||
output_cost_key: 'output_tokens',
|
||||
serviceSpeedRates: {
|
||||
fast: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 5000,
|
||||
ephemeral_5m_input_tokens: 1250,
|
||||
ephemeral_1h_input_tokens: 2000,
|
||||
cache_read_input_tokens: 100,
|
||||
},
|
||||
},
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
input_tokens: 500,
|
||||
@@ -322,6 +266,11 @@ export const CLAUDE_MODELS: IChatModel[] = [
|
||||
cache_read_input_tokens: 500 * 0.1,
|
||||
output_tokens: 2500,
|
||||
web_search_requests: 1_000_000,
|
||||
fast_input_tokens: 1000,
|
||||
fast_output_tokens: 5000,
|
||||
fast_ephemeral_5m_input_tokens: 1250,
|
||||
fast_ephemeral_1h_input_tokens: 2000,
|
||||
fast_cache_read_input_tokens: 100,
|
||||
},
|
||||
context: 1000000,
|
||||
max_tokens: 128000,
|
||||
|
||||
@@ -84,14 +84,8 @@ export class GeminiChatProvider extends OpenAICompatProvider {
|
||||
chat: {
|
||||
completions: {
|
||||
create: (params, options) => {
|
||||
const { model, service_tier } = params as {
|
||||
model: string;
|
||||
service_tier?: string;
|
||||
};
|
||||
if (
|
||||
!GEMINI_NATIVE_MODELS.has(model) &&
|
||||
(service_tier === undefined || !native)
|
||||
) {
|
||||
const { model } = params as { model: string };
|
||||
if (!GEMINI_NATIVE_MODELS.has(model)) {
|
||||
return compat.chat.completions.create(
|
||||
params,
|
||||
options,
|
||||
@@ -109,12 +103,7 @@ export class GeminiChatProvider extends OpenAICompatProvider {
|
||||
},
|
||||
},
|
||||
};
|
||||
const models: IChatModel[] = native
|
||||
? GEMINI_MODELS
|
||||
: COMPAT_ONLY_MODELS.map((model) => ({
|
||||
...model,
|
||||
serviceSpeeds: ['standard'],
|
||||
}));
|
||||
const models = native ? GEMINI_MODELS : COMPAT_ONLY_MODELS;
|
||||
super(meteringService, {
|
||||
client,
|
||||
defaultModel: 'gemini-2.5-flash',
|
||||
@@ -194,10 +183,7 @@ export class GeminiChatProvider extends OpenAICompatProvider {
|
||||
}
|
||||
|
||||
protected override meteredUsage(source: UsageSource, model: IChatModel) {
|
||||
if (
|
||||
GEMINI_NATIVE_MODELS.has(model.id) ||
|
||||
'promptTokenCount' in (source.usage ?? {})
|
||||
) {
|
||||
if (GEMINI_NATIVE_MODELS.has(model.id)) {
|
||||
return nativeMeteredUsage(
|
||||
source.usage as unknown as
|
||||
GenerateContentResponseUsageMetadata | undefined,
|
||||
|
||||
@@ -80,7 +80,6 @@ interface ChatParams {
|
||||
temperature?: number;
|
||||
reasoning_effort?: 'minimal' | 'low' | 'medium' | 'high';
|
||||
stream?: boolean;
|
||||
service_tier?: string;
|
||||
}
|
||||
|
||||
/** Ids made up for calls Gemini returned without one; never sent back. */
|
||||
@@ -241,12 +240,6 @@ const toGenerateContentRequest = (
|
||||
? toolConfigOf(params.tool_choice)
|
||||
: undefined;
|
||||
const config: GenerateContentConfig = {
|
||||
...(params.service_tier !== undefined
|
||||
? {
|
||||
serviceTier:
|
||||
params.service_tier as GenerateContentConfig['serviceTier'],
|
||||
}
|
||||
: {}),
|
||||
...(signal ? { abortSignal: signal } : {}),
|
||||
...(systemInstruction ? { systemInstruction } : {}),
|
||||
...(params.max_completion_tokens !== undefined
|
||||
|
||||
@@ -142,7 +142,6 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.8 },
|
||||
costs: ARGON_LAUNCH_COSTS,
|
||||
pricingOverrides: listPricingFrom(
|
||||
'2026-12-01T00:00:00Z',
|
||||
@@ -175,7 +174,6 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
// Google serves this endpoint with gemini-3.6-flash and bills its rates.
|
||||
serviceSpeedRates: { fast: 1.8 },
|
||||
costs: FLASH_LAUNCH_COSTS,
|
||||
pricingOverrides: FLASH_LIST_PRICING,
|
||||
},
|
||||
@@ -203,7 +201,6 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.8 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30,
|
||||
@@ -231,7 +228,6 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.8 },
|
||||
costs: FLASH_LAUNCH_COSTS,
|
||||
pricingOverrides: FLASH_LIST_PRICING,
|
||||
},
|
||||
@@ -254,7 +250,6 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
// Google serves this endpoint with gemini-3.8-flash and bills its rates.
|
||||
serviceSpeedRates: { fast: 1.8 },
|
||||
costs: FLASH_LAUNCH_COSTS,
|
||||
pricingOverrides: FLASH_LIST_PRICING,
|
||||
},
|
||||
@@ -275,7 +270,6 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.8 },
|
||||
costs: FLASH_LAUNCH_COSTS,
|
||||
pricingOverrides: FLASH_LIST_PRICING,
|
||||
},
|
||||
@@ -297,7 +291,6 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.8 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 25,
|
||||
@@ -325,7 +318,6 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.8 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 50,
|
||||
@@ -352,7 +344,6 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.8 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30,
|
||||
@@ -383,7 +374,6 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.8 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 10,
|
||||
@@ -412,7 +402,6 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.8 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 125,
|
||||
@@ -459,7 +448,6 @@ export const GEMINI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.8 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
|
||||
@@ -266,15 +266,6 @@ export class InfronProvider extends OpenAICompatProvider {
|
||||
return coerced_models;
|
||||
}
|
||||
|
||||
async serviceSpeedModels(model: IChatModel) {
|
||||
const variant = (await this.models()).find(
|
||||
(entry) => entry.id === `${model.id}:priority`,
|
||||
);
|
||||
return variant
|
||||
? { fast: { ...variant, provider: model.provider } }
|
||||
: {};
|
||||
}
|
||||
|
||||
protected override vendorParams(
|
||||
params: Record<string, unknown>,
|
||||
_args: unknown,
|
||||
|
||||
@@ -43,8 +43,6 @@ export const MINIMAX_MODELS: MiniMaxChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.5 },
|
||||
serviceSpeedVariants: { fast: 'minimax-m3-highspeed' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30, // $0.30 per 1M (input <= 512k tokens)
|
||||
@@ -72,7 +70,6 @@ export const MINIMAX_MODELS: MiniMaxChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'minimax-m2.7-highspeed' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30, // $0.30 per 1M
|
||||
@@ -125,7 +122,6 @@ export const MINIMAX_MODELS: MiniMaxChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'minimax-m2.5-highspeed' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30, // $0.30 per 1M
|
||||
@@ -178,7 +174,6 @@ export const MINIMAX_MODELS: MiniMaxChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'minimax-m2.1-highspeed' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30, // $0.30 per 1M
|
||||
@@ -227,7 +222,6 @@ export const MINIMAX_MODELS: MiniMaxChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'minimax-m2-highspeed' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30, // $0.30 per 1M
|
||||
|
||||
@@ -20,12 +20,13 @@
|
||||
/**
|
||||
* Offline unit tests for MistralAIProvider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
|
||||
* constructs MistralAIProvider directly against the live wired
|
||||
* `MeteringService` so the recording side is exercised end-to- end. The Mistral
|
||||
* SDK is mocked at the module boundary (the real network egress point) so the
|
||||
* provider never reaches the network. The companion integration test
|
||||
* (MistralAiProvider.integration.test.ts) exercises the real Mistral endpoint.
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) and constructs MistralAIProvider directly against the live
|
||||
* wired `MeteringService` so the recording side is exercised end-to-
|
||||
* end. The Mistral SDK is mocked at the module boundary (the real
|
||||
* network egress point) so the provider never reaches the network.
|
||||
* The companion integration test (MistralAiProvider.integration.test.ts)
|
||||
* exercises the real Mistral endpoint.
|
||||
*/
|
||||
|
||||
import { Context } from '../../../../core/context.js';
|
||||
@@ -146,9 +147,7 @@ describe('MistralAIProvider construction', () => {
|
||||
it('constructs the Mistral SDK with the configured API key', () => {
|
||||
makeProvider();
|
||||
expect(mistralCtor).toHaveBeenCalledTimes(1);
|
||||
expect(mistralCtor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ apiKey: 'test-key' }),
|
||||
);
|
||||
expect(mistralCtor).toHaveBeenCalledWith({ apiKey: 'test-key' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -607,11 +606,7 @@ describe('MistralAIProvider.complete non-stream output', () => {
|
||||
if (stream) {
|
||||
streamMock.mockResolvedValueOnce(
|
||||
asAsyncIterable([
|
||||
{
|
||||
data: {
|
||||
choices: [{ delta: { content: 'hello' } }],
|
||||
},
|
||||
},
|
||||
{ data: { choices: [{ delta: { content: 'hello' } }] } },
|
||||
{ data: { choices: [], usage } },
|
||||
]),
|
||||
);
|
||||
@@ -648,11 +643,7 @@ describe('MistralAIProvider.complete non-stream output', () => {
|
||||
expect(result).toMatchObject({ message: { content: 'hello' } });
|
||||
}
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
{
|
||||
prompt_tokens: 5,
|
||||
cached_tokens: 1008,
|
||||
completion_tokens: 30,
|
||||
},
|
||||
{ prompt_tokens: 5, cached_tokens: 1008, completion_tokens: 30 },
|
||||
SYSTEM_ACTOR,
|
||||
`mistral:${model}`,
|
||||
{
|
||||
@@ -697,7 +688,8 @@ describe('MistralAIProvider.complete non-stream output', () => {
|
||||
});
|
||||
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
const [usage, actor, prefix, overrides] =
|
||||
recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
@@ -726,11 +718,15 @@ describe('MistralAIProvider.complete non-stream output', () => {
|
||||
content: [
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: [{ type: 'text', text: 'step one.' }],
|
||||
thinking: [
|
||||
{ type: 'text', text: 'step one.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: [{ type: 'text', text: 'step two.' }],
|
||||
thinking: [
|
||||
{ type: 'text', text: 'step two.' },
|
||||
],
|
||||
},
|
||||
{ type: 'text', text: 'the answer' },
|
||||
],
|
||||
@@ -920,7 +916,8 @@ describe('MistralAIProvider.complete streaming', () => {
|
||||
|
||||
// mistral-small-2603: prompt=15, completion=60.
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
const [, , prefix, overrides] =
|
||||
recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('mistral:mistral-small-2603');
|
||||
expect(overrides).toMatchObject({
|
||||
prompt_tokens: 4 * 15,
|
||||
@@ -1118,7 +1115,7 @@ describe('MistralAIProvider image_url coercion', () => {
|
||||
usage: { promptTokens: 1, completionTokens: 1 },
|
||||
};
|
||||
|
||||
it("rewrites canonical image_url parts to the SDK's camelCase imageUrl", async () => {
|
||||
it('rewrites canonical image_url parts to the SDK\'s camelCase imageUrl', async () => {
|
||||
const { provider } = makeProvider();
|
||||
completeMock.mockResolvedValueOnce(baseCompletion);
|
||||
|
||||
@@ -1178,8 +1175,7 @@ describe('MistralAIProvider image_url coercion', () => {
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url:
|
||||
'https://example.com/already-flat.png',
|
||||
image_url: 'https://example.com/already-flat.png',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1255,9 +1251,7 @@ describe('MistralAIProvider image_url coercion', () => {
|
||||
{ type: 'text', text: 'what is in this image?' },
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: 'https://example.com/photo.jpg',
|
||||
},
|
||||
image_url: { url: 'https://example.com/photo.jpg' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1266,11 +1260,7 @@ describe('MistralAIProvider image_url coercion', () => {
|
||||
);
|
||||
|
||||
const [args] = completeMock.mock.calls[0]!;
|
||||
const parts = args.messages[0].content as {
|
||||
type: string;
|
||||
text?: string;
|
||||
imageUrl?: unknown;
|
||||
}[];
|
||||
const parts = args.messages[0].content as { type: string; text?: string; imageUrl?: unknown }[];
|
||||
const textPart = parts.find((p) => p.type === 'text');
|
||||
const imgPart = parts.find((p) => p.type === 'image_url');
|
||||
expect(textPart?.text).toBe('what is in this image?');
|
||||
@@ -1310,41 +1300,3 @@ describe('MistralAIProvider.checkModeration', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mistral service speed wire mapping', () => {
|
||||
it('adds the tier after SDK serialization and removes its internal header', async () => {
|
||||
makeProvider();
|
||||
const options = mistralCtor.mock.calls[0][0] as {
|
||||
httpClient: import('@mistralai/mistralai/lib/http.js').HTTPClient;
|
||||
};
|
||||
let sent: Request | undefined;
|
||||
vi.stubGlobal('fetch', async (input: Request) => {
|
||||
sent = input;
|
||||
return new Response('{}', {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
});
|
||||
try {
|
||||
await options.httpClient.request(
|
||||
new Request('https://api.mistral.ai/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-puter-service-tier': 'auto',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'mistral-medium-latest',
|
||||
messages: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(sent?.headers.has('x-puter-service-tier')).toBe(false);
|
||||
expect(await sent?.json()).toMatchObject({
|
||||
model: 'mistral-medium-latest',
|
||||
service_tier: 'auto',
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,6 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { HTTPClient } from '@mistralai/mistralai/lib/http.js';
|
||||
import { servedServiceSpeed } from '../../utils/serviceSpeed.js';
|
||||
import { Mistral } from '@mistralai/mistralai';
|
||||
import { ChatCompletionResponse } from '@mistralai/mistralai/models/components/chatcompletionresponse.js';
|
||||
import { MistralError } from '@mistralai/mistralai/models/errors/mistralerror.js';
|
||||
@@ -28,7 +26,6 @@ import { Context } from '../../../../core/context.js';
|
||||
import { HttpError } from '../../../../core/http/HttpError.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type {
|
||||
IChatModel,
|
||||
IChatCompleteResult,
|
||||
IChatProvider,
|
||||
ICompleteArguments,
|
||||
@@ -109,7 +106,6 @@ const coerceMistralUsage = (usage: UsageInfo) => {
|
||||
prompt_tokens: (usage.promptTokens ?? 0) - cachedTokens,
|
||||
completion_tokens: usage.completionTokens ?? 0,
|
||||
prompt_tokens_details: { cached_tokens: cachedTokens },
|
||||
service_tier: usage.additionalProperties?.service_tier,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -119,24 +115,7 @@ export class MistralAIProvider implements IChatProvider {
|
||||
#meteringService: MeteringService;
|
||||
|
||||
constructor(config: { apiKey: string }, meteringService: MeteringService) {
|
||||
const httpClient = new HTTPClient();
|
||||
// The SDK strips service_tier during body serialization.
|
||||
httpClient.addHook('beforeRequest', async (request) => {
|
||||
const tier = request.headers.get('x-puter-service-tier');
|
||||
if (!tier) return;
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete('x-puter-service-tier');
|
||||
const body = await request.json();
|
||||
return new Request(request, {
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...body,
|
||||
service_tier: tier === 'auto' ? 'auto' : 'standard_only',
|
||||
}),
|
||||
});
|
||||
});
|
||||
this.#client = new Mistral({
|
||||
httpClient,
|
||||
apiKey: config.apiKey,
|
||||
});
|
||||
this.#meteringService = meteringService;
|
||||
@@ -202,22 +181,17 @@ export class MistralAIProvider implements IChatProvider {
|
||||
return `mistral:${modelId}`;
|
||||
}
|
||||
|
||||
async complete(
|
||||
{
|
||||
messages,
|
||||
stream,
|
||||
model,
|
||||
tools,
|
||||
max_tokens,
|
||||
temperature,
|
||||
normalize,
|
||||
response,
|
||||
custom,
|
||||
service_tier,
|
||||
serviceSpeed: requestedServiceSpeed,
|
||||
}: ICompleteArguments,
|
||||
resolved?: IChatModel,
|
||||
): Promise<IChatCompleteResult> {
|
||||
async complete({
|
||||
messages,
|
||||
stream,
|
||||
model,
|
||||
tools,
|
||||
max_tokens,
|
||||
temperature,
|
||||
normalize,
|
||||
response,
|
||||
custom,
|
||||
}: ICompleteArguments): Promise<IChatCompleteResult> {
|
||||
// Mistral's reasoning prompt mode: with `prompt_mode: 'reasoning'`,
|
||||
// reasoning-capable models return their thinking as structured ThinkChunk
|
||||
// content (which the splitter below separates into `reasoning`)
|
||||
@@ -243,13 +217,10 @@ export class MistralAIProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
const selectedModel =
|
||||
resolved ??
|
||||
((await this.models()).find((m) =>
|
||||
(await this.models()).find((m) =>
|
||||
[m.id, ...(m.aliases || [])].includes(model),
|
||||
) ||
|
||||
(await this.models()).find(
|
||||
(m) => m.id === this.getDefaultModel(),
|
||||
)!);
|
||||
(await this.models()).find((m) => m.id === this.getDefaultModel())!;
|
||||
const actor = Context.get('actor');
|
||||
let completion: Awaited<
|
||||
ReturnType<Mistral['chat']['complete' | 'stream']>
|
||||
@@ -271,9 +242,6 @@ export class MistralAIProvider implements IChatProvider {
|
||||
// The SDK retries nothing by default and has no timeout once a
|
||||
// signal is passed; a stream is bounded by its caller instead.
|
||||
{
|
||||
...(service_tier !== undefined
|
||||
? { headers: { 'x-puter-service-tier': service_tier } }
|
||||
: {}),
|
||||
signal: stream
|
||||
? Context.get('upstreamSignal')
|
||||
: withSdkTimeout(Context.get('upstreamSignal')),
|
||||
@@ -430,8 +398,7 @@ export class MistralAIProvider implements IChatProvider {
|
||||
},
|
||||
completion: completion as ChatCompletionResponse,
|
||||
stream,
|
||||
usage_calculator: ({ usage, setUsageCosts, setUsageDetails }) => {
|
||||
const serviceSpeed = servedServiceSpeed({ usage });
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
// `coerceMistralUsage` has already taken cached reads out of
|
||||
// `prompt_tokens`.
|
||||
const metered = meterChatUsage(
|
||||
@@ -440,16 +407,8 @@ export class MistralAIProvider implements IChatProvider {
|
||||
this.meteringModelKey(selectedModel.id),
|
||||
selectedModel,
|
||||
OpenAIUtil.extractMeteredUsage(usage),
|
||||
{ serviceSpeed },
|
||||
);
|
||||
setUsageCosts(metered.costs);
|
||||
if (requestedServiceSpeed !== undefined)
|
||||
setUsageDetails({
|
||||
...OpenAIUtil.usageDetailsFromTrackedUsage(
|
||||
metered.usage,
|
||||
)!,
|
||||
serviceSpeed,
|
||||
});
|
||||
return metered.usage;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -42,7 +42,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.75 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 136,
|
||||
@@ -76,7 +75,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.75 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 150,
|
||||
@@ -107,7 +105,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.75 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 50,
|
||||
@@ -138,7 +135,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.75 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 15,
|
||||
@@ -163,7 +159,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.75 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 140,
|
||||
@@ -193,7 +188,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.75 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 30,
|
||||
@@ -219,7 +213,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.75 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 20,
|
||||
@@ -245,7 +238,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.75 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 15,
|
||||
@@ -270,7 +262,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 1.75 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 10,
|
||||
|
||||
@@ -30,7 +30,6 @@ export const MOONSHOT_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'kimi-k3.1-pro-highspeed' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200, // $2.00 per 1M
|
||||
@@ -78,7 +77,6 @@ export const MOONSHOT_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'kimi-k3-highspeed' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 300, // $3.00 per 1M
|
||||
@@ -106,7 +104,6 @@ export const MOONSHOT_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'kimi-k2.6-highspeed' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 95, // $0.95 per 1M
|
||||
@@ -129,7 +126,6 @@ export const MOONSHOT_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedVariants: { fast: 'kimi-k2.7-code-highspeed' },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 95,
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
|
||||
import { OpenAI } from 'openai';
|
||||
import { ChatCompletionCreateParams } from 'openai/resources/index.js';
|
||||
import { servedServiceSpeed } from '../../utils/serviceSpeed.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type {
|
||||
@@ -118,7 +117,6 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
|
||||
async complete(
|
||||
params: ICompleteArguments,
|
||||
resolved?: IChatModel,
|
||||
): ReturnType<IChatProvider['complete']> {
|
||||
const {
|
||||
max_tokens,
|
||||
@@ -136,11 +134,6 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
} = params;
|
||||
let { messages, model } = params;
|
||||
const cacheOptions = promptCacheOptionsToWire(prompt_cache_options);
|
||||
if (params.service_tier === 'ultrafast' && this.#responsesProvider) {
|
||||
return resolved
|
||||
? this.#responsesProvider.complete(params, resolved)
|
||||
: this.#responsesProvider.complete(params);
|
||||
}
|
||||
if (
|
||||
tools?.filter((e) => isWebSearchTool(e as Record<string, unknown>))
|
||||
.length
|
||||
@@ -154,9 +147,7 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return await (resolved
|
||||
? this.#responsesProvider.complete(params, resolved)
|
||||
: this.#responsesProvider.complete(params));
|
||||
return await this.#responsesProvider.complete(params);
|
||||
}
|
||||
// Inline compaction is a Responses-API feature; chat.completions can't
|
||||
// express `context_management` or a `compaction` content block.
|
||||
@@ -170,9 +161,7 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return await (resolved
|
||||
? this.#responsesProvider.complete(params, resolved)
|
||||
: this.#responsesProvider.complete(params));
|
||||
return await this.#responsesProvider.complete(params);
|
||||
}
|
||||
// Only the Responses API writes 12h cache entries or takes a lifetime
|
||||
// on a `prompt_cache_breakpoint`; Chat Completions rejects both.
|
||||
@@ -187,9 +176,7 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
return await (resolved
|
||||
? this.#responsesProvider.complete(params, resolved)
|
||||
: this.#responsesProvider.complete(params));
|
||||
return await this.#responsesProvider.complete(params);
|
||||
}
|
||||
// Validate messages
|
||||
if (!Array.isArray(messages)) {
|
||||
@@ -202,11 +189,9 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
model = model ?? this.#defaultModel;
|
||||
|
||||
const modelUsed =
|
||||
resolved ??
|
||||
(this.models().find((m) =>
|
||||
this.models().find((m) =>
|
||||
[m.id, ...(m.aliases || [])].includes(model),
|
||||
) ||
|
||||
this.models().find((m) => m.id === this.getDefaultModel())!);
|
||||
) || this.models().find((m) => m.id === this.getDefaultModel())!;
|
||||
|
||||
// messages.unshift({
|
||||
// role: 'system',
|
||||
@@ -263,9 +248,6 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
: {}),
|
||||
messages: messages,
|
||||
model: modelUsed.id,
|
||||
...(params.service_tier !== undefined
|
||||
? { service_tier: params.service_tier }
|
||||
: {}),
|
||||
...(mappedTools?.length ? { tools: mappedTools } : {}),
|
||||
...(max_tokens !== undefined
|
||||
? { max_completion_tokens: max_tokens }
|
||||
@@ -302,13 +284,7 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
);
|
||||
|
||||
return OpenAiUtil.handle_completion_output({
|
||||
usage_calculator: ({
|
||||
usage,
|
||||
service_tier,
|
||||
setUsageCosts,
|
||||
setUsageDetails,
|
||||
}) => {
|
||||
const serviceSpeed = servedServiceSpeed({ service_tier });
|
||||
usage_calculator: ({ usage, setUsageCosts, setUsageDetails }) => {
|
||||
// GPT-5.6 and later report cache writes inside
|
||||
// `prompt_tokens`, like cached reads; Chat Completions only
|
||||
// writes the default 30m entries, billed at 1.25x input.
|
||||
@@ -320,17 +296,13 @@ export class OpenAiChatProvider implements IChatProvider {
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
modelUsed,
|
||||
trackedUsage,
|
||||
{ serviceSpeed },
|
||||
);
|
||||
setUsageCosts(metered.costs);
|
||||
setUsageDetails({
|
||||
...OpenAiUtil.usageDetailsFromTrackedUsage(metered.usage, {
|
||||
setUsageDetails(
|
||||
OpenAiUtil.usageDetailsFromTrackedUsage(metered.usage, {
|
||||
writeTtl: '30m',
|
||||
})!,
|
||||
...(params.serviceSpeed !== undefined
|
||||
? { serviceSpeed }
|
||||
: {}),
|
||||
});
|
||||
);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
|
||||
@@ -44,9 +44,7 @@ import {
|
||||
type MockInstance,
|
||||
} from 'vitest';
|
||||
|
||||
import { SYSTEM_ACTOR, type Actor } from '../../../../core/actor.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ChatCompletionDriver } from '../../ChatCompletionDriver.js';
|
||||
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import { PuterServer } from '../../../../server.js';
|
||||
import { setupTestServer } from '../../../../testUtil.js';
|
||||
@@ -1582,117 +1580,3 @@ describe('OpenAiResponsesChatProvider.complete error mapping', () => {
|
||||
expect(recordSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatCompletionDriver OpenAI billing', () => {
|
||||
const makeBillingUser = async () => {
|
||||
const user = await server.stores.user.create({
|
||||
username: `billing-${uuidv4().slice(0, 8)}`,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: null,
|
||||
free_storage: 1000000,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
return { user: { ...user, email_confirmed: true } } as Actor;
|
||||
};
|
||||
const makeBillingDriver = async () => {
|
||||
const driver = new ChatCompletionDriver(
|
||||
{
|
||||
providers: {
|
||||
ollama: { enabled: false },
|
||||
'openai-completion': { apiKey: 'test-key' },
|
||||
},
|
||||
} as never,
|
||||
server.clients,
|
||||
server.stores,
|
||||
server.services,
|
||||
);
|
||||
await driver.onServerStart();
|
||||
return driver;
|
||||
};
|
||||
it('meters an empty Responses completion before returning its error', async () => {
|
||||
const actor = await makeBillingUser();
|
||||
const driver = await makeBillingDriver();
|
||||
const before = await server.services.metering.getRemainingUsage(actor);
|
||||
responsesCreateMock.mockResolvedValueOnce({
|
||||
status: 'incomplete',
|
||||
incomplete_details: { reason: 'max_output_tokens' },
|
||||
output: [],
|
||||
output_text: '',
|
||||
usage: {
|
||||
input_tokens: 1200,
|
||||
output_tokens: 2000,
|
||||
output_tokens_details: { reasoning_tokens: 2000 },
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
withTestActor(
|
||||
() =>
|
||||
driver.complete({
|
||||
model: 'gpt-5.4-mini',
|
||||
provider: 'openai-responses',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
max_tokens: 1,
|
||||
fallback: false,
|
||||
}),
|
||||
actor,
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
expect(responsesCreateMock).toHaveBeenCalledTimes(1);
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
expect(recordSpy.mock.calls[0][0]).toMatchObject({
|
||||
prompt_tokens: 1200,
|
||||
completion_tokens: 2000,
|
||||
});
|
||||
await Promise.all(recordSpy.mock.results.map((result) => result.value));
|
||||
expect(
|
||||
await server.services.metering.getRemainingUsage(actor),
|
||||
).toBeLessThan(before);
|
||||
});
|
||||
it('prices a raw priority tier with the same rate card as fast service', async () => {
|
||||
const actor = await makeBillingUser();
|
||||
const driver = await makeBillingDriver();
|
||||
const charges: number[] = [];
|
||||
for (const extra of [
|
||||
{},
|
||||
{ service_tier: 'priority' },
|
||||
{ serviceSpeed: 'fast' },
|
||||
]) {
|
||||
responsesCreateMock.mockResolvedValueOnce({
|
||||
output: [],
|
||||
output_text: 'hi',
|
||||
service_tier: Object.keys(extra).length
|
||||
? 'priority'
|
||||
: 'default',
|
||||
usage: { input_tokens: 100, output_tokens: 100 },
|
||||
});
|
||||
const before =
|
||||
await server.services.metering.getRemainingUsage(actor);
|
||||
await withTestActor(
|
||||
() =>
|
||||
driver.complete({
|
||||
model: 'gpt-6.1-terra',
|
||||
provider: 'openai-responses',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
max_tokens: 100,
|
||||
fallback: false,
|
||||
...extra,
|
||||
} as never),
|
||||
actor,
|
||||
);
|
||||
await Promise.all(
|
||||
recordSpy.mock.results.map((result) => result.value),
|
||||
);
|
||||
charges.push(
|
||||
before -
|
||||
(await server.services.metering.getRemainingUsage(actor)),
|
||||
);
|
||||
}
|
||||
expect(responsesCreateMock.mock.calls[1][0].service_tier).toBe(
|
||||
'priority',
|
||||
);
|
||||
expect(charges[0]).toBeGreaterThan(0);
|
||||
expect(charges[1]).toBe(charges[2]);
|
||||
expect(charges[2]).toBe(charges[0] * 2);
|
||||
});
|
||||
});
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
import { OpenAI } from 'openai';
|
||||
import { ResponseCreateParams } from 'openai/resources/responses/responses.mjs';
|
||||
import { servedServiceSpeed } from '../../utils/serviceSpeed.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type {
|
||||
@@ -135,42 +134,38 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
return rate ? { extraCost: rate } : {};
|
||||
}
|
||||
|
||||
async complete(
|
||||
{
|
||||
messages,
|
||||
model,
|
||||
max_tokens,
|
||||
moderation,
|
||||
tools,
|
||||
tool_choice,
|
||||
parallel_tool_calls,
|
||||
include,
|
||||
conversation,
|
||||
compaction,
|
||||
context_management,
|
||||
previous_response_id,
|
||||
instructions,
|
||||
metadata,
|
||||
prompt,
|
||||
prompt_cache_key,
|
||||
prompt_cache_retention,
|
||||
prompt_cache_options,
|
||||
store,
|
||||
top_p,
|
||||
truncation,
|
||||
background,
|
||||
service_tier,
|
||||
serviceSpeed: requestedServiceSpeed,
|
||||
verbosity,
|
||||
stream,
|
||||
reasoning,
|
||||
reasoning_effort,
|
||||
temperature,
|
||||
text,
|
||||
outputFormat,
|
||||
}: ICompleteArguments,
|
||||
resolved?: IChatModel,
|
||||
): ReturnType<IChatProvider['complete']> {
|
||||
async complete({
|
||||
messages,
|
||||
model,
|
||||
max_tokens,
|
||||
moderation,
|
||||
tools,
|
||||
tool_choice,
|
||||
parallel_tool_calls,
|
||||
include,
|
||||
conversation,
|
||||
compaction,
|
||||
context_management,
|
||||
previous_response_id,
|
||||
instructions,
|
||||
metadata,
|
||||
prompt,
|
||||
prompt_cache_key,
|
||||
prompt_cache_retention,
|
||||
prompt_cache_options,
|
||||
store,
|
||||
top_p,
|
||||
truncation,
|
||||
background,
|
||||
service_tier,
|
||||
verbosity,
|
||||
stream,
|
||||
reasoning,
|
||||
reasoning_effort,
|
||||
temperature,
|
||||
text,
|
||||
outputFormat,
|
||||
}: ICompleteArguments): ReturnType<IChatProvider['complete']> {
|
||||
// Validate messages
|
||||
if (!Array.isArray(messages)) {
|
||||
throw new HttpError(400, '`messages` must be an array', {
|
||||
@@ -188,13 +183,12 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
model = model ?? this.#defaultModel;
|
||||
|
||||
const modelUsed =
|
||||
resolved ??
|
||||
(this.models({ no_restrictions: true }).find((m) =>
|
||||
this.models({ no_restrictions: true }).find((m) =>
|
||||
[m.id, ...(m.aliases || [])].includes(model),
|
||||
) ||
|
||||
this.models({ no_restrictions: true }).find(
|
||||
(m) => m.id === this.getDefaultModel(),
|
||||
)!);
|
||||
this.models({ no_restrictions: true }).find(
|
||||
(m) => m.id === this.getDefaultModel(),
|
||||
)!;
|
||||
|
||||
// messages.unshift({
|
||||
// role: 'system',
|
||||
@@ -328,7 +322,6 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
return OpenAiUtil.handle_completion_output_responses_api({
|
||||
usage_calculator: ({
|
||||
usage,
|
||||
service_tier,
|
||||
webSearchCalls,
|
||||
setUsageCosts,
|
||||
setUsageDetails,
|
||||
@@ -354,31 +347,23 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
|
||||
// model is a reasoning model, not a static per-model
|
||||
// cost-table entry — the model's rates would otherwise
|
||||
// price it (wrongly) at the input/output token rate.
|
||||
{
|
||||
serviceSpeed: servedServiceSpeed({ service_tier }),
|
||||
...(webSearchCalls
|
||||
? {
|
||||
costOverrides: {
|
||||
web_search_calls:
|
||||
webSearchCalls *
|
||||
(webSearchCallRate(
|
||||
tools,
|
||||
modelUsed.id,
|
||||
) ?? 0),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
webSearchCalls
|
||||
? {
|
||||
costOverrides: {
|
||||
web_search_calls:
|
||||
webSearchCalls *
|
||||
(webSearchCallRate(tools, modelUsed.id) ??
|
||||
0),
|
||||
},
|
||||
}
|
||||
: {},
|
||||
);
|
||||
setUsageCosts?.(metered.costs);
|
||||
setUsageDetails?.({
|
||||
...(requestedServiceSpeed !== undefined
|
||||
? { serviceSpeed: servedServiceSpeed({ service_tier }) }
|
||||
: {}),
|
||||
...OpenAiUtil.usageDetailsFromTrackedUsage(metered.usage, {
|
||||
setUsageDetails?.(
|
||||
OpenAiUtil.usageDetailsFromTrackedUsage(metered.usage, {
|
||||
writeTtl: '30m',
|
||||
})!,
|
||||
});
|
||||
);
|
||||
return metered.usage;
|
||||
},
|
||||
stream,
|
||||
|
||||
@@ -46,7 +46,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 2 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 100,
|
||||
@@ -73,7 +72,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 2, ultrafast: 6 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
@@ -100,7 +98,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 2 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
@@ -127,7 +124,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 2 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 10,
|
||||
@@ -154,7 +150,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 2, ultrafast: 6 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 1000,
|
||||
@@ -181,7 +176,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
// OpenAI's promotional pricing, guaranteed only through 2026-11-21.
|
||||
serviceSpeedRates: { fast: 2 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 400,
|
||||
@@ -207,7 +201,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 2 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
@@ -233,7 +226,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 2 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 20,
|
||||
@@ -259,13 +251,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: {
|
||||
fast: {
|
||||
prompt_tokens: 1250,
|
||||
cached_tokens: 125,
|
||||
completion_tokens: 7500,
|
||||
},
|
||||
},
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 500,
|
||||
@@ -310,13 +295,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: {
|
||||
fast: {
|
||||
prompt_tokens: 500,
|
||||
cached_tokens: 50,
|
||||
completion_tokens: 3000,
|
||||
},
|
||||
},
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 250,
|
||||
@@ -360,13 +338,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: {
|
||||
fast: {
|
||||
prompt_tokens: 150,
|
||||
cached_tokens: 15,
|
||||
completion_tokens: 900,
|
||||
},
|
||||
},
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 75,
|
||||
@@ -454,13 +425,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: {
|
||||
fast: {
|
||||
prompt_tokens: 350,
|
||||
cached_tokens: 35,
|
||||
completion_tokens: 2800,
|
||||
},
|
||||
},
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 175,
|
||||
@@ -482,13 +446,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: {
|
||||
fast: {
|
||||
prompt_tokens: 250,
|
||||
cached_tokens: 25,
|
||||
completion_tokens: 2000,
|
||||
},
|
||||
},
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 125,
|
||||
@@ -510,13 +467,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: {
|
||||
fast: {
|
||||
prompt_tokens: 425,
|
||||
cached_tokens: 212.5,
|
||||
completion_tokens: 1700,
|
||||
},
|
||||
},
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 250,
|
||||
@@ -540,13 +490,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: {
|
||||
fast: {
|
||||
prompt_tokens: 25,
|
||||
cached_tokens: 12.5,
|
||||
completion_tokens: 100,
|
||||
},
|
||||
},
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 15,
|
||||
@@ -566,13 +509,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: {
|
||||
fast: {
|
||||
prompt_tokens: 350,
|
||||
cached_tokens: 87.5,
|
||||
completion_tokens: 1400,
|
||||
},
|
||||
},
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
@@ -594,13 +530,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: {
|
||||
fast: {
|
||||
prompt_tokens: 70,
|
||||
cached_tokens: 17.5,
|
||||
completion_tokens: 280,
|
||||
},
|
||||
},
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 40,
|
||||
@@ -642,13 +571,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: {
|
||||
fast: {
|
||||
prompt_tokens: 425,
|
||||
cached_tokens: 212.5,
|
||||
completion_tokens: 1700,
|
||||
},
|
||||
},
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 250,
|
||||
|
||||
@@ -20,14 +20,15 @@
|
||||
/**
|
||||
* Offline unit tests for OpenRouterProvider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
|
||||
* constructs OpenRouterProvider directly against the live wired
|
||||
* `MeteringService` so the recording side is exercised end-to- end. OpenRouter
|
||||
* is OpenAI-compatible, so the OpenAI SDK is mocked at the module boundary; the
|
||||
* model catalog is fetched via `axios` which is mocked at its module boundary
|
||||
* too. Both are the real network egress points. The companion integration test
|
||||
* (OpenRouterProvider.integration.test.ts) exercises the real OpenRouter
|
||||
* endpoint.
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) and constructs OpenRouterProvider directly against the live
|
||||
* wired `MeteringService` so the recording side is exercised end-to-
|
||||
* end. OpenRouter is OpenAI-compatible, so the OpenAI SDK is mocked
|
||||
* at the module boundary; the model catalog is fetched via `axios`
|
||||
* which is mocked at its module boundary too. Both are the real
|
||||
* network egress points.
|
||||
* The companion integration test (OpenRouterProvider.integration.test.ts)
|
||||
* exercises the real OpenRouter endpoint.
|
||||
*/
|
||||
|
||||
import { Writable } from 'node:stream';
|
||||
@@ -299,20 +300,16 @@ describe('OpenRouterProvider model catalog', () => {
|
||||
it('coerces string prices and normalizes conditional tiers', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const models = await provider.models();
|
||||
const sol = models.find(
|
||||
(m) => m.id === 'openrouter:openai/gpt-6.1-sol',
|
||||
)!;
|
||||
const sol = models.find((m) => m.id === 'openrouter:openai/gpt-6.1-sol')!;
|
||||
expect(sol.costs).toEqual({
|
||||
tokens: 1_000_000,
|
||||
prompt: 200,
|
||||
completion: 1000,
|
||||
});
|
||||
expect(sol.pricingOverrides).toEqual([
|
||||
{
|
||||
minPromptTokens: 272000,
|
||||
costs: { prompt: 400, completion: 1500 },
|
||||
},
|
||||
]);
|
||||
expect(sol.pricingOverrides).toEqual([{
|
||||
minPromptTokens: 272000,
|
||||
costs: { prompt: 400, completion: 1500 },
|
||||
}]);
|
||||
});
|
||||
|
||||
it('caches the coerced model list after the first axios round-trip', async () => {
|
||||
@@ -371,12 +368,9 @@ describe('OpenRouterProvider model catalog', () => {
|
||||
|
||||
it('does not reinterpret catalog-added timestamps as release dates', async () => {
|
||||
const { provider } = makeProvider();
|
||||
expect(
|
||||
(await provider.models()).every(
|
||||
(model) => model.release_date === undefined,
|
||||
),
|
||||
).toBe(true);
|
||||
expect((await provider.models()).every(model => model.release_date === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ── Disallowed model gate ───────────────────────────────────────────
|
||||
@@ -874,14 +868,11 @@ describe('OpenRouterProvider billed cost through the driver', () => {
|
||||
event.factor = factor;
|
||||
};
|
||||
server.clients.event.on('ai.cost.factor.*', listener as never);
|
||||
return () =>
|
||||
server.clients.event.off('ai.cost.factor.*', listener as never);
|
||||
return () => server.clients.event.off('ai.cost.factor.*', listener as never);
|
||||
};
|
||||
|
||||
/** Μ¢ the ledger was handed for every usage recorded so far. */
|
||||
const ledgerTotal = (
|
||||
spy: MockInstance<MeteringService['batchIncrementUsages']>,
|
||||
) =>
|
||||
/** µ¢ the ledger was handed for every usage recorded so far. */
|
||||
const ledgerTotal = (spy: MockInstance<MeteringService['batchIncrementUsages']>) =>
|
||||
spy.mock.calls
|
||||
.flatMap(([, usages]) => usages)
|
||||
.reduce((total, usage) => total + (usage.costOverride ?? 0), 0);
|
||||
@@ -937,10 +928,7 @@ describe('OpenRouterProvider billed cost through the driver', () => {
|
||||
it('reports what a billed completion was charged, after the cost factor', async () => {
|
||||
const driver = await makeDriver();
|
||||
const stopFactor = withCostFactor(1.6);
|
||||
const recorded = vi.spyOn(
|
||||
server.services.metering,
|
||||
'batchIncrementUsages',
|
||||
);
|
||||
const recorded = vi.spyOn(server.services.metering, 'batchIncrementUsages');
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
@@ -1035,10 +1023,7 @@ describe('OpenRouterProvider billed cost through the driver', () => {
|
||||
it('ends a stream with what it was charged at the catalog rates, after the cost factor', async () => {
|
||||
const driver = await makeDriver();
|
||||
const stopFactor = withCostFactor(1.6);
|
||||
const recorded = vi.spyOn(
|
||||
server.services.metering,
|
||||
'batchIncrementUsages',
|
||||
);
|
||||
const recorded = vi.spyOn(server.services.metering, 'batchIncrementUsages');
|
||||
createMock.mockReturnValueOnce(
|
||||
asAsyncIterable([
|
||||
{ choices: [{ delta: { content: 'hello' } }] },
|
||||
@@ -1141,180 +1126,31 @@ describe('OpenRouterProvider.checkModeration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('OpenRouter catalog refresh validation', () => {
|
||||
it('keeps missing model fields and prices unknown and blocks unpriced inference', async () => {
|
||||
axiosRequestMock.mockResolvedValue({
|
||||
data: { data: [{ id: 'fixture/model', name: 'Fixture' }] },
|
||||
});
|
||||
axiosRequestMock.mockResolvedValue({ data: { data: [{ id: 'fixture/model', name: 'Fixture' }] } });
|
||||
const { provider } = makeProvider();
|
||||
const [model] = await provider.models();
|
||||
expect(model.costs).toEqual({ tokens: 1_000_000 });
|
||||
expect(model.context).toBeUndefined();
|
||||
expect(model.max_tokens).toBe(0);
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete({
|
||||
model: 'openrouter:fixture/model',
|
||||
messages: [],
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ legacyCode: 'model_pricing_unavailable' });
|
||||
await expect(withTestActor(() => provider.complete({ model: 'openrouter:fixture/model', messages: [] })))
|
||||
.rejects.toMatchObject({ legacyCode: 'model_pricing_unavailable' });
|
||||
expect(createMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['malformed', 'failed', 'ambiguous conditional price'])(
|
||||
'retains last-good data after a %s refresh',
|
||||
async (failure) => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { provider } = makeProvider();
|
||||
const good = await provider.models();
|
||||
vi.advanceTimersByTime(15 * 60 * 1000 + 1);
|
||||
if (failure === 'failed')
|
||||
axiosRequestMock.mockRejectedValueOnce(
|
||||
new Error('unavailable'),
|
||||
);
|
||||
else if (failure === 'malformed')
|
||||
axiosRequestMock.mockResolvedValueOnce({ data: {} });
|
||||
else
|
||||
axiosRequestMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
...SAMPLE_API_MODELS[0],
|
||||
pricing: {
|
||||
prompt: '0.000002',
|
||||
overrides: [
|
||||
{ min_prompt_tokens: 10, prompt: '' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(await provider.models()).toBe(good);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('OpenRouter service speed offerings', () => {
|
||||
it('loads and caches tier prices while reserving for endpoint and context differences', async () => {
|
||||
it.each(['malformed', 'failed', 'ambiguous conditional price'])('retains last-good data after a %s refresh', async failure => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const { provider } = makeProvider();
|
||||
const base = {
|
||||
...(await provider.models())[0],
|
||||
provider: 'openrouter',
|
||||
};
|
||||
axiosRequestMock.mockClear();
|
||||
axiosRequestMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
data: {
|
||||
endpoints: [
|
||||
{
|
||||
service_tier: 'priority',
|
||||
pricing: {
|
||||
prompt: '0.000004',
|
||||
completion: '0.00002',
|
||||
input_cache_read: '0.0000002',
|
||||
},
|
||||
},
|
||||
{
|
||||
service_tier: 'priority',
|
||||
pricing: {
|
||||
prompt: '0.000005',
|
||||
completion: '0.00001',
|
||||
},
|
||||
},
|
||||
{
|
||||
service_tier: 'ultrafast',
|
||||
pricing: {
|
||||
prompt: '0.000012',
|
||||
completion: '0.00006',
|
||||
overrides: [
|
||||
{
|
||||
min_prompt_tokens: 272000,
|
||||
prompt: '0.000024',
|
||||
completion: '0.00009',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
service_tier: 'priority',
|
||||
pricing: { prompt: 'invalid' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const offers = await provider.serviceSpeedModels(base);
|
||||
expect(offers.fast?.costs).toMatchObject({
|
||||
prompt: 500,
|
||||
completion: 2000,
|
||||
input_cache_read: 20,
|
||||
});
|
||||
expect(offers.ultrafast?.costs).toMatchObject({
|
||||
prompt: 2400,
|
||||
completion: 9000,
|
||||
});
|
||||
expect(await provider.serviceSpeedModels(base)).toBe(offers);
|
||||
expect(axiosRequestMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uses standard when the tier catalog cannot be fetched', async () => {
|
||||
const { provider } = makeProvider();
|
||||
const base = {
|
||||
...(await provider.models())[0],
|
||||
provider: 'openrouter',
|
||||
};
|
||||
axiosRequestMock.mockRejectedValueOnce(new Error('Unavailable'));
|
||||
expect(await provider.serviceSpeedModels(base)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenRouter speed billing', () => {
|
||||
it('requires an authoritative bill instead of charging the reservation ceiling', async () => {
|
||||
const { selectServiceSpeed } =
|
||||
await import('../../utils/serviceSpeed.js');
|
||||
const { provider } = makeProvider();
|
||||
const base = {
|
||||
...(await provider.models())[0],
|
||||
provider: 'openrouter',
|
||||
};
|
||||
const selected = selectServiceSpeed(
|
||||
base,
|
||||
{
|
||||
model: base.id,
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
serviceSpeed: 'fast',
|
||||
},
|
||||
() => undefined,
|
||||
{
|
||||
fast: {
|
||||
...base,
|
||||
costs: { ...base.costs, prompt: 500, completion: 2000 },
|
||||
},
|
||||
},
|
||||
);
|
||||
const completion = {
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'OK' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 1 },
|
||||
};
|
||||
createMock.mockResolvedValueOnce({
|
||||
...completion,
|
||||
service_tier: 'priority',
|
||||
usage: { ...completion.usage, cost: undefined },
|
||||
});
|
||||
await expect(
|
||||
withTestActor(() =>
|
||||
provider.complete(selected.args, selected.model),
|
||||
),
|
||||
).rejects.toMatchObject({ legacyCode: 'model_pricing_unavailable' });
|
||||
expect(recordSpy).not.toHaveBeenCalled();
|
||||
const good = await provider.models();
|
||||
vi.advanceTimersByTime(15 * 60 * 1000 + 1);
|
||||
if (failure === 'failed') axiosRequestMock.mockRejectedValueOnce(new Error('unavailable'));
|
||||
else if (failure === 'malformed') axiosRequestMock.mockResolvedValueOnce({ data: {} });
|
||||
else axiosRequestMock.mockResolvedValueOnce({ data: { data: [{ ...SAMPLE_API_MODELS[0], pricing: {
|
||||
prompt: '0.000002', overrides: [{ min_prompt_tokens: 10, prompt: '' }],
|
||||
} }] } });
|
||||
expect(await provider.models()).toBe(good);
|
||||
});
|
||||
});
|
||||
@@ -22,10 +22,8 @@ import { OpenAI } from 'openai';
|
||||
import { HttpError, isHttpError } from '../../../../core/http/HttpError.js';
|
||||
import { UpstreamResponseError } from '../../../util/upstreamErrors.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import type { IChatModel, ICompleteArguments, ModelCost } from '../../types.js';
|
||||
import { BoundedTtlMap } from '../../../../util/boundedTtlMap.js';
|
||||
import type { IChatModel, ICompleteArguments } from '../../types.js';
|
||||
import { cachedRemoteCatalog } from '../../utils/cachedRemoteCatalog.js';
|
||||
import { servedServiceSpeed } from '../../utils/serviceSpeed.js';
|
||||
import { catalogPrice } from '../../utils/pricing.js';
|
||||
import { mapOpenRouterPricing } from './pricing.js';
|
||||
import { sdkClientOptions } from '../../utils/sdkClient.js';
|
||||
@@ -66,75 +64,6 @@ const isPolicyBlock = (err: unknown): boolean => {
|
||||
export class OpenRouterProvider extends OpenAICompatProvider {
|
||||
#apiBaseUrl: string;
|
||||
|
||||
#speedCatalogs = new BoundedTtlMap<
|
||||
string,
|
||||
() => Promise<Partial<Record<'fast' | 'ultrafast', IChatModel>>>
|
||||
>({ maxEntries: 256 });
|
||||
|
||||
async serviceSpeedModels(model: IChatModel) {
|
||||
let load = this.#speedCatalogs.get(model.id);
|
||||
if (!load) {
|
||||
load = cachedRemoteCatalog({
|
||||
name: 'OpenRouter speed catalog',
|
||||
fallback: {} as Partial<
|
||||
Record<'fast' | 'ultrafast', IChatModel>
|
||||
>,
|
||||
fetch: async (signal) => {
|
||||
const id = model.id.replace(/^openrouter:/, '');
|
||||
const response = await axios.request({
|
||||
url: `${this.#apiBaseUrl}/models/${id}/endpoints`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
maxContentLength: 2 * 1024 * 1024,
|
||||
});
|
||||
const offers: Partial<
|
||||
Record<'fast' | 'ultrafast', IChatModel>
|
||||
> = {};
|
||||
for (const endpoint of response.data?.data?.endpoints ??
|
||||
[]) {
|
||||
const speed =
|
||||
endpoint.service_tier === 'ultrafast'
|
||||
? 'ultrafast'
|
||||
: ['priority', 'fast'].includes(
|
||||
endpoint.service_tier,
|
||||
)
|
||||
? 'fast'
|
||||
: undefined;
|
||||
if (!speed || !endpoint.pricing) continue;
|
||||
const pricing = mapOpenRouterPricing(endpoint.pricing);
|
||||
if (
|
||||
pricing.costs.prompt === undefined ||
|
||||
pricing.costs.completion === undefined
|
||||
)
|
||||
continue;
|
||||
// Reserve for the most expensive eligible endpoint and context tier.
|
||||
const costs: ModelCost = {
|
||||
...offers[speed]?.costs,
|
||||
tokens: model.costs.tokens,
|
||||
};
|
||||
for (const rates of [
|
||||
pricing.costs,
|
||||
...pricing.overrides.map(
|
||||
(override) => override.costs,
|
||||
),
|
||||
]) {
|
||||
for (const [key, value] of Object.entries(rates))
|
||||
costs[key] = Math.max(costs[key] ?? 0, value);
|
||||
}
|
||||
offers[speed] = {
|
||||
...model,
|
||||
costs,
|
||||
pricingOverrides: [],
|
||||
};
|
||||
}
|
||||
return offers;
|
||||
},
|
||||
});
|
||||
this.#speedCatalogs.set(model.id, load);
|
||||
}
|
||||
return load();
|
||||
}
|
||||
|
||||
constructor(config: ChatProviderConfig, meteringService: MeteringService) {
|
||||
const apiBaseUrl = config.apiBaseUrl || 'https://openrouter.ai/api/v1';
|
||||
super(meteringService, {
|
||||
@@ -273,17 +202,8 @@ export class OpenRouterProvider extends OpenAICompatProvider {
|
||||
return { ...params, usage: { include: true } };
|
||||
}
|
||||
|
||||
protected override meteredUsage(source: UsageSource, model: IChatModel) {
|
||||
protected override meteredUsage(source: UsageSource) {
|
||||
const usage = source.usage as OpenrouterUsage;
|
||||
if (
|
||||
model.serviceSpeedPricing &&
|
||||
servedServiceSpeed(source) !== 'standard' &&
|
||||
catalogPrice(usage.cost) === undefined
|
||||
) {
|
||||
throw new HttpError(503, 'Service tier billing is unavailable', {
|
||||
legacyCode: 'model_pricing_unavailable',
|
||||
});
|
||||
}
|
||||
return {
|
||||
usage: {
|
||||
prompt:
|
||||
|
||||
@@ -20,12 +20,13 @@
|
||||
/**
|
||||
* Offline unit tests for XAIProvider.
|
||||
*
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
|
||||
* constructs XAIProvider directly against the live wired `MeteringService` so
|
||||
* the recording side is exercised end-to-end. xAI is OpenAI-compatible so the
|
||||
* OpenAI SDK is mocked at the module boundary; that's the real network egress
|
||||
* point. The companion integration test (XAIProvider.integration.test.ts)
|
||||
* exercises the real xAI endpoint.
|
||||
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
|
||||
* redis) and constructs XAIProvider directly against the live wired
|
||||
* `MeteringService` so the recording side is exercised end-to-end.
|
||||
* xAI is OpenAI-compatible so the OpenAI SDK is mocked at the module
|
||||
* boundary; that's the real network egress point. The companion
|
||||
* integration test (XAIProvider.integration.test.ts) exercises the
|
||||
* real xAI endpoint.
|
||||
*/
|
||||
|
||||
import { Writable } from 'node:stream';
|
||||
@@ -932,7 +933,8 @@ describe('XAIProvider.complete non-stream output', () => {
|
||||
// namespace, actor, and a costsOverride priced from
|
||||
// models.ts (grok-4.6: prompt=200, completion=600, cached=50).
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
const [usage, actor, prefix, overrides] =
|
||||
recordSpy.mock.calls[0]!;
|
||||
expect(usage).toEqual({
|
||||
prompt_tokens: 90,
|
||||
completion_tokens: 50,
|
||||
@@ -1020,7 +1022,8 @@ describe('XAIProvider.complete non-stream output', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
|
||||
const [usage, , , overrides] =
|
||||
recordSpy.mock.calls[0]!;
|
||||
expect(usage.cached_tokens).toBe(0);
|
||||
// 0 cached tokens × any rate = 0 metered cost.
|
||||
expect(overrides).toMatchObject({ cached_tokens: 0 });
|
||||
@@ -1078,7 +1081,8 @@ describe('XAIProvider.complete streaming', () => {
|
||||
|
||||
// grok-build-0.1 costs: prompt=100, completion=200, cached=20.
|
||||
expect(recordSpy).toHaveBeenCalledTimes(1);
|
||||
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
|
||||
const [, , prefix, overrides] =
|
||||
recordSpy.mock.calls[0]!;
|
||||
expect(prefix).toBe('xai:grok-build-0.1');
|
||||
expect(overrides).toEqual({
|
||||
prompt_tokens: 3 * 100,
|
||||
@@ -1196,60 +1200,3 @@ describe('XAIProvider.checkModeration', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('xAI service speed billing', () => {
|
||||
it('records the reported Priority charge with cached input and reasoning', async () => {
|
||||
const { selectServiceSpeed } =
|
||||
await import('../../utils/serviceSpeed.js');
|
||||
const { provider } = makeProvider();
|
||||
const base = {
|
||||
...(await provider.models()).find(
|
||||
(entry) => entry.id === 'grok-4.7',
|
||||
)!,
|
||||
provider: 'xai',
|
||||
};
|
||||
const selected = selectServiceSpeed(
|
||||
base,
|
||||
{
|
||||
model: base.id,
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
serviceSpeed: 'ultrafast',
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
createMock.mockResolvedValueOnce({
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'OK' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
service_tier: 'priority',
|
||||
usage: {
|
||||
prompt_tokens: 1247,
|
||||
completion_tokens: 1,
|
||||
total_tokens: 1282,
|
||||
prompt_tokens_details: { cached_tokens: 1152 },
|
||||
completion_tokens_details: { reasoning_tokens: 34 },
|
||||
cost_in_usd_ticks: 19520000,
|
||||
},
|
||||
});
|
||||
const result = await withTestActor(() =>
|
||||
provider.complete(selected.args, selected.model),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
usageDetails: {
|
||||
serviceSpeed: 'fast',
|
||||
outputTokens: 35,
|
||||
reasoningTokens: 34,
|
||||
},
|
||||
});
|
||||
expect(recordSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ billedUsage: 1, completion_tokens: 35 }),
|
||||
expect.anything(),
|
||||
'xai:grok-4.7',
|
||||
expect.objectContaining({ billedUsage: 195200 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -19,8 +19,6 @@
|
||||
|
||||
import { OpenAI } from 'openai';
|
||||
import { ChatCompletionCreateParams } from 'openai/resources/index.js';
|
||||
import { catalogPrice } from '../../utils/pricing.js';
|
||||
import { servedServiceSpeed } from '../../utils/serviceSpeed.js';
|
||||
import { Context } from '../../../../core/context.js';
|
||||
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
|
||||
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
|
||||
@@ -76,15 +74,7 @@ export class XAIProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
async complete(
|
||||
{
|
||||
messages,
|
||||
stream,
|
||||
model,
|
||||
tools,
|
||||
max_tokens,
|
||||
service_tier,
|
||||
serviceSpeed: requestedServiceSpeed,
|
||||
}: ICompleteArguments,
|
||||
{ messages, stream, model, tools, max_tokens }: ICompleteArguments,
|
||||
resolved?: IChatModel,
|
||||
): Promise<IChatCompleteResult> {
|
||||
const actor = Context.get('actor');
|
||||
@@ -98,7 +88,6 @@ export class XAIProvider implements IChatProvider {
|
||||
{
|
||||
messages,
|
||||
model: modelUsed.id,
|
||||
...(service_tier !== undefined ? { service_tier } : {}),
|
||||
...(tools ? { tools } : {}),
|
||||
...(max_tokens !== undefined ? { max_tokens } : {}),
|
||||
stream,
|
||||
@@ -118,49 +107,18 @@ export class XAIProvider implements IChatProvider {
|
||||
}
|
||||
|
||||
return OpenAIUtil.handle_completion_output({
|
||||
usage_calculator: ({
|
||||
usage,
|
||||
service_tier: servedTier,
|
||||
setUsageCosts,
|
||||
setUsageDetails,
|
||||
}) => {
|
||||
const serviceSpeed = servedServiceSpeed({
|
||||
service_tier: servedTier,
|
||||
});
|
||||
usage_calculator: ({ usage, setUsageCosts }) => {
|
||||
// xAI reports cached reads inside `prompt_tokens`; bill them
|
||||
// only at the cached rate.
|
||||
const metered = OpenAIUtil.extractMeteredUsage(usage);
|
||||
const reasoningTokens =
|
||||
usage.completion_tokens_details?.reasoning_tokens ?? 0;
|
||||
const trackedUsage = OpenAIUtil.splitCachedPrompt(usage);
|
||||
trackedUsage.completion_tokens += reasoningTokens;
|
||||
const reportedTicks = catalogPrice(
|
||||
(usage as typeof usage & { cost_in_usd_ticks?: number })
|
||||
.cost_in_usd_ticks,
|
||||
);
|
||||
const { costs } = meterChatUsage(
|
||||
this.#meteringService,
|
||||
actor,
|
||||
this.meteringModelKey(modelUsed.id),
|
||||
modelUsed,
|
||||
trackedUsage,
|
||||
{
|
||||
serviceSpeed,
|
||||
authoritativeUsd:
|
||||
reportedTicks === undefined
|
||||
? undefined
|
||||
: reportedTicks / 10_000_000_000,
|
||||
},
|
||||
OpenAIUtil.splitCachedPrompt(usage),
|
||||
);
|
||||
setUsageCosts(costs);
|
||||
if (requestedServiceSpeed !== undefined)
|
||||
setUsageDetails?.({
|
||||
...OpenAIUtil.usageDetailsFromTrackedUsage(
|
||||
trackedUsage,
|
||||
)!,
|
||||
serviceSpeed,
|
||||
...(reasoningTokens ? { reasoningTokens } : {}),
|
||||
});
|
||||
return metered;
|
||||
},
|
||||
stream,
|
||||
|
||||
@@ -42,7 +42,6 @@ export const XAI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 2 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
@@ -66,7 +65,6 @@ export const XAI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 2 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
@@ -89,7 +87,6 @@ export const XAI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 2 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 200,
|
||||
@@ -112,7 +109,6 @@ export const XAI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 2 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 125,
|
||||
@@ -155,7 +151,6 @@ export const XAI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 2 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 125,
|
||||
@@ -191,7 +186,6 @@ export const XAI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 2 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 125,
|
||||
@@ -223,7 +217,6 @@ export const XAI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 2 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 125,
|
||||
@@ -253,7 +246,6 @@ export const XAI_MODELS: IChatModel[] = [
|
||||
costs_currency: 'usd-cents',
|
||||
input_cost_key: 'prompt_tokens',
|
||||
output_cost_key: 'completion_tokens',
|
||||
serviceSpeedRates: { fast: 2 },
|
||||
costs: {
|
||||
tokens: 1_000_000,
|
||||
prompt_tokens: 100,
|
||||
|
||||
@@ -26,8 +26,6 @@
|
||||
|
||||
export type ModelCost = Record<string, number>;
|
||||
|
||||
export type ServiceSpeed = 'standard' | 'fast' | 'ultrafast';
|
||||
|
||||
export interface ModelModalities {
|
||||
input: string[];
|
||||
output: string[];
|
||||
@@ -69,22 +67,6 @@ export interface IChatModel<T extends ModelCost = ModelCost> extends Record<
|
||||
output_multiplier: number;
|
||||
};
|
||||
pricingOverrides?: ModelPricingOverride[];
|
||||
/**
|
||||
* A token-rate multiplier or explicit per-key rates merged with standard
|
||||
* costs.
|
||||
*/
|
||||
serviceSpeedRates?: Partial<
|
||||
Record<'fast' | 'ultrafast', number | ModelCost>
|
||||
>;
|
||||
serviceSpeedVariants?: { fast?: string };
|
||||
/** Request-local pricing snapshot; never part of the model catalog. */
|
||||
serviceSpeedPricing?: {
|
||||
speed: ServiceSpeed;
|
||||
standardCosts: ModelCost;
|
||||
standardOverrides?: ModelPricingOverride[];
|
||||
fastCosts?: ModelCost;
|
||||
ultrafastCosts?: ModelCost;
|
||||
};
|
||||
context?: number;
|
||||
max_tokens: number;
|
||||
subscriberOnly?: boolean;
|
||||
@@ -94,8 +76,6 @@ export interface IChatModel<T extends ModelCost = ModelCost> extends Record<
|
||||
tool_call?: boolean;
|
||||
responses_api?: boolean;
|
||||
responses_api_only?: boolean;
|
||||
/** Restricts service speeds for deployments with a limited API dialect. */
|
||||
serviceSpeeds?: ServiceSpeed[];
|
||||
/**
|
||||
* Omitted preserves sampling; reasoningDisabled requires explicit effort
|
||||
* none.
|
||||
@@ -183,7 +163,6 @@ export interface UsageDetails {
|
||||
webFetchRequests?: number;
|
||||
speed?: 'fast' | 'standard';
|
||||
serviceTier?: string;
|
||||
serviceSpeed?: ServiceSpeed;
|
||||
/**
|
||||
* Per-pass breakdown when the upstream ran several passes (compaction,
|
||||
* advisor).
|
||||
@@ -219,7 +198,6 @@ export interface ICompleteArguments {
|
||||
* request on another provider that serves the same model.
|
||||
*/
|
||||
fallback?: boolean;
|
||||
serviceSpeed?: ServiceSpeed;
|
||||
stream?: boolean;
|
||||
model: string;
|
||||
test_mode?: boolean;
|
||||
@@ -239,7 +217,10 @@ export interface ICompleteArguments {
|
||||
taskBudget?: { total: number; remaining?: number };
|
||||
/** Claude only: top-level automatic `cache_control`. */
|
||||
cacheControl?: CacheControl;
|
||||
/** Claude only; fast is forwarded when the catalog declares fast rates. */
|
||||
/**
|
||||
* Claude only; forwarded only when the model's catalog entry has
|
||||
* `fast_output_tokens`.
|
||||
*/
|
||||
speed?: 'fast' | 'standard';
|
||||
/** Claude only. */
|
||||
safeguards?: SafeguardRequest[];
|
||||
@@ -279,15 +260,7 @@ export interface ICompleteArguments {
|
||||
truncation?: 'auto' | 'disabled' | undefined;
|
||||
background?: boolean;
|
||||
service_tier?:
|
||||
| 'auto'
|
||||
| 'default'
|
||||
| 'flex'
|
||||
| 'scale'
|
||||
| 'priority'
|
||||
| 'fast'
|
||||
| 'ultrafast'
|
||||
| 'standard'
|
||||
| undefined;
|
||||
'auto' | 'default' | 'flex' | 'scale' | 'priority' | undefined;
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
reasoning?: { effort: ReasoningEffort } | undefined;
|
||||
@@ -355,10 +328,6 @@ export interface IChatMessageResult {
|
||||
export type IChatCompleteResult = IChatStreamResult | IChatMessageResult;
|
||||
|
||||
export interface IChatProvider {
|
||||
/** Account/catalog-specific speed offerings, fetched before the credit hold. */
|
||||
serviceSpeedModels?(
|
||||
model: IChatModel,
|
||||
): Promise<Partial<Record<'fast' | 'ultrafast', IChatModel>>>;
|
||||
models(extra_params?: unknown): IChatModel[] | Promise<IChatModel[]>;
|
||||
getDefaultModel(): string;
|
||||
/**
|
||||
|
||||
@@ -650,15 +650,12 @@ export const create_chat_stream_handler =
|
||||
block.end();
|
||||
};
|
||||
|
||||
let serviceTier;
|
||||
let last_usage = null;
|
||||
let last_extra_content = null;
|
||||
let finish_reason = null;
|
||||
for await (let chunk of completion) {
|
||||
await chatStream.waitForDrain();
|
||||
chunk = deviations.chunk_but_like_actually(chunk);
|
||||
if (chunk.service_tier !== undefined)
|
||||
serviceTier = chunk.service_tier;
|
||||
const chunk_usage = deviations.index_usage_from_stream_chunk(chunk);
|
||||
if (chunk_usage) last_usage = chunk_usage;
|
||||
if (chunk.choices.length < 1) continue;
|
||||
@@ -749,7 +746,6 @@ export const create_chat_stream_handler =
|
||||
const usage = last_usage
|
||||
? usage_calculator({
|
||||
usage: last_usage,
|
||||
service_tier: serviceTier,
|
||||
extra_content: last_extra_content,
|
||||
setUsageCosts: (c) => chatStream.setUsageCosts(c),
|
||||
setUsageDetails: (d) => {
|
||||
@@ -783,7 +779,6 @@ export const create_chat_stream_handler_responses_api =
|
||||
const message = chatStream.message();
|
||||
const textblock = message.contentBlock({ type: 'text' });
|
||||
|
||||
let serviceTier;
|
||||
let last_usage = null;
|
||||
let completed = false;
|
||||
let sawFunctionCall = false;
|
||||
@@ -792,8 +787,6 @@ export const create_chat_stream_handler_responses_api =
|
||||
let toolUsage = null;
|
||||
for await (const chunk of completion) {
|
||||
await chatStream.waitForDrain();
|
||||
if (chunk.response?.service_tier !== undefined)
|
||||
serviceTier = chunk.response.service_tier;
|
||||
if (chunk.type === 'response.failed' || chunk.type === 'error') {
|
||||
throw new Error(
|
||||
chunk.response?.error?.message ??
|
||||
@@ -925,7 +918,6 @@ export const create_chat_stream_handler_responses_api =
|
||||
const usage = last_usage
|
||||
? usage_calculator({
|
||||
usage: last_usage,
|
||||
service_tier: serviceTier,
|
||||
webSearchCalls,
|
||||
tool_usage: toolUsage,
|
||||
setUsageCosts: (c) => chatStream.setUsageCosts(c),
|
||||
@@ -980,7 +972,6 @@ export const handle_completion_output = async (
|
||||
/**
|
||||
* @type {Record<string, unknown> & {
|
||||
* usage_calculator: (args: {
|
||||
* service_tier?: string;
|
||||
* usage: import('openai/resources/completions.mjs').CompletionUsage;
|
||||
* setUsageCosts: (costs: Record<string, number>) => void;
|
||||
* setUsageDetails: (
|
||||
@@ -1075,7 +1066,6 @@ export const handle_completion_output = async (
|
||||
* @param {((text: string) => Promise<{ flagged: boolean }>) | undefined} [params.moderate]
|
||||
* @param {(args: {
|
||||
* usage: import('openai/resources/responses/responses.mjs').ResponseUsage;
|
||||
* service_tier?: string;
|
||||
* webSearchCalls?: number;
|
||||
* tool_usage?: Record<string, unknown>;
|
||||
* setUsageCosts?: (costs: Record<string, number>) => void;
|
||||
@@ -1122,6 +1112,16 @@ export const handle_completion_output_responses_api = async ({
|
||||
// Inline-compaction artifact, if the upstream compacted this turn.
|
||||
const compactionItem = output.find((item) => item?.type === 'compaction');
|
||||
|
||||
const is_empty = completion.output_text.trim() === '';
|
||||
if (is_empty && responseToolCalls.length < 1 && !compactionItem) {
|
||||
// GPT refuses to generate an empty response if you ask it to,
|
||||
// so this will probably only happen on an error condition.
|
||||
// A compaction-only output is legitimate, so don't reject it.
|
||||
throw new HttpError(400, 'an empty response was generated', {
|
||||
legacyCode: 'bad_response',
|
||||
});
|
||||
}
|
||||
|
||||
// Reasoning models return `reasoning` output items; their human-readable
|
||||
// text only exists when the caller requested summaries via
|
||||
// `reasoning: { summary: ... }` (raw chain-of-thought is never returned).
|
||||
@@ -1224,14 +1224,6 @@ export const handle_completion_output_responses_api = async ({
|
||||
if (usageDetails) ret.usageDetails = usageDetails;
|
||||
}
|
||||
|
||||
// Empty output can still include paid reasoning and input tokens.
|
||||
const isEmpty = completion.output_text.trim() === '';
|
||||
if (isEmpty && responseToolCalls.length < 1 && !compactionItem) {
|
||||
throw new HttpError(400, 'an empty response was generated', {
|
||||
legacyCode: 'bad_response',
|
||||
});
|
||||
}
|
||||
|
||||
const mod_text = completion.output_text;
|
||||
if (moderate && mod_text !== null) {
|
||||
await applyModeration(moderate, mod_text);
|
||||
|
||||
@@ -19,15 +19,13 @@
|
||||
|
||||
import type { Actor } from '../../../core/actor.js';
|
||||
import type { MeteringService } from '../../../services/metering/MeteringService.js';
|
||||
import type { IChatModel, ServiceSpeed } from '../types.js';
|
||||
import { serviceSpeedBillingModel } from './serviceSpeed.js';
|
||||
import type { IChatModel } from '../types.js';
|
||||
import { buildCostsOverride } from './pricing.js';
|
||||
|
||||
/** Microcents per US dollar. */
|
||||
const MICROCENTS_PER_USD = 100_000_000;
|
||||
|
||||
export interface MeterChatUsageOptions {
|
||||
serviceSpeed?: ServiceSpeed;
|
||||
/**
|
||||
* What the upstream says it billed, in USD. Recorded as one `billedUsage`
|
||||
* line instead of pricing the tokens, which are still recorded at zero.
|
||||
@@ -79,15 +77,10 @@ export const meterChatUsage = (
|
||||
authoritativeUsd,
|
||||
costOverrides,
|
||||
pricingTime,
|
||||
serviceSpeed = 'standard',
|
||||
}: MeterChatUsageOptions = {},
|
||||
): { usage: Record<string, number>; costs: Record<string, number> } => {
|
||||
const priced = {
|
||||
...buildCostsOverride(
|
||||
usage,
|
||||
serviceSpeedBillingModel(model, serviceSpeed),
|
||||
pricingTime,
|
||||
),
|
||||
...buildCostsOverride(usage, model, pricingTime),
|
||||
...costOverrides,
|
||||
};
|
||||
if (authoritativeUsd !== undefined) {
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Puter is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Puter. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { IChatModel, ServiceSpeed } from '../types.js';
|
||||
import { ALIBABA_MODELS } from '../providers/alibaba/models.js';
|
||||
import { AZURE_MODELS } from '../providers/azure/models.js';
|
||||
import { BYTEPLUS_MODELS } from '../providers/byteplus/models.js';
|
||||
import { CLAUDE_MODELS } from '../providers/claude/models.js';
|
||||
import { DEEPSEEK_MODELS } from '../providers/deepseek/models.js';
|
||||
import { GEMINI_MODELS } from '../providers/gemini/models.js';
|
||||
import { GROQ_MODELS } from '../providers/groq/models.js';
|
||||
import { HOONIFY_MODELS } from '../providers/hoonify/models.js';
|
||||
import { META_MODELS } from '../providers/meta/models.js';
|
||||
import { MINIMAX_MODELS } from '../providers/minimax/models.js';
|
||||
import { MISTRAL_MODELS } from '../providers/mistral/models.js';
|
||||
import { MOONSHOT_MODELS } from '../providers/moonshot/models.js';
|
||||
import { OPEN_AI_MODELS } from '../providers/openai/models.js';
|
||||
import { XAI_MODELS } from '../providers/xai/models.js';
|
||||
import { ZAI_MODELS } from '../providers/zai/models.js';
|
||||
import { shouldPresentAsOpenAI } from './normalizeToOpenAI.js';
|
||||
import {
|
||||
isServiceSpeedUnavailable,
|
||||
lowerServiceSpeed,
|
||||
selectServiceSpeed,
|
||||
servedServiceSpeed,
|
||||
serviceSpeedBillingModel,
|
||||
} from './serviceSpeed.js';
|
||||
|
||||
const catalogs: Array<[string, IChatModel[]]> = [
|
||||
['alibaba', ALIBABA_MODELS],
|
||||
['azure-openai', AZURE_MODELS],
|
||||
['byteplus', BYTEPLUS_MODELS],
|
||||
['claude', CLAUDE_MODELS],
|
||||
['deepseek', DEEPSEEK_MODELS],
|
||||
['gemini', GEMINI_MODELS],
|
||||
['groq', GROQ_MODELS],
|
||||
['hoonify', HOONIFY_MODELS],
|
||||
['meta', META_MODELS],
|
||||
['minimax', MINIMAX_MODELS],
|
||||
['mistral', MISTRAL_MODELS],
|
||||
['moonshotai', MOONSHOT_MODELS],
|
||||
['openai-completion', OPEN_AI_MODELS],
|
||||
['openai-responses', OPEN_AI_MODELS],
|
||||
['xai', XAI_MODELS],
|
||||
['zai', ZAI_MODELS],
|
||||
];
|
||||
const speeds: ServiceSpeed[] = ['standard', 'fast', 'ultrafast'];
|
||||
|
||||
describe('service speed seeded fuzzing', () => {
|
||||
it('preserves catalog, request, normalization, and billing invariants across model changes', () => {
|
||||
let seed = 0x51eed;
|
||||
const pick = <T>(values: T[]): T => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||
return values[seed % values.length]!;
|
||||
};
|
||||
const seenProviders = new Set<string>();
|
||||
for (let index = 0; index < 10_000; index++) {
|
||||
const [provider, catalog] = pick(catalogs);
|
||||
seenProviders.add(provider);
|
||||
const base = { ...structuredClone(pick(catalog)), provider };
|
||||
const before = structuredClone(base);
|
||||
Object.freeze(base.costs);
|
||||
Object.freeze(base);
|
||||
const args = Object.freeze({
|
||||
model: base.id,
|
||||
messages: [{ role: 'user', content: `Case ${index}` }],
|
||||
serviceSpeed: pick([...speeds, undefined]),
|
||||
normalize: pick([true, false, undefined]),
|
||||
temperature: pick([0, 0.5, 1]),
|
||||
service_tier: pick(['flex' as const, 'ultrafast' as const, undefined]),
|
||||
speed: pick(['standard' as const, 'fast' as const, undefined]),
|
||||
});
|
||||
const selected = selectServiceSpeed(base, args, (id) => {
|
||||
const variant = catalog.find((entry) => entry.id === id);
|
||||
return variant && { ...variant, provider };
|
||||
});
|
||||
expect(base).toEqual(before);
|
||||
expect(selected.args.messages).toBe(args.messages);
|
||||
expect(selected.args.temperature).toBe(args.temperature);
|
||||
expect(selected.model.provider).toBe(provider);
|
||||
expect(selected.model.release_date).toBe(base.release_date);
|
||||
expect(
|
||||
shouldPresentAsOpenAI(selected.args, selected.model.release_date),
|
||||
).toBe(shouldPresentAsOpenAI(args, base.release_date));
|
||||
if (args.serviceSpeed === undefined) {
|
||||
expect(selected.args).toBe(args);
|
||||
expect(selected.model).toBe(base);
|
||||
continue;
|
||||
}
|
||||
expect(speeds.indexOf(selected.speed)).toBeLessThanOrEqual(
|
||||
speeds.indexOf(args.serviceSpeed),
|
||||
);
|
||||
expect(selected.args.serviceSpeed).toBe(selected.speed);
|
||||
if (args.serviceSpeed === 'standard') {
|
||||
expect(selected.args.speed).not.toBe('fast');
|
||||
expect(selected.args.service_tier).not.toBe('ultrafast');
|
||||
}
|
||||
for (const cost of Object.values(selected.model.costs)) {
|
||||
expect(Number.isFinite(cost)).toBe(true);
|
||||
expect(cost).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
if (selected.model.serviceSpeedPricing) {
|
||||
const settled = serviceSpeedBillingModel(selected.model, 'standard');
|
||||
expect(settled.costs).toEqual(base.costs);
|
||||
expect(settled.pricingOverrides).toEqual(base.pricingOverrides);
|
||||
}
|
||||
let speed = selected.speed;
|
||||
for (let attempts = 0; speed !== 'standard'; attempts++) {
|
||||
expect(attempts).toBeLessThan(2);
|
||||
speed = lowerServiceSpeed(speed);
|
||||
}
|
||||
}
|
||||
expect([...seenProviders].sort()).toEqual(catalogs.map(([provider]) => provider).sort());
|
||||
});
|
||||
|
||||
it('handles malformed upstream tier and admission-error metadata without coercion or retries', () => {
|
||||
const values = [null, false, 0, 429, '', 'unrelated', [], {},
|
||||
['service_tier capacity unavailable'],
|
||||
{ toString: null }, { toString: 'priority capacity unavailable' }];
|
||||
for (const value of values) {
|
||||
expect(isServiceSpeedUnavailable(value)).toBe(false);
|
||||
for (const status of [undefined, 400, 401, 403, 404, 429, 500, 503]) {
|
||||
for (const variant of [false, true]) {
|
||||
expect(isServiceSpeedUnavailable({ status, message: value }, variant)).toBe(false);
|
||||
expect(isServiceSpeedUnavailable({ status, error: { message: value } }, variant)).toBe(false);
|
||||
}
|
||||
}
|
||||
expect(servedServiceSpeed({ usage: value })).toBe('standard');
|
||||
expect(servedServiceSpeed({ service_tier: value })).toBe('standard');
|
||||
expect(servedServiceSpeed({ usage: { speed: value, service_tier: value } })).toBe('standard');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
makeMeteringStub,
|
||||
optionalEnv,
|
||||
withTestActor,
|
||||
} from '../../integrationTestUtil.js';
|
||||
import type {
|
||||
IChatMessageResult,
|
||||
IChatProvider,
|
||||
ICompleteArguments,
|
||||
} from '../types.js';
|
||||
import { OpenAiResponsesChatProvider } from '../providers/openai/OpenAiChatResponsesProvider.js';
|
||||
import { GeminiChatProvider } from '../providers/gemini/GeminiChatProvider.js';
|
||||
import { MiniMaxProvider } from '../providers/minimax/MiniMaxProvider.js';
|
||||
import { MoonshotProvider } from '../providers/moonshot/MoonshotProvider.js';
|
||||
import { MistralAIProvider } from '../providers/mistral/MistralAiProvider.js';
|
||||
import { selectServiceSpeed } from './serviceSpeed.js';
|
||||
|
||||
type Case = {
|
||||
name: string;
|
||||
env: string;
|
||||
provider: string;
|
||||
model: string;
|
||||
build: (key: string) => IChatProvider;
|
||||
};
|
||||
const cases: Case[] = [
|
||||
{
|
||||
name: 'OpenAI Ultrafast',
|
||||
env: 'PUTER_TEST_AI_OPENAI_API_KEY',
|
||||
provider: 'openai-responses',
|
||||
model: 'gpt-6.1-sol',
|
||||
build: (key) =>
|
||||
new OpenAiResponsesChatProvider(
|
||||
{ apiKey: key },
|
||||
makeMeteringStub(),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'Gemini Priority',
|
||||
env: 'PUTER_TEST_AI_GEMINI_API_KEY',
|
||||
provider: 'gemini',
|
||||
model: 'gemini-3.8-flash',
|
||||
build: (key) =>
|
||||
new GeminiChatProvider({ apiKey: key }, makeMeteringStub()),
|
||||
},
|
||||
{
|
||||
name: 'MiniMax Priority',
|
||||
env: 'PUTER_TEST_AI_MINIMAX_API_KEY',
|
||||
provider: 'minimax',
|
||||
model: 'minimax-m3',
|
||||
build: (key) =>
|
||||
new MiniMaxProvider({ apiKey: key }, makeMeteringStub()),
|
||||
},
|
||||
{
|
||||
name: 'Moonshot Highspeed',
|
||||
env: 'PUTER_TEST_AI_MOONSHOT_API_KEY',
|
||||
provider: 'moonshotai',
|
||||
model: 'kimi-k3.1-pro',
|
||||
build: (key) =>
|
||||
new MoonshotProvider({ apiKey: key }, makeMeteringStub()),
|
||||
},
|
||||
{
|
||||
name: 'Mistral served tier',
|
||||
env: 'PUTER_TEST_AI_MISTRAL_API_KEY',
|
||||
provider: 'mistral',
|
||||
model: 'mistral-medium-latest',
|
||||
build: (key) =>
|
||||
new MistralAIProvider({ apiKey: key }, makeMeteringStub()),
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
describe.skipIf(!optionalEnv(testCase.env))(
|
||||
`serviceSpeed ${testCase.name} (integration)`,
|
||||
() => {
|
||||
it(
|
||||
'completes with a selected speed and normalized text',
|
||||
{ timeout: 90_000 },
|
||||
async () => {
|
||||
const provider = testCase.build(optionalEnv(testCase.env)!);
|
||||
const catalog = (await provider.models()).map((model) => ({
|
||||
...model,
|
||||
provider: testCase.provider,
|
||||
}));
|
||||
const base = catalog.find(
|
||||
(model) =>
|
||||
model.id === testCase.model ||
|
||||
model.aliases?.includes(testCase.model),
|
||||
)!;
|
||||
expect(base).toBeDefined();
|
||||
const args: ICompleteArguments = {
|
||||
model: base.id,
|
||||
messages: [
|
||||
{ role: 'user', content: 'Reply with exactly OK.' },
|
||||
],
|
||||
serviceSpeed: 'ultrafast',
|
||||
max_tokens: 256,
|
||||
reasoning_effort: 'low',
|
||||
normalize: true,
|
||||
};
|
||||
const selection = selectServiceSpeed(base, args, (id) =>
|
||||
catalog.find((model) => model.id === id),
|
||||
);
|
||||
expect(selection.speed).toBe(
|
||||
testCase.provider === 'openai-responses'
|
||||
? 'ultrafast'
|
||||
: 'fast',
|
||||
);
|
||||
const result = (await withTestActor(() =>
|
||||
provider.complete(
|
||||
{ ...selection.args, model: selection.model.id },
|
||||
selection.model,
|
||||
),
|
||||
)) as IChatMessageResult;
|
||||
expect(typeof result.message.content).toBe('string');
|
||||
expect(result.message.content.length).toBeGreaterThan(0);
|
||||
if (
|
||||
['openai-responses', 'gemini', 'minimax'].includes(
|
||||
testCase.provider,
|
||||
)
|
||||
)
|
||||
expect(result.usageDetails?.serviceSpeed).toBe(
|
||||
selection.speed,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Writable } from 'node:stream';
|
||||
import { AIChatStream } from './Streaming.js';
|
||||
import {
|
||||
handle_completion_output,
|
||||
handle_completion_output_responses_api,
|
||||
} from './OpenAIUtil.js';
|
||||
import type { IChatModel, ServiceSpeed } from '../types.js';
|
||||
import {
|
||||
requestedServiceSpeed,
|
||||
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([
|
||||
[{ service_tier: 'priority' }, 'openai-responses', 'fast'],
|
||||
[{ service_tier: 'ultrafast' }, 'openai-responses', 'ultrafast'],
|
||||
[{ service_tier: 'auto' }, 'mistral', 'fast'],
|
||||
[{ speed: 'fast' }, 'claude', 'fast'],
|
||||
[{ service_tier: 'auto' }, 'openai-responses', undefined],
|
||||
[{ service_tier: 'flex' }, 'openai-responses', undefined],
|
||||
[{ speed: 'fast' }, 'openai-responses', undefined],
|
||||
[{ serviceSpeed: 'standard', service_tier: 'priority' }, 'openai-responses', 'standard'],
|
||||
] as const)('reads %j on %s as speed %s', (args, provider, speed) => {
|
||||
expect(requestedServiceSpeed(args, provider)).toBe(speed);
|
||||
});
|
||||
it('prices a native priority tier like fast service', () => {
|
||||
const entry = model('openai-responses', 'gpt-6.1-sol');
|
||||
const args = { ...request(), service_tier: 'priority' as const };
|
||||
const selected = selectServiceSpeed(
|
||||
entry,
|
||||
{ ...args, serviceSpeed: requestedServiceSpeed(args, entry.provider) },
|
||||
noVariant,
|
||||
);
|
||||
const explicit = selectServiceSpeed(entry, request('fast'), noVariant);
|
||||
expect(selected.speed).toBe('fast');
|
||||
expect(selected.model).toEqual(explicit.model);
|
||||
expect(selected.args.service_tier).toBe('priority');
|
||||
});
|
||||
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.each([
|
||||
['fast', 2],
|
||||
['ultrafast', 6],
|
||||
] as const)(
|
||||
'bills a served %s tier at its catalog rate without a selection snapshot',
|
||||
(speed, multiplier) => {
|
||||
const entry = model('openai-responses', 'gpt-6.1-sol');
|
||||
entry.pricingOverrides = [
|
||||
{
|
||||
minPromptTokens: 100,
|
||||
costs: { prompt_tokens: 400, completion_tokens: 1500 },
|
||||
},
|
||||
];
|
||||
const billed = serviceSpeedBillingModel(entry, speed);
|
||||
expect(billed.costs.prompt_tokens).toBe(200 * multiplier);
|
||||
expect(billed.pricingOverrides?.[0]?.costs.prompt_tokens).toBe(
|
||||
400 * multiplier,
|
||||
);
|
||||
expect(serviceSpeedBillingModel(entry, 'standard')).toBe(entry);
|
||||
},
|
||||
);
|
||||
it('bills a served premium tier as standard when the catalog has no rate for it', () => {
|
||||
const entry = model('groq', 'some-model');
|
||||
expect(serviceSpeedBillingModel(entry, 'fast').costs).toEqual(
|
||||
entry.costs,
|
||||
);
|
||||
});
|
||||
it('restores standard scheduled and long-context rates after a downgrade', () => {
|
||||
const entry = model('openai-responses', 'gpt-6.1-sol');
|
||||
entry.pricingOverrides = [
|
||||
{
|
||||
minPromptTokens: 100,
|
||||
costs: { prompt_tokens: 400, completion_tokens: 1500 },
|
||||
},
|
||||
];
|
||||
const selected = selectServiceSpeed(
|
||||
entry,
|
||||
request('ultrafast'),
|
||||
noVariant,
|
||||
).model;
|
||||
expect(
|
||||
serviceSpeedBillingModel(selected, 'standard').pricingOverrides,
|
||||
).toEqual(entry.pricingOverrides);
|
||||
expect(
|
||||
serviceSpeedBillingModel(selected, 'fast').pricingOverrides?.[0]
|
||||
.costs.prompt_tokens,
|
||||
).toBe(800);
|
||||
});
|
||||
it.each([
|
||||
[{ service_tier: 'priority' }, 'fast'],
|
||||
[{ usage: { serviceTier: 'priority' } }, 'fast'],
|
||||
[{ usage: { service_tier: 'standard' } }, 'standard'],
|
||||
[{ service_tier: 'ultrafast' }, 'ultrafast'],
|
||||
[{}, 'standard'],
|
||||
])('reads actual speed from %j', (source, speed) => {
|
||||
expect(servedServiceSpeed(source)).toBe(speed);
|
||||
});
|
||||
});
|
||||
|
||||
describe('speed admission failures', () => {
|
||||
it.each([
|
||||
[{ status: 429, message: 'Fast mode rate limit exceeded' }, true],
|
||||
[{ status: 403, message: 'Ultrafast access unavailable' }, true],
|
||||
[{ status: 400, message: 'service_tier is not supported' }, true],
|
||||
[{ status: 401, message: 'Invalid API key' }, false],
|
||||
[{ status: 429, message: 'Rate limit exceeded' }, false],
|
||||
[{ status: 400, message: 'Invalid messages' }, false],
|
||||
])('classifies %j without swallowing unrelated errors', (error, retry) => {
|
||||
expect(isServiceSpeedUnavailable(error)).toBe(retry);
|
||||
});
|
||||
it('downgrades a missing speed variant without retrying an invalid base model', () => {
|
||||
const error = { status: 404, message: 'Model not found' };
|
||||
expect(isServiceSpeedUnavailable(error, true)).toBe(true);
|
||||
expect(isServiceSpeedUnavailable(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamed service tier metadata', () => {
|
||||
it('retains a tier from an earlier Chat Completions chunk until usage arrives', async () => {
|
||||
let servedTier: string | undefined;
|
||||
const result = await handle_completion_output({
|
||||
stream: true,
|
||||
completion: (async function* () {
|
||||
yield {
|
||||
service_tier: 'priority',
|
||||
choices: [{ index: 0, delta: { content: 'OK' } }],
|
||||
};
|
||||
yield {
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
};
|
||||
yield {
|
||||
choices: [],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 1 },
|
||||
};
|
||||
})(),
|
||||
usage_calculator: (source) => {
|
||||
servedTier = source.service_tier;
|
||||
return source.usage;
|
||||
},
|
||||
});
|
||||
const chatStream = new AIChatStream({
|
||||
stream: new Writable({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
});
|
||||
await result.init_chat_stream!({ chatStream });
|
||||
expect(servedTier).toBe('priority');
|
||||
});
|
||||
|
||||
it('uses the final Responses tier when the provider downgrades a stream', async () => {
|
||||
let servedTier: string | undefined;
|
||||
const result = await handle_completion_output_responses_api({
|
||||
stream: true,
|
||||
completion: (async function* () {
|
||||
yield {
|
||||
type: 'response.created',
|
||||
response: { service_tier: 'ultrafast' },
|
||||
};
|
||||
yield { type: 'response.output_text.delta', delta: 'OK' };
|
||||
yield {
|
||||
type: 'response.completed',
|
||||
response: {
|
||||
service_tier: 'default',
|
||||
usage: { input_tokens: 10, output_tokens: 1 },
|
||||
},
|
||||
};
|
||||
})(),
|
||||
usage_calculator: (source) => {
|
||||
servedTier = source.service_tier;
|
||||
return source.usage;
|
||||
},
|
||||
});
|
||||
const chatStream = new AIChatStream({
|
||||
stream: new Writable({
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
});
|
||||
await result.init_chat_stream!({ chatStream });
|
||||
expect(servedTier).toBe('default');
|
||||
});
|
||||
});
|
||||
@@ -1,292 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { requiredInputModalities } from './mediaParts.js';
|
||||
import type {
|
||||
IChatModel,
|
||||
ICompleteArguments,
|
||||
ModelCost,
|
||||
ServiceSpeed,
|
||||
} from '../types.js';
|
||||
|
||||
export const lowerServiceSpeed = (speed: ServiceSpeed): ServiceSpeed =>
|
||||
speed === 'ultrafast' ? 'fast' : 'standard';
|
||||
|
||||
const tokenRates = (costs: ModelCost, multiplier: number): ModelCost =>
|
||||
Object.fromEntries(
|
||||
Object.entries(costs).map(([key, value]) => [
|
||||
key,
|
||||
key !== 'tokens' &&
|
||||
/token|^prompt$|^completion$|^input_cache_/.test(key)
|
||||
? value * multiplier
|
||||
: value,
|
||||
]),
|
||||
);
|
||||
|
||||
export const serviceSpeedCosts = (
|
||||
model: IChatModel,
|
||||
speed: 'fast' | 'ultrafast',
|
||||
): ModelCost | undefined => {
|
||||
if (model.serviceSpeeds && !model.serviceSpeeds.includes(speed))
|
||||
return undefined;
|
||||
const rates = model.serviceSpeedRates?.[speed];
|
||||
return typeof rates === 'number'
|
||||
? tokenRates(model.costs, rates)
|
||||
: rates
|
||||
? { ...model.costs, ...rates }
|
||||
: undefined;
|
||||
};
|
||||
|
||||
export interface ServiceSpeedSelection {
|
||||
model: IChatModel;
|
||||
args: ICompleteArguments;
|
||||
speed: ServiceSpeed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The speed a request asks for. Native premium tier parameters count as the
|
||||
* matching `serviceSpeed`, so they are priced like it; `serviceSpeed` wins.
|
||||
*/
|
||||
export const requestedServiceSpeed = (
|
||||
args: Pick<ICompleteArguments, 'serviceSpeed' | 'service_tier' | 'speed'>,
|
||||
provider: string | undefined,
|
||||
): ServiceSpeed | undefined => {
|
||||
if (args.serviceSpeed !== undefined) return args.serviceSpeed;
|
||||
if (args.service_tier === 'ultrafast') return 'ultrafast';
|
||||
if (
|
||||
args.service_tier === 'priority' ||
|
||||
(provider === 'claude' && args.speed === 'fast') ||
|
||||
// Mistral's `auto` tier is its priority tier.
|
||||
(provider === 'mistral' && args.service_tier === 'auto')
|
||||
)
|
||||
return 'fast';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** 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';
|
||||
};
|
||||
|
||||
/**
|
||||
* The model priced at the speed the provider served. Without a selection
|
||||
* snapshot the catalog's rates for that speed apply, so a premium tier reached
|
||||
* some other way is never billed as standard.
|
||||
*/
|
||||
export const serviceSpeedBillingModel = (
|
||||
model: IChatModel,
|
||||
speed: ServiceSpeed,
|
||||
): IChatModel => {
|
||||
const pricing = model.serviceSpeedPricing ?? {
|
||||
speed: 'standard',
|
||||
standardCosts: model.costs,
|
||||
standardOverrides: model.pricingOverrides,
|
||||
fastCosts: serviceSpeedCosts(model, 'fast'),
|
||||
ultrafastCosts: serviceSpeedCosts(model, 'ultrafast'),
|
||||
};
|
||||
if (pricing.speed === speed) return model;
|
||||
const costs =
|
||||
(speed === 'ultrafast' ? pricing.ultrafastCosts : undefined) ??
|
||||
(speed !== 'standard' ? pricing.fastCosts : undefined) ??
|
||||
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,
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -190,39 +190,6 @@ describe('VoiceChangerDriver.convert argument validation', () => {
|
||||
withActor(actor, () => driver.convert({ audio: undefined })),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an unpriced', 'eleven_multilingual_sts_v1'],
|
||||
['a malformed', 'eleven_english_sts_v2&x=1'],
|
||||
])(
|
||||
'rejects %s model before loading audio or calling ElevenLabs',
|
||||
async (_label, model) => {
|
||||
const { actor } = await makeUser();
|
||||
await expect(
|
||||
withActor(actor, () =>
|
||||
driver.convert({
|
||||
audio: 'https://audio.example/sample.wav',
|
||||
model,
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 400,
|
||||
legacyCode: 'field_invalid',
|
||||
fields: {
|
||||
key: 'model',
|
||||
expected: [
|
||||
'eleven_multilingual_sts_v2',
|
||||
'eleven_english_sts_v2',
|
||||
],
|
||||
got: model,
|
||||
},
|
||||
});
|
||||
expect(webFetchMock).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(admitSpy).not.toHaveBeenCalled();
|
||||
expect(incrementUsageSpy).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ── Credit gate ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -51,10 +51,6 @@ const MAX_AUDIO_FILE_SIZE = 25 * 1024 * 1024;
|
||||
// Covers the upload as well as the conversion.
|
||||
const CONVERT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
const SUPPORTED_MODELS = Object.keys(VOICE_CHANGER_COSTS).map(
|
||||
(key) => key.split(':')[1]!,
|
||||
);
|
||||
|
||||
const VOICE_CHANGER_CATALOG: ProviderCatalog = {
|
||||
label: 'Speech-to-speech',
|
||||
ids: ['elevenlabs'],
|
||||
@@ -169,6 +165,14 @@ export class VoiceChangerDriver extends PuterDriver {
|
||||
});
|
||||
}
|
||||
|
||||
const loaded = await loadFileInput(
|
||||
this.stores,
|
||||
this.services.fs,
|
||||
actor,
|
||||
args.audio,
|
||||
{ maxBytes: MAX_AUDIO_FILE_SIZE, acceptWebInput: true },
|
||||
);
|
||||
|
||||
const modelId =
|
||||
args.model_id || args.model || elevenlabs.defaultModelId;
|
||||
const voiceId =
|
||||
@@ -180,41 +184,21 @@ export class VoiceChangerDriver extends PuterDriver {
|
||||
throw new HttpError(400, '`voice` is required', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
// `voiceId` lands in the request URL path, which carries our API key,
|
||||
// so anything outside ElevenLabs' `[A-Za-z0-9_-]+` id shape could
|
||||
// steer the request at a different endpoint.
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(voiceId))
|
||||
// `voiceId` lands in the request URL path; `modelId` lands in a
|
||||
// multipart field. Both are forwarded to ElevenLabs with our
|
||||
// long-lived API key, so anything other than a strict alphanumeric
|
||||
// shape lets a caller steer the request at a different endpoint or
|
||||
// inject parameters. ElevenLabs voice/model IDs are always
|
||||
// `[A-Za-z0-9_-]+` in practice.
|
||||
const ID_REGEX = /^[A-Za-z0-9_-]+$/;
|
||||
if (!ID_REGEX.test(voiceId))
|
||||
throw new HttpError(400, '`voice` must be alphanumeric', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
// Gate on the cost table: an id we can't price is one we can't bill
|
||||
// for, and ElevenLabs bills us for it either way. This also keeps
|
||||
// anything but a known id out of the multipart `model_id` field.
|
||||
const usageKey = `elevenlabs:${modelId}:second`;
|
||||
if (!Object.hasOwn(VOICE_CHANGER_COSTS, usageKey)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Invalid model: ${modelId}. Expected: ${SUPPORTED_MODELS.join(', ')}`,
|
||||
{
|
||||
legacyCode: 'field_invalid',
|
||||
fields: {
|
||||
key: 'model',
|
||||
expected: SUPPORTED_MODELS,
|
||||
got: modelId,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
const ucentsPerSecond = VOICE_CHANGER_COSTS[usageKey]!;
|
||||
|
||||
const loaded = await loadFileInput(
|
||||
this.stores,
|
||||
this.services.fs,
|
||||
actor,
|
||||
args.audio,
|
||||
{ maxBytes: MAX_AUDIO_FILE_SIZE, acceptWebInput: true },
|
||||
);
|
||||
if (!ID_REGEX.test(modelId))
|
||||
throw new HttpError(400, '`model` must be alphanumeric', {
|
||||
legacyCode: 'bad_request',
|
||||
});
|
||||
|
||||
// The hold estimates seconds from the input at 128 kbps: compact audio
|
||||
// is under-held rather than refused. Usage is metered from the length
|
||||
@@ -223,6 +207,8 @@ export class VoiceChangerDriver extends PuterDriver {
|
||||
1,
|
||||
Math.ceil(loaded.buffer.byteLength / 16000),
|
||||
);
|
||||
const usageKey = `elevenlabs:${modelId}:second`;
|
||||
const ucentsPerSecond = VOICE_CHANGER_COSTS[usageKey] ?? 0;
|
||||
const estimatedCost = ucentsPerSecond * estimatedSeconds;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
postGeminiJson,
|
||||
geminiTokenCount as tokenCount,
|
||||
} from '../../../util/geminiRest.js';
|
||||
import { decodeStrictBase64InSlices } from '../../../../util/mediaSniff.js';
|
||||
import { decodeStrictBase64 } from '../../../../util/mediaSniff.js';
|
||||
import { isUpstreamTimeoutError } from '../../../util/upstreamErrors.js';
|
||||
import { SAMPLE_AUDIO_URL } from '../../../util/testMode.js';
|
||||
import type {
|
||||
@@ -456,8 +456,7 @@ export class GeminiTTSProvider implements ITTSProvider {
|
||||
},
|
||||
);
|
||||
}
|
||||
const { body, contentType } =
|
||||
await this.#decodeAudio(audio);
|
||||
const { body, contentType } = this.#decodeAudio(audio);
|
||||
|
||||
outputTokens = usageTokens(
|
||||
usage?.output_tokens_by_modality,
|
||||
@@ -475,7 +474,7 @@ export class GeminiTTSProvider implements ITTSProvider {
|
||||
dataType: 'stream',
|
||||
content_type: contentType,
|
||||
chunked: true,
|
||||
stream: Readable.from(body, { objectMode: false }),
|
||||
stream: Readable.from(body),
|
||||
};
|
||||
} finally {
|
||||
// Every outcome pays once for the counts it reports.
|
||||
@@ -520,15 +519,13 @@ export class GeminiTTSProvider implements ITTSProvider {
|
||||
}
|
||||
|
||||
/** WAV passes through byte for byte; raw PCM is wrapped in a WAV header. */
|
||||
async #decodeAudio(audio: InteractionContent): Promise<{
|
||||
body: Buffer[];
|
||||
#decodeAudio(audio: InteractionContent): {
|
||||
body: Buffer;
|
||||
contentType: string;
|
||||
}> {
|
||||
} {
|
||||
const { data, mime_type: mimeType } = audio;
|
||||
const bytes =
|
||||
typeof data === 'string'
|
||||
? await decodeStrictBase64InSlices(data)
|
||||
: null;
|
||||
typeof data === 'string' ? decodeStrictBase64(data) : null;
|
||||
if (!bytes || typeof mimeType !== 'string') {
|
||||
throw upstreamFailure('Gemini TTS returned invalid audio data');
|
||||
}
|
||||
@@ -544,7 +541,7 @@ export class GeminiTTSProvider implements ITTSProvider {
|
||||
) {
|
||||
throw upstreamFailure('Gemini TTS returned invalid audio data');
|
||||
}
|
||||
return { body: [bytes], contentType: 'audio/wav' };
|
||||
return { body: bytes, contentType: 'audio/wav' };
|
||||
}
|
||||
if (PCM_MIME_TYPES.has(essence) && bytes.length > 0) {
|
||||
const rate = Number(
|
||||
@@ -554,9 +551,8 @@ export class GeminiTTSProvider implements ITTSProvider {
|
||||
Number.isSafeInteger(rate) && rate > 0
|
||||
? rate
|
||||
: DEFAULT_PCM_SAMPLE_RATE;
|
||||
// Sent ahead of the samples, which are never copied behind it.
|
||||
return {
|
||||
body: [this.#wavHeader(bytes.length, sampleRate, 1, 16), bytes],
|
||||
body: this.#wrapPcmInWav(bytes, sampleRate, 1, 16),
|
||||
contentType: 'audio/wav',
|
||||
};
|
||||
}
|
||||
@@ -564,15 +560,17 @@ export class GeminiTTSProvider implements ITTSProvider {
|
||||
}
|
||||
|
||||
/** Wrap raw PCM samples in a WAV container so browsers can play it. */
|
||||
#wavHeader(
|
||||
dataSize: number,
|
||||
#wrapPcmInWav(
|
||||
pcm: Buffer,
|
||||
sampleRate: number,
|
||||
channels: number,
|
||||
bitsPerSample: number,
|
||||
): Buffer {
|
||||
const byteRate = (sampleRate * channels * bitsPerSample) / 8;
|
||||
const blockAlign = (channels * bitsPerSample) / 8;
|
||||
const buffer = Buffer.alloc(44);
|
||||
const dataSize = pcm.length;
|
||||
const headerSize = 44;
|
||||
const buffer = Buffer.alloc(headerSize + dataSize);
|
||||
|
||||
// RIFF header
|
||||
buffer.write('RIFF', 0);
|
||||
@@ -592,6 +590,7 @@ export class GeminiTTSProvider implements ITTSProvider {
|
||||
// data sub-chunk
|
||||
buffer.write('data', 36);
|
||||
buffer.writeUInt32LE(dataSize, 40);
|
||||
pcm.copy(buffer, headerSize);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { insufficientCreditsError } from '../../../../services/metering/enforcem
|
||||
import type { UsageInput } from '../../../../services/metering/types.js';
|
||||
import { normalizeRawBase64ImageString } from '../../../../util/appIcon.js';
|
||||
import {
|
||||
decodeStrictBase64InSlices,
|
||||
decodeStrictBase64,
|
||||
sniffImageMime,
|
||||
sniffVideoMime,
|
||||
} from '../../../../util/mediaSniff.js';
|
||||
@@ -405,7 +405,7 @@ export class GeminiVideoProvider extends VideoProvider {
|
||||
'Gemini video generation did not complete the request',
|
||||
);
|
||||
}
|
||||
const video = await this.#readVideo(videoBlock);
|
||||
const video = this.#readVideo(videoBlock);
|
||||
if (!billed) {
|
||||
throw geminiUpstreamFailure(
|
||||
'Gemini video generation returned unusable usage',
|
||||
@@ -722,7 +722,7 @@ export class GeminiVideoProvider extends VideoProvider {
|
||||
}
|
||||
|
||||
/** The base64 MP4 held by the video block. */
|
||||
async #readVideo(video: InteractionContent | undefined): Promise<string> {
|
||||
#readVideo(video: InteractionContent | undefined): string {
|
||||
if (!video) {
|
||||
throw new HttpError(400, 'Gemini did not return a video', {
|
||||
legacyCode: 'upstream_bad_request',
|
||||
@@ -731,7 +731,7 @@ export class GeminiVideoProvider extends VideoProvider {
|
||||
}
|
||||
const bytes =
|
||||
video.mime_type === 'video/mp4' && typeof video.data === 'string'
|
||||
? await decodeStrictBase64InSlices(video.data)
|
||||
? decodeStrictBase64(video.data)
|
||||
: null;
|
||||
if (!bytes || sniffVideoMime(bytes) !== 'video/mp4') {
|
||||
throw geminiUpstreamFailure(
|
||||
|
||||
@@ -330,11 +330,14 @@ export class AppDriver extends PuterDriver {
|
||||
}
|
||||
|
||||
/**
|
||||
* Paged listing of the caller's own apps. Apps owned by anyone else are
|
||||
* reachable only by name or uid, through `read` and `/query/app`.
|
||||
* Paged app listing. Unlike `/query/app`, which answers only for apps named
|
||||
* by the caller and so must not confirm ones it may not see, this is the
|
||||
* listing surface a launcher reads: `protected` apps are filtered by the
|
||||
* permission check below, and everything else is listable by design. Narrow
|
||||
* it with `predicate: ['user-can-edit']` to the caller's own.
|
||||
*/
|
||||
async select(args = {}) {
|
||||
const { params = {} } = args;
|
||||
const { predicate, params = {} } = args;
|
||||
const actor = requireContextActor();
|
||||
this.#requireUserOrAppActor(actor);
|
||||
|
||||
@@ -350,12 +353,16 @@ export class AppDriver extends PuterDriver {
|
||||
const includeTotal = args.includeTotal === true;
|
||||
const paginated = hasCursor || offset !== undefined || includeTotal;
|
||||
|
||||
// Always the caller's own. `predicate: ['user-can-edit']` asks for the
|
||||
// same thing and is accepted for older clients.
|
||||
const ownerUserId = actor.user.id;
|
||||
const filters = {};
|
||||
// predicate: ['user-can-edit'] → scope to owner
|
||||
const ownerScoped =
|
||||
Array.isArray(predicate) && predicate[0] === 'user-can-edit';
|
||||
if (ownerScoped) {
|
||||
filters.ownerUserId = actor.user.id;
|
||||
}
|
||||
|
||||
let apps = await this.appStore.list({
|
||||
ownerUserId,
|
||||
...filters,
|
||||
limit: paginated ? limit + 1 : limit,
|
||||
offset,
|
||||
afterId: payload?.id !== undefined ? Number(payload.id) : undefined,
|
||||
@@ -364,18 +371,23 @@ export class AppDriver extends PuterDriver {
|
||||
let cursor;
|
||||
if (paginated && apps.length > limit) {
|
||||
apps = apps.slice(0, limit);
|
||||
// The cursor tracks the last fetched row, not the last visible
|
||||
// one, so rows hidden by the permission filter below aren't
|
||||
// re-scanned on the next page.
|
||||
cursor = sealCursor(
|
||||
{ id: Number(apps[apps.length - 1].id) },
|
||||
this.config.jwt_secret_v2,
|
||||
);
|
||||
}
|
||||
|
||||
// Stats and the shared views are each one batched lookup per page.
|
||||
// Protected apps are filtered by one batched grant check; stats and
|
||||
// the shared views are each one batched lookup across the page.
|
||||
const visible = await this.services.app.filterReadable(apps, actor);
|
||||
const [statsByUid, views] = await Promise.all([
|
||||
this.appStore.getAppsStats(apps.map((a) => a.uid)),
|
||||
this.services.app.views(apps, actor),
|
||||
this.appStore.getAppsStats(visible.map((a) => a.uid)),
|
||||
this.services.app.views(visible, actor),
|
||||
]);
|
||||
const items = apps.map((app, i) =>
|
||||
const items = visible.map((app, i) =>
|
||||
toAppReadView(app, views[i], {
|
||||
viewer: actor.user,
|
||||
stats: statsByUid.get(app.uid),
|
||||
@@ -384,9 +396,14 @@ export class AppDriver extends PuterDriver {
|
||||
);
|
||||
if (!paginated) return items;
|
||||
|
||||
const total = includeTotal
|
||||
? await this.appStore.count({ ownerUserId })
|
||||
: undefined;
|
||||
let total;
|
||||
if (includeTotal) {
|
||||
total = ownerScoped
|
||||
? await this.appStore.count({ ownerUserId: actor.user.id })
|
||||
: await this.appStore.count({
|
||||
visibleToUserId: actor.user?.id ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
|
||||
@@ -509,7 +509,7 @@ describe('AppDriver.read', () => {
|
||||
// ── select ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('AppDriver.select', () => {
|
||||
it("returns only the caller's apps when no predicate is sent", async () => {
|
||||
it('returns visible apps including those owned by other users', async () => {
|
||||
const a = await makeUser();
|
||||
const b = await makeUser();
|
||||
const aName = uniqueName('a');
|
||||
@@ -538,7 +538,7 @@ describe('AppDriver.select', () => {
|
||||
)) as Array<Record<string, unknown>>;
|
||||
const names = result.map((r) => r.name);
|
||||
expect(names).toContain(aName);
|
||||
expect(names).not.toContain(bName);
|
||||
expect(names).toContain(bName);
|
||||
});
|
||||
|
||||
it('predicate `user-can-edit` filters to actor-owned apps only', async () => {
|
||||
@@ -1670,10 +1670,11 @@ describe('AppDriver.select pagination', () => {
|
||||
expect(page.total).toBe(names.length);
|
||||
});
|
||||
|
||||
it("pages through only the caller's apps, protected ones included", async () => {
|
||||
it("hides other users' protected apps from paginated catalog listings", async () => {
|
||||
const a = await makeApps(3);
|
||||
const [visible1, hidden, visible2] = a.names;
|
||||
const created = await withActor(a.actor, () =>
|
||||
driver.read({ id: { name: a.names[1] } }),
|
||||
driver.read({ id: { name: hidden } }),
|
||||
);
|
||||
const row = await server.stores.app.getByUid(
|
||||
(created as Record<string, unknown>).uid as string,
|
||||
@@ -1684,29 +1685,20 @@ describe('AppDriver.select pagination', () => {
|
||||
);
|
||||
await server.stores.app.invalidateByUid(row!.uid as string);
|
||||
|
||||
const listAll = async (actor: Actor) => {
|
||||
const seen: string[] = [];
|
||||
let total: number | undefined;
|
||||
let cursor: string | null | undefined = null;
|
||||
do {
|
||||
const page = (await withActor(actor, () =>
|
||||
driver.select({ limit: 2, cursor, includeTotal: true }),
|
||||
)) as {
|
||||
items: Array<{ name: string }>;
|
||||
cursor?: string;
|
||||
total?: number;
|
||||
};
|
||||
seen.push(...page.items.map((r) => r.name));
|
||||
total ??= page.total;
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
return { seen, total };
|
||||
};
|
||||
|
||||
expect(await listAll(a.actor)).toEqual({ seen: a.names, total: 3 });
|
||||
|
||||
const b = await makeUser();
|
||||
expect(await listAll(b.actor)).toEqual({ seen: [], total: 0 });
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null | undefined = null;
|
||||
do {
|
||||
const page = (await withActor(b.actor, () =>
|
||||
driver.select({ limit: 50, cursor }),
|
||||
)) as { items: Array<{ name: string }>; cursor?: string };
|
||||
seen.push(...page.items.map((r) => r.name));
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
|
||||
expect(seen).toContain(visible1);
|
||||
expect(seen).toContain(visible2);
|
||||
expect(seen).not.toContain(hidden);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2525,6 +2517,22 @@ describe('AppDriver.select query-count regression', () => {
|
||||
// -- select / read parity --
|
||||
|
||||
describe('AppDriver.select / read parity', () => {
|
||||
const findViaBroadSelect = async (
|
||||
actor: Actor,
|
||||
uid: string,
|
||||
): Promise<Record<string, unknown> | undefined> => {
|
||||
let cursor: string | null | undefined = null;
|
||||
do {
|
||||
const page = (await withActor(actor, () =>
|
||||
driver.select({ limit: 50, cursor }),
|
||||
)) as { items: Array<Record<string, unknown>>; cursor?: string };
|
||||
const found = page.items.find((r) => r.uid === uid);
|
||||
if (found) return found;
|
||||
cursor = page.cursor;
|
||||
} while (cursor);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
it('every select item (minus stats) deep-equals the corresponding read', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const names: string[] = [];
|
||||
@@ -2632,7 +2640,7 @@ describe('AppDriver.select / read parity', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('dangling hosted app: owner keeps index_url in select, others do not in read', async () => {
|
||||
it('dangling hosted app via select: owner keeps index_url, others do not', async () => {
|
||||
const owner = await makeUser();
|
||||
const other = await makeUser();
|
||||
const sub = uniqueName('qcdangsel');
|
||||
@@ -2664,12 +2672,13 @@ describe('AppDriver.select / read parity', () => {
|
||||
?.reason,
|
||||
).toBe('hosted_backing_unavailable');
|
||||
|
||||
const otherItem = (await withActor(other.actor, () =>
|
||||
driver.read({ uid: created.uid }),
|
||||
)) as Record<string, unknown>;
|
||||
expect(otherItem.index_url).toBeUndefined();
|
||||
const otherItem = await findViaBroadSelect(
|
||||
other.actor,
|
||||
created.uid as string,
|
||||
);
|
||||
expect(otherItem?.index_url).toBeUndefined();
|
||||
expect(
|
||||
(otherItem.privateAccess as { hasAccess?: boolean } | undefined)
|
||||
(otherItem?.privateAccess as { hasAccess?: boolean } | undefined)
|
||||
?.hasAccess,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { parseJsonInSlices } from '../../util/slicedJson.js';
|
||||
import { readBoundedBody } from './fileInput.js';
|
||||
import { upstreamFetch } from './upstreamErrors.js';
|
||||
|
||||
@@ -40,8 +39,8 @@ export const geminiUpstreamFailure = (message: string): HttpError =>
|
||||
/**
|
||||
* POSTs `body` to a Gemini REST `path` and parses the JSON object it answers
|
||||
* with. `timeoutMs` covers the body read, and a reply over `maxBytes` fails as
|
||||
* an upstream error unless `tooLarge` supplies another. `T` is the documented
|
||||
* reply shape; callers check every field before use.
|
||||
* an upstream error unless `tooLarge` supplies another. `T` is the documented reply shape; callers check every
|
||||
* field before use.
|
||||
*/
|
||||
export async function postGeminiJson<T extends object>(
|
||||
apiKey: string,
|
||||
@@ -88,8 +87,7 @@ export async function postGeminiJson<T extends object>(
|
||||
const raw = await readBoundedBody(response, maxBytes, tooLarge);
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
// Media replies run to tens of megabytes of base64.
|
||||
parsed = await parseJsonInSlices(raw);
|
||||
parsed = JSON.parse(raw.toString('utf8'));
|
||||
} catch {
|
||||
// Handled with the non-object case below.
|
||||
}
|
||||
|
||||
+1
-126
@@ -368,117 +368,12 @@ describe('PuterServer host header validation', () => {
|
||||
expect(api.status).toBe(404);
|
||||
});
|
||||
|
||||
it('lets a deployment subdomain frame the feedback embed and names it', async () => {
|
||||
const parent = `http://spreadsheet.puter.localhost:${port}`;
|
||||
const res = await request('/embed/feedback', {
|
||||
host: `puter.localhost:${port}`,
|
||||
referer: `${parent}/sheet/1`,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['x-frame-options']).toBeUndefined();
|
||||
expect(res.headers['content-security-policy']).toBe(
|
||||
`frame-ancestors 'self' ${parent}`,
|
||||
);
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
expect(res.body).toContain('/dist/feedback-embed.min.js');
|
||||
expect(res.body).not.toContain('/dist/bundle.min.js');
|
||||
const config = JSON.parse(
|
||||
res.body.match(/id="feedback-embed-config">(.*?)<\/script>/)[1],
|
||||
);
|
||||
expect(config).toEqual({
|
||||
apiOrigin: `http://api.puter.localhost:${port}`,
|
||||
hostOrigin: parent,
|
||||
});
|
||||
|
||||
const hosted = await request('/embed/feedback', {
|
||||
host: `puter.localhost:${port}`,
|
||||
referer: `http://someone.site.puter.localhost:${port}/`,
|
||||
});
|
||||
expect(hosted.headers['content-security-policy']).toBe("frame-ancestors 'self'");
|
||||
expect(
|
||||
JSON.parse(
|
||||
hosted.body.match(/id="feedback-embed-config">(.*?)<\/script>/)[1],
|
||||
).hostOrigin,
|
||||
).toBeNull();
|
||||
|
||||
const redirected = await request('/embed/feedback?puter.api_origin=https://other.test', {
|
||||
host: `puter.localhost:${port}`,
|
||||
});
|
||||
expect(redirected.status).toBe(302);
|
||||
expect(redirected.headers.location).toBe('/embed/feedback');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'sign-in',
|
||||
'show-open-file-picker',
|
||||
'show-save-file-picker',
|
||||
'show-directory-picker',
|
||||
'upgrade',
|
||||
'top-up',
|
||||
'request-email-confirmation',
|
||||
'attach-custom-domain',
|
||||
])('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) =>
|
||||
JSON.parse(body.match(/gui\((\{.*\})\);/)[1]);
|
||||
const res = await request(`/embed/${action}`, {
|
||||
host: `puter.localhost:${port}`,
|
||||
referer: `${parent}/sheet/1`,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['x-frame-options']).toBeUndefined();
|
||||
expect(res.headers['content-security-policy']).toBe(
|
||||
`frame-ancestors 'self' ${parent}`,
|
||||
);
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
expect(guiParams(res.body).embed_host_origin).toBe(parent);
|
||||
|
||||
const hosted = await request(`/embed/${action}`, {
|
||||
host: `puter.localhost:${port}`,
|
||||
referer: `http://someone.site.puter.localhost:${port}/`,
|
||||
});
|
||||
expect(hosted.headers['content-security-policy']).toBe("frame-ancestors 'self'");
|
||||
expect(guiParams(hosted.body).embed_host_origin).toBeNull();
|
||||
|
||||
const redirected = await request(`/embed/${action}?auth_token=x&embedded_in_popup=true`, {
|
||||
host: `puter.localhost:${port}`,
|
||||
referer: `${parent}/sheet/1`,
|
||||
});
|
||||
expect(redirected.status).toBe(302);
|
||||
expect(redirected.headers.location).toBe(`/embed/${action}`);
|
||||
expect(redirected.headers['x-frame-options']).toBe('SAMEORIGIN');
|
||||
});
|
||||
|
||||
it.each(['/embed/SIGN-IN', '/embed/Show-Open-File-Picker', '/embed/sign-in/', '/embed/Upgrade', '/embed/upgrade/', '/embed/Top-Up', '/embed/top-up/', '/embed/Request-Email-Confirmation'])(
|
||||
'lets a first-party page frame only the exact spelling of %s',
|
||||
async (spelling) => {
|
||||
const res = await request(spelling, {
|
||||
host: `puter.localhost:${port}`,
|
||||
referer: `http://spreadsheet.puter.localhost:${port}/`,
|
||||
});
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toBe(spelling.toLowerCase().replace(/\/$/, ''));
|
||||
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
|
||||
expect(res.headers['content-security-policy']).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it('names no framing page to the popups it serves in a window', async () => {
|
||||
const res = await request('/action/sign-in?embedded_in_popup=true', {
|
||||
host: `puter.localhost:${port}`,
|
||||
referer: `http://spreadsheet.puter.localhost:${port}/`,
|
||||
});
|
||||
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
|
||||
expect(res.headers['content-security-policy']).toBeUndefined();
|
||||
expect(res.body).not.toContain('embed_host_origin');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a user-hosted site', 'http://someone.site.puter.localhost:PORT/'],
|
||||
['a private app host', 'http://someone.app.puter.localhost:PORT/'],
|
||||
['the API host', 'http://api.puter.localhost:PORT/'],
|
||||
['the WebDAV host', 'http://dav.puter.localhost:PORT/'],
|
||||
['a native app host', 'http://about.puter.localhost:PORT/'],
|
||||
['a native app host', 'http://editor.puter.localhost:PORT/'],
|
||||
['a nested subdomain', 'http://a.spreadsheet.puter.localhost:PORT/'],
|
||||
['another site', 'http://evil.test:PORT/'],
|
||||
['another port', 'http://spreadsheet.puter.localhost:1/'],
|
||||
@@ -794,26 +689,6 @@ describe('PuterServer request parsing', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('parses a chat call above 50 MiB whose body opens with its token', async () => {
|
||||
const fields = `"interface":"puter-chat-completion","method":"no-such-method","pad":"${'x'.repeat(51 * MiB)}"`;
|
||||
const asText = { 'content-type': 'text/plain;actually=json' };
|
||||
const first = await postJson(
|
||||
'/drivers/call',
|
||||
`{"auth_token":"${token}",${fields}}`,
|
||||
asText,
|
||||
);
|
||||
expect(first.status).toBe(404);
|
||||
expect(first.body).toContain('no-such-method');
|
||||
|
||||
// A token after other fields can't be read ahead of the body.
|
||||
const last = await postJson(
|
||||
'/drivers/call',
|
||||
`{${fields},"auth_token":"${token}"}`,
|
||||
asText,
|
||||
);
|
||||
expect(last.status).toBe(413);
|
||||
});
|
||||
|
||||
it('keeps the default JSON limit for other drivers', async () => {
|
||||
const payload = JSON.stringify({
|
||||
interface: 'puter-kvstore',
|
||||
|
||||
+11
-30
@@ -36,7 +36,6 @@ import { createAuthProbe } from './core/http/middleware/authProbe';
|
||||
import { createRequestContextMiddleware } from './core/http/middleware/requestContext';
|
||||
import { createFingerprintMiddleware } from './core/http/middleware/fingerprint';
|
||||
import { createErrorHandler } from './core/http/middleware/errorHandler';
|
||||
import { createJsonBodyParser } from './core/http/middleware/jsonBody';
|
||||
import {
|
||||
createLargeJsonBodyParser,
|
||||
DEFAULT_JSON_LIMIT_BYTES,
|
||||
@@ -145,8 +144,6 @@ const LARGE_JSON_ROUTES = [
|
||||
const LARGE_JSON_LIMIT_BYTES = 128 * 1024 * 1024;
|
||||
/** Two max-size bodies per process. */
|
||||
const LARGE_JSON_BUDGET_BYTES = 2 * LARGE_JSON_LIMIT_BYTES;
|
||||
/** These parse in one go, so they stay small enough for that to be quick. */
|
||||
const NON_UTF8_JSON_LIMIT_BYTES = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Through `/drivers/call`, only chat takes a large body; other drivers keep the
|
||||
@@ -578,42 +575,26 @@ export class PuterServer {
|
||||
}),
|
||||
);
|
||||
}
|
||||
// A WebDAV body is the file being stored, whatever content type the
|
||||
// client labels it with, so the controller reads it off the stream.
|
||||
const exceptDav =
|
||||
(parser: RequestHandler): RequestHandler =>
|
||||
(req, res, next) => {
|
||||
if (activeSubdomain(req) === 'dav') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
return parser(req, res, next);
|
||||
};
|
||||
this.#app.use(
|
||||
exceptDav(
|
||||
createJsonBodyParser({
|
||||
limit: DEFAULT_JSON_LIMIT_BYTES,
|
||||
verify: captureRawBody,
|
||||
}),
|
||||
),
|
||||
express.json({
|
||||
limit: DEFAULT_JSON_LIMIT_BYTES,
|
||||
verify: captureRawBody,
|
||||
}),
|
||||
);
|
||||
// JSON in another charset, which the parser above leaves alone.
|
||||
this.#app.use(
|
||||
exceptDav(
|
||||
express.json({
|
||||
limit: NON_UTF8_JSON_LIMIT_BYTES,
|
||||
verify: captureRawBody,
|
||||
}),
|
||||
),
|
||||
express.json({
|
||||
limit: DEFAULT_JSON_LIMIT_BYTES,
|
||||
type: (req) =>
|
||||
req.headers['content-type'] === 'text/plain;actually=json',
|
||||
verify: captureRawBody,
|
||||
}),
|
||||
);
|
||||
// Form-encoded bodies (e.g. `/down` from the GUI's iframe-triggered
|
||||
// download form). Needs to run before the auth probe so
|
||||
// `req.body.auth_token` is populated for urlencoded POSTs the same
|
||||
// way it is for JSON POSTs. Small cap — this parser is only here to
|
||||
// cover the auth_token / anti_csrf field shape, not file uploads.
|
||||
this.#app.use(
|
||||
exceptDav(express.urlencoded({ extended: true, limit: '100kb' })),
|
||||
);
|
||||
this.#app.use(express.urlencoded({ extended: true, limit: '100kb' }));
|
||||
|
||||
// -- Auth probe ----------------------------------------------
|
||||
if (authProbe) {
|
||||
|
||||
@@ -31,13 +31,24 @@ export const RECOMMENDED_APP_NAMES = [
|
||||
'spreadsheet',
|
||||
'presentation',
|
||||
'whiteboard',
|
||||
'notes',
|
||||
'pdf-editor',
|
||||
'contacts',
|
||||
'calendar',
|
||||
'meetings',
|
||||
'teamchat',
|
||||
'email',
|
||||
'dev-center',
|
||||
'cap-table',
|
||||
'invoices',
|
||||
'crm',
|
||||
'signatures',
|
||||
'projects',
|
||||
'point-of-sale',
|
||||
'inventory',
|
||||
'hiring',
|
||||
'time-tracking',
|
||||
'shift-planner',
|
||||
'equipment',
|
||||
'chess',
|
||||
'checkers',
|
||||
'backgammon',
|
||||
|
||||
@@ -2864,7 +2864,7 @@ export class AuthService extends PuterService {
|
||||
|
||||
#actorUserFromRow(user: UserRow) {
|
||||
// Strip the password hash; pass everything else through so callers
|
||||
// can read metadata, otp_enabled, etc. without
|
||||
// can read metadata, desktop_bg_*, otp_enabled, etc. without
|
||||
// re-fetching the user. Mirrors what /whoami exposes off of UserRow.
|
||||
const { password: _password, ...rest } = user;
|
||||
return {
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isTokenForAnotherUser } from '../../../gui/src/helpers/tokenUser.js';
|
||||
import { TokenService } from './TokenService.js';
|
||||
|
||||
const V2_SECRET = 'test-v2-secret';
|
||||
@@ -385,21 +384,3 @@ describe('TokenService payload key handling', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('session token read by the desktop', () => {
|
||||
// The desktop reads the user out of a token another tab stored, to tell
|
||||
// an account switch from a renewed token for the same account.
|
||||
it('names its user in the form the desktop decodes', () => {
|
||||
const svc = createTokenService();
|
||||
const userUid = '5d4adce0-a381-4982-9c02-6e2540026238';
|
||||
const otherUid = 'a1b2c3d4-0000-4000-8000-00000000beef';
|
||||
const token = svc.sign('auth', {
|
||||
type: 'gui',
|
||||
version: '2',
|
||||
user_uid: userUid,
|
||||
});
|
||||
|
||||
expect(isTokenForAnotherUser(token, { uuid: userUid })).toBe(false);
|
||||
expect(isTokenForAnotherUser(token, { uuid: otherUid })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,7 @@ export type TokenType = 'session' | 'gui' | 'app-under-user' | 'access-token';
|
||||
|
||||
/**
|
||||
* Browser session token. `'session'` is the HTTP-only-cookie flavor, `'gui'`
|
||||
* the same shape served in a response body (e.g. the login response).
|
||||
* the same shape served in a response body (e.g. QR login).
|
||||
*/
|
||||
export interface SessionTokenPayload extends TokenPayloadBase {
|
||||
type: 'session' | 'gui';
|
||||
@@ -109,7 +109,9 @@ export interface AccessTokenPayload extends TokenPayloadBase {
|
||||
}
|
||||
|
||||
export type AnyTokenPayload =
|
||||
SessionTokenPayload | AppUnderUserTokenPayload | AccessTokenPayload;
|
||||
| SessionTokenPayload
|
||||
| AppUnderUserTokenPayload
|
||||
| AccessTokenPayload;
|
||||
|
||||
// -- Session row (from `sessions` table) ----------------------------
|
||||
|
||||
|
||||
@@ -647,22 +647,6 @@ describe('FSService overwrite and dedupe resolution', () => {
|
||||
expect(await readBack(second)).toBe('second');
|
||||
});
|
||||
|
||||
it('leaves one readable file when two overwrites create the same path at once', async () => {
|
||||
const path = `${user.home}/Documents/raced.txt`;
|
||||
const written = await Promise.all([
|
||||
writeFile(user, path, 'short', { overwrite: true }),
|
||||
writeFile(user, path, 'a much longer body', { overwrite: true }),
|
||||
]);
|
||||
|
||||
// Both started before the row existed, so each uploaded under its own
|
||||
// key; the row has to end up describing bytes that are really there.
|
||||
expect(written[0].uuid).toBe(written[1].uuid);
|
||||
const stored = (await entryAt(user, '/Documents/raced.txt'))!;
|
||||
const body = await readBack(stored);
|
||||
expect(['short', 'a much longer body']).toContain(body);
|
||||
expect(stored.size).toBe(Buffer.byteLength(body));
|
||||
});
|
||||
|
||||
it('dedupes into an unused " (n)" name, skipping names already taken', async () => {
|
||||
await writeFile(user, `${user.home}/Documents/d.txt`, 'a');
|
||||
await writeFile(user, `${user.home}/Documents/d (1).txt`, 'a');
|
||||
|
||||
@@ -2073,7 +2073,10 @@ export class FSService extends PuterService {
|
||||
);
|
||||
});
|
||||
|
||||
const fsEntries = await this.#createUploadedEntries(createInputs);
|
||||
const fsEntries = await this.stores.fsEntry.batchCreateEntries(
|
||||
createInputs,
|
||||
true,
|
||||
);
|
||||
return preparedBatch.items.map((item, index) => {
|
||||
const fsEntry = fsEntries[index];
|
||||
if (!fsEntry) {
|
||||
@@ -2998,97 +3001,30 @@ export class FSService extends PuterService {
|
||||
legacyCode: 'conflict',
|
||||
});
|
||||
}
|
||||
await this.#moveObjectOntoKey(
|
||||
session.objectKey,
|
||||
liveKey,
|
||||
{ bucket, region, size },
|
||||
session.contentType,
|
||||
);
|
||||
}
|
||||
|
||||
async #moveObjectOntoKey(
|
||||
fromKey: string,
|
||||
toKey: string,
|
||||
at: { bucket: string; region: string; size: number },
|
||||
contentType?: string | null,
|
||||
): Promise<void> {
|
||||
await this.stores.s3Object.copyObjectOfAnySize(
|
||||
{
|
||||
sourceBucket: at.bucket,
|
||||
sourceKey: fromKey,
|
||||
destinationBucket: at.bucket,
|
||||
destinationKey: toKey,
|
||||
...(contentType ? { contentType } : {}),
|
||||
sourceBucket: bucket,
|
||||
sourceKey: session.objectKey,
|
||||
destinationBucket: bucket,
|
||||
destinationKey: liveKey,
|
||||
contentType: session.contentType,
|
||||
metadataDirective: 'REPLACE',
|
||||
},
|
||||
at.region,
|
||||
at.size,
|
||||
region,
|
||||
size,
|
||||
);
|
||||
try {
|
||||
await this.stores.s3Object.deleteObject(
|
||||
at.bucket,
|
||||
fromKey,
|
||||
at.region,
|
||||
bucket,
|
||||
session.objectKey,
|
||||
region,
|
||||
);
|
||||
} catch (e) {
|
||||
// The destination is already right; the source is only litter.
|
||||
console.warn('[fs] moved object left behind:', e);
|
||||
// The live object is already right; the staged one is only litter.
|
||||
console.warn('[fs] staged overwrite left behind:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the rows for objects already uploaded. A row another writer
|
||||
* created first keeps its uuid, so those bytes move onto that key.
|
||||
*/
|
||||
async #createUploadedEntries(
|
||||
inputs: FSEntryCreateInput[],
|
||||
): Promise<FSEntry[]> {
|
||||
const entries = await this.stores.fsEntry.batchCreateEntries(
|
||||
inputs,
|
||||
true,
|
||||
);
|
||||
const raced = inputs.flatMap((input, index) => {
|
||||
const liveKey = entries[index]?.uuid;
|
||||
return liveKey && liveKey !== input.uuid
|
||||
? [{ input, index, liveKey }]
|
||||
: [];
|
||||
});
|
||||
if (raced.length === 0) return entries;
|
||||
|
||||
await Promise.all(
|
||||
raced.map(({ input, liveKey }) =>
|
||||
this.#moveObjectOntoKey(
|
||||
input.uuid,
|
||||
liveKey,
|
||||
{
|
||||
bucket: this.stores.s3Object.resolveBucket(
|
||||
input.bucket,
|
||||
),
|
||||
region: this.stores.s3Object.resolveRegion(
|
||||
input.bucketRegion,
|
||||
),
|
||||
size: input.size,
|
||||
},
|
||||
input.contentType,
|
||||
),
|
||||
),
|
||||
);
|
||||
const adopted = await this.stores.fsEntry.batchCreateEntries(
|
||||
raced.map(({ input, liveKey }) => ({ ...input, uuid: liveKey })),
|
||||
true,
|
||||
);
|
||||
raced.forEach(({ index, liveKey }, i) => {
|
||||
const entry = adopted[i];
|
||||
if (entry?.uuid !== liveKey) {
|
||||
throw new HttpError(409, 'Write target changed mid-write', {
|
||||
legacyCode: 'conflict',
|
||||
});
|
||||
}
|
||||
entries[index] = entry;
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
async completeUrlWrite(
|
||||
userId: number,
|
||||
completeWriteRequest: CompleteWriteRequest,
|
||||
@@ -3645,10 +3581,10 @@ export class FSService extends PuterService {
|
||||
: uploadBody.contentHashSha256;
|
||||
|
||||
const createInput = this.#toCreateInput(normalizedInput, objectKey);
|
||||
const [fsEntry] = await this.#createUploadedEntries([createInput]);
|
||||
if (!fsEntry) {
|
||||
throw new Error('Failed to create entry');
|
||||
}
|
||||
const fsEntry = await this.stores.fsEntry.createEntry(
|
||||
createInput,
|
||||
normalizedInput.createMissingParents,
|
||||
);
|
||||
|
||||
this.#emitFsEvent(
|
||||
existingEntry ? 'fs.write.file' : 'fs.create.file',
|
||||
@@ -3746,18 +3682,17 @@ export class FSService extends PuterService {
|
||||
updates.filter(isValid),
|
||||
);
|
||||
let next = 0;
|
||||
return updates.map(
|
||||
(update): PromiseSettledResult<FSEntry> =>
|
||||
isValid(update)
|
||||
? stored[next++]
|
||||
: {
|
||||
status: 'rejected',
|
||||
reason: new HttpError(
|
||||
400,
|
||||
'Invalid file entry identifier for thumbnail update',
|
||||
{ legacyCode: 'bad_request' },
|
||||
),
|
||||
},
|
||||
return updates.map((update): PromiseSettledResult<FSEntry> =>
|
||||
isValid(update)
|
||||
? stored[next++]
|
||||
: {
|
||||
status: 'rejected',
|
||||
reason: new HttpError(
|
||||
400,
|
||||
'Invalid file entry identifier for thumbnail update',
|
||||
{ legacyCode: 'bad_request' },
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -107,31 +107,6 @@ describe('app browser embed shell', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('feedback embed shell', () => {
|
||||
it('carries the framing page and API origin, and loads only its own script', () => {
|
||||
const html = makeService({
|
||||
api_base_url: 'https://api.deployment.test',
|
||||
gui_bundle: '/static/bundle.min.js?v=42',
|
||||
}).renderFeedbackEmbed({ hostOrigin: 'https://notes.deployment.test' });
|
||||
const configJson = html.match(/id="feedback-embed-config">(.*?)<\/script>/)![1];
|
||||
expect(JSON.parse(configJson)).toEqual({
|
||||
apiOrigin: 'https://api.deployment.test',
|
||||
hostOrigin: 'https://notes.deployment.test',
|
||||
});
|
||||
expect(html).toContain('<script defer src="/static/feedback-embed.min.js?v=42"></script>');
|
||||
expect(html).toContain('<link rel="stylesheet" href="/css/feedback-embed.css">');
|
||||
expect(html).toContain('<main id="feedback-embed"></main>');
|
||||
expect(html).not.toContain('bundle.min.js');
|
||||
expect(html).not.toContain('apps-embed');
|
||||
});
|
||||
|
||||
it('carries no framing page when none is allowed', () => {
|
||||
const html = makeService({}).renderFeedbackEmbed({ hostOrigin: null });
|
||||
const configJson = html.match(/id="feedback-embed-config">(.*?)<\/script>/)![1];
|
||||
expect(JSON.parse(configJson).hostOrigin).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/** Capture what the service sends, and return the rendered HTML. */
|
||||
const render = async (
|
||||
service: PuterHomepageService,
|
||||
|
||||
@@ -48,15 +48,6 @@ export interface LaunchOptions {
|
||||
on_initialized?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A GUI popup a first-party page frames at `/embed/<action>`. `hostOrigin` is
|
||||
* that page, read from the referrer and pinned with frame-ancestors; null when
|
||||
* no allowed page frames it.
|
||||
*/
|
||||
export interface PopupFrame {
|
||||
hostOrigin: string | null;
|
||||
}
|
||||
|
||||
interface PuterGuiAddonsEvent {
|
||||
req: Request;
|
||||
path: string;
|
||||
@@ -180,50 +171,15 @@ export class PuterHomepageService extends PuterService {
|
||||
renderAppBrowserEmbed({
|
||||
guestApps = [],
|
||||
}: { guestApps?: Array<Record<string, unknown>> } = {}): string {
|
||||
return this.#renderEmbed({
|
||||
rootId: 'app-browser',
|
||||
rootClass: 'dashboard',
|
||||
stylesheet: 'apps-embed.css',
|
||||
script: 'apps-embed.min.js',
|
||||
config: {
|
||||
apiOrigin: this.config.api_base_url,
|
||||
sdkUrl:
|
||||
this.config.env === 'dev'
|
||||
? '/sdk/puter.dev.js'
|
||||
: (this.config.gui_puterjs_bundle ??
|
||||
'https://js.puter.com/v2/'),
|
||||
guestApps,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `hostOrigin` is the first-party page framing the dialog, which the
|
||||
* feedback is sent for; null when no allowed page frames it.
|
||||
*/
|
||||
renderFeedbackEmbed({ hostOrigin }: { hostOrigin: string | null }): string {
|
||||
return this.#renderEmbed({
|
||||
rootId: 'feedback-embed',
|
||||
stylesheet: 'feedback-embed.css',
|
||||
script: 'feedback-embed.min.js',
|
||||
config: { apiOrigin: this.config.api_base_url, hostOrigin },
|
||||
});
|
||||
}
|
||||
|
||||
#renderEmbed({
|
||||
rootId,
|
||||
rootClass,
|
||||
stylesheet,
|
||||
script,
|
||||
config,
|
||||
}: {
|
||||
rootId: string;
|
||||
rootClass?: string;
|
||||
stylesheet: string;
|
||||
script: string;
|
||||
config: Record<string, unknown>;
|
||||
}): string {
|
||||
const configJson = JSON.stringify(config).replace(/</g, '\\u003c');
|
||||
const configJson = JSON.stringify({
|
||||
apiOrigin: this.config.api_base_url,
|
||||
sdkUrl:
|
||||
this.config.env === 'dev'
|
||||
? '/sdk/puter.dev.js'
|
||||
: (this.config.gui_puterjs_bundle ??
|
||||
'https://js.puter.com/v2/'),
|
||||
guestApps,
|
||||
}).replace(/</g, '\\u003c');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -234,27 +190,27 @@ export class PuterHomepageService extends PuterService {
|
||||
<title>${encode(String(this.config.gui_params?.title ?? 'Puter'))}</title>
|
||||
<link rel="stylesheet" href="/css/normalize.css">
|
||||
<link rel="stylesheet" href="/css/dashboard.css">
|
||||
<link rel="stylesheet" href="/css/${stylesheet}">
|
||||
<link rel="stylesheet" href="/css/apps-embed.css">
|
||||
</head>
|
||||
<body>
|
||||
<main id="${rootId}"${rootClass ? ` class="${rootClass}"` : ''}></main>
|
||||
<script type="application/json" id="${rootId}-config">${configJson}</script>
|
||||
<script defer src="${encode(this.#embedScript(script))}"></script>
|
||||
<main id="app-browser" class="dashboard"></main>
|
||||
<script type="application/json" id="app-browser-config">${configJson}</script>
|
||||
<script defer src="${encode(this.#appBrowserEmbedScript())}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed scripts are built beside the GUI bundle, so they are served from
|
||||
* The embed's script is built beside the GUI bundle, so it is served from
|
||||
* wherever `gui_bundle` is (a CDN in production), with the same query.
|
||||
*/
|
||||
#embedScript(name: string): string {
|
||||
#appBrowserEmbedScript(): string {
|
||||
const base = 'http://relative.invalid';
|
||||
const bundle = new URL(
|
||||
this.config.gui_bundle ?? '/dist/bundle.min.js',
|
||||
base,
|
||||
);
|
||||
const script = new URL(name, bundle);
|
||||
const script = new URL('apps-embed.min.js', bundle);
|
||||
script.search = bundle.search;
|
||||
return script.origin === base
|
||||
? `${script.pathname}${script.search}`
|
||||
@@ -312,7 +268,6 @@ export class PuterHomepageService extends PuterService {
|
||||
ctx: { req: Request; res: Response; actor?: Actor | null },
|
||||
meta: PageMeta,
|
||||
launchOptions: LaunchOptions = {},
|
||||
popupFrame?: PopupFrame,
|
||||
): Promise<void> {
|
||||
const { req, res, actor } = ctx;
|
||||
|
||||
@@ -337,7 +292,6 @@ export class PuterHomepageService extends PuterService {
|
||||
{ req, actor: actor ?? null },
|
||||
meta,
|
||||
launchOptions,
|
||||
popupFrame,
|
||||
);
|
||||
res.send(html);
|
||||
}
|
||||
@@ -348,7 +302,6 @@ export class PuterHomepageService extends PuterService {
|
||||
ctx: { req: Request; actor: Actor | null },
|
||||
meta: PageMeta,
|
||||
launchOptions: LaunchOptions,
|
||||
popupFrame?: PopupFrame,
|
||||
): Promise<string> {
|
||||
const { req, actor } = ctx;
|
||||
const env = this.config.env ?? 'prod';
|
||||
@@ -394,8 +347,6 @@ export class PuterHomepageService extends PuterService {
|
||||
eventsNotifications: notificationsFoldInEnabled(this.config),
|
||||
...meta,
|
||||
launch_options: launchOptions,
|
||||
// After `meta`, so nothing page-specific can name the opener.
|
||||
...(popupFrame ? { embed_host_origin: popupFrame.hostOrigin } : {}),
|
||||
};
|
||||
|
||||
const event: PuterGuiAddonsEvent = {
|
||||
@@ -517,6 +468,8 @@ export class PuterHomepageService extends PuterService {
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
${bundled ? `<link rel="stylesheet" href="${guiCss}">` : ''}
|
||||
|
||||
<link rel="preload" as="image" href="https://puter-assets.b-cdn.net/wallpaper.webp">
|
||||
|
||||
<script>
|
||||
if ( ! window.service_script ) {
|
||||
window.service_script_api_promise = (() => {
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
|
||||
import { Writable } from 'node:stream';
|
||||
import v8 from 'node:v8';
|
||||
import type { Request, Response } from 'express';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
@@ -230,37 +229,6 @@ describe('ProfileService', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// V8 stops optimizing regexps once a process holds enough compiled code,
|
||||
// as long-running servers do, and an unoptimized one overflows on this.
|
||||
it('answers a picture of several megabytes with 413', () => {
|
||||
const picture = `data:image/png;base64,${'A'.repeat(6 * 1024 * 1024)}`;
|
||||
v8.setFlagsFromString('--no-regexp-optimization');
|
||||
try {
|
||||
expect(() => service.validatePatch({ picture })).toThrow(
|
||||
expect.objectContaining({
|
||||
statusCode: 413,
|
||||
code: 'profile_picture_too_large',
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
v8.setFlagsFromString('--regexp-optimization');
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a picture of the largest size, with or without padding', () => {
|
||||
const prefix = 'data:image/png;base64,';
|
||||
const full = `${prefix}${'A'.repeat(PROFILE_PICTURE_MAX_BYTES - prefix.length)}`;
|
||||
expect(service.validatePatch({ picture: full })).toEqual({
|
||||
picture: full,
|
||||
});
|
||||
expect(service.validatePatch({ picture: `${prefix}QQ` })).toEqual({
|
||||
picture: `${prefix}QQ`,
|
||||
});
|
||||
expect(() => service.validatePatch({ picture: `${full}A` })).toThrow(
|
||||
expect.objectContaining({ code: 'profile_picture_too_large' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps what validates from a legacy profile and names the rest', () => {
|
||||
expect(
|
||||
service.normalizeLegacyProfile({
|
||||
|
||||
@@ -83,14 +83,6 @@ const validateField = (field: ProfileField, value: unknown): string | null => {
|
||||
);
|
||||
}
|
||||
if (field === 'picture') {
|
||||
// Sized before the pattern runs, which fails outright on megabytes of
|
||||
// text. Only ASCII passes the pattern, so the length is the byte count.
|
||||
if (value.length > PROFILE_PICTURE_MAX_BYTES) {
|
||||
throw new HttpError(413, 'picture is too large', {
|
||||
code: 'profile_picture_too_large',
|
||||
fields: { field, maxBytes: PROFILE_PICTURE_MAX_BYTES },
|
||||
});
|
||||
}
|
||||
if (!PICTURE_DATA_URL.test(value)) {
|
||||
throw invalid(
|
||||
field,
|
||||
@@ -98,6 +90,12 @@ const validateField = (field: ProfileField, value: unknown): string | null => {
|
||||
'profile_picture_invalid',
|
||||
);
|
||||
}
|
||||
if (Buffer.byteLength(value) > PROFILE_PICTURE_MAX_BYTES) {
|
||||
throw new HttpError(413, 'picture is too large', {
|
||||
code: 'profile_picture_too_large',
|
||||
fields: { field, maxBytes: PROFILE_PICTURE_MAX_BYTES },
|
||||
});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
const maxLength =
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# OpenAI Realtime relay
|
||||
|
||||
The relay serves `/puterai/openai/v1/realtime` on the API host. The SDK exposes
|
||||
it through [`puter.ai.realtime()`](../../../docs/src/AI/realtime.md).
|
||||
it through [`puter.ai.convo()`](../../../docs/src/AI/convo.md).
|
||||
|
||||
**Beta:** This API is incomplete. Its methods, options, events, and behavior may change abruptly, including breaking changes without notice.
|
||||
|
||||
Direct clients are documented in the [Realtime WebSocket API reference](../../../docs/src/AI/realtime-websocket.md).
|
||||
Direct clients are documented in the [Realtime WebSocket API reference](../../../docs/src/AI/realtime.md).
|
||||
|
||||
## Transport
|
||||
|
||||
@@ -27,7 +27,7 @@ The socket opens before authentication completes. Authentication and model
|
||||
failures arrive as OpenAI-shaped error events followed by a close frame.
|
||||
|
||||
```js
|
||||
const conversation = await puter.ai.realtime({ output: ['text'] });
|
||||
const conversation = await puter.ai.convo({ output: ['text'] });
|
||||
conversation.on('error', (event) => console.error(event));
|
||||
conversation.on('text', ({ delta }) => console.log(delta));
|
||||
conversation.on('turn.done', () => conversation.close());
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
PuterStore,
|
||||
} from '../types';
|
||||
import { HttpError } from '../../core/http/HttpError.js';
|
||||
import { runWithConcurrencyLimit } from '../../util/concurrency.js';
|
||||
import { isUniqueViolation } from '../../util/dbError.js';
|
||||
import {
|
||||
validateUrl,
|
||||
@@ -90,8 +89,6 @@ const STATS_CACHE_TTL_SECONDS = 30 * 60;
|
||||
// `app-` + a UUID. ClickHouse's `app_opens.app_uid` is FixedString(40) and
|
||||
// fails the whole query on a longer value.
|
||||
const APP_UID_MAX_BYTES = 40;
|
||||
// ClickHouse caps the size of one query parameter, and the uid list is one.
|
||||
const CLICKHOUSE_UIDS_PER_QUERY = 1000;
|
||||
|
||||
// Period helpers for detailed/grouped stats. Ported from v1
|
||||
// AppInformationService — queries go straight to ClickHouse/MySQL on demand
|
||||
@@ -689,7 +686,12 @@ export class AppStore extends PuterStore {
|
||||
return apps;
|
||||
}
|
||||
|
||||
/** Count apps matching the same equality filters as `list`. */
|
||||
/**
|
||||
* Count apps matching the same equality filters as `list`.
|
||||
* `visibleToUserId` counts the public catalog for one caller —
|
||||
* non-protected apps plus their own; apps visible only via explicit
|
||||
* permission grants are not included.
|
||||
*/
|
||||
async count(filters = {}) {
|
||||
const where = [];
|
||||
const params = [];
|
||||
@@ -702,6 +704,12 @@ export class AppStore extends PuterStore {
|
||||
where.push('`app_owner` = ?');
|
||||
params.push(filters.appOwner);
|
||||
}
|
||||
if (filters.visibleToUserId !== undefined) {
|
||||
where.push(
|
||||
'(`protected` IS NULL OR `protected` = false OR `owner_user_id` = ?)',
|
||||
);
|
||||
params.push(filters.visibleToUserId);
|
||||
}
|
||||
|
||||
const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
const rows = await this.clients.db.read(
|
||||
@@ -1730,30 +1738,20 @@ export class AppStore extends PuterStore {
|
||||
|
||||
const clickhouse = this.clients.clickhouse;
|
||||
if (clickhouse) {
|
||||
const chunks = [];
|
||||
for (let i = 0; i < uids.length; i += CLICKHOUSE_UIDS_PER_QUERY) {
|
||||
chunks.push(uids.slice(i, i + CLICKHOUSE_UIDS_PER_QUERY));
|
||||
}
|
||||
const results = await runWithConcurrencyLimit(
|
||||
chunks,
|
||||
4,
|
||||
async (chunk) => {
|
||||
const res = await clickhouse.query({
|
||||
query: `
|
||||
SELECT app_uid,
|
||||
uniqExact((user_id, intDiv(ts, ${APP_OPEN_COUNT_PERIOD_SECONDS}))) AS open_count,
|
||||
count(DISTINCT user_id) AS user_count
|
||||
FROM app_opens
|
||||
WHERE app_uid IN {uids:Array(String)}
|
||||
GROUP BY app_uid
|
||||
`,
|
||||
query_params: { uids: chunk.map((u) => String(u)) },
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
);
|
||||
for (const row of results.flat()) {
|
||||
const res = await clickhouse.query({
|
||||
query: `
|
||||
SELECT app_uid,
|
||||
uniqExact((user_id, intDiv(ts, ${APP_OPEN_COUNT_PERIOD_SECONDS}))) AS open_count,
|
||||
count(DISTINCT user_id) AS user_count
|
||||
FROM app_opens
|
||||
WHERE app_uid IN {uids:Array(String)}
|
||||
GROUP BY app_uid
|
||||
`,
|
||||
query_params: { uids: uids.map((u) => String(u)) },
|
||||
format: 'JSONEachRow',
|
||||
});
|
||||
const rows = await res.json();
|
||||
for (const row of rows) {
|
||||
out.set(row.app_uid, {
|
||||
open_count: parseInt(row.open_count, 10) || 0,
|
||||
user_count: parseInt(row.user_count, 10) || 0,
|
||||
|
||||
@@ -200,37 +200,6 @@ describe('AppStore app stats (cache-on-read)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('splits a long uid list across ClickHouse queries', async () => {
|
||||
const uids = Array.from({ length: 2500 }, () => freshUid());
|
||||
const queried = [];
|
||||
|
||||
server.clients.clickhouse = {
|
||||
query: async ({ query_params }) => {
|
||||
queried.push(query_params.uids.length);
|
||||
return {
|
||||
json: async () =>
|
||||
query_params.uids.map((u) => ({
|
||||
app_uid: u,
|
||||
open_count: '2',
|
||||
user_count: '1',
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const stats = await appStore.getAppsStats(uids);
|
||||
expect(queried.sort((a, b) => b - a)).toEqual([1000, 1000, 500]);
|
||||
expect(stats.size).toBe(2500);
|
||||
expect(stats.get(uids[2499])).toMatchObject({
|
||||
open_count: 2,
|
||||
user_count: 1,
|
||||
});
|
||||
} finally {
|
||||
delete server.clients.clickhouse;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps ids longer than an app uid out of the ClickHouse query', async () => {
|
||||
const uid = freshUid();
|
||||
const longId = `iap-${'a'.repeat(36)}-extra`;
|
||||
@@ -1323,14 +1292,31 @@ describe('AppStore CRUD and cache invalidation', () => {
|
||||
).toBe('Bumped');
|
||||
});
|
||||
|
||||
it('counts apps by owner and creator', async () => {
|
||||
it('counts apps by owner, creator and per-caller visibility', async () => {
|
||||
const countOwner = 4600;
|
||||
const viewer = 4601;
|
||||
const open = await createApp({}, { ownerUserId: countOwner });
|
||||
await createApp({}, { ownerUserId: countOwner, appOwner: open.id });
|
||||
await createApp({}, { ownerUserId: countOwner });
|
||||
const hidden = await createApp({}, { ownerUserId: countOwner });
|
||||
await db.write('UPDATE `apps` SET `protected` = 1 WHERE `id` = ?', [
|
||||
hidden.id,
|
||||
]);
|
||||
|
||||
expect(await appStore.count({ ownerUserId: countOwner })).toBe(3);
|
||||
expect(await appStore.count({ appOwner: open.id })).toBe(1);
|
||||
|
||||
// The protected app is invisible to a stranger but visible to its
|
||||
// owner.
|
||||
const strangerView = await appStore.count({
|
||||
ownerUserId: countOwner,
|
||||
visibleToUserId: viewer,
|
||||
});
|
||||
expect(strangerView).toBe(2);
|
||||
const ownerView = await appStore.count({
|
||||
ownerUserId: countOwner,
|
||||
visibleToUserId: countOwner,
|
||||
});
|
||||
expect(ownerView).toBe(3);
|
||||
});
|
||||
|
||||
// -- filetype association batch + recent opens ----------------------
|
||||
|
||||
@@ -444,7 +444,7 @@ describe('FSEntryStore cache invalidation', () => {
|
||||
store.batchCreateEntries([
|
||||
{
|
||||
userId: user.userId,
|
||||
uuid: file.uuid,
|
||||
uuid: uuidv4(),
|
||||
path: file.path,
|
||||
size: 7,
|
||||
overwrite: true,
|
||||
@@ -1185,7 +1185,7 @@ describe('FSEntryStore entry creation', () => {
|
||||
const [updated] = await store.batchCreateEntries([
|
||||
{
|
||||
userId: user.userId,
|
||||
uuid: original.uuid,
|
||||
uuid: uuidv4(),
|
||||
path: `${user.home}/Documents/ow.txt`,
|
||||
size: 42,
|
||||
overwrite: true,
|
||||
@@ -1204,33 +1204,6 @@ describe('FSEntryStore entry creation', () => {
|
||||
expect(updated?.thumbnail).toBe('data:image/png;base64,AA');
|
||||
});
|
||||
|
||||
it('leaves the row alone when an overwrite brings a different object key', async () => {
|
||||
const user = await makeUser();
|
||||
const original = await createFile(
|
||||
user,
|
||||
`${user.home}/Documents/otherkey.txt`,
|
||||
{ size: 1 },
|
||||
);
|
||||
|
||||
// The bytes for this write sit under a key the row doesn't name, so
|
||||
// repointing bucket or size would describe an object that isn't there.
|
||||
const returned = await createFile(user, original.path, {
|
||||
size: 42,
|
||||
overwrite: true,
|
||||
bucket: 'elsewhere',
|
||||
bucketRegion: 'eu-central-1',
|
||||
});
|
||||
|
||||
expect(returned.uuid).toBe(original.uuid);
|
||||
await expect(
|
||||
store.getEntryByPath(original.path),
|
||||
).resolves.toMatchObject({
|
||||
uuid: original.uuid,
|
||||
size: 1,
|
||||
bucket: 'puter-local',
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to clobber an existing entry without overwrite, or a directory with it', async () => {
|
||||
const user = await makeUser();
|
||||
await createFile(user, `${user.home}/Documents/keep.txt`);
|
||||
@@ -1254,10 +1227,7 @@ describe('FSEntryStore entry creation', () => {
|
||||
|
||||
it('propagates the underlying failure when an overwrite update fails', async () => {
|
||||
const user = await makeUser();
|
||||
const existing = await createFile(
|
||||
user,
|
||||
`${user.home}/Documents/upfail.txt`,
|
||||
);
|
||||
await createFile(user, `${user.home}/Documents/upfail.txt`);
|
||||
|
||||
const db = server.clients.db;
|
||||
const originalWrite = (Object.getPrototypeOf(db) as typeof db).write;
|
||||
@@ -1273,7 +1243,6 @@ describe('FSEntryStore entry creation', () => {
|
||||
await expect(
|
||||
createFile(user, `${user.home}/Documents/upfail.txt`, {
|
||||
overwrite: true,
|
||||
uuid: existing.uuid,
|
||||
}),
|
||||
).rejects.toThrow('update rejected');
|
||||
|
||||
@@ -3832,7 +3801,7 @@ describe('FSEntryStore lost-insert recovery', () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('hands back the race winner untouched when overwrite was requested', async () => {
|
||||
it('updates the race winner in place when overwrite was requested', async () => {
|
||||
const user = await makeUser();
|
||||
const uuid = uuidv4();
|
||||
const spy = loseInsertRace(uuid, () =>
|
||||
@@ -3858,14 +3827,13 @@ describe('FSEntryStore lost-insert recovery', () => {
|
||||
]);
|
||||
spy.mockRestore();
|
||||
|
||||
// Our object is under `uuid`, not the winner's key, so the winner's
|
||||
// row is returned as it stands for the caller to move the bytes onto.
|
||||
// The winner's row survived; our payload was written onto it.
|
||||
expect(entry?.uuid).not.toBe(uuid);
|
||||
expect(entry?.size).not.toBe(77);
|
||||
expect(entry?.thumbnail).not.toBe('thumb');
|
||||
expect(entry?.size).toBe(77);
|
||||
expect(entry?.thumbnail).toBe('thumb');
|
||||
await expect(
|
||||
store.getEntryByPath(`${user.home}/Documents/recovered.txt`),
|
||||
).resolves.toMatchObject({ size: entry?.size, uuid: entry?.uuid });
|
||||
).resolves.toMatchObject({ size: 77, uuid: entry?.uuid });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1941,11 +1941,6 @@ export class FSEntryStore extends PuterStore {
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* An overwrite whose `uuid` is not the existing row's leaves that row
|
||||
* untouched and returns it: the caller's object is under another key, so
|
||||
* the row can't describe it until the caller moves the bytes.
|
||||
*/
|
||||
async batchCreateEntries(
|
||||
entries: FSEntryCreateInput[],
|
||||
createPaths = true,
|
||||
@@ -2079,10 +2074,6 @@ export class FSEntryStore extends PuterStore {
|
||||
{ legacyCode: 'cannot_overwrite_a_directory' },
|
||||
);
|
||||
}
|
||||
if (existingEntry.uuid !== entry.input.uuid) {
|
||||
updatedResultsByIndex.set(entry.index, existingEntry);
|
||||
continue;
|
||||
}
|
||||
|
||||
const updatedEntry = {
|
||||
...existingEntry,
|
||||
@@ -2407,10 +2398,6 @@ export class FSEntryStore extends PuterStore {
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (existing.uuid !== entry.input.uuid) {
|
||||
updatedResultsByIndex.set(entry.index, existing);
|
||||
continue;
|
||||
}
|
||||
|
||||
const updatedEntry: FSEntry = {
|
||||
...existing,
|
||||
|
||||
@@ -1102,9 +1102,8 @@ interface IConfigOptional {
|
||||
/** Force the bundled GUI even in dev. Default: false. */
|
||||
use_bundled_gui: boolean;
|
||||
/**
|
||||
* Override the GUI bundle JS path. Default: `/dist/bundle.min.js`. The
|
||||
* `/embed/*` pages load `apps-embed.min.js` and `feedback-embed.min.js`
|
||||
* from the same directory.
|
||||
* Override the GUI bundle JS path. Default: `/dist/bundle.min.js`. The app
|
||||
* browser embed loads `apps-embed.min.js` from the same directory.
|
||||
*/
|
||||
gui_bundle: string;
|
||||
/** Override the GUI CSS path when bundled. Default: `/dist/bundle.min.css`. */
|
||||
|
||||
@@ -18,10 +18,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
decodeStrictBase64,
|
||||
decodeStrictBase64InSlices,
|
||||
} from './mediaSniff.ts';
|
||||
import { decodeStrictBase64 } from './mediaSniff.ts';
|
||||
|
||||
describe('decodeStrictBase64', () => {
|
||||
it.each([
|
||||
@@ -53,52 +50,3 @@ describe('decodeStrictBase64', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeStrictBase64InSlices', () => {
|
||||
// Several slices, with a last one that is partial and padded.
|
||||
const bytes = Buffer.alloc(5 * 1024 * 1024 + 2);
|
||||
for (let i = 0; i < bytes.length; i += 4099) bytes[i] = i % 251;
|
||||
const valid = bytes.toString('base64');
|
||||
|
||||
it('decodes a multi-MB payload to the same bytes', async () => {
|
||||
expect(valid.endsWith('==')).toBe(true);
|
||||
expect((await decodeStrictBase64InSlices(valid))?.equals(bytes)).toBe(
|
||||
true,
|
||||
);
|
||||
const short = Buffer.from('abcd').toString('base64');
|
||||
expect(await decodeStrictBase64InSlices(short)).toEqual(
|
||||
Buffer.from('abcd'),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['empty input', ''],
|
||||
['unpadded input', valid.slice(0, -2)],
|
||||
[
|
||||
'a character outside the alphabet in the first slice',
|
||||
`*${valid.slice(1)}`,
|
||||
],
|
||||
[
|
||||
'a character outside the alphabet in the last slice',
|
||||
`${valid.slice(0, -5)}*${valid.slice(-4)}`,
|
||||
],
|
||||
[
|
||||
'the URL-safe alphabet',
|
||||
`${valid.slice(0, 3_000_000)}-${valid.slice(3_000_001)}`,
|
||||
],
|
||||
[
|
||||
'a non-ASCII character',
|
||||
`${valid.slice(0, 3_000_000)}ī${valid.slice(3_000_001)}`,
|
||||
],
|
||||
[
|
||||
'padding where a slice ends',
|
||||
`${valid.slice(0, 1024 * 1024 - 1)}=${valid.slice(1024 * 1024)}`,
|
||||
],
|
||||
[
|
||||
'padding inside a slice',
|
||||
`${valid.slice(0, 2_000_002)}==${valid.slice(2_000_004)}`,
|
||||
],
|
||||
])('rejects %s', async (_label, value) => {
|
||||
expect(await decodeStrictBase64InSlices(value)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,6 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { setImmediate as nextTurn } from 'node:timers/promises';
|
||||
|
||||
// Identify uploaded media by its bytes. A caller-supplied MIME type or file
|
||||
// extension is never trusted.
|
||||
|
||||
@@ -71,33 +69,6 @@ export function decodeStrictBase64(value: string): Buffer | null {
|
||||
return bytes.length === (value.length / 4) * 3 - padding ? bytes : null;
|
||||
}
|
||||
|
||||
/** Base64 characters decoded per turn; a multiple of four. */
|
||||
const BASE64_SLICE_CHARS = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* `decodeStrictBase64` for payloads of many megabytes: decodes a slice per turn
|
||||
* of the event loop, so the whole string never holds it.
|
||||
*/
|
||||
export async function decodeStrictBase64InSlices(
|
||||
value: string,
|
||||
): Promise<Buffer | null> {
|
||||
if (value.length <= BASE64_SLICE_CHARS) return decodeStrictBase64(value);
|
||||
if (value.length % 4 !== 0) return null;
|
||||
const padding = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0;
|
||||
const bytes = Buffer.allocUnsafe((value.length / 4) * 3 - padding);
|
||||
let written = 0;
|
||||
for (let at = 0; at < value.length; at += BASE64_SLICE_CHARS) {
|
||||
const slice = decodeStrictBase64(
|
||||
value.slice(at, at + BASE64_SLICE_CHARS),
|
||||
);
|
||||
// Padding inside the string leaves a slice short, and the total with it.
|
||||
if (!slice || written + slice.length > bytes.length) return null;
|
||||
written += slice.copy(bytes, written);
|
||||
await nextTurn();
|
||||
}
|
||||
return written === bytes.length ? bytes : null;
|
||||
}
|
||||
|
||||
function looksLikeSvg(bytes: Buffer): boolean {
|
||||
let head = bytes.subarray(0, SVG_SNIFF_WINDOW).toString('utf8');
|
||||
if (head.charCodeAt(0) === 0xfeff) head = head.slice(1);
|
||||
|
||||
@@ -1,374 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
MAX_DEPTH,
|
||||
parseJsonInSlices,
|
||||
SLICE_BYTES,
|
||||
STRING_PIECE_BYTES,
|
||||
} from './slicedJson.js';
|
||||
|
||||
type Outcome = { value: unknown } | { error: string };
|
||||
|
||||
const native = (body: Buffer): Outcome => {
|
||||
try {
|
||||
return { value: JSON.parse(body.toString('utf8')) };
|
||||
} catch (e) {
|
||||
return { error: (e as Error).name };
|
||||
}
|
||||
};
|
||||
|
||||
const sliced = async (body: Buffer): Promise<Outcome> => {
|
||||
try {
|
||||
return { value: await parseJsonInSlices(body) };
|
||||
} catch (e) {
|
||||
return { error: (e as Error).name };
|
||||
}
|
||||
};
|
||||
|
||||
/** Key order, own `__proto__` keys and lone surrogates all show in the text. */
|
||||
const shown = (outcome: Outcome): string =>
|
||||
'error' in outcome ? outcome.error : JSON.stringify(outcome.value);
|
||||
|
||||
const expectSameAsNative = async (body: Buffer | string): Promise<void> => {
|
||||
const buffer = typeof body === 'string' ? Buffer.from(body) : body;
|
||||
expect(shown(await sliced(buffer))).toBe(shown(native(buffer)));
|
||||
};
|
||||
|
||||
/** Whitespace that pushes `text` onto the sliced path without changing it. */
|
||||
const padded = (text: string): string => `${' '.repeat(SLICE_BYTES)}${text}`;
|
||||
|
||||
// mulberry32: the cases must be the same on every run.
|
||||
const seeded = (seed: number) => () => {
|
||||
seed = (seed + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
|
||||
const STRING_PARTS = [
|
||||
'a',
|
||||
'word ',
|
||||
'"',
|
||||
'\\',
|
||||
'\\\\"',
|
||||
'\n',
|
||||
'\u0001',
|
||||
'é',
|
||||
'日本',
|
||||
'😀',
|
||||
'\ud83d',
|
||||
'\u2028',
|
||||
'/',
|
||||
'\\u00e9',
|
||||
];
|
||||
const KEYS = ['id', 'name', '__proto__', '7', '3', 'constructor', 'a b', ''];
|
||||
// No key holds a backslash: with one in a key seen earlier, some V8 versions
|
||||
// misread a later escaped key, in `JSON.parse` itself.
|
||||
const KEY_PARTS = STRING_PARTS.filter((part) => !part.includes('\\'));
|
||||
const SCALARS = [
|
||||
'0',
|
||||
'-0',
|
||||
'1.5e300',
|
||||
'-12.25',
|
||||
'1e-7',
|
||||
'9007199254740993',
|
||||
'true',
|
||||
'false',
|
||||
'null',
|
||||
];
|
||||
const GAPS = ['', '', '', ' ', '\n\t', '\r\n '];
|
||||
|
||||
/** A random JSON document, written as text so spacing and escapes vary. */
|
||||
const randomDocument = (
|
||||
random: () => number,
|
||||
targetBytes: number,
|
||||
{ plainKeys = false } = {},
|
||||
): string => {
|
||||
const pick = <T>(items: readonly T[]): T =>
|
||||
items[Math.floor(random() * items.length)]!;
|
||||
const gap = () => pick(GAPS);
|
||||
const string = (maxParts: number, from = STRING_PARTS): string => {
|
||||
let text = '';
|
||||
const parts = Math.floor(random() * maxParts);
|
||||
for (let i = 0; i < parts; i++) text += pick(from);
|
||||
return JSON.stringify(text);
|
||||
};
|
||||
let written = 0;
|
||||
const value = (depth: number): string => {
|
||||
const roll = random();
|
||||
let text: string;
|
||||
if (depth > 0 && roll < 0.25) {
|
||||
const count = Math.floor(random() * 12);
|
||||
const items: string[] = [];
|
||||
for (let i = 0; i < count; i++) items.push(value(depth - 1));
|
||||
text = `[${gap()}${items.join(`${gap()},${gap()}`)}${gap()}]`;
|
||||
} else if (depth > 0 && roll < 0.5) {
|
||||
const count = Math.floor(random() * 12);
|
||||
const members: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const key =
|
||||
plainKeys || random() < 0.7
|
||||
? JSON.stringify(pick(KEYS))
|
||||
: string(6, KEY_PARTS);
|
||||
members.push(`${key}${gap()}:${gap()}${value(depth - 1)}`);
|
||||
}
|
||||
text = `{${gap()}${members.join(`${gap()},${gap()}`)}${gap()}}`;
|
||||
} else if (roll < 0.75) {
|
||||
// Now and then one long enough to be read in pieces.
|
||||
text = string(random() < 0.002 ? 3 * SLICE_BYTES : 20);
|
||||
} else {
|
||||
text = pick(SCALARS);
|
||||
}
|
||||
written += text.length;
|
||||
return text;
|
||||
};
|
||||
const items: string[] = [];
|
||||
while (written < targetBytes) items.push(value(6));
|
||||
return random() < 0.5
|
||||
? `[${items.join(',')}]`
|
||||
: `{${items.map((item, i) => `"k${i % 50}":${item}`).join(',')}}`;
|
||||
};
|
||||
|
||||
describe('parseJsonInSlices', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('parses what JSON.parse parses, the same way', async () => {
|
||||
const random = seeded(1);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const size = SLICE_BYTES * (1 + Math.floor(random() * 12));
|
||||
await expectSameAsNative(randomDocument(random, size));
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it('parses the same when every clock read ends the turn', async () => {
|
||||
let now = 0;
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => (now += 1000));
|
||||
const random = seeded(3);
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await expectSameAsNative(randomDocument(random, 6 * SLICE_BYTES));
|
||||
}
|
||||
// Strings long enough for a turn to end inside them.
|
||||
await expectSameAsNative(
|
||||
`["${'ab\\n'.repeat(SLICE_BYTES)}",{"k":"${'\\\\'.repeat(SLICE_BYTES)}"},${'1234567890'.repeat(SLICE_BYTES / 2)}]`,
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
it('rejects what JSON.parse rejects', async () => {
|
||||
const random = seeded(2);
|
||||
const replacements = Buffer.from('[]{},:"\\ 0a\néx-.e');
|
||||
for (let i = 0; i < 40; i++) {
|
||||
// Plain keys, so no edit can leave a backslash in one.
|
||||
const valid = Buffer.from(
|
||||
randomDocument(random, 2 * SLICE_BYTES, { plainKeys: true }),
|
||||
);
|
||||
for (let edit = 0; edit < 12; edit++) {
|
||||
const body = Buffer.from(valid);
|
||||
const at = Math.floor(random() * body.length);
|
||||
const roll = random();
|
||||
if (roll < 0.4) {
|
||||
body[at] =
|
||||
replacements[
|
||||
Math.floor(random() * replacements.length)
|
||||
]!;
|
||||
await expectSameAsNative(body);
|
||||
} else if (roll < 0.7) {
|
||||
await expectSameAsNative(
|
||||
Buffer.concat([
|
||||
body.subarray(0, at),
|
||||
body.subarray(at + 1),
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
await expectSameAsNative(body.subarray(0, at));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
it.each([
|
||||
['an empty body', ''],
|
||||
['only whitespace', ' \n '],
|
||||
['two values', '[1] [2]'],
|
||||
['a trailing comma in an array', '[1,2,]'],
|
||||
['a trailing comma in an object', '{"a":1,}'],
|
||||
['a leading comma', '[,1]'],
|
||||
['a doubled comma', '[1,,2]'],
|
||||
['a missing colon', '{"a" 1}'],
|
||||
['a missing value', '{"a":}'],
|
||||
['a bare key', '{a:1}'],
|
||||
['a colon in an array', '[1:2]'],
|
||||
['mismatched brackets', '[1}'],
|
||||
['an extra close', '[1]]'],
|
||||
['an unterminated string', '["abc'],
|
||||
['an unterminated array', '[1,2'],
|
||||
['a byte order mark', '\ufeff[1]'],
|
||||
['a comment', '[1/* x */]'],
|
||||
['a bad literal', '[tru]'],
|
||||
['a bad number', '[01]'],
|
||||
['a bad escape', '["\\x"]'],
|
||||
['a raw control character in a string', '["a\tb"]'],
|
||||
['a wide space between values', '[1,\u00a02]'],
|
||||
])('rejects %s', async (_name, text) => {
|
||||
await expectSameAsNative(text);
|
||||
// Short input takes the native path, so check the sliced one too.
|
||||
await expectSameAsNative(padded(text));
|
||||
await expectSameAsNative(
|
||||
`[${'1,'.repeat(SLICE_BYTES)}${text},${'2,'.repeat(SLICE_BYTES)}3]`,
|
||||
);
|
||||
});
|
||||
|
||||
it('reads a string longer than one piece, wherever a piece ends', async () => {
|
||||
const units = [
|
||||
'\\u00e9',
|
||||
'\\n',
|
||||
'\\\\',
|
||||
'\\"',
|
||||
'é',
|
||||
'日',
|
||||
'😀',
|
||||
'\\ud83d\\ude00',
|
||||
];
|
||||
for (const unit of units) {
|
||||
for (let shift = 0; shift < 9; shift++) {
|
||||
const lead = 'a'.repeat(STRING_PIECE_BYTES - 12 + shift);
|
||||
const text = `{"text":"${lead}${unit.repeat(8)}${lead}","n":1}`;
|
||||
await expectSameAsNative(text);
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it('reads long runs of escapes and stray UTF-8 bytes', async () => {
|
||||
const length = STRING_PIECE_BYTES + SLICE_BYTES;
|
||||
await expectSameAsNative(`["${'\\\\'.repeat(length)}"]`);
|
||||
await expectSameAsNative(`["${'\\"'.repeat(length)}"]`);
|
||||
await expectSameAsNative(`["${'\\'.repeat(2 * length + 1)}"]`);
|
||||
await expectSameAsNative(
|
||||
Buffer.concat([
|
||||
Buffer.from('["'),
|
||||
Buffer.alloc(2 * length, 0x80),
|
||||
Buffer.from([0xe2, 0x82]),
|
||||
Buffer.from('"]'),
|
||||
]),
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
it('reads a long key', async () => {
|
||||
const key = 'k'.repeat(2 * SLICE_BYTES);
|
||||
await expectSameAsNative(`{"a":1,"${key}":{"b":[2]},"${key}x":"v"}`);
|
||||
await expectSameAsNative(`{"${key}\\n":2,"z":3}`);
|
||||
});
|
||||
|
||||
it('reads long gaps of whitespace', async () => {
|
||||
const gap = ' '.repeat(2 * SLICE_BYTES);
|
||||
await expectSameAsNative(
|
||||
`${gap}{${gap}"a"${gap}:${gap}1${gap},${gap}"b":[${gap}2${gap},3${gap}]${gap}}${gap}`,
|
||||
);
|
||||
await expectSameAsNative(`[1,${gap}2${gap}]`);
|
||||
});
|
||||
|
||||
it('reads a scalar or a string at the top level', async () => {
|
||||
await expectSameAsNative(padded('12.5'));
|
||||
await expectSameAsNative(padded('null'));
|
||||
await expectSameAsNative(`"${'x'.repeat(4 * SLICE_BYTES)}"`);
|
||||
});
|
||||
|
||||
it('keeps the last of a repeated key, in its first position', async () => {
|
||||
const filler = `"f":"${'x'.repeat(SLICE_BYTES)}"`;
|
||||
await expectSameAsNative(`{"a":1,"b":2,${filler},"a":3,"7":4,"3":5}`);
|
||||
const value = (await parseJsonInSlices(
|
||||
Buffer.from(`{"a":1,"b":2,${filler},"a":3}`),
|
||||
)) as Record<string, unknown>;
|
||||
expect(Object.keys(value)).toEqual(['a', 'b', 'f']);
|
||||
expect(value.a).toBe(3);
|
||||
});
|
||||
|
||||
it('keeps __proto__ as a plain own property', async () => {
|
||||
const filler = `"f":"${'x'.repeat(SLICE_BYTES)}"`;
|
||||
const bodies = [
|
||||
// In a batch of members, as a container of its own, and alone.
|
||||
`{${filler},"__proto__":{"polluted":true},"z":1}`,
|
||||
`{"__proto__":{"polluted":true,${filler}}}`,
|
||||
`{"__proto__":"${'y'.repeat(2 * SLICE_BYTES)}"}`,
|
||||
];
|
||||
for (const body of bodies) {
|
||||
const value = (await parseJsonInSlices(
|
||||
Buffer.from(body),
|
||||
)) as Record<string, unknown>;
|
||||
expect(Object.getPrototypeOf(value)).toBe(Object.prototype);
|
||||
expect(Object.hasOwn(value, '__proto__')).toBe(true);
|
||||
expect(({} as { polluted?: boolean }).polluted).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the sign of negative zero', async () => {
|
||||
const value = (await parseJsonInSlices(
|
||||
Buffer.from(`[${'0,'.repeat(SLICE_BYTES)}-0]`),
|
||||
)) as number[];
|
||||
expect(Object.is(value.at(-1), -0)).toBe(true);
|
||||
});
|
||||
|
||||
it('reads nesting up to the depth limit and refuses more', async () => {
|
||||
const nested = (depth: number) =>
|
||||
padded(`${'['.repeat(depth)}${']'.repeat(depth)}`);
|
||||
// The root is not a level of nesting.
|
||||
let value = await parseJsonInSlices(Buffer.from(nested(MAX_DEPTH)));
|
||||
let depth = 0;
|
||||
while (Array.isArray(value)) {
|
||||
depth++;
|
||||
value = value[0];
|
||||
}
|
||||
expect(depth).toBe(MAX_DEPTH);
|
||||
|
||||
await expect(
|
||||
parseJsonInSlices(Buffer.from(nested(MAX_DEPTH + 1))),
|
||||
).rejects.toThrow(SyntaxError);
|
||||
});
|
||||
|
||||
it('reads members spread thinly across many levels', async () => {
|
||||
// No level holds a slice of members, but together they hold many.
|
||||
const members = '1,'.repeat(SLICE_BYTES / 8);
|
||||
const depth = 200;
|
||||
await expectSameAsNative(
|
||||
`${`[${members}`.repeat(depth)}0${']'.repeat(depth)}`,
|
||||
);
|
||||
await expectSameAsNative(
|
||||
`${`{"a":[${members}0],"b":`.repeat(depth)}0${'}'.repeat(depth)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('lets the event loop run while it parses', async () => {
|
||||
// Dense input: the slowest kind to parse for its size.
|
||||
const body = Buffer.from(`[${'[],'.repeat(1_000_000)}[]]`);
|
||||
let turns = 0;
|
||||
let running = true;
|
||||
const tick = () => {
|
||||
turns++;
|
||||
if (running) setImmediate(tick);
|
||||
};
|
||||
setImmediate(tick);
|
||||
const value = (await parseJsonInSlices(body)) as unknown[];
|
||||
running = false;
|
||||
expect(value).toHaveLength(1_000_001);
|
||||
expect(turns).toBeGreaterThan(5);
|
||||
});
|
||||
});
|
||||
@@ -1,581 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { setImmediate as nextTurn } from 'node:timers/promises';
|
||||
|
||||
/**
|
||||
* Text handed to one native `JSON.parse` call stays within a few multiples of
|
||||
* this, which bounds how long the call can run. Shorter input parses whole.
|
||||
*/
|
||||
export const SLICE_BYTES = 32 * 1024;
|
||||
/** Bytes of a long string decoded per turn. */
|
||||
export const STRING_PIECE_BYTES = 512 * 1024;
|
||||
/** Deeper nesting is refused. No mainstream serializer writes this deep. */
|
||||
export const MAX_DEPTH = 10_000;
|
||||
/** How long a parse runs before the event loop gets a turn. */
|
||||
const TURN_BUDGET_MS = 3;
|
||||
/** Bytes scanned between clock reads. */
|
||||
const CLOCK_INTERVAL_BYTES = 16 * 1024;
|
||||
/** Bytes of a string scanned one at a time before a native search takes over. */
|
||||
const BYTEWISE_BYTES = 64;
|
||||
/** Rounds of a string scan between clock reads. */
|
||||
const CLOCK_INTERVAL_ROUNDS = 64;
|
||||
|
||||
const QUOTE = 0x22;
|
||||
const BACKSLASH = 0x5c;
|
||||
|
||||
// Byte classes. Anything unlisted is part of a number or literal.
|
||||
const C_SCALAR = 0;
|
||||
const C_SPACE = 1;
|
||||
const C_QUOTE = 2;
|
||||
const C_OPEN_ARRAY = 3;
|
||||
const C_OPEN_OBJECT = 4;
|
||||
const C_CLOSE_ARRAY = 5;
|
||||
const C_CLOSE_OBJECT = 6;
|
||||
const C_COMMA = 7;
|
||||
const C_COLON = 8;
|
||||
const BYTE_CLASS = new Uint8Array(256);
|
||||
for (const byte of [0x20, 0x09, 0x0a, 0x0d]) BYTE_CLASS[byte] = C_SPACE;
|
||||
BYTE_CLASS[QUOTE] = C_QUOTE;
|
||||
BYTE_CLASS[0x5b] = C_OPEN_ARRAY;
|
||||
BYTE_CLASS[0x7b] = C_OPEN_OBJECT;
|
||||
BYTE_CLASS[0x5d] = C_CLOSE_ARRAY;
|
||||
BYTE_CLASS[0x7d] = C_CLOSE_OBJECT;
|
||||
BYTE_CLASS[0x2c] = C_COMMA;
|
||||
BYTE_CLASS[0x3a] = C_COLON;
|
||||
|
||||
const ROOT = 0;
|
||||
const ARRAY = 1;
|
||||
const OBJECT = 2;
|
||||
|
||||
/** Just opened: a member or the close. */
|
||||
const S_FIRST = 0;
|
||||
/** After a comma: a member. */
|
||||
const S_MEMBER = 1;
|
||||
/** After an object key. */
|
||||
const S_COLON = 2;
|
||||
/** After a colon, and the root before its value. */
|
||||
const S_VALUE = 3;
|
||||
/** After a value: a comma or the close. The root is done. */
|
||||
const S_AFTER = 4;
|
||||
|
||||
const DONE = 0;
|
||||
const TURN = 1;
|
||||
const LONG_STRING = 2;
|
||||
|
||||
/**
|
||||
* One open container, or the root. Members are scanned, not parsed: `pending`
|
||||
* marks text handed to `JSON.parse` once enough of it has built up, and a
|
||||
* container too large for one call gets its `out` filled a batch at a time.
|
||||
*/
|
||||
class Frame {
|
||||
kind = ROOT;
|
||||
state = S_VALUE;
|
||||
/** Offset of the opening bracket. */
|
||||
start = 0;
|
||||
/** The container under construction, once it is known to be large. */
|
||||
out: unknown[] | Record<string, unknown> | null = null;
|
||||
/** Where the members not yet parsed start, or -1. */
|
||||
pending = -1;
|
||||
/** Where the last complete member ended. */
|
||||
lastEnd = -1;
|
||||
/** Bounds of the member being scanned. */
|
||||
memberStart = -1;
|
||||
keyStart = -1;
|
||||
keyEnd = -1;
|
||||
valueStart = -1;
|
||||
/** The current member's key, when the member is parsed apart from `pending`. */
|
||||
ownKey: string | null = null;
|
||||
/** Bytes of the parent's `pending` waiting behind this container. */
|
||||
backlog = 0;
|
||||
|
||||
open(kind: number, start: number): void {
|
||||
this.kind = kind;
|
||||
this.state = kind === ROOT ? S_VALUE : S_FIRST;
|
||||
this.start = start;
|
||||
this.out = null;
|
||||
this.pending = -1;
|
||||
this.lastEnd = -1;
|
||||
this.memberStart = -1;
|
||||
this.keyStart = -1;
|
||||
this.keyEnd = -1;
|
||||
this.valueStart = -1;
|
||||
this.ownKey = null;
|
||||
this.backlog = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const unexpected = (at: number): SyntaxError =>
|
||||
new SyntaxError(`Unexpected token in JSON at position ${at}`);
|
||||
|
||||
/** `JSON.parse` keeps `__proto__` as an own property; assignment would not. */
|
||||
const setOwn = (
|
||||
target: Record<string, unknown>,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void => {
|
||||
if (key === '__proto__') {
|
||||
Object.defineProperty(target, key, {
|
||||
value,
|
||||
writable: true,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
} else {
|
||||
target[key] = value;
|
||||
}
|
||||
};
|
||||
|
||||
class SlicedJsonParser {
|
||||
result: unknown;
|
||||
/** Bounds of the string `run` stopped at, quotes included. */
|
||||
longStart = -1;
|
||||
longEnd = -1;
|
||||
|
||||
readonly #body: Buffer;
|
||||
readonly #end: number;
|
||||
#pos = 0;
|
||||
#frames = [new Frame()];
|
||||
#depth = 0;
|
||||
/** Frames below this depth have an `out`; the root counts as one. */
|
||||
#filling = 1;
|
||||
/** Bytes of `pending` in frames below the top one. */
|
||||
#backlog = 0;
|
||||
#inScalar = false;
|
||||
/** The string being scanned, and where its scan resumes. */
|
||||
#stringStart = -1;
|
||||
#stringPos = 0;
|
||||
/** Next quote and backslash at or after the last search origin. */
|
||||
#quoteAt = -1;
|
||||
#backslashAt = -1;
|
||||
#longIsKey = false;
|
||||
|
||||
constructor(body: Buffer) {
|
||||
this.#body = body;
|
||||
this.#end = body.length;
|
||||
}
|
||||
|
||||
/** Scans until the input ends, `deadline` passes, or a long string starts. */
|
||||
run(deadline: number): number {
|
||||
const body = this.#body;
|
||||
const end = this.#end;
|
||||
let i = this.#pos;
|
||||
let frame = this.#frames[this.#depth]!;
|
||||
let clockAt = i + CLOCK_INTERVAL_BYTES;
|
||||
|
||||
for (;;) {
|
||||
if (this.#stringStart !== -1) {
|
||||
const close = this.#stringEnd(deadline);
|
||||
if (close === -1) return TURN;
|
||||
const start = this.#stringStart;
|
||||
this.#stringStart = -1;
|
||||
i = close + 1;
|
||||
if (this.#string(frame, start, close)) {
|
||||
this.#pos = i;
|
||||
return LONG_STRING;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (i >= clockAt) {
|
||||
clockAt = i + CLOCK_INTERVAL_BYTES;
|
||||
if (performance.now() >= deadline) {
|
||||
this.#pos = i;
|
||||
return TURN;
|
||||
}
|
||||
}
|
||||
if (i >= end) break;
|
||||
|
||||
const byteClass = BYTE_CLASS[body[i]!]!;
|
||||
if (byteClass === C_SCALAR) {
|
||||
if (!this.#inScalar) {
|
||||
this.#valueStart(frame, i);
|
||||
this.#inScalar = true;
|
||||
}
|
||||
const stop = Math.min(end, clockAt);
|
||||
i++;
|
||||
while (i < stop && BYTE_CLASS[body[i]!] === C_SCALAR) i++;
|
||||
continue;
|
||||
}
|
||||
if (this.#inScalar) {
|
||||
this.#inScalar = false;
|
||||
this.#valueEnd(frame, i);
|
||||
}
|
||||
|
||||
switch (byteClass) {
|
||||
case C_SPACE: {
|
||||
const stop = Math.min(end, clockAt);
|
||||
i++;
|
||||
while (i < stop && BYTE_CLASS[body[i]!] === C_SPACE) i++;
|
||||
break;
|
||||
}
|
||||
case C_QUOTE:
|
||||
this.#stringStart = i;
|
||||
this.#stringPos = i + 1;
|
||||
break;
|
||||
case C_OPEN_ARRAY:
|
||||
case C_OPEN_OBJECT: {
|
||||
this.#valueStart(frame, i);
|
||||
if (this.#depth === MAX_DEPTH) {
|
||||
throw new SyntaxError('JSON is nested too deeply');
|
||||
}
|
||||
const parent = frame;
|
||||
// Members before this one wait until it closes or grows.
|
||||
const before =
|
||||
parent.pending !== -1 &&
|
||||
parent.pending !== parent.memberStart
|
||||
? parent.lastEnd - parent.pending
|
||||
: 0;
|
||||
this.#depth++;
|
||||
frame = this.#frames[this.#depth] ??= new Frame();
|
||||
frame.open(byteClass === C_OPEN_ARRAY ? ARRAY : OBJECT, i);
|
||||
frame.backlog = before;
|
||||
this.#backlog += before;
|
||||
// Parsed now, so deep nesting can't save it all up.
|
||||
if (this.#backlog >= SLICE_BYTES) this.#fill();
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
case C_CLOSE_ARRAY:
|
||||
case C_CLOSE_OBJECT: {
|
||||
if (
|
||||
frame.kind !==
|
||||
(byteClass === C_CLOSE_ARRAY ? ARRAY : OBJECT) ||
|
||||
(frame.state !== S_FIRST && frame.state !== S_AFTER)
|
||||
) {
|
||||
throw unexpected(i);
|
||||
}
|
||||
let filled = this.#depth < this.#filling;
|
||||
if (!filled && i + 1 - frame.start > SLICE_BYTES) {
|
||||
this.#fill();
|
||||
filled = true;
|
||||
}
|
||||
if (filled) {
|
||||
if (frame.pending !== -1) {
|
||||
this.#parsePending(frame);
|
||||
}
|
||||
frame.out = null;
|
||||
this.#filling = this.#depth;
|
||||
}
|
||||
this.#backlog -= frame.backlog;
|
||||
this.#depth--;
|
||||
frame = this.#frames[this.#depth]!;
|
||||
i++;
|
||||
if (filled) {
|
||||
frame.state = S_AFTER;
|
||||
frame.lastEnd = i;
|
||||
} else {
|
||||
this.#valueEnd(frame, i);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case C_COMMA:
|
||||
if (frame.kind === ROOT || frame.state !== S_AFTER) {
|
||||
throw unexpected(i);
|
||||
}
|
||||
frame.state = S_MEMBER;
|
||||
i++;
|
||||
break;
|
||||
default:
|
||||
if (frame.kind !== OBJECT || frame.state !== S_COLON) {
|
||||
throw unexpected(i);
|
||||
}
|
||||
frame.state = S_VALUE;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#inScalar) {
|
||||
this.#inScalar = false;
|
||||
this.#valueEnd(frame, end);
|
||||
}
|
||||
if (this.#depth !== 0 || frame.state !== S_AFTER) {
|
||||
throw new SyntaxError('Unexpected end of JSON input');
|
||||
}
|
||||
if (frame.pending !== -1) this.#parsePending(frame);
|
||||
return DONE;
|
||||
}
|
||||
|
||||
/** Supplies the string `run` stopped at. */
|
||||
resolveLongString(value: string): void {
|
||||
const frame = this.#frames[this.#depth]!;
|
||||
if (this.#longIsKey) {
|
||||
frame.ownKey = value;
|
||||
return;
|
||||
}
|
||||
this.#attach(frame, frame.ownKey, value);
|
||||
frame.ownKey = null;
|
||||
frame.lastEnd = this.longEnd + 1;
|
||||
frame.state = S_AFTER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Offset of the quote closing the string being scanned, or -1 when
|
||||
* `deadline` passed first.
|
||||
*/
|
||||
#stringEnd(deadline: number): number {
|
||||
const body = this.#body;
|
||||
const end = this.#end;
|
||||
let at = this.#stringPos;
|
||||
|
||||
for (let round = 1; ; round++) {
|
||||
// Bytewise through a short string or a stretch of escapes.
|
||||
const near = Math.min(at + BYTEWISE_BYTES, end);
|
||||
while (at < near) {
|
||||
const byte = body[at]!;
|
||||
if (byte === QUOTE) return at;
|
||||
at += byte === BACKSLASH ? 2 : 1;
|
||||
}
|
||||
if (at >= end) throw new SyntaxError('Unterminated string in JSON');
|
||||
|
||||
// Then a native search across text that holds neither.
|
||||
if (this.#quoteAt < at) {
|
||||
this.#quoteAt = body.indexOf(QUOTE, at);
|
||||
if (this.#quoteAt === -1) {
|
||||
throw new SyntaxError('Unterminated string in JSON');
|
||||
}
|
||||
}
|
||||
if (this.#backslashAt < at) {
|
||||
const found = body.indexOf(BACKSLASH, at);
|
||||
this.#backslashAt = found === -1 ? end : found;
|
||||
}
|
||||
if (this.#backslashAt > this.#quoteAt) return this.#quoteAt;
|
||||
at = this.#backslashAt;
|
||||
|
||||
if (round % CLOCK_INTERVAL_ROUNDS === 0) {
|
||||
if (performance.now() >= deadline) {
|
||||
this.#stringPos = at;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Handles a scanned string; true when `run` must stop for a long one. */
|
||||
#string(frame: Frame, start: number, close: number): boolean {
|
||||
const long = close - start > SLICE_BYTES;
|
||||
if (
|
||||
frame.kind === OBJECT &&
|
||||
(frame.state === S_FIRST || frame.state === S_MEMBER)
|
||||
) {
|
||||
this.#memberStart(frame, start);
|
||||
frame.keyStart = start;
|
||||
frame.keyEnd = close;
|
||||
frame.state = S_COLON;
|
||||
if (!long) return false;
|
||||
this.#detachMember(frame);
|
||||
this.#longIsKey = true;
|
||||
} else {
|
||||
this.#valueStart(frame, start);
|
||||
if (!long) {
|
||||
this.#valueEnd(frame, close + 1);
|
||||
return false;
|
||||
}
|
||||
if (frame.ownKey === null) {
|
||||
this.#detachMember(frame);
|
||||
if (frame.kind === OBJECT) frame.ownKey = this.#key(frame);
|
||||
}
|
||||
this.#longIsKey = false;
|
||||
}
|
||||
this.longStart = start;
|
||||
this.longEnd = close;
|
||||
return true;
|
||||
}
|
||||
|
||||
#memberStart(frame: Frame, at: number): void {
|
||||
if (frame.pending !== -1 && at - frame.pending >= SLICE_BYTES) {
|
||||
this.#fill();
|
||||
this.#parsePending(frame);
|
||||
}
|
||||
frame.memberStart = at;
|
||||
if (frame.pending === -1) frame.pending = at;
|
||||
}
|
||||
|
||||
#valueStart(frame: Frame, at: number): void {
|
||||
if (frame.kind === OBJECT) {
|
||||
if (frame.state !== S_VALUE) throw unexpected(at);
|
||||
// A long gap after the key would reach `JSON.parse` with it.
|
||||
if (frame.ownKey === null && at - frame.memberStart > SLICE_BYTES) {
|
||||
this.#detachMember(frame);
|
||||
frame.ownKey = this.#key(frame);
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
frame.kind === ROOT
|
||||
? frame.state !== S_VALUE
|
||||
: frame.state > S_MEMBER
|
||||
) {
|
||||
throw unexpected(at);
|
||||
}
|
||||
this.#memberStart(frame, at);
|
||||
}
|
||||
frame.valueStart = at;
|
||||
}
|
||||
|
||||
#valueEnd(frame: Frame, at: number): void {
|
||||
frame.lastEnd = at;
|
||||
frame.state = S_AFTER;
|
||||
if (frame.ownKey !== null) {
|
||||
this.#attach(
|
||||
frame,
|
||||
frame.ownKey,
|
||||
JSON.parse(this.#text(frame.valueStart, at)),
|
||||
);
|
||||
frame.ownKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes the current member out of `pending`; its value is attached alone. */
|
||||
#detachMember(frame: Frame): void {
|
||||
this.#fill();
|
||||
if (frame.pending !== -1 && frame.pending !== frame.memberStart) {
|
||||
this.#parsePending(frame);
|
||||
}
|
||||
frame.pending = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives every open container an `out`, attached to its parent after the
|
||||
* members that precede it.
|
||||
*/
|
||||
#fill(): void {
|
||||
const frames = this.#frames;
|
||||
for (let level = this.#filling; level <= this.#depth; level++) {
|
||||
const parent = frames[level - 1]!;
|
||||
const child = frames[level]!;
|
||||
if (
|
||||
parent.pending !== -1 &&
|
||||
parent.pending !== parent.memberStart
|
||||
) {
|
||||
this.#parsePending(parent);
|
||||
}
|
||||
parent.pending = -1;
|
||||
const key =
|
||||
parent.kind === OBJECT
|
||||
? (parent.ownKey ?? this.#key(parent))
|
||||
: null;
|
||||
parent.ownKey = null;
|
||||
child.out = child.kind === ARRAY ? [] : {};
|
||||
child.backlog = 0;
|
||||
this.#attach(parent, key, child.out);
|
||||
}
|
||||
this.#filling = this.#depth + 1;
|
||||
this.#backlog = 0;
|
||||
}
|
||||
|
||||
/** Parses the complete members in `pending` into the frame's container. */
|
||||
#parsePending(frame: Frame): void {
|
||||
const text = this.#text(frame.pending, frame.lastEnd);
|
||||
frame.pending = -1;
|
||||
if (frame.kind === ROOT) {
|
||||
this.result = JSON.parse(text);
|
||||
} else if (frame.kind === ARRAY) {
|
||||
const out = frame.out as unknown[];
|
||||
const items = JSON.parse(`[${text}]`) as unknown[];
|
||||
for (let k = 0; k < items.length; k++) out.push(items[k]);
|
||||
} else {
|
||||
const out = frame.out as Record<string, unknown>;
|
||||
const members = JSON.parse(`{${text}}`) as Record<string, unknown>;
|
||||
for (const key of Object.keys(members)) {
|
||||
setOwn(out, key, members[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#attach(frame: Frame, key: string | null, value: unknown): void {
|
||||
if (frame.kind === ROOT) this.result = value;
|
||||
else if (frame.kind === ARRAY) (frame.out as unknown[]).push(value);
|
||||
else setOwn(frame.out as Record<string, unknown>, key!, value);
|
||||
}
|
||||
|
||||
#key(frame: Frame): string {
|
||||
return JSON.parse(
|
||||
this.#text(frame.keyStart, frame.keyEnd + 1),
|
||||
) as string;
|
||||
}
|
||||
|
||||
#text(from: number, to: number): string {
|
||||
return this.#body.toString('utf8', from, to);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a string's content can be cut at or before `target` without splitting
|
||||
* an escape or a UTF-8 sequence. `from` starts a character or an escape.
|
||||
*/
|
||||
const pieceEnd = (body: Buffer, from: number, target: number): number => {
|
||||
// Far enough back that a `\uXXXX` reaching `target` starts after it.
|
||||
let at = Math.max(from, target - 8);
|
||||
// An odd run of backslashes before `at` means `at` is an escaped byte.
|
||||
let run = 0;
|
||||
while (at - run > from && body[at - run - 1] === BACKSLASH) run++;
|
||||
at -= run % 2;
|
||||
for (;;) {
|
||||
const length =
|
||||
body[at] !== BACKSLASH ? 1 : body[at + 1] === 0x75 ? 6 : 2;
|
||||
if (at + length > target) break;
|
||||
at += length;
|
||||
}
|
||||
// A sequence has at most three continuation bytes; more are stray.
|
||||
for (let back = 0; back < 3 && (body[at]! & 0xc0) === 0x80; back++) at--;
|
||||
return at;
|
||||
};
|
||||
|
||||
/** The string literal at `[start, close]`, decoded a piece per turn. */
|
||||
const readLongString = async (
|
||||
body: Buffer,
|
||||
start: number,
|
||||
close: number,
|
||||
): Promise<string> => {
|
||||
const pieces: string[] = [];
|
||||
let at = start + 1;
|
||||
while (at < close) {
|
||||
const end =
|
||||
close - at <= STRING_PIECE_BYTES
|
||||
? close
|
||||
: pieceEnd(body, at, at + STRING_PIECE_BYTES);
|
||||
pieces.push(
|
||||
JSON.parse(`"${body.toString('utf8', at, end)}"`) as string,
|
||||
);
|
||||
at = end;
|
||||
await nextTurn();
|
||||
}
|
||||
return pieces.join('');
|
||||
};
|
||||
|
||||
/**
|
||||
* `JSON.parse` of UTF-8 `body`, done in slices with the event loop running
|
||||
* between them, so no input holds it for long. Throws a `SyntaxError` for
|
||||
* malformed JSON, and for nesting deeper than `MAX_DEPTH`.
|
||||
*/
|
||||
export async function parseJsonInSlices(body: Buffer): Promise<unknown> {
|
||||
if (body.length <= SLICE_BYTES) return JSON.parse(body.toString('utf8'));
|
||||
|
||||
const parser = new SlicedJsonParser(body);
|
||||
for (;;) {
|
||||
const signal = parser.run(performance.now() + TURN_BUDGET_MS);
|
||||
if (signal === DONE) return parser.result;
|
||||
if (signal === LONG_STRING) {
|
||||
parser.resolveLongString(
|
||||
await readLongString(body, parser.longStart, parser.longEnd),
|
||||
);
|
||||
} else {
|
||||
await nextTurn();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,9 @@ export async function buildUserDetails(
|
||||
cardFallbackDepsFrom(clients),
|
||||
)
|
||||
: false,
|
||||
desktop_bg_url: user.desktop_bg_url,
|
||||
desktop_bg_color: user.desktop_bg_color,
|
||||
desktop_bg_fit: user.desktop_bg_fit,
|
||||
is_temp: user.password === null && user.email === null,
|
||||
is_user_token: true,
|
||||
// Null until the account asks for a code; never minted here.
|
||||
|
||||
@@ -1339,7 +1339,7 @@ async function saveEditedApp (mode) {
|
||||
// Update title
|
||||
$(`.app-title[data-uid="${uid}"]`).html(html_encode(app.title));
|
||||
// Update app link
|
||||
$(`.app-url[data-uid="${uid}"]`).html(html_encode(applink(app)));
|
||||
$(`.app-url[data-uid="${uid}"]`).html(applink(app));
|
||||
$(`.app-url[data-uid="${uid}"]`).attr('href', applink(app));
|
||||
// Update icons
|
||||
$(`.app-icon[data-uid="${uid}"]`).attr('src', html_encode(app.icon ? app.icon : './img/app.svg'));
|
||||
|
||||
@@ -38,27 +38,22 @@ window.user = null;
|
||||
}
|
||||
})();
|
||||
|
||||
// domain and port are built into links and deploy URLs, so anything other
|
||||
// than a bare hostname and a numeric port is ignored
|
||||
const domainParam = window.url_params.get('puter.domain');
|
||||
if ( domainParam && /^[a-z0-9-]+(\.[a-z0-9-]+)*$/i.test(domainParam) ) {
|
||||
window.domain = domainParam;
|
||||
// domain and APIOrigin
|
||||
if ( window.url_params.has('puter.domain') ) {
|
||||
window.domain = window.url_params.get('puter.domain');
|
||||
}
|
||||
|
||||
const portParam = window.url_params.get('puter.port');
|
||||
window.port = portParam && /^\d{1,5}$/.test(portParam) ? portParam : '';
|
||||
|
||||
// static hosting domain
|
||||
window.static_hosting_domain = 'puter.site';
|
||||
if ( window.domain === 'puter.localhost' ) {
|
||||
window.static_hosting_domain = 'site.puter.localhost';
|
||||
} else if ( window.domain && window.domain !== 'puter.com' ) {
|
||||
window.static_hosting_domain = `site.${window.domain}`;
|
||||
}
|
||||
if ( window.domain === 'puter.localhost' ) {
|
||||
window.static_hosting_domain = 'site.puter.localhost';
|
||||
} else if ( window.domain && window.domain !== 'puter.com' ) {
|
||||
window.static_hosting_domain = `site.${window.domain}`;
|
||||
}
|
||||
|
||||
// add port to static_hosting_domain if provided
|
||||
if ( window.port ) {
|
||||
window.static_hosting_domain = `${window.static_hosting_domain}:${window.port}`;
|
||||
if ( window.url_params.has('puter.port') && window.url_params.get('puter.port') ) {
|
||||
window.static_hosting_domain = `${window.static_hosting_domain }:${ html_encode(window.url_params.get('puter.port'))}`;
|
||||
}
|
||||
|
||||
// protocol
|
||||
@@ -68,6 +63,12 @@ if ( window.url_params.has('puter.protocol') && window.url_params.get('puter.pro
|
||||
window.protocol = 'http';
|
||||
}
|
||||
|
||||
// port
|
||||
window.port = '';
|
||||
if ( window.url_params.has('puter.port') && window.url_params.get('puter.port') ) {
|
||||
window.port = html_encode(window.url_params.get('puter.port'));
|
||||
}
|
||||
|
||||
// source_path
|
||||
if ( window.url_params.has('source_path') ) {
|
||||
window.source_path = window.url_params.get('source_path');
|
||||
|
||||
+2
-2
@@ -200,8 +200,8 @@ These AI features are supported out of the box when using Puter.js:
|
||||
- **[`puter.ai.img2txt()`](/AI/img2txt/)** - Extract text from images (OCR)
|
||||
- **[`puter.ai.txt2vid()`](/AI/txt2vid/)** - Generate short video clips from text or a reference image with Wan, Seedance, Veo, Kling, MiniMax H3 and other models, or edit a video you supply with Gemini Omni, FLUX 3 or Kling Omni
|
||||
- **[`puter.ai.speech2speech()`](/AI/speech2speech/)** - Convert speech in one voice to another voice
|
||||
- **[`puter.ai.realtime()`](/AI/realtime/)** - Prepare a beta realtime conversation with optional text, microphone, playback, and interruption helpers
|
||||
- **[Realtime WebSocket API](/AI/realtime-websocket/)** - Connect directly without puter.js. This API is in beta and may change abruptly.
|
||||
- **[`puter.ai.convo()`](/AI/convo/)** - Prepare a beta realtime conversation with optional text, microphone, playback, and interruption helpers
|
||||
- **[Realtime WebSocket API](/AI/realtime/)** - Connect directly without puter.js. This API is in beta and may change abruptly.
|
||||
- **[`puter.ai.listModels()`](/AI/listModels/)** - List available AI chat models (and providers) that Puter currently exposes.
|
||||
- **[`puter.ai.listModelProviders()`](/AI/listModelProviders/)** - List the AI providers that Puter currently exposes.
|
||||
- **[`puter.ai.txt2speech.listEngines()`](/AI/txt2speech.listEngines/)** - List available TTS engines/models
|
||||
|
||||
Loaded 100 of 365 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user