Compare commits

..
Author SHA1 Message Date
Filip Kujundžić 62bc8d6d6f Merge pull request #3955 from HeyPuter/fix/put-1992-deepseek-pricing 2026-09-28 21:22:39 +02:00
404oops 4700c2dc65 Fix DeepSeek routing and follow published peak pricing 2026-09-27 15:17:50 +02:00
Anuj Shrivastava e041fb6b9b Remove deprecated AI chat models flagged by live model check
OpenAI's o3-pro, Mistral's mistral-large-2512 and zai-glm-5-2, and
DeepSeek's deepseek-v4-flash / deepseek-v4-flash-vision-exp are no
longer served by their upstream APIs, so drop them from the hardcoded
provider model lists (closes HeyPuter/puter#3945).

DeepSeek's legacy chat/reasoner aliases and default model previously
pointed at deepseek-v4-flash; they now resolve to deepseek-v4-pro, the
remaining model in that catalog, so existing callers keep working.

Signed-off-by: Anuj Shrivastava <[email protected]>
Signed-off-by: Anuj Shrivastava <[email protected]>
2026-09-25 19:40:02 +05:30
Nariman Jelveh 5721bdeb1d Add link to AI Builder in README (#3946) 2026-09-24 21:00:04 -07:00
Daniel Salazar 132cae9f6a Bug bash fixes (PUT-1876, 1877, 1880, 1881, 1948, 1949, 1951) and KV event fan-out by plan (#3943)
* fix(kv): keep disableSharing entries out of other apps' events (PUT-1876)

Private kv keys and values are no longer delivered to cross-app subscriptions, including forwarded and durable deliveries.

* fix(fs): refuse usernames whose home path still holds another account's rows (PUT-1877)

Username claims (signup, save_account, change-username, OIDC, team provisioning) now also check for leftover rows under /<name>/, and the root listing only returns the actor's own home.

* fix(puter-js): reconnect the events channel after a server-side disconnect (PUT-1881)

A server hang-up is retried with a bounded backoff; persistent handlers survive it and get an optional onError when the connection can't be restored.

* fix(backend): let the graceful shutdown finish before telemetry exits (PUT-1948)

The telemetry preload no longer exits on signals; the entry point drains, tears down top-down, flushes telemetry last, then exits. DB pools stay open until teardown.

* fix(fs): reserve quota for signed uploads in progress (PUT-1880)

startWrite/startBatchWrite hold each upload's declared size in a per-owner cache reservation until it completes, aborts or expires, so a burst of starts can't all read the same committed usage. Caps in-flight uploads at 10,000 per account.

* fix(fs): refuse to complete a signed upload whose object never arrived (PUT-1951)

Completion now 400s when the object store reports the key missing (transient errors stay lenient), abort no longer deletes an object a live entry still uses, and a ghost entry is only removed after a second miss at its recorded location.

* fix(metering): move usage to v2 keys with a small totals item, per-model detail shards and a 3-month TTL (PUT-1949)

The billed item holds only totals; per-model detail lives in 100 hash-sharded items read in one batch and cached for 60s, and more than 5,000 distinct usage types in a month fold into other. Global and per-app aggregates store totals only, appTotals comes from a prefix listing, a refused path only skips the key that refused it, exact reads near the allowance are throttled per key, and every metering record expires after 3 months. September usage restarts at deploy; the monthly-charge claim stays on the v1 key through September so recurring charges don't fire twice.

* test(events): let the worker backoff test tolerate a loaded run

* feat(events): scale KV event fan-out with the key owner's plan

A KV change delivers to up to 512 matching subscriptions (2,048 filter evaluations) for a paid key owner and 128 (512) otherwise, inline values are dropped once more than 128 match, and paid accounts may hold up to 512 share handles per app. The owner's plan is only looked up when a change has more candidates than any cap.

* test(metering): seed the 150-app listing test directly so it fits under coverage
2026-09-24 16:00:52 -07:00
Neal Shah 4a9e5a2440 Ns/worker no socket (#3944)
* remove socket creation from puter worker startup

* Remove really extra comment
2026-09-24 18:55:10 -04:00
Juan Fernando Castro 600064dcee Merge pull request #3942 from HeyPuter/juancastro/put-1944-teams-hardening
fix: Teams lifecycle robustness — owner deletion, cap serialization, provisioned-account scope (PUT-1944)

fixes requested by Daniel pushed
2026-09-24 16:51:43 -04:00
Juan Castro 66ee50317c style: restore prettier formatting displaced by the main merge 2026-09-24 16:35:09 -04:00
Juan Castro 8f0a317a97 Merge remote-tracking branch 'origin/juancastro/put-1944-teams-hardening' into juancastro/put-1944-teams-hardening 2026-09-24 16:33:40 -04:00
Juan Castro 8c816060a3 fix: answer a cap-lock failure as 409, like contention, per review 2026-09-24 16:33:12 -04:00
Juan Castro 0131ebfe9b Merge branch 'main' into juancastro/put-1944-teams-hardening 2026-09-24 12:20:12 -04:00
Juan Fernando Castro 2cb7516141 Merge pull request #3936 from HeyPuter/juancastro/put-1883-alternate-host-bootstrap-app-row-shadows-a-private-app-and
fix(hosting): keep alternate-host bootstrap stubs from shadowing private apps
2026-09-24 12:19:31 -04:00
Juan Castro 0c5c1be777 fix: Teams lifecycle robustness ahead of wider enablement
Closing an account that owns a team left `owner_user_id` NULL while
`deleted_at` stayed NULL, so the team remained live while every team
route resolves by membership — which the deletion cascades away. The team
could then not be administered by anyone, and the accounts it provisioned
could not be closed. Declined now in both directions: live teams via
`countOwned`, and soft-deleted ones still holding accounts via a new
store read, since a deleted team's accounts are suspended rather than
removed.

The cap lock gave up after 200ms and proceeded unserialized, so
overlapping provisioning could each read the seat count before any insert
committed. It declines instead; a single create never contends. An
unreachable lock backend is a 503 rather than a silent pass.

An account a team provisioned can no longer create a team of its own
(PUT-1890) — that is not the intended lifecycle, since such accounts do
not go through the normal sign-up path. Keyed on `org_owned = 1`, so the
owner and joined members are unaffected, and not via `teamsAvailableTo`,
which is permissive by design.

The directory declines scoped access tokens, as `listMembers` already
bounds non-account callers.
2026-09-24 11:06:00 -04:00
Juan Castro 8ecaa77a0e Merge remote-tracking branch 'origin/main' into juancastro/put-1883-alternate-host-bootstrap-app-row-shadows-a-private-app-and
# Conflicts:
#	src/backend/controllers/auth/AuthController.test.ts
2026-09-24 10:04:00 -04:00
Neal Shah 07a11240ea PUT-1868 (#3940) 2026-09-24 00:30:47 -04:00
Daniel Salazar 7758aea78c fix: acl check (#3939) 2026-09-23 19:03:16 -07:00
Rasad RegmiandDaniel Salazar 83de67cca9 fix(tools): spawn npm.cmd through a shell on Windows (#3923)
* fix(tools): spawn npm.cmd through a shell on Windows

* fix(tools): join the npm command line on Windows to avoid DEP0190

Passing args alongside shell: true warns on Node 24. Test the spawn
tuple as data and run a real npm spawn instead of mocking child_process.

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-09-23 18:54:43 -07:00
Echa ApriliyantoandDaniel Salazar 9f69483593 feat: allow browser extension origins in auth requests (#3907)
* feat: allow browser extension origins in auth requests

Add chrome-extension://, moz-extension://, safari-extension://, safari-web-extension://, and extension:// to the protocol allow-list so browser extensions can obtain app tokens via /auth/get-user-app-token.

Extract WEB_AND_EXTENSION_PROTOCOLS constant in validation.js so the allow-list is defined once and shared by AuthService, AppStore, and AppDriver.

* fix: harden the extension-origin allow-list

Review follow-ups on the extension-origin change:

- Require a host in `validateUrl`. Only "special" schemes need an
  authority, so `chrome-extension:` parsed with an empty hostname and
  slipped past the reserved-system-host guard in AppDriver.
- Lowercase the host in `AuthService#normalizedOrigin`. `new URL()`
  lowercases http(s) hosts but leaves opaque ones alone, so one
  extension in two spellings resolved to two app uids — two AppData
  trees, two permission sets — and missed the origin blocklist.
- Drop `extension:`. No browser emits it, and it accepted
  `extension://evil.com` as an app origin.
- Freeze the allow-list and derive `validateUrl`'s http(s) default from
  `WEB_PROTOCOLS` so the two spellings can't drift apart.
- Pin the tests to the real uid derivation, use unique extension ids so
  a row left by another test can't mask the bootstrap path, and cover
  the host-less and near-miss schemes.
- Fix the prettier/eslint failure in AppStore.js.

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-09-23 18:24:31 -07:00
Daniel Salazar 606f5a27a3 chore: CODEOWNERS (#3938) 2026-09-23 18:24:09 -07:00
Daniel Salazar 58ed2f485f feat(auth): signup bonus codes
Signup links can carry a bonusCode. Core checks its shape and previews it through `puter.signup-bonus.check`, refuses a dead code before the abuse hook records the attempt, then hands a live one to `puter.signup-bonus.validate` after `puter.signup.validate`, which may raise the phone/card gates; the accepted code rides on `puter.signup.success`, and OIDC carries it in the signed state. `user.card-verified` is now awaited like `user.phone-verified`. The signup form shows the offer and sends the code.
2026-09-23 14:47:34 -07:00
Daniel Salazar 4c3c684cb4 fix: refuse app-issued scoped tokens on events handler and worker routes (#3935)
* fix: new cache costs for claude

* fix: refuse app-issued scoped tokens on events handler and worker routes

An access token an app mints (e.g. a getReadURL() token) resolves
effectiveApp to the issuing app, so #handlerApp and listEventsWorkers
treated it as the app: it could publish, remove and list handlers, and
list or destroy the app's worker, regardless of its permission manifest.
Refuse any scoped token there, as the kv-handle routes already do.

* test: freeze the clock before the first backoff read in workerInvoker

The first hold was read on real time, so a slow run measured the 2s
backoff as 1s.
2026-09-23 13:58:49 -07:00
Juan Castro 438a4584de fix(hosting): keep alternate-host bootstrap stubs from shadowing private apps
A public origin-bootstrap app row planted on an alternate hosting domain
could resolve ahead of the owner's private app and skip the private
access gate (PUT-1883).

- get-user-app-token canonicalizes hosted-subdomain origins before uid
  derivation and bootstrap, so every hosting variant shares one app row
- the app create/update conflict check crosses hosting-domain variants,
  so an existing alt-host stub is absorbed by the owner's create
- resolveOwnedAppForHostedSite and the canonical-uid lookup order
  matches private-first with a deterministic id tiebreak
2026-09-23 16:39:22 -04:00
Daniel Salazar b3fda186e7 fix: let apps read opted-in teams, scope worker listing, fix revokeReadURL (#3933)
- GET /teams and /teams/:uid/members admit apps for teams whose owner opened
  the directory; apps get active members' usernames only
- events.workers.list() from an app returns only its own worker
- hide account-session-only SDK methods from the public types
- unpublish the updateProfile docs page (redirects to getProfile)
- revokeReadURL() works through a new /auth/revoke-own-access-token route
2026-09-23 12:06:56 -07:00
Filip KujundžićandClaude Opus 5.5 bc0f4dc279 feat(ai): add GPT-6 Sol and Luna and sync Claude pricing (#3931)
* feat(ai): sync GPT-6 Sol, GPT-6 Luna, and Claude Opus 5.5

* chore: remove documentation changes from model sync

* fix(ai): bill OpenAI cache writes and long-context pricing

GPT-5.6 and later bill prompt-cache writes at 1.25x input and report them
in `cache_write_tokens`, inside the input total. Both OpenAI calculators now
split them out of the prompt count and meter them under their own key, and
the six GPT-5.6+ models carry the rate.

GPT-6, GPT-5.6, GPT-5.5 and GPT-5.4 (incl. Pro) bill a request with more
than 272K input tokens at 2x input (cached reads and cache writes included)
and 1.5x output for the whole request. Models declare this as
`long_context_pricing`, and the ledger overrides, the reported `usd_cents`
and the credit gate all apply the multipliers.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

* fix(ai): bill GPT-5.6 Sol at OpenAI's promotional pricing

The catalog carried GPT-5.5's $5/$0.50/$30, but OpenAI bills GPT-5.6 Sol
at $4/$0.40/$20 per million input/cached/output tokens, promotional
through at least 2026-11-21. PUT-1943 tracks re-checking before then.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-23 01:59:10 -07:00
Juan Fernando Castro 17f5454265 Merge pull request #3915 from HeyPuter/juancastro/put-1871-authcheck-app-reports-every-app-as-already-authorized
fix: check-app reported every app as already authorized
2026-09-22 18:24:20 -04:00
Filip Kujundžić 5ddff2b3d6 Merge pull request #3927 from HeyPuter/404oops/put-1870-ai-upstream-credit-exhaustion-is-the-largest-error-class-in
fix(ai): classify upstream credit exhaustion, contain the route, redact vendor text (PUT-1870)
2026-09-22 22:29:58 +02:00
404oops 52f0176854 fix(ai): classify and sanitize upstream credit exhaustion 2026-09-22 21:51:44 +02:00
Daniel Salazar 300fb115a1 feat: opus 5.5 (#3930)
* feat: opus 5.5

* fix: bad typing
2026-09-22 12:31:44 -07:00
dependabot[bot] 6debd7df76 chore(deps-dev): bump prettier from 3.9.6 to 3.9.8 (#3925)
Bumps [prettier](https://github.com/prettier/prettier) from 3.9.6 to 3.9.8.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 11:33:49 -07:00
dependabot[bot] 70404579f2 chore(deps-dev): bump @types/node from 24.13.4 to 24.13.6 (#3926)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 24.13.4 to 24.13.6.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 24.13.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-22 11:33:40 -07:00
Juan Fernando Castro 7462a8eda9 Merge pull request #3914 from HeyPuter/juancastro/put-1869-unhandled-rejection-on-the-hottest-permission-path-can-take
fix: stop leaking an unawaited permission traversal on every check
2026-09-22 14:28:44 -04:00
Daniel Salazar 718de8a9c6 fix: bump server timeouts for better error handling (#3929) 2026-09-22 10:13:46 -07:00
Juan Fernando Castro 8abdcf4f7f Merge pull request #3928 from HeyPuter/juancastro/put-1873-restrict-godmode-app-launches-to-godmode-callers
fix: restrict godmode app launches to godmode callers
2026-09-22 12:32:32 -04:00
Juan Fernando Castro 476f7e075d Merge pull request #3913 from HeyPuter/juancastro/put-1865-apps-repeatedly-ask-permission-when-it-wants-to-access-files
fix: settle a URL file-access prompt from a grant the app already holds
2026-09-22 12:32:06 -04:00
Juan Castro 1bc8cb964e fix: restrict godmode app launches to godmode callers
`ExecService.launchApp`, reachable from `puter.ui.launchApp`, launched any
named app with no check on the target. Gate it: a godmode target may only
be launched by a godmode caller. Desktop launches (tile, double-click,
URL) call `launch_app` directly and are unaffected; `connectToInstance`
already had its own allowlist.
2026-09-22 09:12:25 -04:00
Daniel Salazar 2690f4934c fix: tests (#3922) 2026-09-21 23:33:23 -07:00
Daniel Salazar d07ddeaaf5 fix(fs): keep a home directory on its account's username (#3920)
A root was free to be renamed or moved, so it could be parked on a
username no account held yet. The next account to take that name
provisioned a second root at the same path, and from then on either row
could answer a lookup of it — new entries inherit their owner from
whichever row resolved as the parent, so one account's files were
created owned by the other, and renaming the parked root dragged the
other account's subtree along with it.

Rename and move now refuse a root, every username claim checks the home
path before the user row is written, and renameUserHome refuses to heal
onto a path another account holds.
2026-09-21 23:29:09 -07:00
Daniel Salazar 1261976088 feat: user profiles in a system-owned store, public only for paid accounts (#3919)
- ProfileService keeps each profile as /system/profiles/<uuid>.profile,
  admin-owned and served by the protected puter-profiles subdomain
- GET/POST /profile; puter.auth.getProfile/updateProfile, with
  getProfilePicture reading through them
- another user's profile is served only while its owner is on a paid
  plan (profileGate.enabled kill switch); the hosted file is gated
  through the new site.access.check hook the hosting middleware asks
  before streaming any file
- SubdomainStore.create takes isProtected
- the GUI reads and writes the profile through the SDK
- docs: getProfile, updateProfile, UserProfile, limits

PUT-1859 PUT-1860 PUT-1861 PUT-1862
2026-09-21 23:28:56 -07:00
Daniel Salazar 2abd5533ac fix(puter-js): restore file pickers on third-party sites (#3917)
The clickjacking guard in #3912 pinned `event.source` to `messageTarget`,
which is only assigned when `env === 'app'`. On a third-party site it is
undefined, so the UI listener dropped every message and
showOpenFilePicker, showSaveFilePicker and showDirectoryPicker never
settled.

Split the two environments. `app` still pins the host frame, whose origin
is whatever the deployment is served from. `web` has no host frame — the
picker popups post back directly — so pin the GUI origin we opened them
on, plus the set of popups we opened. Origin pinning is safe there,
unlike the parent-frame case, because we chose the URL, so self-hosted
and local deployments keep working.

Falls back to the origin alone when `event.source` is null: the picker
calls window.close() right after posting and a discarded browsing
context can drop the source.

PUT-1867
2026-09-21 21:43:58 -07:00
922d203e18 fix(ai): send stable, non-sequential user identifiers to AI providers (#3856)
* fix(ai): send stable, non-sequential user identifiers to AI providers

A precedence bug in the AI providers' identifier expression made every
request send `user: ":undefined"` (the ternary bound the app-uid suffix to
the whole `actor.user.id + actor.app?.uid` sum instead of just the suffix),
or read `actor.user.id` on a missing user. The same expression also shipped
the sequential internal user id, letting AI vendors correlate a single
account across apps and sessions.

All eight OpenAI-, Azure-, xAI-, Meta- and ZAI-style providers now build
the identifier through one shared helper, `aiUserIdentifier()`:

- `puter-<user-uuid>[-<app-token>]`: the random user UUID is always
  preserved in full; `maxLength` constrains only the app-bearing form
- app attribution reads `effectiveApp`, so access-token requests name the
  issuing app instead of looking like direct user traffic
- the app token is truncated to fit the budget, and omitted entirely when
  the remaining budget is below 8 chars, where a truncation could collide
  with another app's uid
- nothing is sent for the system actor
- Meta and ZAI keep a caller-supplied `safety_identifier` / `user_id`
  override, applied before the helper result

`user` is deprecated by OpenAI; the SDK types direct callers to
`safety_identifier` (abuse detection) and `prompt_cache_key` (cache-hit
bucketing). The four OpenAI/Azure chat providers and MetaProvider now send
`prompt_cache_key` as well, defaulting it to the same per-user identifier
unless the caller supplies one; Azure's Grok branch drops both fields,
matching its rejection of unknown args. The cap comment cites only verified
limits: OpenAI's 64 for `safety_identifier` (from the SDK types) and Z.AI's
6-128 for `user_id` (from Z.AI's docs); Meta and xAI document none, so none
is claimed.

The xAI image `#edit` path now carries the identifier like generation, and
takes a named-options param so `user` cannot be transposed with the
adjacent same-typed `aspectRatio`.

Tests share a four-actor matrix (`user` / `user+app` / `access token` /
`system`) with `assertActorMatrixIdentifiers()` across the six
OpenAI-style suites; the helper has exact-string and boundary coverage
(size caps, zero-budget and sub-base cases, no dangling separator, UUID
never truncated, collision guard); the Azure Grok assertions run under a
real user actor so they cannot pass vacuously. 212 provider-suite tests
pass; typecheck and ESLint are clean.

* fix(ai): lock the vendor identifier down and keep vitest out of the test util

Meta and Z.AI no longer let `custom` override the abuse identifier; it is
Puter's attribution, not the caller's. The shared test util exposes pure
field pickers instead of importing vitest into a file the production
tsconfig compiles. The helper's length-cap comment now matches vendor docs
(Meta does cap `safety_identifier` at 64), the redundant budget branch and
the unused export are gone, and the per-user `prompt_cache_key` trade-off is
stated once in the helper instead of five times in providers.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

---------

Co-authored-by: 404oops <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-21 16:21:47 -07:00
404oopsandClaude Fable 5.1 027e9a71f3 fix(ai-image): refresh model catalogs, unify txt2img requests, expand docs (#3889)
* fix(ai-image): sync image catalogs, unify request shape, document models

Model sync against every vendor listing and a live generation sweep:
Gemini stable ids replace the retired preview spellings (2.5 Flash Image
delisted ahead of its 2026-10-02 shutdown), OpenAI gpt-image-1/-1-mini/-1.5
are delisted but routable until their shutdown dates with gpt-image-2 as
the default, xAI gains grok-imagine-image-2.0 with its quality tiers,
BytePlus gains the 3K/4K tiers and per-model pixel bounds, Cloudflare
gains SDXL Lightning/Base and SD 1.5 Inpainting, Replicate gains a
schema-driven catalog of 88 additional models with version-pinned
community predictions, and every Together image route is excluded for
the third-party data-sharing requirement.

Request normalization: the driver collapses ratio/width/height/aspect_ratio
into one imageSize with aspect-versus-pixel intent, validates prompt,
quality and resolution once, resolves provider hints (short names or
full driver ids) and aliases with exact ids winning over resellers, and
hands each provider an immutable copy of the caller's args. Providers
share prompt validation, aspect snapping, and content-sniffed data URIs
so responses carry the right MIME type. Replicate predictions are created
once, cancelled on abort or deadline, and bounded in input fan-out.

SDK: txt2img copies caller options, rejects blank prompts with
prompt_required in every call form, and documents that normalize has no
effect because the image return shape is already uniform.

Docs: txt2img options per provider, provider defaults and discovery,
availability notes, a new image model catalog and pricing page, and the
image-generation limits in the quotas page.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(ai-image): sniff SVG outputs correctly, reject hex/exponent dimensions

imageDataUri only decoded the first 24 base64 chars (18 bytes) before
sniffImageMime, which cannot see an <svg> root behind an XML prolog -
SVG outputs from recraft-v*-svg models were being labeled image/png.
Decode enough of the payload to cover the 8 KB SVG sniff window.

dimension() accepted string number literals ('0x10', '1e3') as if they
were decimal dimensions; restrict to plain decimal notation.

* docs(ai): link to the model directory instead of a static catalog

The image model catalog and pricing page duplicated the always up to date
model directory at developer.puter.com/ai/models. Link to the directory
from txt2img-related docs and keep the Together data-sharing exclusion
note self-contained.

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-21 15:00:02 -07:00
Juan Castro 259cb69fde fix: check-app reported every app as already authorized
The token grant writes `flag:app-is-authenticated` on the user->app row,
but the check asked for `service:<app_uid>:ii:flag:app-is-authenticated`.
Those never match, and the parent walk reduces the question to a bare
`service` — a root every user holds by default — so the check answered
true for any app_uid and handed back an app-under-user token for an app
the user had never opened.

Read the row the grant writes, through one shared constant so the two
cannot drift again, and mint against the resolved uid.

A cancelled account picker no longer leaves `popup_signin_consent` set:
with no relationship there is no token to flip it.
2026-09-21 16:13:53 -04:00
404oopsandClaude Fable 5.1 081941a6f3 fix: null-prototype model maps in chat/video drivers; txt2vid prompt and option guards (#3887)
Chat and video drivers keyed their provider and model maps on plain objects,
so `model` or `provider` values such as `__proto__` reached Object.prototype
and surfaced as 500s. Both maps are now null-prototype objects and reject
those names as ordinary unknown models.

txt2vid now rejects blank or non-string prompts with `prompt_required` before
any request, tolerates `null` in either argument slot, and copies the caller's
options before resolving the `duration` alias and output path so frozen
option objects work and caller objects are never mutated.

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-09-21 13:12:35 -07:00
404oops e2a5ab8cdc docs: document local Docker builds with Compose overrides (#3867) 2026-09-21 13:12:16 -07:00
Juan Castro 07e889f135 fix: stop leaking an unawaited permission traversal on every check
`validateUserPerms` started the linked SQL traversal beside the flat read
and returned on a flat hit without awaiting it. The hit is the common
case, so nearly every user-permission check left a floating promise: with
`keep_alive_on_uncaught` unset no `unhandledRejection` listener is
registered, so a DB wobble inside the traversal exits the process.

Run it only when the flat read misses. That also stops a SQL walk the
cache had already answered.
2026-09-21 16:07:26 -04:00
Juan Castro ab7a5f325a fix: refuse an empty app_uid and survive a failed grant check
A present-but-empty `app_uid` fell through to checking the user, where
every `fs:` scope on their own file answers `true` — a prompt settled by
a question nobody answered. It is a 400 now.

The launch-path check also sat outside any catch, so a rejecting read
would have taken the whole launch down instead of falling through to the
prompt. Renamed the seam it is injected through to match the function it
defaults to, since wiring the token-based sibling in its place would send
an app uid as a bearer token and silently always prompt.
2026-09-21 15:56:39 -04:00
Juan Castro b4b464554b fix: settle a URL file-access prompt from a grant the app already holds
Opening `/app/<name>?file=<path>` asked for consent on every launch: the
gate prompted unconditionally and never read the `fs:<uid>:write` grant
its own Allow had written.

`/auth/check-permissions` takes an optional `app_uid` so the account can
ask what one of its apps holds, and the launch gate skips the dialog when
the answer is yes. Sessions only — an app or a scoped token asking would
be a window onto its neighbours' grants.
2026-09-21 15:00:02 -04:00
Nariman Jelveh 4de1d1fb39 Redesign the Teams tab in the dashboard (#3901)
* Redesign the Teams tab in the dashboard

Hero card with a lettered tile, role pill, handle and headcount; the
directory setting becomes a settings row with the shared toggle switch;
the add-account form gets labels, Enter to submit, and a copy button on
the one-time password. The accounts and record tables drop the grid for
hairlines, avatar tiles and status pills, and stack into labelled rows
on phones so actions stay reachable. Loading, empty and error states
get a skeleton, a create call to action, and a retry.

Also fixes audit dates showing 1970 (the audit routes send unix
seconds), the actions cell breaking the row border by being a flex
table cell, and the "0 suspended" clause on a healthy team.

* Use the dashboard's overlay dialog in the Teams tab, not UIAlert

Adds UIDashboardDialog, a confirm / alert / prompt card built like the
share and properties modals: mounted in the dashboard window, revealed
after a frame, dismissed by Escape, the close button or a backdrop
press-and-release, with a focus trap and focus restored on close. On
phones it docks to the bottom as a sheet. The Teams tab's confirms,
error alerts, seat-limit warning and rename/create prompts all go
through it; UIAlert and UIPrompt are no longer imported there.
2026-09-21 09:30:03 -07:00
Daniel Salazar 4e9b03bf5c fix: harden clickjacking (#3912) 2026-09-21 09:19:37 -07:00
Juan Fernando Castro a63172e9ae Merge pull request #3892 from HeyPuter/juancastro/put-1806-invite-email-addresses-are-disclosed-to-apps-manage
fix: stop disclosing invite addresses to apps, tokens and delegates

approvals already in place
2026-09-21 12:05:08 -04:00
Reynaldi Chernando 29d805d793 show app icon when login from external app (#3905)
* show app icon when login from external app

* fix
2026-09-21 22:33:38 +07:00
Juan Fernando Castro 059fae3d15 Merge pull request #3872 from HeyPuter/juancastro/put-1813-team-share-blocklist-and-unshare-paging
fix: make the block list bite on team shares, and unshare every re-share (PUT-1813)

Approvals in place to move with the merge
2026-09-21 11:31:24 -04:00
Juan Castro 18ada4764a chore: drop a stray lockfile marker
A local `npm install` (needed to pick up main's new `postal-mime`) wrote
`"extraneous": true` onto a bundled `@clack/prompts` dependency. It was the
branch's only lockfile change and it does not belong to this PR.
2026-09-21 11:26:27 -04:00
Juan Castro 72203152d9 fix: bound a scoped token to what it issued, which is nothing
CI caught three HTTP-level tests the earlier merge left behind, and they
were right to fail: dropping this branch's `manage` gate in favour of
main's row filter lost a case main's filter does not cover.

Main bounds an app to the rows it issued. A *scoped* access token is not
an app, so it was falling through unbounded — `/fs/stat` with
`return_shares` handed a `fs:<uid>:list` token the owner's whole share
list. Addresses stayed withheld, but who else can reach a file is no
more a narrow token's business than the addresses are.

So the filter is generalised rather than the gate restored: a scoped
token is bounded to nothing, since it issues nothing under its own name.
Filtering it by a null app would have been worse than not filtering —
that matches the owner's own rows. Apps and sessions behave exactly as
they do on main, and a full-access token still holds the account's reach.

The three tests now assert the answer instead of a refusal, including
the one whose name had always promised a refusal its body never checked.
2026-09-21 11:12:37 -04:00
Juan Castro cec8382d4d Merge branch 'main' into juancastro/put-1806-invite-email-addresses-are-disclosed-to-apps-manage
Two textual conflicts, both additive on each side: `isAccountContext`
(here) and `isPlainUserActor` (main) are both imported and both used,
and the `stat()` note keeps both sentences — this branch's on who may
read an invite address, main's on the share-read limit it spends.

The rest is adapting to main, which grew its own answer to half of what
this branch was for. `listSharesOf` there bounds an app to the rows it
issued itself; this branch instead required the credential to hold
`manage` and refused it otherwise. Main's is the better mechanism — it
answers the app rather than turning it away, and it hides other
issuers' rows outright rather than redacting a field on them — so the
`manage` gate goes, and with it the two tests that asserted the
refusal. They are replaced by tests that hold main's line: an app sees
none of the invites it did not send, with or without `manage`.

What this branch still carries is the gap main does not close. Its row
filter only applies to apps, so a plain manage delegate still reads the
owner's invite addresses; `#maySeeInviteAddress` is what withholds
those, and its delegate tests pass unchanged. The `stat()` note is
corrected to describe main's behaviour rather than the removed gate.
2026-09-21 10:46:07 -04:00
Juan Castro 3f335939b3 fix: team shares are not subject to a recipient's per-sender block
Review call from the ticket's author: a team share is the team's, not
one colleague's to withhold from another, so a personal block should not
hide it. This drops the enforcement added earlier on the branch — the
listing, its total and the fs-event fan-out no longer filter team rows
by the recipient's block list, and the SQL fragment that did it goes
with them.

What a block still does for a team share is suppress the notification,
which was already true before this branch: it stops the interruption
without pretending to stop the access. Leaving the team is what ends
that. Said so in the block API docs, the settings copy and the code,
since the mismatch between the two was the original complaint.

The unshare sweep is untouched — that half of the ticket stands.
2026-09-21 10:28:50 -04:00
Juan Castro 4e821c128c Merge remote-tracking branch 'origin/main' into juancastro/put-1813-team-share-blocklist-and-unshare-paging
# Conflicts:
#	src/backend/services/share/ShareService.ts
#	src/backend/stores/share/ShareStore.js
2026-09-21 10:28:33 -04:00
Juan Fernando Castro c313a381b6 Merge pull request #3896 from HeyPuter/juancastro/put-1843-plan-card-unknown-not-free
fix: a team seat's plan card names its team, and an unreadable plan is not Free (PUT-1843)
2026-09-21 09:58:41 -04:00
Juan Fernando Castro 76e0d2ec93 Merge pull request #3871 from HeyPuter/juancastro/put-1798-sharing-email-notification-changes
feat: name the issuing app in share emails, and cut the digest window to 5s
2026-09-21 09:58:09 -04:00
Juan Castro dfd7925872 fix: say which team a seat's plan belongs to, paid or free
A provisioned account read as a plain "Free" plan with an "Upgrade for
more features" badge: nothing said the account belongs to a team, and
the prompt asked for an upgrade only its owner can buy. Its free tier is
not the free plan either — org_seat_free is half the allowance.

The card now names the team on both paths: a paid seat keeps its tier
and status and gains "Managed by <team>", and a seat with no tier reads
"Team account" instead of an upgrade pitch.
2026-09-17 14:15:01 -04:00
Juan Castro e50d210423 fix: an unreadable plan is not a free plan (PUT-1843)
The Home tab read /marketplace/subscriptions/current and folded every
failure into the free state: a non-OK response left `subscription` null,
and the catch said so explicitly. So a refusal rendered as a confident
"Free" with an Upgrade badge.

That is reachable: verification gates are default-on for authenticated
routes, and a team seat that still owes its password change is refused
there — it then reads Free while its team pays for a tier. A rate limit
or a blip does the same to anyone.

The plan card moves into its own method so a failed read can return
without touching it, leaving the card as it was rather than naming a
plan the account does not have. A 200 carrying no subscription still
reads Free, which is the one case that actually means it.
2026-09-17 13:52:03 -04:00
Juan Castro 461cb7d0a9 fix: close two gaps the review found in the invite-address fix
- A full-access token was denied the address while `shared-by-me` still
  handed it the same rows, so the clause bought no privacy and cost an
  API client the address `unshare()` takes. `isAccountContext` is the
  boundary the rest of the codebase already uses for 'acting as the
  account': plain session or full-access token, never a scoped one.
- The Dashboard share modal dropped a withheld invite entirely, since
  its aggregate keys a pending row on the address — so a delegate saw no
  sign of an outstanding invite and accessCount under-reported who could
  reach the item. It is now kept, keyed on the share uid, labelled, and
  without the controls that would need a recipient to address.
2026-09-17 13:21:13 -04:00
Juan Castro 3e3d02bafe fix: stop disclosing invite addresses to apps, tokens and delegates
getShares (and stat's return_shares, which runs the same listing) gated
on #assertCanManage's default 'see' mode, so any credential that could
see the node got every unclaimed invite's raw email — including an app
handed one file by the picker, a list-scoped token on the stat surface,
and a manage delegate reading the owner's invitees.

Two bounds, matching the invariant clientShare.ts already claimed:

- An invite's address goes only to the item's owner and to whoever sent
  it, and never to an app or token. A delegate can revoke only what they
  issued, so withholding costs them nothing they could act on.
- An app or token must hold manage reach of its own to read the listing
  at all; it answers for the ancestors too, which is not what being
  handed one file grants. tryListSharesOf turns that into an empty
  shares array, so stat itself keeps working.

The share dialog names an unattributable invite rather than rendering a
blank row with a dead revoke button.

Closes PUT-1806.
2026-09-16 18:59:10 -04:00
Juan Castro 728966bfb1 Merge branch 'main' into juancastro/put-1813-team-share-blocklist-and-unshare-paging 2026-09-16 18:05:58 -04:00
Juan Castro 3f7ee34706 Merge branch 'main' into juancastro/put-1798-sharing-email-notification-changes 2026-09-16 18:05:39 -04:00
Juan Castro 23c33b2e06 Merge remote-tracking branch 'origin/main' into juancastro/put-1813-team-share-blocklist-and-unshare-paging
# Conflicts:
#	src/backend/stores/share/ShareStore.js
2026-09-16 15:27:09 -04:00
Juan Castro 0593844acf Merge branch 'main' into juancastro/put-1813-team-share-blocklist-and-unshare-paging 2026-09-16 13:05:03 -04:00
Juan Castro 67d048148c Merge branch 'main' into juancastro/put-1798-sharing-email-notification-changes 2026-09-16 13:04:43 -04:00
Juan Castro 5d20e054ac Merge branch 'main' into juancastro/put-1813-team-share-blocklist-and-unshare-paging 2026-09-16 10:33:03 -04:00
Juan Castro 3bd3955e0d Merge branch 'main' into juancastro/put-1798-sharing-email-notification-changes 2026-09-16 10:32:05 -04:00
Juan Castro 27e858d760 fix: gate the group-share index delete on authority, qualify the live filter
Review follow-ups on #3872: deleteActiveGroup ran even when every
canManagePermission check was denied, deleting the index row while the
team's grant survived — unlisted live access, the class this PR exists
to remove. And memberIdsAmong's join left deleted_at unqualified, which
only works while jct_user_group has no such column; #live now takes an
alias.
2026-09-15 18:57:50 -04:00
Juan Castro 0bd6458441 test: pin the mixed-digest button link carrying no app
Review follow-up on #3871: the flush-level rule — shared_app on the
button only when every entry came through one app — had no direct
coverage. An app share and a plain share folded into one digest now
prove the line and item link keep their attribution while the button
stays clean.
2026-09-15 18:56:38 -04:00
Juan Castro af6547724d Merge branch 'main' into juancastro/put-1813-team-share-blocklist-and-unshare-paging 2026-09-15 15:23:22 -04:00
Juan Castro e9922aea26 Merge branch 'main' into juancastro/put-1798-sharing-email-notification-changes 2026-09-15 15:23:11 -04:00
Juan Castro aadcda073d fix: keep blocks out of the authority graph, and sweep what unshare missed
Code review on the previous commit confirmed three ways the block filter
inside readUserGroupPerms turned a reversible block into permanent loss:
canManagePermission reads the same rows, so a block-suspended grant
looked revoked to #revokeDownstream's cascade, to unshare's authority
gate, and to invite claiming — deleting re-shares and invites that were
supposed to come back on unblock. The scan is now deliberately blind to
blocks; a block suspends delivery only (listing, count, fan-out,
telling), which the share store filters itself through one shared
notBlockedSql fragment owned by UserBlockStore.

The team-unshare sweep also now covers what it claimed to: a member's
re-share *to another team* is revoked with them (group rows swept in
 #revokeDownstream, user path included), and the sweep is skipped
entirely while another issuer's grant still backs the team — members'
re-shares rest on that authority and revoking them would orphan live
access. The blanket block-all note in settings now says team shares
still arrive, matching what the code deliberately does.
2026-09-15 14:49:54 -04:00
Juan Castro 9da03555ae fix: make the block list bite on team shares, and unshare every re-share (PUT-1813)
A sender a member blocked could still land items in that member's
shared-with-me — and grant them real access — by sharing with a common
team. The group grant is one row and cannot exclude a member, so the
block is now enforced where delivery is derived per member: the
permission scan, the inbound listing and its count, and the fs-event
fan-out all skip team rows whose issuer the member blocked. Nothing is
revoked, so unblocking restores everything. Blocking now also bumps the
blocker's permission cache so it bites immediately. Direct shares keep
their contract: existing ones stand.

#unshareTeam swept re-shares by listing members with a 200 cap and
never following the cursor, so members past the cap kept their orphaned
rows. It now walks the share rows in the subtree — the set that can
actually need sweeping — and filters those issuers to members, which
has no cap by construction.
2026-09-15 14:49:54 -04:00
Juan Castro 577693bf84 feat: name the issuing app in share emails, and cut the digest window to 5s
When an app shares on a user's behalf, the mail now says so — the
digest line reads 'alice shared report.txt via Mail App', preferring
the app's title, then its name, then its index_url host. Both the
per-item links and the 'Open Puter' button carry a new shared_app
query param (the app's name, or its uid) so the GUI can match the
share back to the app; the button only carries it when the whole
digest came from one app. Plain shares are byte-for-byte unchanged.

Also drops SHARE_EMAIL_BATCH_SECONDS from 30 to 5: the window only
has to fold one gesture's worth of calls, and every second there is
a second the common single-share email arrives late.

Closes PUT-1798.
2026-09-15 12:15:30 -04:00
338 changed files with 37502 additions and 4008 deletions

No files matched your search

+45 -45
View File
@@ -6,56 +6,56 @@
/src/gui/ @jelveh @Salazareo
# Documentation, developer site, and CLI
/doc/ @reynaldichernando
/src/docs/ @reynaldichernando
/src/dev-center/ @reynaldichernando
/src/cli/ @reynaldichernando
/doc/ @reynaldichernando @Salazareo
/src/docs/ @reynaldichernando @Salazareo
/src/dev-center/ @reynaldichernando @Salazareo
/src/cli/ @reynaldichernando @Salazareo
# Backend: AI, hosting, workers, networking, and WebDAV
/src/backend/controllers/puterai/ @ProgrammerIn-wonderland
/src/backend/controllers/hosting/ @ProgrammerIn-wonderland
/src/backend/controllers/webdav/ @ProgrammerIn-wonderland
/src/backend/controllers/wisp/ @ProgrammerIn-wonderland
/src/backend/drivers/ai-*/ @ProgrammerIn-wonderland
/src/backend/drivers/util/ @ProgrammerIn-wonderland
/src/backend/drivers/workers/ @ProgrammerIn-wonderland
/src/backend/services/localworker/ @ProgrammerIn-wonderland
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland
/extensions/workerSandbox* @ProgrammerIn-wonderland
/src/worker/ @ProgrammerIn-wonderland
/src/worker-types/ @ProgrammerIn-wonderland
/src/backend/controllers/peer/ @ProgrammerIn-wonderland
/src/backend/controllers/puterai/ @ProgrammerIn-wonderland @Salazareo
/src/backend/controllers/hosting/ @ProgrammerIn-wonderland @Salazareo
/src/backend/controllers/webdav/ @ProgrammerIn-wonderland @Salazareo
/src/backend/controllers/wisp/ @ProgrammerIn-wonderland @Salazareo
/src/backend/drivers/ai-*/ @ProgrammerIn-wonderland @Salazareo
/src/backend/drivers/util/ @ProgrammerIn-wonderland @Salazareo
/src/backend/drivers/workers/ @ProgrammerIn-wonderland @Salazareo
/src/backend/services/localworker/ @ProgrammerIn-wonderland @Salazareo
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland @Salazareo
/extensions/workerSandbox* @ProgrammerIn-wonderland @Salazareo
/src/worker/ @ProgrammerIn-wonderland @Salazareo
/src/worker-types/ @ProgrammerIn-wonderland @Salazareo
/src/backend/controllers/peer/ @ProgrammerIn-wonderland @Salazareo
# Backend: sharing, permissions, and teams
/src/backend/controllers/share/ @jfcastro92
/src/backend/controllers/team/ @jfcastro92
/src/backend/services/share/ @jfcastro92
/src/backend/services/permission/ @jfcastro92
/src/backend/services/acl/ @jfcastro92
/src/backend/services/team/ @jfcastro92
/src/backend/services/apps/AppPermissionService* @jfcastro92
/src/backend/stores/share/ @jfcastro92
/src/backend/stores/permission/ @jfcastro92
/src/backend/stores/group/ @jfcastro92
/src/backend/stores/team/ @jfcastro92
/src/backend/controllers/share/ @jfcastro92 @Salazareo
/src/backend/controllers/team/ @jfcastro92 @Salazareo
/src/backend/services/share/ @jfcastro92 @Salazareo
/src/backend/services/permission/ @jfcastro92 @Salazareo
/src/backend/services/acl/ @jfcastro92 @Salazareo
/src/backend/services/team/ @jfcastro92 @Salazareo
/src/backend/services/apps/AppPermissionService* @jfcastro92 @Salazareo
/src/backend/stores/share/ @jfcastro92 @Salazareo
/src/backend/stores/permission/ @jfcastro92 @Salazareo
/src/backend/stores/group/ @jfcastro92 @Salazareo
/src/backend/stores/team/ @jfcastro92 @Salazareo
# SDK: AI, hosting, workers, and networking
/src/puter-js/src/modules/ai/ @ProgrammerIn-wonderland
/src/puter-js/src/modules/hosting/ @ProgrammerIn-wonderland
/src/puter-js/src/modules/Workers* @ProgrammerIn-wonderland
/src/puter-js/src/modules/networking/ @ProgrammerIn-wonderland
/src/puter-js/src/lib/networkUtils* @ProgrammerIn-wonderland
/src/puter-js/tests/api/suites/ai.suite.ts @ProgrammerIn-wonderland
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland
/src/puter-js/src/modules/Peer* @ProgrammerIn-wonderland
/src/puter-js/src/modules/ai/ @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/hosting/ @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/Workers* @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/networking/ @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/lib/networkUtils* @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/ai.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland @Salazareo
/src/puter-js/src/modules/Peer* @ProgrammerIn-wonderland @Salazareo
# SDK: sharing and permissions
/src/puter-js/src/modules/perms/ @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/share* @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/unshare* @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/getShares* @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/listShared* @jfcastro92
/src/puter-js/tests/api/suites/perms.suite.ts @jfcastro92
/src/puter-js/tests/api/suites/sharing.suite.ts @jfcastro92
/src/puter-js/src/modules/perms/ @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/share* @jfcastro92 @Salazareo
/src/puter-js/src/modules/FileSystem/operations/unshare* @jfcastro92 @Salazareo
/src/puter-js/src/modules/FileSystem/operations/getShares* @jfcastro92 @Salazareo
/src/puter-js/src/modules/FileSystem/operations/listShared* @jfcastro92 @Salazareo
/src/puter-js/tests/api/suites/perms.suite.ts @jfcastro92 @Salazareo
/src/puter-js/tests/api/suites/sharing.suite.ts @jfcastro92 @Salazareo
+3
View File
@@ -72,3 +72,6 @@ servers.json
config.*.json
volatile/
# Local build override
docker-compose.override.yml
+30
View File
@@ -58,6 +58,36 @@ When in doubt, return less. Auth-, permission-, or data-export-related changes d
---
## Local development configuration
To test a local Docker build, create `docker-compose.override.yml` in the repository
root (or merge into the existing file):
```yaml
services:
puter:
pull_policy: never
build:
context: .
```
Compose merges it automatically. Run `docker compose up -d --build` to build and
start the stack, and repeat after source changes. Keep local build settings in
this gitignored override instead of editing `docker-compose.yml`, so they stay
out of pull requests and do not conflict when pulling updates. See
[Self-Hosting Puter](doc/self-hosting.md#building-from-source-instead-of-pulling)
for the required runtime configuration.
For local API testing, agents may decide whether to set `"unlimitedMetering": true`
when budget checks block unrelated tests; no additional approval is needed.
Merge it into the ignored `config.json` for `npm start` or
`puter/config/config.json` for Docker, then restart Puter. It applies to all
accounts on that local instance, including guests, and usage is still recorded.
Keep normal settings when testing budget or subscription enforcement, and leave
shared defaults unchanged.
---
## Backend
A layered stack with explicit dependency injection: each layer depends only on the layers beneath it, receives them through its constructor, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together. [doc/architecture.md](doc/architecture.md) is the full reference.
+4
View File
@@ -10,6 +10,8 @@
·
<a href="https://apps.puter.com/">App Store</a>
·
<a href="https://builder.puter.com/">AI Builder</a>
·
<a href="https://developer.puter.com" target="_blank">Developers</a>
·
<a href="https://twitter.com/HeyPuter">X</a>
@@ -46,6 +48,8 @@ npm start
```
**→** This should launch Puter at http://puter.localhost:4100
To run this checkout with Docker, follow [Building from source](./doc/self-hosting.md#building-from-source-instead-of-pulling). Create a local `docker-compose.override.yml` to select the local build; keeping these settings out of `docker-compose.yml` avoids conflicts when pulling updates and keeps local configuration out of pull requests.
<br/>
+10
View File
@@ -32,6 +32,10 @@
"port": 4100,
// Externally-visible port (set this when behind a reverse proxy on 80/443).
"pub_port": 4100,
// Idle keep-alive timeout in ms. Must stay above the idle timeout of any
// proxy in front, or the proxy reuses connections this server has closed
// and its clients see 502s.
"keep_alive_timeout": 620000,
"protocol": "http",
"domain": "puter.localhost",
// Fully-qualified externally-visible URL. Computed from protocol/domain/
@@ -538,4 +542,10 @@
// what this deployment's normal traffic costs, so it has to be measured
// rather than guessed, and a stale number here alarms on healthy growth.
// "maxGlobalUsagePerMinute": 200000000
// How many full months past its own a monthly usage record is kept before
// it expires. Defaults to 3; set to 0 to keep every month's records
// forever. Changing it, including to 0, applies to records first written
// after the change; records already stamped keep their expiry.
// "meteringRetentionMonths": 3
}
+42 -1
View File
@@ -408,6 +408,12 @@ Every account then resolves to an unlimited policy. Usage is still recorded, so
the dashboard still shows what is being consumed; nothing is ever refused for
lack of budget.
For API testing, you can also set `"unlimitedMetering": true` in your
ignored runtime config: `puter/config/config.json` for Docker, or the repository's
`config.json` for `npm start`. Merge it into the existing config and restart Puter.
This includes guest accounts and applies to the whole local instance. Keep normal
metering settings when testing budget or subscription enforcement.
To keep the budgets but stop them blocking anything — recording only:
```json
@@ -418,6 +424,17 @@ Calls driven by a deployed worker are exempt from enforcement by default,
because a worker has no prompt to show and nobody watching it fail. Set
`"meteringEnforcement": { "workers": true }` to include them.
Monthly usage records expire on their own once they're old enough to be of no
further use — `meteringRetentionMonths` full months past the month they
belong to, 3 by default. Set it to `0` to keep every month's records forever:
```json
"meteringRetentionMonths": 6
```
Changing it, including to `0`, applies to records first written after the
change; records already stamped keep their expiry.
### Captcha on signup / login
Built-in proof-of-work captcha — no external service needed.
@@ -488,12 +505,36 @@ For GPU acceleration (NVIDIA), uncomment the `deploy:` block under the `ollama`
## Building from source instead of pulling
If you want to test local Dockerfile changes against the full stack, uncomment the `build:` block in [docker-compose.yml](../docker-compose.yml) under the `puter` service, change `pull_policy: always` → `pull_policy: never`, then:
To run a local build against the full stack, use a source checkout and complete
the configuration steps above. Create `docker-compose.override.yml` in the
repository root, next to [docker-compose.yml](../docker-compose.yml):
```yaml
services:
puter:
pull_policy: never
build:
context: .
```
If that file already exists, merge these settings into its `puter` service.
Compose loads and merges the override automatically. `build.context: .` selects
this checkout, and `pull_policy: never` prevents pulling the published Puter image.
Use this override for local build settings instead of editing `docker-compose.yml`.
The override is ignored by Git, keeping local configuration out of pull requests
and avoiding conflicts in the shared Compose file when pulling updates.
From the repository root, build and start the stack:
```bash
docker compose up -d --build
```
Run the same command after changing or pulling source code to rebuild the image.
To return to the published image, remove the local build settings (or the override
file if those are its only settings), then run `docker compose up -d`.
---
## Managing running backend
+2 -9
View File
@@ -230,15 +230,8 @@ services:
puter:
image: ghcr.io/heyputer/puter:main
pull_policy: always
# Uncomment to build from this directory instead of pulling the published
# image. Also flip pull_policy to `never` so compose doesn't overwrite
# your local build by re-pulling :latest.
# build:
# context: .
# # buildx-only: cross-compile to both archs in a single push
# # platforms:
# # - linux/amd64
# # - linux/arm64
# For local builds, create a docker-compose.override.yml;
# see doc/self-hosting.md#building-from-source-instead-of-pulling.
container_name: puter
restart: unless-stopped
depends_on:
+1 -2
View File
@@ -303,8 +303,7 @@ 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;
+7 -7
View File
@@ -6055,9 +6055,9 @@
}
},
"node_modules/@types/node": {
"version": "24.13.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz",
"integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==",
"version": "24.13.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz",
"integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
@@ -14336,9 +14336,9 @@
}
},
"node_modules/prettier": {
"version": "3.9.6",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
"integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
"version": "3.9.8",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz",
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
"dev": true,
"license": "MIT",
"bin": {
@@ -17667,7 +17667,7 @@
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/mime-types": "^2.1.4",
"@types/node": "^24.13.4",
"@types/node": "^24.13.6",
"@types/nodemailer": "^8.0.1",
"@types/pg": "^8.6.1",
"@types/validator": "^13.15.10",
@@ -693,31 +693,19 @@ describe('MySQLDatabaseClient — pool lifecycle', () => {
expect(createdPools[1].ended).toBe(true);
});
it('closes the pools once the drain window elapses', async () => {
it('leaves the pools open through prepare-shutdown; only onServerShutdown closes them', async () => {
vi.useFakeTimers();
try {
const client = await startClient();
await client.onServerPrepareShutdown();
expect(createdPools[0].ended).toBe(false);
// No timer closes the pools on its own — the layers above are
// still draining through them for as long as the process drains.
await vi.advanceTimersByTimeAsync(60_000);
expect(createdPools[0].ended).toBe(true);
} finally {
vi.useRealTimers();
}
});
it('cancels the drain timer when shutdown arrives first', async () => {
vi.useFakeTimers();
try {
const client = await startClient();
await client.onServerPrepareShutdown();
// A second prepare is a no-op — only one drain timer may exist.
await client.onServerPrepareShutdown();
expect(vi.getTimerCount()).toBe(1);
expect(createdPools[0].ended).toBe(false);
await client.onServerShutdown();
expect(vi.getTimerCount()).toBe(0);
expect(createdPools[0].ended).toBe(true);
} finally {
vi.useRealTimers();
@@ -58,7 +58,6 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
private dbPrimaryRead!: SQLBatcher;
private configuration = Configuration.SINGLE;
private shutdownStarted = false;
private shutdownTimer: ReturnType<typeof setTimeout> | null = null;
constructor(config: IConfig) {
super(config);
@@ -101,29 +100,12 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
if (this.shutdownStarted) return;
this.shutdownStarted = true;
// Allow in-flight queries to drain before closing pools
const drainMs = 60_000;
console.log(
`[mysql] draining in-flight queries (${drainMs}ms) before closing pools`,
);
this.shutdownTimer = setTimeout(() => {
this.shutdownTimer = null;
this.closeCurrentPools('drain').catch((e) =>
console.error('[mysql] error closing pools after drain', e),
);
}, drainMs);
if (typeof this.shutdownTimer.unref === 'function') {
this.shutdownTimer.unref();
}
// Blocks reinitPrimary/reinitReplica from here on. Pools stay open;
// onServerShutdown closes them after the layers above have drained.
console.log('[mysql] entering drain mode');
}
override async onServerShutdown(): Promise<void> {
if (this.shutdownTimer) {
clearTimeout(this.shutdownTimer);
this.shutdownTimer = null;
}
await this.closeCurrentPools('shutdown');
}
@@ -18,7 +18,7 @@
*/
import type { FieldDef, QueryResult } from 'pg';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { IConfig } from '../../types';
import { DatabaseClientFactory } from './index.js';
import {
@@ -357,4 +357,33 @@ describe('PostgresDatabaseClient', () => {
]);
expect(conn.released).toBe(true);
});
it('leaves the pool open through prepare-shutdown; only onServerShutdown closes it', async () => {
vi.useFakeTimers();
try {
const pool = new RecordingPool();
let ended = false;
pool.end = async () => {
ended = true;
};
const client = new PostgresDatabaseClient(
postgresConfig(),
() => pool,
);
await client.onServerStart();
await client.onServerPrepareShutdown();
expect(ended).toBe(false);
// No timer closes the pool on its own — the layers above are
// still draining through it for as long as the process drains.
await vi.advanceTimersByTimeAsync(60_000);
expect(ended).toBe(false);
await client.onServerShutdown();
expect(ended).toBe(true);
} finally {
vi.useRealTimers();
}
});
});
@@ -138,7 +138,6 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
private replicaPool!: PostgresPool;
private configuration = Configuration.SINGLE;
private shutdownStarted = false;
private shutdownTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
config: IConfig,
@@ -172,28 +171,12 @@ export class PostgresDatabaseClient extends AbstractDatabaseClient {
if (this.shutdownStarted) return;
this.shutdownStarted = true;
const drainMs = 60_000;
console.log(
`[postgres] draining in-flight queries (${drainMs}ms) before closing pools`,
);
this.shutdownTimer = setTimeout(() => {
this.shutdownTimer = null;
this.closeCurrentPools().catch((e) =>
console.error('[postgres] error closing pools after drain', e),
);
}, drainMs);
if (typeof this.shutdownTimer.unref === 'function') {
this.shutdownTimer.unref();
}
// Pools stay open; onServerShutdown closes them after the layers above
// have drained through them.
console.log('[postgres] entering drain mode');
}
override async onServerShutdown(): Promise<void> {
if (this.shutdownTimer) {
clearTimeout(this.shutdownTimer);
this.shutdownTimer = null;
}
await this.closeCurrentPools();
}
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
/** Highest schema version the migration table can reach. */
const CURRENT_SCHEMA_VERSION = 81;
const CURRENT_SCHEMA_VERSION = 82;
/**
* These suites migrate real files on disk. Idle they finish in well under a
@@ -276,6 +276,68 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
]);
});
it('deletes the subdomain rows an app owns when the app is deleted', async () => {
const [user] = (await client.read(
'SELECT MIN(`id`) AS id FROM `user`',
)) as { id: number }[];
await client.write(
'INSERT INTO `apps` (`uid`, `owner_user_id`, `name`, `title`, `index_url`) ' +
'VALUES (?, ?, ?, ?, ?)',
[
'app-cascade-test',
user.id,
'cascade-test',
'cascade-test',
'https://cascade.test/',
],
);
const [app] = (await client.read(
'SELECT `id` FROM `apps` WHERE `uid` = ?',
['app-cascade-test'],
)) as { id: number }[];
const insertSubdomain = (
uuid: string,
name: string,
appOwner: number | null,
) =>
client.write(
'INSERT INTO `subdomains` (`uuid`, `subdomain`, `user_id`, `app_owner`) ' +
'VALUES (?, ?, ?, ?)',
[uuid, name, user.id, appOwner],
);
await insertSubdomain('sd-cascade-owned', 'cascade-owned', app.id);
await insertSubdomain('sd-cascade-unowned', 'cascade-unowned', null);
await client.write('DELETE FROM `apps` WHERE `id` = ?', [app.id]);
await expect(
client.read(
'SELECT `subdomain` FROM `subdomains` WHERE `subdomain` LIKE ? ORDER BY `id`',
['cascade-%'],
),
).resolves.toEqual([{ subdomain: 'cascade-unowned' }]);
});
it('keeps every subdomains column through the 0086 rebuild', async () => {
const columns = (await client.read(
"SELECT `name` FROM pragma_table_info('subdomains') ORDER BY `cid`",
)) as { name: string }[];
expect(columns.map((c) => c.name)).toEqual([
'id',
'uuid',
'subdomain',
'user_id',
'root_dir_id',
'associated_app_id',
'ts',
'app_owner',
'protected',
'domain',
'database_id',
'preamble_version',
]);
});
it('constrains team shares that the user-holder index cannot', async () => {
const [user] = (await client.read(
'SELECT MIN(`id`) AS id FROM `user`',
@@ -115,6 +115,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[78, ['0083_team-directory.sql']],
[79, ['0084_share-anyone-with-link.sql']],
[80, ['0085_event-subscriptions-include-value.sql']],
[81, ['0086_subdomains-app-owner-cascade.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -0,0 +1,75 @@
-- 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/>.
-- Mirrors SQLite migration 0086: `subdomains.app_owner` cascades on app
-- delete instead of nulling. The existing constraint is found by column
-- rather than by name, since an older database need not carry the name
-- mysql_mig_1 declares. Guarded procedure as mysql_mig_34: a replay sees
-- DELETE_RULE already CASCADE and does nothing.
DROP PROCEDURE IF EXISTS _puter_subdomains_app_owner_cascade;
DELIMITER //
CREATE PROCEDURE _puter_subdomains_app_owner_cascade()
BEGIN
DECLARE fk_name VARCHAR(64) DEFAULT NULL;
-- A subquery, not SELECT ... INTO: no matching row yields NULL quietly.
SET fk_name = (
SELECT rc.CONSTRAINT_NAME
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
ON kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
AND kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
AND kcu.TABLE_NAME = rc.TABLE_NAME
WHERE rc.CONSTRAINT_SCHEMA = DATABASE()
AND rc.TABLE_NAME = 'subdomains'
AND rc.REFERENCED_TABLE_NAME = 'apps'
AND kcu.COLUMN_NAME = 'app_owner'
AND rc.DELETE_RULE <> 'CASCADE'
LIMIT 1
);
IF fk_name IS NOT NULL THEN
SET @s := CONCAT('ALTER TABLE `subdomains` DROP FOREIGN KEY `', fk_name, '`');
PREPARE stmt FROM @s;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
IF NOT EXISTS (
SELECT 1
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS rc
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
ON kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA
AND kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME
AND kcu.TABLE_NAME = rc.TABLE_NAME
WHERE rc.CONSTRAINT_SCHEMA = DATABASE()
AND rc.TABLE_NAME = 'subdomains'
AND rc.REFERENCED_TABLE_NAME = 'apps'
AND kcu.COLUMN_NAME = 'app_owner'
AND rc.DELETE_RULE = 'CASCADE'
) THEN
ALTER TABLE `subdomains` ADD CONSTRAINT `fk_subdomains_app_owner`
FOREIGN KEY (`app_owner`) REFERENCES `apps` (`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END//
DELIMITER ;
CALL _puter_subdomains_app_owner_cascade();
DROP PROCEDURE IF EXISTS _puter_subdomains_app_owner_cascade;
@@ -0,0 +1,59 @@
-- 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/>.
-- Mirrors SQLite migration 0086: `subdomains.app_owner` cascades on app
-- delete instead of nulling. The existing constraint is found by column, not
-- by name. Idempotent: there is no per-file applied-state tracking, and a
-- replay sees the cascade already in place and does nothing.
DO $$
DECLARE
fk_name text;
BEGIN
SELECT c.conname INTO fk_name
FROM pg_constraint c
JOIN pg_attribute a
ON a.attrelid = c.conrelid
AND a.attnum = ANY (c.conkey)
WHERE c.conrelid = 'subdomains'::regclass
AND c.contype = 'f'
AND c.confrelid = 'apps'::regclass
AND a.attname = 'app_owner'
AND c.confdeltype <> 'c'
LIMIT 1;
IF fk_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE subdomains DROP CONSTRAINT %I', fk_name);
END IF;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_attribute a
ON a.attrelid = c.conrelid
AND a.attnum = ANY (c.conkey)
WHERE c.conrelid = 'subdomains'::regclass
AND c.contype = 'f'
AND c.confrelid = 'apps'::regclass
AND a.attname = 'app_owner'
AND c.confdeltype = 'c'
) THEN
ALTER TABLE subdomains ADD CONSTRAINT subdomains_app_owner_fkey
FOREIGN KEY (app_owner) REFERENCES apps (id)
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
@@ -0,0 +1,52 @@
-- 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/>.
-- A deleted app takes the subdomain rows it owns (its hosted sites and its
-- workers) with it. `SET NULL` left them behind as unowned rows, and an
-- unowned worker row is redeployed with the account's own credential.
-- SQLite cannot alter a constraint, so the table is rebuilt as 0043 does.
PRAGMA foreign_keys = OFF;
CREATE TABLE `subdomains_new` (
`id` INTEGER PRIMARY KEY,
`uuid` varchar(40) DEFAULT NULL,
`subdomain` varchar(64) NOT NULL,
`user_id` int(10) NOT NULL,
`root_dir_id` int(10) DEFAULT NULL,
`associated_app_id` int(10) DEFAULT NULL,
`ts` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`app_owner` int(10) DEFAULT NULL,
`protected` tinyint(1) DEFAULT '0',
`domain` varchar(256) DEFAULT NULL,
`database_id` varchar(40) DEFAULT NULL,
`preamble_version` varchar(64) DEFAULT NULL,
FOREIGN KEY (`app_owner`) REFERENCES `apps` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO `subdomains_new`
(`id`, `uuid`, `subdomain`, `user_id`, `root_dir_id`, `associated_app_id`, `ts`,
`app_owner`, `protected`, `domain`, `database_id`, `preamble_version`)
SELECT
`id`, `uuid`, `subdomain`, `user_id`, `root_dir_id`, `associated_app_id`, `ts`,
`app_owner`, `protected`, `domain`, `database_id`, `preamble_version`
FROM `subdomains`;
DROP TABLE `subdomains`;
ALTER TABLE `subdomains_new` RENAME TO `subdomains`;
PRAGMA foreign_keys = ON;
@@ -559,3 +559,118 @@ describe('DDBClient — unprocessed batch items', () => {
).resolves.toEqual({ ConsumedCapacity: [] });
});
});
// UnprocessedKeys are batchGet's analogue of UnprocessedItems above, driven
// against the same kind of wire-format stub.
describe('DDBClient — unprocessed batch keys', () => {
let server: Server;
let endpoint: string;
let responses: unknown[];
let requestCount = 0;
beforeAll(async () => {
server = createServer((req, res) => {
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', () => {
const next = responses.shift() ?? {};
requestCount += 1;
res.writeHead(200, {
'content-type': 'application/x-amz-json-1.0',
});
res.end(JSON.stringify(next));
});
});
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve),
);
endpoint = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
const stubClient = () =>
new DDBClient({
port: 0,
extensions: [],
dynamo: {
aws: { access_key: 'a', secret_key: 'b', region: 'us-west-2' },
endpoint,
},
} as unknown as IConfig);
const unprocessed = (pk: string) => ({
UnprocessedKeys: {
[TABLE]: { Keys: [{ pk: { S: pk } }] },
},
ConsumedCapacity: [{ TableName: TABLE, CapacityUnits: 1 }],
});
it('retries leftover keys and merges responses and capacity across attempts', async () => {
requestCount = 0;
responses = [
unprocessed('retry-me'),
{
Responses: {
[TABLE]: [{ pk: { S: 'retry-me' }, v: { N: '1' } }],
},
UnprocessedKeys: {},
ConsumedCapacity: [{ TableName: TABLE, CapacityUnits: 2 }],
},
];
const result = await stubClient().batchGet([
{ table: TABLE, items: { pk: 'retry-me' } },
]);
expect(requestCount).toBe(2);
expect(result.Responses?.[TABLE]).toEqual([{ pk: 'retry-me', v: 1 }]);
expect(result.ConsumedCapacity).toEqual([
{ TableName: TABLE, CapacityUnits: 3 },
]);
});
it('gives up with a bad-request error when keys never drain', async () => {
requestCount = 0;
responses = Array.from({ length: 12 }, () => unprocessed('stuck'));
await expect(
stubClient().batchGet([{ table: TABLE, items: { pk: 'stuck' } }]),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
message: 'Failed to batch get all items from DynamoDB',
});
// One initial attempt plus the full retry budget.
expect(requestCount).toBe(9);
}, 30_000);
it('tolerates a response that reports no items or capacity at all', async () => {
requestCount = 0;
responses = [{ Responses: {}, UnprocessedKeys: {} }];
await expect(
stubClient().batchGet([{ table: TABLE, items: { pk: 'quiet' } }]),
).resolves.toEqual({ Responses: {}, ConsumedCapacity: [] });
expect(requestCount).toBe(1);
});
it('ignores capacity entries with no table name', async () => {
requestCount = 0;
responses = [
{
Responses: {},
UnprocessedKeys: {},
ConsumedCapacity: [{ CapacityUnits: 7 }],
},
];
await expect(
stubClient().batchGet([{ table: TABLE, items: { pk: 'anon' } }]),
).resolves.toEqual({ Responses: {}, ConsumedCapacity: [] });
});
});
+81 -17
View File
@@ -56,6 +56,8 @@ const localDynaliteEndpointPromises = new Map<string, Promise<string>>();
const MAX_BATCH_WRITE_ITEMS = 25;
const MAX_BATCH_WRITE_RETRIES = 8;
const BATCH_WRITE_RETRY_BASE_MS = 25;
const MAX_BATCH_GET_RETRIES = 8;
const BATCH_GET_RETRY_BASE_MS = 25;
// In-memory mode gives each DDBClient its own unique key, which keys
// into a fresh dynalite server. This is what test parallelism needs:
@@ -234,6 +236,9 @@ export class DDBClient extends PuterClient {
);
}
// UnprocessedKeys retried with capped exponential backoff, same posture as
// #batchWrite's UnprocessedItems: a batch this size can be throttled or
// partially served, and the caller shouldn't have to know that.
@Span('ddb.batchGet', (params: unknown[]) => ({
'db.batch_size': params.length,
}))
@@ -250,25 +255,84 @@ export class DDBClient extends PuterClient {
{} as Record<string, Record<string, unknown>[]>,
);
const requestItems: BatchGetCommandInput['RequestItems'] =
Object.entries(allRequestItemsPerTable).reduce(
(acc, [table, keyList]) => {
acc[table] = {
Keys: keyList,
ConsistentRead: consistentRead,
};
return acc;
},
{} as NonNullable<BatchGetCommandInput['RequestItems']>,
);
const command = new BatchGetCommand({
RequestItems: requestItems,
ReturnConsumedCapacity: 'TOTAL',
});
let requestItems: BatchGetCommandInput['RequestItems'] = Object.entries(
allRequestItemsPerTable,
).reduce(
(acc, [table, keyList]) => {
acc[table] = {
Keys: keyList,
ConsistentRead: consistentRead,
};
return acc;
},
{} as NonNullable<BatchGetCommandInput['RequestItems']>,
);
const client = await this.#getDocumentClient();
return client.send(command);
const responsesByTable = new Map<string, Record<string, unknown>[]>();
const consumedCapacityByTable = new Map<string, number>();
for (let attempt = 0; attempt <= MAX_BATCH_GET_RETRIES; attempt++) {
if (Object.keys(requestItems).length === 0) break;
const response = await client.send(
new BatchGetCommand({
RequestItems: requestItems,
ReturnConsumedCapacity: 'TOTAL',
}),
);
for (const [table, items] of Object.entries(
response.Responses ?? {},
)) {
const existing = responsesByTable.get(table) ?? [];
existing.push(...items);
responsesByTable.set(table, existing);
}
for (const entry of response.ConsumedCapacity ?? []) {
if (!entry.TableName) continue;
consumedCapacityByTable.set(
entry.TableName,
(consumedCapacityByTable.get(entry.TableName) ?? 0) +
Number(entry.CapacityUnits ?? 0),
);
}
const unprocessedKeys = response.UnprocessedKeys ?? {};
if (Object.keys(unprocessedKeys).length === 0) {
requestItems = {};
break;
}
requestItems = unprocessedKeys as NonNullable<
BatchGetCommandInput['RequestItems']
>;
if (attempt < MAX_BATCH_GET_RETRIES) {
const delayMs = Math.min(
1000,
BATCH_GET_RETRY_BASE_MS * 2 ** attempt,
);
await sleep(delayMs);
}
}
if (Object.keys(requestItems).length > 0) {
throw new HttpError(
400,
'Failed to batch get all items from DynamoDB',
{ legacyCode: 'bad_request' },
);
}
return {
Responses: Object.fromEntries(responsesByTable),
ConsumedCapacity: Array.from(consumedCapacityByTable.entries()).map(
([TableName, CapacityUnits]) => ({
TableName,
CapacityUnits,
}),
),
};
}
@Span('ddb.batchPut', (params: unknown[]) => ({
+3 -3
View File
@@ -184,7 +184,7 @@ const SHARE_LIST_ROW = `
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width: 100%;">
{{#each shares}}
<tr>
<td class="ink rule" style="padding: 12px 0;{{#unless @first}} border-top: 1px solid ${RULE};{{/unless}} font-family: ${FONT}; font-size: 16px; line-height: 24px; color: ${INK};"><strong style="font-weight: 600;">{{this.sender}}</strong> shared ${SHARE_ITEM_LIST}</td>
<td class="ink rule" style="padding: 12px 0;{{#unless @first}} border-top: 1px solid ${RULE};{{/unless}} font-family: ${FONT}; font-size: 16px; line-height: 24px; color: ${INK};"><strong style="font-weight: 600;">{{this.sender}}</strong> shared ${SHARE_ITEM_LIST}{{#if this.via}} via {{this.via}}{{/if}}</td>
</tr>
{{/each}}
</table>
@@ -458,7 +458,7 @@ immediately</p>
Shared with you on Puter:
{{#each shares}}
- {{this.sender}} shared {{this.what}}
- {{this.sender}} shared {{this.what}}{{#if this.via}} via {{this.via}}{{/if}}
{{#each this.items}}{{#if this.link}} {{this.name}}: {{this.link}}
{{/if}}{{/each}}{{/each}}
@@ -496,7 +496,7 @@ immediately</p>
Shared with you on Puter:
{{#each shares}}
- {{this.sender}} shared {{this.what}}
- {{this.sender}} shared {{this.what}}{{#if this.via}} via {{this.via}}{{/if}}
{{/each}}
There's no Puter account for {{email}} yet. Create one with this
+48 -5
View File
@@ -227,8 +227,40 @@ export type EventMap = {
fingerprint?: string | null;
/** True when the created account is a temp user (no email/password). */
is_temp?: boolean;
/** The bonus code `puter.signup-bonus.validate` accepted, if any. */
bonus_code?: string;
[key: string]: unknown;
};
// Signup bonus codes are pure mechanism here: an extension decides what a
// code is worth and fills these in (both emitted via `emitAndWait`).
'puter.signup-bonus.check': {
code: string;
ip: string | null;
fingerprint: string | null;
valid: boolean;
/** Opaque to core; forwarded to the client when `valid` is false. */
reason: string | null;
display: { title: string; description: string } | null;
/** Verification the code will require after signup. */
requirements: { phone: boolean; card: boolean } | null;
};
/**
* Emitted once a signup has passed `puter.signup.validate`, so listeners
* see the harness's verdict. A listener sets `accepted` to honor the code,
* and may raise (never lower) the verification requirements.
*/
'puter.signup-bonus.validate': {
code: string;
email?: string;
clean_email?: string;
ip?: string | null;
fingerprint?: string | null;
reputation?: number | null;
source?: 'oidc';
requires_phone_verification: boolean;
requires_card_verification: boolean;
accepted: boolean;
};
'email.validate': {
email: string;
allow: boolean;
@@ -429,6 +461,8 @@ export type EventMap = {
* did not have it in hand, as an `expire` does not.
*/
values?: unknown[];
/** Keys among `keys` private to the namespace's app. */
noShareKeys?: string[];
};
/**
* A whole namespace was emptied. Namespace-level on purpose: `flush`'s own
@@ -605,6 +639,16 @@ export type EventMap = {
requestHash?: string;
mime: string;
};
// Asked before a hosted file is streamed. A listener that owns the site
// sets `result.allowed = false` to withhold the entry; the visitor then
// sees the same 404 as for a missing file.
'site.access.check': {
subdomain: string;
host: string;
requestPath: string;
entry: { name: string; path: string };
result: { allowed: boolean };
};
// ---- Thumbnails ----
// The listener rewrites `thumbnail` in place (an s3:// key or legacy
@@ -808,11 +852,10 @@ export type EventKey = keyof EventMap & string;
// Generates a wildcard for every non-final dot-separated prefix of K.
export type WildcardPrefixes<K extends string> =
K extends `${infer Head}.${infer Tail}`
?
| `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
? | `${Head}.*`
| (Tail extends `${string}.${string}`
? `${Head}.${WildcardPrefixes<Tail>}`
: never)
: never;
export type ListenKey = EventKey | WildcardPrefixes<EventKey>;
@@ -0,0 +1,346 @@
/*
* 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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { makeActor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
/**
* HTTP-level coverage for `/auth/revoke-own-access-token` — the route that
* lets an app revoke a read-URL token it minted itself, without touching the
* account-only `/auth/revoke-access-token`.
*/
describe('revoke-own-access-token over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const call = (
method: string,
path: string,
token: string,
body?: unknown,
) =>
fetch(new URL(path, env.apiOrigin), {
method,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const tokenReadStatus = async (fileUid: string, token: string) => {
const url = new URL('/token-read', env.apiOrigin);
url.searchParams.set('uid', fileUid);
url.searchParams.set('token', token);
return (await fetch(url)).status;
};
/** A file with real content in `owner`'s home, readable by uid. */
const makeFile = async (owner: { username: string }) => {
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const content = Buffer.from(`revoke-http-${crypto.randomUUID()}`);
const path = `/${owner.username}/Documents/${crypto.randomUUID()}.txt`;
await env.server.services.fs.write(user!.id, {
fileMetadata: {
path,
size: content.byteLength,
contentType: 'text/plain',
},
fileContent: content,
});
const entry = await env.server.stores.fsEntry.getEntryByPath(path);
return { uid: entry!.uid };
};
/** An app of `owner`'s, with a token and read reach over `file`. */
const makeApp = async (
owner: { username: string },
file: { uid: string },
) => {
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const actor = makeActor({ user: user! });
const app = await env.server.stores.app.create(
{
name: `auth-http-app-${crypto.randomUUID()}`,
title: 'Auth app',
index_url: `https://auth-${crypto.randomUUID()}.test/`,
},
{ ownerUserId: actor.user.id! },
);
await runWithContext({ actor }, () =>
env.server.services.permission.grantUserAppPermission(
actor,
app.uid,
`fs:${file.uid}:read`,
),
);
const token = await env.server.services.auth.getUserAppToken(
actor,
app.uid,
);
return { ...app, token };
};
/** A scoped (non-full-access) access token for `owner`, carrying no app. */
const makeScopedToken = async (owner: {
username: string;
token: string;
}) => {
const res = await call(
'POST',
'/auth/create-access-token',
owner.token,
{ permissions: ['service:foo:ii:read'], expiresIn: '1h' },
);
expect(res.status).toBe(200);
return ((await res.json()) as { token: string }).token;
};
/** Mints a read-URL token the way `getReadURL()` does. */
const mintReadToken = async (callerToken: string, fileUid: string) => {
const res = await call(
'POST',
'/auth/create-access-token',
callerToken,
{ permissions: [`fs:${fileUid}:read`], expiresIn: '1h' },
);
expect(res.status).toBe(200);
return ((await res.json()) as { token: string }).token;
};
it('lets an app revoke a read token it minted itself', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const app = await makeApp(owner, file);
const readToken = await mintReadToken(app.token, file.uid);
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
app.token,
{ token: readToken },
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ ok: true });
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
});
it('404s a second app of the same user, and the URL still works', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const app = await makeApp(owner, file);
const otherApp = await makeApp(owner, file);
const readToken = await mintReadToken(app.token, file.uid);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
otherApp.token,
{ token: readToken },
);
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'not_found' });
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
});
it('lets the account session revoke an app-minted token', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const app = await makeApp(owner, file);
const readToken = await mintReadToken(app.token, file.uid);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
owner.token,
{ token: readToken },
);
expect(res.status).toBe(200);
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
});
it('404s another user entirely', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const app = await makeApp(owner, file);
const readToken = await mintReadToken(app.token, file.uid);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
env.users.other.token,
{ token: readToken },
);
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'not_found' });
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
});
it('404s an app presenting an account-issued token', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const app = await makeApp(owner, file);
// Minted with the owner's own session, so it carries no `app_uid`.
const accountToken = await mintReadToken(owner.token, file.uid);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
app.token,
{ token: accountToken },
);
expect(res.status).toBe(404);
expect(await res.json()).toMatchObject({ code: 'not_found' });
expect(await tokenReadStatus(file.uid, accountToken)).toBe(200);
});
it('refuses a scoped access token as caller', async () => {
const owner = env.users.user;
const scoped = await makeScopedToken(owner);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
scoped,
{ token: scoped },
);
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'forbidden' });
});
it('rejects a non-JWT string with token_invalid', async () => {
const owner = env.users.user;
const res = await call(
'POST',
'/auth/revoke-own-access-token',
owner.token,
{ token: 'not-a-jwt' },
);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ code: 'token_invalid' });
});
it('still requires `token` in the body', async () => {
const owner = env.users.user;
const res = await call(
'POST',
'/auth/revoke-own-access-token',
owner.token,
{},
);
expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ code: 'bad_request' });
});
/** A second personal API token for `owner`, minted the way the dashboard does. */
const mintPersonalToken = async (owner: { token: string }) => {
const { actor } = await env.server.services.auth.authenticate(
owner.token,
);
return env.server.services.auth.createAccessToken(actor!, [
[FULL_API_ACCESS],
]);
};
const whoamiStatus = async (token: string) =>
(await call('GET', '/whoami', token)).status;
it('refuses to revoke a personal API token, even for its own account', async () => {
const owner = env.users.user;
const pat = await mintPersonalToken(owner);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
owner.token,
{ token: pat },
);
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'forbidden' });
expect(await whoamiStatus(pat)).toBe(200);
});
it('keeps a personal API token from revoking a sibling one', async () => {
const owner = env.users.user;
const sibling = await mintPersonalToken(owner);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
owner.apiToken,
{ token: sibling },
);
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'forbidden' });
expect(await whoamiStatus(sibling)).toBe(200);
});
it('lets a personal API token revoke its account`s read token', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
// Access tokens can't mint, so the account session creates the URL.
const readToken = await mintReadToken(owner.token, file.uid);
expect(await tokenReadStatus(file.uid, readToken)).toBe(200);
const res = await call(
'POST',
'/auth/revoke-own-access-token',
owner.apiToken,
{ token: readToken },
);
expect(res.status).toBe(200);
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
});
it('resolves a second revoke of the same token', async () => {
const owner = env.users.user;
const file = await makeFile(owner);
const app = await makeApp(owner, file);
const readToken = await mintReadToken(app.token, file.uid);
for (let i = 0; i < 2; i++) {
const res = await call(
'POST',
'/auth/revoke-own-access-token',
app.token,
{ token: readToken },
);
expect(res.status).toBe(200);
}
expect(await tokenReadStatus(file.uid, readToken)).not.toBe(200);
});
});
@@ -0,0 +1,413 @@
/*
* 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/>.
*/
/**
* Signup bonus codes through the real controller: the check route, the signup
* hand-off to `puter.signup-bonus.validate`, and the awaited
* `user.card-verified` a grant on card verification depends on. Tests stand in
* for the extension with shared listeners (EventClient has no `off()`).
*/
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import type { Actor } from '../../core/actor.js';
import type { EventMap } from '../../clients/event/types.js';
import type { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
let server: PuterServer;
let controller: any;
type Handler<K extends keyof EventMap> = ((data: EventMap[K]) => void) | null;
let onBonusCheck: Handler<'puter.signup-bonus.check'> = null;
let onBonusValidate: Handler<'puter.signup-bonus.validate'> = null;
let onSignupValidate: ((data: Record<string, unknown>) => void) | null = null;
let onCardConfirm: ((data: Record<string, unknown>) => void) | null = null;
let onCardVerified: (() => Promise<void>) | null = null;
const heardSignupSuccess: Array<Record<string, unknown>> = [];
beforeAll(async () => {
server = await setupTestServer();
controller = server.controllers.auth;
const events = server.clients.event;
events.on('puter.signup-bonus.check', (_k, data) => onBonusCheck?.(data));
events.on('puter.signup-bonus.validate', (_k, data) =>
onBonusValidate?.(data),
);
events.on('puter.signup.validate', (_k, data) =>
onSignupValidate?.(data as Record<string, unknown>),
);
events.on('puter.signup.success', (_k, data) => {
heardSignupSuccess.push(data as Record<string, unknown>);
});
events.on('puter.card-verification.confirm', (_k, data) =>
onCardConfirm?.(data as Record<string, unknown>),
);
events.on('user.card-verified' as never, async () => {
await onCardVerified?.();
});
});
afterAll(async () => {
await server?.shutdown();
});
afterEach(() => {
onBonusCheck = null;
onBonusValidate = null;
onSignupValidate = null;
onCardConfirm = null;
onCardVerified = null;
});
const uniq = () => Math.random().toString(36).slice(2, 10);
/** A check listener that finds the code open, as an extension would. */
const openCheck: Handler<'puter.signup-bonus.check'> = (data) => {
data.valid = true;
data.display = { title: 'T', description: 'D' };
};
const makeReq = (
body: Record<string, unknown> = {},
extra: { actor?: Actor; ip?: string } = {},
) => ({
body,
headers: {},
connection: { remoteAddress: extra.ip ?? '127.0.0.1' },
socket: { remoteAddress: extra.ip ?? '127.0.0.1' },
ip: extra.ip ?? '127.0.0.1',
params: {},
actor: extra.actor,
});
const makeRes = () => {
const res = {
statusCode: 200,
body: undefined as unknown,
status(code: number) {
this.statusCode = code;
return this;
},
json(b: unknown) {
this.body = b;
return this;
},
cookie() {
return this;
},
clearCookie() {
return this;
},
send() {
return this;
},
end() {
return this;
},
};
return res;
};
const signupBody = (extra: Record<string, unknown> = {}) => {
const username = `b_${uniq()}`;
return {
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
...extra,
};
};
const signup = async (body: Record<string, unknown>) => {
const res = makeRes();
await controller.handleSignup(makeReq(body), res);
return res;
};
const findUser = (username: unknown) =>
server.stores.user.getByUsername(username as string, { force: true });
/** `puter.signup.success` is fire-and-forget; wait for it to land. */
const waitForSuccess = async (userUuid: string) => {
const deadline = Date.now() + 2000;
while (Date.now() < deadline) {
const hit = heardSignupSuccess.find((e) => e.user_uuid === userUuid);
if (hit) return hit;
await new Promise((r) => setTimeout(r, 10));
}
throw new Error('puter.signup.success never arrived');
};
// -- POST /signup/bonus-code/check -----------------------------------
describe('AuthController.handleSignupBonusCheck', () => {
const check = async (body: Record<string, unknown>) => {
const res = makeRes();
await controller.handleSignupBonusCheck(makeReq(body), res);
return res.body;
};
it('answers invalid when no extension is listening', async () => {
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
valid: false,
});
});
it('returns the display and requirements a listener fills in', async () => {
let seen: EventMap['puter.signup-bonus.check'] | null = null;
onBonusCheck = (data) => {
seen = { ...data };
data.valid = true;
data.display = { title: '3 months of Basic', description: 'Free' };
data.requirements = { phone: true, card: false };
};
expect(
await check({
bonusCode: 'Startup3M-ABCD1234',
fingerprint: 'fp1',
}),
).toEqual({
valid: true,
display: { title: '3 months of Basic', description: 'Free' },
requirements: { phone: true, card: false },
});
// Listeners only ever see the canonical form.
expect(seen).toMatchObject({
code: 'startup3mabcd1234',
fingerprint: 'fp1',
ip: '127.0.0.1',
});
});
it('forwards an opaque reason for a refused code', async () => {
onBonusCheck = (data) => {
data.reason = 'ended';
};
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
valid: false,
reason: 'ended',
});
});
it('treats valid without display as invalid', async () => {
onBonusCheck = (data) => {
data.valid = true;
};
expect(await check({ bonusCode: 'startup3m-abcd1234' })).toEqual({
valid: false,
});
});
it('answers a malformed code without consulting listeners', async () => {
let called = false;
onBonusCheck = () => {
called = true;
};
expect(await check({ bonusCode: 'a:b' })).toEqual({ valid: false });
expect(called).toBe(false);
});
it('400s a non-string bonusCode', async () => {
await expect(
controller.handleSignupBonusCheck(
makeReq({ bonusCode: 42 }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
// -- POST /signup with bonusCode -------------------------------------
describe('AuthController.handleSignup with a bonus code', () => {
it('refuses the signup when nothing accepts the code', async () => {
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
await expect(signup(body)).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bonus_code_invalid',
});
expect(await findUser(body.username)).toBeFalsy();
});
it('refuses a dead code before the abuse gate records the attempt', async () => {
let validated = false;
onSignupValidate = () => {
validated = true;
};
await expect(
signup(signupBody({ bonusCode: 'startup3m-abcd1234' })),
).rejects.toMatchObject({ legacyCode: 'bonus_code_invalid' });
expect(validated).toBe(false);
});
it('still refuses a code that passed the check but is refused after the gate', async () => {
onBonusCheck = openCheck;
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
await expect(signup(body)).rejects.toMatchObject({
legacyCode: 'bonus_code_invalid',
});
expect(await findUser(body.username)).toBeFalsy();
});
it('refuses a malformed code before any listener runs', async () => {
let called = false;
onBonusValidate = () => {
called = true;
};
await expect(
signup(signupBody({ bonusCode: 'nope' + '!'.repeat(3) })),
).rejects.toMatchObject({ legacyCode: 'bonus_code_invalid' });
expect(called).toBe(false);
});
it('400s a non-string bonusCode', async () => {
await expect(
signup(signupBody({ bonusCode: ['startup3m'] })),
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
});
it('applies the requirements an accepted code raises and reports the code', async () => {
onBonusCheck = openCheck;
let seen: EventMap['puter.signup-bonus.validate'] | null = null;
onBonusValidate = (data) => {
seen = { ...data };
data.accepted = true;
data.requires_card_verification = true;
};
const body = signupBody({
bonusCode: 'STARTUP6M-abcd1234',
fingerprint: 'fp-bonus',
});
const res = await signup(body);
expect(
(res.body as { user: Record<string, unknown> }).user,
).toMatchObject({ requires_card_verification: true });
expect(seen).toMatchObject({
code: 'startup6mabcd1234',
email: body.email,
fingerprint: 'fp-bonus',
requires_phone_verification: false,
requires_card_verification: false,
});
const user = await findUser(body.username);
expect(Boolean(user!.requires_card_verification)).toBe(true);
expect(Boolean(user!.requires_phone_verification)).toBe(false);
const success = await waitForSuccess(user!.uuid);
expect(success.bonus_code).toBe('startup6mabcd1234');
});
it('keeps a requirement the abuse harness set even if the listener clears it', async () => {
onBonusCheck = openCheck;
onSignupValidate = (data) => {
data.requires_phone_verification = true;
};
onBonusValidate = (data) => {
expect(data.requires_phone_verification).toBe(true);
data.accepted = true;
data.requires_phone_verification = false;
};
const body = signupBody({ bonusCode: 'startup3m-abcd1234' });
await signup(body);
const user = await findUser(body.username);
expect(Boolean(user!.requires_phone_verification)).toBe(true);
});
it('never offers the code for a signup the abuse harness blocked', async () => {
onBonusCheck = openCheck;
let called = false;
onSignupValidate = (data) => {
data.allow = false;
data.message = 'Signup blocked';
};
onBonusValidate = () => {
called = true;
};
await expect(
signup(signupBody({ bonusCode: 'startup3m-abcd1234' })),
).rejects.toMatchObject({ statusCode: 403 });
expect(called).toBe(false);
});
it('leaves the success event without a code when none was presented', async () => {
const body = signupBody();
await signup(body);
const user = await findUser(body.username);
const success = await waitForSuccess(user!.uuid);
expect(success).not.toHaveProperty('bonus_code');
});
it('ignores a bonus code on a temp signup', async () => {
let called = false;
onBonusValidate = () => {
called = true;
};
const res = makeRes();
await controller.handleSignup(
makeReq({ is_temp: true, bonusCode: 'startup3m-abcd1234' }),
res,
);
expect((res.body as { user: { is_temp: boolean } }).user.is_temp).toBe(
true,
);
expect(called).toBe(false);
});
});
// -- /card-verification/confirm awaits user.card-verified ------------
describe('AuthController.handleCardVerificationConfirm', () => {
it('finishes user.card-verified listeners before responding', async () => {
const body = signupBody();
await signup(body);
const created = await findUser(body.username);
await server.stores.user.update(created!.id, {
requires_card_verification: 1,
});
const actor = {
user: {
id: created!.id,
uuid: created!.uuid,
username: created!.username,
email: created!.email ?? null,
email_confirmed: false,
},
} as Actor;
onCardConfirm = (data) => {
data.enabled = true;
data.verified = true;
data.fingerprint = 'fp_card_1';
};
let listenerDone = false;
onCardVerified = async () => {
await new Promise((r) => setTimeout(r, 30));
listenerDone = true;
};
const res = makeRes();
await controller.handleCardVerificationConfirm(
makeReq({ setup_intent_id: 'seti_1' }, { actor }),
res,
);
expect(res.body).toMatchObject({ card_verified: true });
expect(listenerDone).toBe(true);
});
});
@@ -705,6 +705,79 @@ describe('AuthController.handleSignup', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it('rejects 400 when the home path is already occupied by a foreign row', async () => {
const parked = `s_${uniq()}`;
const occupantUsername = `s_${uniq()}`;
await controller.handleSignup(
makeReq({
username: occupantUsername,
email: `${occupantUsername}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const occupant = await server.stores.user.getByUsername(
occupantUsername,
);
const root = (await server.stores.fsEntry.getRootEntryForUser(
occupant!.id,
))!;
await server.clients.db.write(
'UPDATE fsentries SET path = ? WHERE id = ?',
[`/${parked}`, root.id],
);
await expect(
controller.handleSignup(
makeReq({
username: parked,
email: `${parked}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
server.stores.user.getByUsername(parked, { force: true }),
).resolves.toBeNull();
});
it('rejects 400 when only a foreign row sits under the home path', async () => {
const parked = `s_${uniq()}`;
const occupantUsername = `s_${uniq()}`;
await controller.handleSignup(
makeReq({
username: occupantUsername,
email: `${occupantUsername}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
);
const docs = (await server.stores.fsEntry.getEntryByPath(
`/${occupantUsername}/Documents`,
))!;
await server.clients.db.write(
'UPDATE fsentries SET path = ? WHERE id = ?',
[`/${parked}/Documents`, docs.id],
);
await expect(
controller.handleSignup(
makeReq({
username: parked,
email: `${parked}@test.local`,
password: 'correct-horse-battery',
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
server.stores.user.getByUsername(parked, { force: true }),
).resolves.toBeNull();
});
it('rejects reserved usernames (e.g. "admin")', async () => {
await expect(
controller.handleSignup(
@@ -2970,6 +3043,35 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
expect(typeof body.token).toBe('string');
});
// What decides whether a cancelled "Sign in with Puter" still hands over a token.
it('check-app reports an app the user never opened as unauthorized, with no token', async () => {
const untouched = await (
server.stores.app.create as unknown as (
fields: Record<string, unknown>,
opts: { ownerUserId: number },
) => Promise<{ uid: string; id: number }>
)(
{
name: `ca-${uuidv4()}`,
title: 'Never opened',
index_url: 'https://never-opened.example.test/index.html',
},
{ ownerUserId: user.id },
);
const res = makeRes();
await inCtx(actor, () =>
controller.handleCheckApp(
makeReq({ app_uid: untouched.uid }, { actor }),
res,
),
);
expect(res.body).toEqual({
app_uid: untouched.uid,
authenticated: false,
});
});
it('check-app returns the {app_uid, authenticated} envelope shape', async () => {
// Create a brand-new actor with no app-related history so the
// permission scan can't cache-hit anything from prior tests, AND
@@ -3032,16 +3134,7 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
authenticated: boolean;
token?: string;
};
expect(body.app_uid).toBe(otherApp.uid);
expect(typeof body.authenticated).toBe('boolean');
// Whether `authenticated` is true depends on the user's full
// permission set (default group, owned-app implicits, etc.) — this
// test only pins the response *shape*, since the substantive case
// (`authenticated: true` after a paired get-user-app-token) is
// covered by the test above.
if (!body.authenticated) {
expect(body.token).toBeUndefined();
}
expect(body).toEqual({ app_uid: otherApp.uid, authenticated: false });
});
it('falls back to origin → app_uid resolution and bootstraps a new app row', async () => {
@@ -3099,6 +3192,60 @@ describe('AuthController.handleGetUserAppToken + handleCheckApp', () => {
expect(bootstrapped).toBeTruthy();
expect(bootstrapped?.owner_user_id).toBe(owner!.id);
});
it('canonicalizes alternate-host origins to one bootstrap row per subdomain', async () => {
const subdomain = `sd-${uuidv4().slice(0, 8)}`;
await server.stores.subdomain.create({ userId: user.id, subdomain });
const res = makeRes();
await inCtx(actor, () =>
controller.handleGetUserAppToken(
makeReq(
{ origin: `https://${subdomain}.host.puter.localhost` },
{ actor },
),
res,
),
);
const body = res.body as { token: string; app_uid: string };
const bootstrapped = await server.stores.app.getByUid(body.app_uid);
expect(bootstrapped?.index_url).toBe(
`http://${subdomain}.site.puter.localhost`,
);
const res2 = makeRes();
await inCtx(actor, () =>
controller.handleGetUserAppToken(
makeReq(
{ origin: `https://${subdomain}.app.puter.localhost` },
{ actor },
),
res2,
),
);
expect((res2.body as { app_uid: string }).app_uid).toBe(body.app_uid);
});
it('supports browser extension origins in handleGetUserAppToken', async () => {
// Random id: a pre-existing row for this origin would resolve through
// the canonical lookup and never exercise the bootstrap path.
const origin = `chrome-extension://${uuidv4()}`;
const res = makeRes();
await inCtx(actor, () =>
controller.handleGetUserAppToken(
makeReq({ origin }, { actor }),
res,
),
);
const body = res.body as { token: string; app_uid: string };
expect(body.app_uid).toBe(
`app-${uuidv5(origin, APP_ORIGIN_UUID_NAMESPACE)}`,
);
const bootstrapped = await server.stores.app.getByUid(body.app_uid);
expect(bootstrapped?.index_url).toBe(origin);
// Proves the row came from the bootstrap path, not an earlier test.
expect(bootstrapped?.description).toMatch(/^App created from origin /);
});
});
// ── Access tokens: create + revoke ─────────────────────────────────
@@ -5034,6 +5181,102 @@ describe('AuthController.handleSaveAccount address conflicts', () => {
expect(untouched!.email).toBeNull();
expect(untouched!.password).toBeNull();
});
it('refuses to promote a temp account when the home path is already occupied', async () => {
const parked = `save_home_${Math.random().toString(36).slice(2, 10)}`;
const { user: occupant } = await makeUserAndActor();
const root = (await server.stores.fsEntry.getRootEntryForUser(
occupant.id,
))!;
await server.clients.db.write(
'UPDATE fsentries SET path = ? WHERE id = ?',
[`/${parked}`, root.id],
);
const tempRes = makeRes();
await controller.handleSignup(makeReq({ is_temp: true }), tempRes);
const tempUser = (
tempRes.body as { user: { username: string; uuid: string } }
).user;
const tempRow = await server.stores.user.getByUuid(tempUser.uuid);
const actor = {
user: {
id: tempRow!.id,
uuid: tempRow!.uuid,
username: tempRow!.username,
email: null,
email_confirmed: false,
},
} as Actor;
await expect(
controller.handleSaveAccount(
makeReq(
{
username: parked,
email: `${parked}@test.local`,
password: 'another-strong-password',
},
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
const untouched = await server.stores.user.getById(tempRow!.id, {
force: true,
});
expect(untouched!.username).toBe(tempRow!.username);
expect(untouched!.password).toBeNull();
});
it('refuses to promote a temp account when only a foreign row sits under the home path', async () => {
const parked = `save_home_${Math.random().toString(36).slice(2, 10)}`;
const { user: occupant } = await makeUserAndActor();
const docs = (await server.stores.fsEntry.getEntryByPath(
`/${occupant.username}/Documents`,
))!;
await server.clients.db.write(
'UPDATE fsentries SET path = ? WHERE id = ?',
[`/${parked}/Documents`, docs.id],
);
const tempRes = makeRes();
await controller.handleSignup(makeReq({ is_temp: true }), tempRes);
const tempUser = (
tempRes.body as { user: { username: string; uuid: string } }
).user;
const tempRow = await server.stores.user.getByUuid(tempUser.uuid);
const actor = {
user: {
id: tempRow!.id,
uuid: tempRow!.uuid,
username: tempRow!.username,
email: null,
email_confirmed: false,
},
} as Actor;
await expect(
controller.handleSaveAccount(
makeReq(
{
username: parked,
email: `${parked}@test.local`,
password: 'another-strong-password',
},
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
const untouched = await server.stores.user.getById(tempRow!.id, {
force: true,
});
expect(untouched!.username).toBe(tempRow!.username);
expect(untouched!.password).toBeNull();
});
});
// ── Password recovery flow ──────────────────────────────────────────
@@ -5092,7 +5335,9 @@ describe('AuthController password recovery', () => {
expect((res.body as { message: string }).message).toMatch(
/If that account exists/i,
);
const after = await server.stores.user.getById(seat.id, { force: true });
const after = await server.stores.user.getById(seat.id, {
force: true,
});
expect(after!.pass_recovery_token).toBeFalsy();
});
@@ -5357,7 +5602,10 @@ describe('AuthController user-protected mutations (validation paths)', () => {
await expect(
controller.handleChangeEmail(
makeReq({ new_email: `moved_${uniq()}@example.com` }, { actor }),
makeReq(
{ new_email: `moved_${uniq()}@example.com` },
{ actor },
),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 403 });
@@ -5385,7 +5633,9 @@ describe('AuthController user-protected mutations (validation paths)', () => {
),
).rejects.toMatchObject({ statusCode: 403 });
const after = await server.stores.user.getById(seat.id, { force: true });
const after = await server.stores.user.getById(seat.id, {
force: true,
});
expect(after!.username).toBe(seat.username);
});
@@ -5423,6 +5673,62 @@ describe('AuthController user-protected mutations (validation paths)', () => {
}
});
it('change-username: 400 when the home path is already occupied, before the rename', async () => {
const { user, actor } = await makeUserAndActor();
// A name no account holds, whose home path a stray row does. This is
// the shape of legacy drift; it used to leave two roots at one path.
const parked = `p_${uniq()}`;
const root = (await server.stores.fsEntry.getRootEntryForUser(
user.id,
))!;
await server.clients.db.write(
'UPDATE fsentries SET path = ? WHERE id = ?',
[`/${parked}`, root.id],
);
const { user: mover, actor: moverActor } = await makeUserAndActor();
await expect(
controller.handleChangeUsername(
makeReq({ new_username: parked }, { actor: moverActor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
// The username claim is refused whole: the user row keeps its name.
const after = await server.stores.user.getById(mover.id, {
force: true,
});
expect(after!.username).toBe(mover.username);
void actor;
});
it('change-username: 400 when only a foreign row sits under the target home path', async () => {
const { user } = await makeUserAndActor();
// Nothing sits at `/parked` itself — only a leftover child row does,
// the shape a partial cascade or a user-scoped cleanup leaves behind.
const parked = `p_${uniq()}`;
const docs = (await server.stores.fsEntry.getEntryByPath(
`/${user.username}/Documents`,
))!;
await server.clients.db.write(
'UPDATE fsentries SET path = ? WHERE id = ?',
[`/${parked}/Documents`, docs.id],
);
const { user: mover, actor: moverActor } = await makeUserAndActor();
await expect(
controller.handleChangeUsername(
makeReq({ new_username: parked }, { actor: moverActor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
const after = await server.stores.user.getById(mover.id, {
force: true,
});
expect(after!.username).toBe(mover.username);
});
it('change-email: 400 on missing/invalid email and on a confirmed-account collision', async () => {
const { actor } = await makeUserAndActor();
await expect(
@@ -5865,6 +6171,152 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
});
});
// What lets a launch settle a consent prompt it holds no app token for.
it('check-permissions: `app_uid` answers for that app, not for the asking user', async () => {
const { user, actor } = await makeUserAndActor();
const app = await server.stores.app.create(
{
name: `cpa-${uuidv4()}`,
title: 'TestCheckPermsAppUid',
index_url: 'https://check-perms-uid.example.test/index.html',
},
{ ownerUserId: user.id },
);
const permission = `user:${user.uuid}:email:read`;
// Held by the user, so asking as the user would answer `true`.
const asUser = makeRes();
await inCtx(actor, () =>
controller.handleCheckPermissions(
makeReq({ permissions: [permission] }, { actor }),
asUser,
),
);
expect(asUser.body).toEqual({ permissions: { [permission]: true } });
const before = makeRes();
await inCtx(actor, () =>
controller.handleCheckPermissions(
makeReq(
{ permissions: [permission], app_uid: app.uid },
{ actor },
),
before,
),
);
expect(before.body).toEqual({ permissions: { [permission]: false } });
await inCtx(actor, () =>
controller.handleGrantUserApp(
makeReq({ app_uid: app.uid, permission, extra: {} }, { actor }),
makeRes(),
),
);
const after = makeRes();
await inCtx(actor, () =>
controller.handleCheckPermissions(
makeReq(
{ permissions: [permission], app_uid: app.uid },
{ actor },
),
after,
),
);
expect(after.body).toEqual({ permissions: { [permission]: true } });
});
it('check-permissions: an app cannot ask about another app, and an unknown app 404s', async () => {
const { user, actor } = await makeUserAndActor();
const app = await server.stores.app.create(
{
name: `cpx-${uuidv4()}`,
title: 'TestCheckPermsCrossApp',
index_url: 'https://check-perms-x.example.test/index.html',
},
{ ownerUserId: user.id },
);
const appActor = makeActor({
user: actor.user,
app: { id: app.id, uid: app.uid },
});
await expect(
inCtx(appActor, () =>
controller.handleCheckPermissions(
makeReq(
{
permissions: ['service:foo:ii:read'],
app_uid: app.uid,
},
{ actor: appActor },
),
makeRes(),
),
),
).rejects.toMatchObject({ statusCode: 403 });
await expect(
inCtx(actor, () =>
controller.handleCheckPermissions(
makeReq(
{
permissions: ['service:foo:ii:read'],
app_uid: `app-${uuidv4()}`,
},
{ actor },
),
makeRes(),
),
),
).rejects.toMatchObject({ statusCode: 404 });
await expect(
inCtx(actor, () =>
controller.handleCheckPermissions(
makeReq(
{
permissions: ['service:foo:ii:read'],
app_uid: { not: 'a string' } as unknown as string,
},
{ actor },
),
makeRes(),
),
),
).rejects.toMatchObject({ statusCode: 400 });
});
// Falling through to the user here would answer `true` for any file they own.
it('check-permissions: a present-but-empty `app_uid` is refused, not read as "no app"', async () => {
const { user, actor } = await makeUserAndActor();
const permission = `user:${user.uuid}:email:read`;
for (const app_uid of ['', null]) {
await expect(
inCtx(actor, () =>
controller.handleCheckPermissions(
makeReq(
{ permissions: [permission], app_uid },
{ actor },
),
makeRes(),
),
),
).rejects.toMatchObject({ statusCode: 400 });
}
// Omitted entirely still means "what do I hold?".
const res = makeRes();
await inCtx(actor, () =>
controller.handleCheckPermissions(
makeReq({ permissions: [permission] }, { actor }),
res,
),
);
expect(res.body).toEqual({ permissions: { [permission]: true } });
});
it('list-permissions: returns the shape and includes a user→app grant with its app_uid', async () => {
const { user, actor } = await makeUserAndActor();
const app = await server.stores.app.create(
@@ -6641,7 +7093,9 @@ describe('AuthController.handleDeleteOwnUser', () => {
controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes()),
).rejects.toMatchObject({ statusCode: 403 });
const after = await server.stores.user.getById(seat.id, { force: true });
const after = await server.stores.user.getById(seat.id, {
force: true,
});
expect(after).toBeTruthy();
});
+282 -24
View File
@@ -33,6 +33,7 @@ import {
hasVerifiedPhone,
} from '../../core/http/middleware/gates.js';
import type { Actor } from '../../core/actor.js';
import { isPlainUserActor, makeActor } from '../../core/actor.js';
import { checkRateLimit } from '../../core/http/middleware/rateLimit.js';
import {
signStepUpToken,
@@ -75,6 +76,13 @@ import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { cleanEmail, isBlockedEmail } from '../../util/email.js';
import { generate_identifier } from '../../util/identifier.js';
import { parsePhone } from '../../util/phone.js';
import {
bonusCodeInvalidError,
checkSignupBonus,
isAbsentBonusCode,
normalizeBonusCode,
validateSignupBonus,
} from '../../util/signupBonus.js';
import { isTemporaryPasswordExpired } from '../../util/temporaryPassword.js';
import { getTaskbarItems } from '../../util/taskbarItems.js';
import {
@@ -109,6 +117,8 @@ const FINGERPRINT_MAX_LENGTH = 128;
// crafted request from turning a single grant call into a bulk write.
const MAX_PERMISSIONS_PER_REQUEST = 16;
const DISPATCH_ID_MAX_LENGTH = 128;
// One name for the flag, so the write and the read cannot drift apart.
const APP_AUTHENTICATED_FLAG = 'flag:app-is-authenticated';
// -- Post-login route limits -----------------------------------------
//
@@ -130,6 +140,14 @@ const CREDENTIAL_MINT_LIMIT = {
key: 'user',
} as const;
/** Revoking is cheap and undoing a mistake shouldn't fight the mint budget. */
const ACCESS_TOKEN_REVOKE_LIMIT = {
scope: 'auth-access-token-revoke',
limit: 60,
window: 60_000,
key: 'user',
} as const;
/**
* Second-factor configuration, including the verify leg. Shorter window than
* the mint limit because enabling 2FA legitimately involves a few attempts in a
@@ -804,6 +822,18 @@ export class AuthController extends PuterController {
}
}
// Only the shape is judged here; whether the code is honored is up to
// `puter.signup-bonus.validate`, after the abuse checks below.
let bonusCode: string | null = null;
if (!is_temp && !isAbsentBonusCode(body.bonusCode)) {
if (typeof body.bonusCode !== 'string')
throw new HttpError(400, 'bonusCode must be a string.', {
legacyCode: 'bad_request',
});
bonusCode = normalizeBonusCode(body.bonusCode);
if (!bonusCode) throw bonusCodeInvalidError();
}
// Signup-disabled gate. Runs before the duplicate checks so a
// disabled endpoint doesn't reveal which usernames or emails
// exist. Claiming a pre-existing placeholder row is still
@@ -836,6 +866,22 @@ export class AuthController extends PuterController {
);
}
// ...and the same against the filesystem: a free username whose home
// path is occupied (exactly or by leftover rows underneath it) would
// provision a second root there, and the two trees then resolve
// interchangeably.
if (
await this.stores.fsEntry.findHomePathConflict(
body.username,
undefined,
{ includeDescendants: true },
)
) {
throw new HttpError(400, 'This username is not available.', {
legacyCode: 'bad_request',
});
}
// Duplicate confirmed-email check. A confirmed account (any
// credential type — password OR OIDC) on this email → reject.
//
@@ -868,6 +914,20 @@ export class AuthController extends PuterController {
? null
: await this.#resolveSignupEmailClaim(body.email);
// A dead code fails here rather than after the gate below, which records
// an allowed attempt against the address as if an account followed.
if (
bonusCode &&
!(
await checkSignupBonus(this.clients.event, bonusCode, {
ip: clientIp,
fingerprint,
})
).valid
) {
throw bonusCodeInvalidError();
}
// Extension-level validation gate. Abuse-prevention extensions
// inspect the incoming signup and can:
// - block it outright via `event.allow = false`
@@ -954,18 +1014,37 @@ export class AuthController extends PuterController {
const force_email_confirmation = Boolean(
validateEvent.requires_email_confirmation,
);
const force_phone_verification =
let force_phone_verification =
Boolean(validateEvent.requires_phone_verification) ||
// Test/QA switch: force the SMS gate on every signup regardless of
// reputation (see config.always_require_phone_verification).
Boolean(this.config.always_require_phone_verification);
const force_card_verification = Boolean(
let force_card_verification = Boolean(
validateEvent.requires_card_verification ||
// Test/QA switch: force the card gate on every signup regardless of
// reputation (see config.always_require_card_verification).
this.config.always_require_card_verification,
);
if (bonusCode) {
const verdict = await validateSignupBonus(
this.clients.event,
bonusCode,
{
email: body.email,
clean_email: cleanEmail(body.email),
ip: clientIp,
fingerprint,
reputation: validateEvent.reputation,
requires_phone_verification: force_phone_verification,
requires_card_verification: force_card_verification,
},
);
if (!verdict.accepted) throw bonusCodeInvalidError();
force_phone_verification = verdict.requiresPhoneVerification;
force_card_verification = verdict.requiresCardVerification;
}
// Prepare shared fields
const user_uuid = uuidv4();
const email_confirm_code = String(crypto.randomInt(100000, 1000000));
@@ -1186,6 +1265,7 @@ export class AuthController extends PuterController {
// have to agree or per-IP counters are written under one
// key and read under another.
ip: clientIp,
...(bonusCode ? { bonus_code: bonusCode } : {}),
} as never,
{},
);
@@ -1207,6 +1287,64 @@ export class AuthController extends PuterController {
await this.#completeLogin(req, res, user!);
}
/**
* Preview a signup bonus code before the account exists, so the signup form
* can say what it grants. Answers only valid/invalid: an extension owns the
* codes, and with none installed every code is invalid.
*/
@Post('/signup/bonus-code/check', {
rateLimit: [
{ scope: 'signup-bonus-check', limit: 20, window: 15 * 60_000 },
{
scope: 'signup-bonus-check-ip',
limit: 60,
window: 15 * 60_000,
key: 'ip',
},
],
})
async handleSignupBonusCheck(req: Request, res: Response): Promise<void> {
const raw = req.body?.bonusCode;
if (typeof raw !== 'string')
throw new HttpError(400, 'bonusCode must be a string.', {
legacyCode: 'bad_request',
});
const code = normalizeBonusCode(raw);
if (!code) {
res.json({ valid: false });
return;
}
const fingerprint =
typeof req.body?.fingerprint === 'string' &&
req.body.fingerprint.length <= FINGERPRINT_MAX_LENGTH
? req.body.fingerprint
: null;
const checkEvent = await checkSignupBonus(this.clients.event, code, {
ip: (req.ip || req.socket?.remoteAddress || null) as string | null,
fingerprint,
});
if (checkEvent.valid !== true || !checkEvent.display) {
res.json({
valid: false,
...(checkEvent.reason ? { reason: checkEvent.reason } : {}),
});
return;
}
res.json({
valid: true,
display: {
title: String(checkEvent.display.title),
description: String(checkEvent.display.description),
},
requirements: {
phone: checkEvent.requirements?.phone === true,
card: checkEvent.requirements?.card === true,
},
});
}
// -- Logout ------------------------------------------------------
@Post('/logout', {
@@ -2200,8 +2338,11 @@ export class AuthController extends PuterController {
...(clearedPhoneGate ? { requires_phone_verification: 0 } : {}),
});
// Awaited like `user.phone-verified`, so whatever a listener grants on
// verification is in place before the client refreshes. emitAndWait
// swallows listener errors, so confirm never fails on one.
try {
this.clients.event?.emit(
await this.clients.event?.emitAndWait(
'user.card-verified' as never,
{
user_id: user.id,
@@ -2214,7 +2355,7 @@ export class AuthController extends PuterController {
{},
);
} catch {
// ignore — event is a side-channel signal, not load-bearing
// ignore — listeners are best-effort
}
// Notify other tabs/devices for this user so they refresh + drop the gate.
try {
@@ -2587,6 +2728,20 @@ export class AuthController extends PuterController {
legacyCode: 'username_already_in_use',
});
}
// Before the username is written, not after: the rename below is what
// keeps the account's files reachable, and it can't run onto a taken
// path.
if (
await this.stores.fsEntry.findHomePathConflict(
new_username,
req.actor!.user.id!,
{ includeDescendants: true },
)
) {
throw new HttpError(400, 'This username is not available.', {
legacyCode: 'username_already_in_use',
});
}
await this.stores.user.update(req.actor!.user.id!, {
username: new_username,
@@ -2911,6 +3066,15 @@ export class AuthController extends PuterController {
legacyCode: 'username_already_in_use',
});
}
if (
await this.stores.fsEntry.findHomePathConflict(username, user.id, {
includeDescendants: true,
})
) {
throw new HttpError(400, 'This username is not available.', {
legacyCode: 'username_already_in_use',
});
}
// Match raw + canonical to catch gmail-alias collisions, and
// reject on ANY confirmed account (OIDC accounts have
// password=null but are real) — not just password-holders.
@@ -3535,27 +3699,66 @@ export class AuthController extends PuterController {
// -- Permission checks -------------------------------------------
/**
* The caller's account acting as `appIdentifier`, resolved uid-or-name like
* the grant handlers.
*/
async #appUnderUserActor(
actor: Actor,
appIdentifier: unknown,
): Promise<Actor> {
this.#validateAppPermissionParams({ app_uid: appIdentifier });
// Sessions only: for an app, this would be a window onto its neighbours' grants.
if (!isPlainUserActor(actor)) {
throw new HttpError(403, 'actor must be a user', {
legacyCode: 'forbidden',
});
}
const app = await this.stores.app.resolveApp(appIdentifier as string);
if (!app) {
throw new HttpError(404, `App ${appIdentifier} does not exist`, {
legacyCode: 'not_found',
});
}
return makeActor({
user: actor.user,
app: { id: app.id, uid: app.uid },
});
}
@Post('/auth/check-permissions', {
subdomain: 'api',
requireAuth: true,
rateLimit: AUTH_CHECK_LIMIT,
})
async handleCheckPermissions(req: Request, res: Response): Promise<void> {
const { permissions } = req.body ?? {};
const { permissions, app_uid } = req.body ?? {};
if (!Array.isArray(permissions)) {
throw new HttpError(400, 'Missing or invalid `permissions` array', {
legacyCode: 'bad_request',
});
}
// Present but empty must not fall through to checking the user: on their own file every `fs:` scope answers `true`.
if (
app_uid !== undefined &&
(typeof app_uid !== 'string' || !app_uid)
) {
throw new HttpError(400, 'Invalid `app_uid`', {
legacyCode: 'bad_request',
});
}
// `app_uid` asks what an app of mine holds, not what I hold.
const actor = app_uid
? await this.#appUnderUserActor(req.actor!, app_uid)
: req.actor!;
const unique = [...new Set(permissions)] as string[];
const result: Record<string, boolean> = {};
let granted: Map<string, boolean>;
try {
granted = await this.services.permission.checkMany(
req.actor!,
unique,
);
granted = await this.services.permission.checkMany(actor, unique);
} catch {
granted = new Map<string, boolean>();
}
@@ -3839,11 +4042,18 @@ export class AuthController extends PuterController {
if (!app && resolvedFromOrigin) {
// Hosted-subdomain origins get the site owner stamped as the
// app's creator at bootstrap; external origins stay unowned.
// Canonical origin only, so alternate hosts share one row.
const canonicalOrigin =
this.services.auth.canonicalizeOrigin(origin);
const ownerUserId =
await this.services.auth.subdomainOwnerIdFromOrigin(origin);
app = await this.stores.app.createFromOrigin(app_uid, origin, {
ownerUserId,
});
await this.services.auth.subdomainOwnerIdFromOrigin(
canonicalOrigin,
);
app = await this.stores.app.createFromOrigin(
app_uid,
canonicalOrigin,
{ ownerUserId },
);
// An origin's uid is a deterministic uuidv5, so a deleted app
// reappears here under the identical uid. Withdraw any cross-app
// data grants left pointing at it before this new row can inherit
@@ -3871,7 +4081,7 @@ export class AuthController extends PuterController {
this.services.permission.grantUserAppPermission(
req.actor!,
app_uid,
'flag:app-is-authenticated',
APP_AUTHENTICATED_FLAG,
{},
{},
);
@@ -3955,13 +4165,15 @@ export class AuthController extends PuterController {
legacyCode: 'bad_request',
});
// Check if the app is authenticated for this user
const authenticated = await this.services.permission
.check(
req.actor!,
`service:${app_uid}:ii:flag:app-is-authenticated`,
)
.catch(() => false);
// The exact row, not a scan: a `service:`-shaped check falls open on the `service` root.
const app = await this.stores.app.resolveApp(app_uid);
const userId = req.actor!.user?.id;
const authenticated =
!!app?.id &&
!!userId &&
(await this.stores.permission
.hasUserAppPerm(userId, app.id, APP_AUTHENTICATED_FLAG)
.catch(() => false));
const result: {
app_uid: string;
@@ -3969,9 +4181,10 @@ export class AuthController extends PuterController {
token?: string;
} = { app_uid, authenticated };
if (authenticated) {
// The resolved uid: the token carries it as the app's identity.
result.token = await this.services.auth.getUserAppToken(
req.actor!,
app_uid,
app!.uid,
);
}
res.json(result);
@@ -4044,10 +4257,36 @@ export class AuthController extends PuterController {
res.json({ token });
}
/**
* Revokes an access token given as its JWT. Unlike
* `/auth/revoke-access-token`, apps may call this; the service limits an
* app to tokens it issued and refuses personal API tokens.
*/
@Post('/auth/revoke-own-access-token', {
subdomain: 'api',
requireAuth: true,
rateLimit: ACCESS_TOKEN_REVOKE_LIMIT,
})
async handleRevokeOwnAccessToken(
req: Request,
res: Response,
): Promise<void> {
const { token } = req.body ?? {};
if (!token || typeof token !== 'string') {
throw new HttpError(400, 'Missing `token`', {
legacyCode: 'bad_request',
});
}
await this.services.auth.revokeOwnAccessToken(req.actor!, token);
res.json({ ok: true });
}
// Wired imperatively in `registerRoutes` so the cookie-only gate
// (built from `this.config`) can be composed in. Cookie-only is
// mandatory: a leaked access token must not be able to silently
// revoke its own siblings.
// mandatory: a leaked access token must not be able to revoke a
// personal API token, or revoke by raw uuid — those stay web-session-only
// here; `/auth/revoke-own-access-token` covers revoking scoped tokens by
// JWT and already refuses PATs itself.
async handleRevokeAccessToken(req: Request, res: Response): Promise<void> {
let { tokenOrUuid } = req.body ?? {};
if (!tokenOrUuid || typeof tokenOrUuid !== 'string') {
@@ -4399,6 +4638,25 @@ export class AuthController extends PuterController {
{ legacyCode: 'forbidden' },
);
}
// The other direction: deleting an owner sets `owner_user_id` NULL and
// cascades their membership, leaving a team no one can administer —
// not even an admin, since every team route resolves by membership.
if (await this.stores.team.countOwned(userId)) {
throw new HttpError(
403,
'Delete the teams you own before closing this account.',
{ legacyCode: 'forbidden' },
);
}
// A deleted team's seats are suspended, not gone, and only its owner
// can retire them.
if (await this.stores.team.countOwnedTeamsWithAccounts(userId)) {
throw new HttpError(
403,
'Close the accounts your teams created before closing this one.',
{ legacyCode: 'forbidden' },
);
}
res.clearCookie(this.config.cookie_name ?? 'puter_token');
res.clearCookie('puter_token_v2');
res.clearCookie('puter_revalidation');
@@ -179,6 +179,28 @@ const throwing = (payload: unknown) => () => {
// -- Upstream status extraction --------------------------------------
describe('DriverController upstream error translation', () => {
it('maps an upstream 402 to sanitized credit exhaustion', async () => {
const { err } = await callWith(
throwing(
Object.assign(
new Error(
'<html>Insufficient credits. Add more using https://openrouter.ai/settings/credits (request id: req-secret)</html>',
),
{ status: 402 },
),
),
);
expect(err).toMatchObject({
statusCode: 503,
legacyCode: 'upstream_credits_exhausted',
message: 'AI provider out of credits',
fields: { upstreamStatus: 402 },
});
expect(JSON.stringify(err)).not.toContain('http');
expect(JSON.stringify(err)).not.toMatch(/request id|<html>/i);
});
it('maps an upstream 429 to a Puter 429 with upstream_rate_limited', async () => {
const { err } = await callWith(
throwing(
@@ -31,7 +31,11 @@ import {
} from '../../core/http/middleware/rateLimit.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import type { DriverMeta } from '../../drivers/meta.js';
import { isUpstreamTimeoutError } from '../../drivers/util/upstreamErrors.js';
import {
isCreditExhaustion,
isUpstreamTimeoutError,
sanitizeUpstreamMessage,
} from '../../drivers/util/upstreamErrors.js';
import {
isDriverStreamResult,
resolveCallableMethods,
@@ -120,6 +124,18 @@ const translateProviderError = (err: unknown): unknown => {
cause?: unknown;
};
const status = extractUpstreamStatus(e);
const msg = sanitizeUpstreamMessage(
e.error?.message ?? e.message ?? 'Upstream provider error',
);
const upstreamCode = e.error?.code ?? e.code;
const fields = { upstreamStatus: status, upstreamCode };
if (isCreditExhaustion(status, upstreamCode, msg)) {
return new HttpError(503, 'AI provider out of credits', {
legacyCode: 'upstream_credits_exhausted',
fields,
});
}
if (typeof status !== 'number') {
if (isUpstreamTimeoutError(e)) {
const cause = e.cause as { code?: string } | undefined;
@@ -132,10 +148,6 @@ const translateProviderError = (err: unknown): unknown => {
return err;
}
const msg = e.error?.message ?? e.message ?? 'Upstream provider error';
const upstreamCode = e.error?.code ?? e.code;
const fields = { upstreamStatus: status, upstreamCode };
if (status === 429) {
return new HttpError(429, msg, {
legacyCode: 'upstream_rate_limited',
@@ -287,12 +287,14 @@ export class EventsController extends PuterController {
// -- Events workers ------------------------------------------------
//
// The billable artifact a published handler set implies. Account-scoped
// like the kv-handle surface above: this is the owner's own view of what
// it is paying for, so — unlike the handler routes — an app token cannot
// act here on its owner's behalf.
// The billable artifact a published handler set implies. An account
// session or full-access token sees every app it owns; an app sees only
// its own worker, the same scope publish and destroy use.
/** GET /events/workers — the caller's own events workers, one per app. */
/**
* GET /events/workers — an account's events workers, one per app, or an
* app's own worker (0 or 1 item).
*/
@Get('/workers', {
subdomain: 'api',
requireAuth: true,
@@ -36,6 +36,8 @@ import {
EVENTS_DURABLE_SUBSCRIPTIONS_PER_USER,
EVENTS_KV_HANDLES_PER_APP,
EVENTS_KV_HANDLES_PER_USER,
EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT,
EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT,
EVENTS_SINGLE_DELIVERY_LIMIT,
EVENTS_WORKER_INVOCATION_LIMIT,
limitFor,
@@ -119,6 +121,34 @@ describe('the tiered subscription quotas', () => {
});
describe('the delivery budgets', () => {
it('sets KV fan-out and filter work by the namespace owner tier', () => {
for (const [plan, matched, evaluated] of [
[null, 512, 2048],
[DEFAULT_FREE_SUBSCRIPTION, 128, 512],
[DEFAULT_TEMP_SUBSCRIPTION, 128, 512],
[ORG_SEAT_FREE_SUBSCRIPTION, 128, 512],
] as const) {
expect(
limitFor(EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT, plan),
).toBe(matched);
expect(
limitFor(EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT, plan),
).toBe(evaluated);
expect(evaluated).toBeGreaterThanOrEqual(matched);
}
});
it('pins the share-handle quotas by plan', () => {
expect(EVENTS_KV_HANDLES_PER_USER.limit).toBe(512);
expect(EVENTS_KV_HANDLES_PER_APP.limit).toBe(512);
expect(
limitFor(EVENTS_KV_HANDLES_PER_USER, DEFAULT_FREE_SUBSCRIPTION),
).toBe(200);
expect(
limitFor(EVENTS_KV_HANDLES_PER_APP, DEFAULT_FREE_SUBSCRIPTION),
).toBe(128);
});
it('keeps `single` well under broadcast — each one costs far more', () => {
expect(EVENTS_SINGLE_DELIVERY_LIMIT.limit).toBeLessThan(
EVENTS_BROADCAST_DELIVERY_LIMIT.limit,
+23 -11
View File
@@ -167,16 +167,15 @@ export const EVENTS_KV_HANDLE_LIMIT = userWindow('events:kvHandles', 60);
* grow forever. A temporary account holds none: the grant outlives the session
* that made it, and no account is left to take it back.
*/
export const EVENTS_KV_HANDLES_PER_USER = tiered(500, 200, 0);
export const EVENTS_KV_HANDLES_PER_USER = tiered(512, 200, 0);
/**
* Live share handles one app may hold out for one account.
*
* Below the per-account cap so that one app cannot spend an account's whole
* budget: a handle names one namespace, so without this a second app on the
* same account would be left with nothing.
* Separately bounded from the account cap so one namespace cannot consume the
* whole budget on free accounts.
*/
export const EVENTS_KV_HANDLES_PER_APP = tiered(100, 50, 0);
export const EVENTS_KV_HANDLES_PER_APP = tiered(512, 128, 0);
// -- Handler surface -------------------------------------------------
@@ -256,17 +255,30 @@ export const EVENTS_WORKER_LIST_LIMIT = userWindow('events:workers:list', 120);
// -- Dispatch fan-out ------------------------------------------------
/**
* Subscriptions one event may deliver to before dispatch stops and reports a
* gap. The amplification ceiling: without it, one write costs as many
* Subscriptions one non-KV event may deliver to before dispatch stops and
* reports a gap. The amplification ceiling: without it, one write costs as many
* deliveries as an account cared to register.
*/
export const EVENTS_MATCHED_SUBSCRIPTIONS_PER_EVENT = 50;
/**
* Largest key-value value a delivery inlines, in serialized bytes. A value over
* this is left out and the subscriber re-reads the key: a delivery fans out to
* many rows, may cross regions and may sit in a backlog, none of which is sized
* for the store's own ceiling.
* Key-value subscriptions one mutation may deliver to. A namespace owner's plan
* sets this ceiling; the listeners' plans do not affect it.
*/
export const EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT = tiered(512, 128, 128);
/**
* Filter evaluations a key-value mutation may spend. This leaves room for
* non-matching rows while retaining a finite bound on dispatch work.
*/
export const EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT = tiered(2048, 512, 512);
/** Omit all inline KV values when the matched audience exceeds this count. */
export const EVENTS_KV_VALUE_OMIT_MATCHED_SUBSCRIPTIONS = 128;
/**
* Largest inline KV value in serialized bytes. Larger values are omitted;
* deliveries retain the key metadata.
*/
export const EVENTS_KV_VALUE_MAX_BYTES = 16 * 1024;
@@ -54,12 +54,7 @@ import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
/** Why a deploy could not happen, for the callers that answer differently. */
export type EventsDeployOutcome =
| 'deployed'
| 'stale'
| 'no-handlers'
| 'no-owner'
| 'throttled'
| 'failed';
'deployed' | 'stale' | 'no-handlers' | 'no-owner' | 'throttled' | 'failed';
interface DeployLayers {
config: IConfig;
@@ -354,3 +354,134 @@ describe('GET /fs/readdir over HTTP', () => {
});
});
});
describe('POST /fs/completeWrite over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
it('refuses completion of a session whose object never arrived', async () => {
const { username, token } = env.users.user;
const started = await fetch(new URL('/fs/startWrite', env.apiOrigin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
fileMetadata: {
path: `/${username}/Documents/http-nobytes-${Date.now()}.bin`,
size: 4,
},
}),
});
expect(started.status).toBe(200);
const { sessionId } = (await started.json()) as { sessionId: string };
// No PUT to the presigned URL.
const response = await fetch(
new URL('/fs/completeWrite', env.apiOrigin),
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ uploadId: sessionId }),
},
);
expect(response.status).toBe(400);
const body = (await response.json()) as {
code?: string;
message?: string;
};
expect(body.code).toBe('bad_request');
expect(body.message).toBe('Upload content was not received');
});
});
describe('POST /fs/startWrite storage quota over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv({
is_storage_limited: true,
storage_capacity: 10 * 1024,
} as never);
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
it('rejects the excess of a parallel burst of startWrite calls', async () => {
const { username, token } = env.users.user;
const user = await env.server.stores.user.getByUsername(username);
const fs = env.server.services.fs as unknown as {
getUsersStorageAllowance: (
userId: number,
) => Promise<{ curr: number; max: number }>;
};
const { curr, max } = await fs.getUsersStorageAllowance(user!.id);
const size = Math.floor((max - curr) / 3) + 1;
const responses = await Promise.all(
Array.from({ length: 8 }, (_, i) =>
fetch(new URL('/fs/startWrite', env.apiOrigin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
fileMetadata: {
path: `/${username}/Documents/http-quota-burst-${i}.bin`,
size,
},
}),
}),
),
);
const ok = responses.filter((response) => response.status === 200);
const rejected = responses.filter(
(response) => response.status === 413,
);
expect(ok).toHaveLength(2);
expect(rejected).toHaveLength(6);
for (const response of rejected) {
const body = (await response.json()) as { code?: string };
expect(body.code).toBe('storage_limit_reached');
}
});
// A raw JSON body carries `1e400` as a numeral, not a computed value, so
// `JSON.stringify` can't produce it (it turns `Infinity` into `null`) —
// the wire text is built by hand to exercise what the server's own
// `JSON.parse` does with it.
it('rejects a declared size of 1e400 (parses to Infinity) with 400', async () => {
const { username, token } = env.users.user;
const body = `{"fileMetadata":{"path":"/${username}/Documents/http-huge.bin","size":1e400}}`;
const response = await fetch(new URL('/fs/startWrite', env.apiOrigin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body,
});
expect(response.status).toBe(400);
const responseBody = (await response.json()) as { code?: string };
expect(responseBody.code).toBe('bad_request');
});
});
@@ -342,9 +342,16 @@ describe('FSController.completeBatchWrites', () => {
.body as ClientSignedWriteResponse[];
expect(startResponses).toHaveLength(2);
// 2) Complete via the controller. Single-mode completion only
// needs the session row → it doesn't read the S3 object back,
// so we can skip the actual upload step in this test.
// 2) PUT the declared bytes through each presigned URL, then
// complete via the controller.
await fetch(startResponses[0]!.url!, {
method: 'PUT',
body: '12345',
});
await fetch(startResponses[1]!.url!, {
method: 'PUT',
body: '1234567',
});
const { res, captured } = makeRes();
const completeBody: CompleteWriteRequest[] = startResponses.map(
(r) => ({ uploadId: r.sessionId }),
@@ -416,6 +423,7 @@ describe('FSController.completeBatchWrites', () => {
);
const [firstResponse] = firstStart.captured
.body as ClientSignedWriteResponse[];
await fetch(firstResponse!.url!, { method: 'PUT', body: 'x' });
const firstComplete = makeRes();
await withActor(actor, () =>
controller.completeBatchWrites(
@@ -547,6 +555,7 @@ describe('FSController.completeBatchWrites', () => {
);
const [firstResponse] = firstStart.captured
.body as ClientSignedWriteResponse[];
await fetch(firstResponse!.url!, { method: 'PUT', body: 'x' });
await withActor(actor, () =>
controller.completeBatchWrites(
makeReq<CompleteWriteRequest[]>({
@@ -2731,6 +2740,7 @@ describe('FSController associatedAppId entitlement gate', () => {
),
);
const [started] = startRes.captured.body as ClientSignedWriteResponse[];
await fetch(started.url!, { method: 'PUT', body: 'abc' });
const completeRes = makeRes();
await withActor(actor, () =>
controller.completeBatchWrites(
@@ -1024,6 +1024,7 @@ describe('FSController.completeWrite', () => {
const { actor, username } = await makeUser();
const target = `/${username}/Documents/complete-single.txt`;
const started = await startSignedWrite(actor, target, 4);
await fetch(started.url!, { method: 'PUT', body: 'wxyz' });
const { res, captured } = makeRes();
await withActor(actor, () =>
@@ -1073,6 +1074,7 @@ describe('FSController.completeWrite', () => {
`/${username}/Documents/complete-thumb.txt`,
2,
);
await fetch(started.url!, { method: 'PUT', body: 'yz' });
const { res } = makeRes();
await withActor(actor, () =>
controller.completeWrite(
@@ -2089,6 +2091,7 @@ describe('FSController upload session access re-checks', () => {
const target = `${folder}/kept-complete.bin`;
const single = await startUpload(recipient.actor, target);
await fetch(single.url!, { method: 'PUT', body: '12345678' });
await withActor(recipient.actor, () =>
controller.completeWrite(
makeReq<CompleteWriteRequest>({
@@ -2113,6 +2116,9 @@ describe('FSController upload session access re-checks', () => {
await startUpload(recipient.actor, targets[0]!),
await startUpload(recipient.actor, targets[1]!),
];
for (const s of started) {
await fetch(s.url!, { method: 'PUT', body: '12345678' });
}
await withActor(recipient.actor, () =>
controller.completeBatchWrites(
makeReq<CompleteWriteRequest[]>({
@@ -23,6 +23,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { makeActor, type Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import type { HttpError } from '../../core/http/HttpError.js';
import { consumeRouteRateLimit } from '../../core/http/middleware/rateLimit.js';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
@@ -2348,6 +2349,72 @@ describe('LegacyFSController.checkAppAcl', () => {
const body = captured.body as { allowed: boolean };
expect(typeof body.allowed).toBe('boolean');
});
it("answers another user's entry the same as a missing one", async () => {
const { actor: victim } = await makeUser();
const victimPath = `/${victim.user!.username!}/Documents/secret.txt`;
await withActor(victim, () =>
controller.touch(
makeReq({
body: { path: victimPath, set_modified_to_now: true },
actor: victim,
}),
makeRes().res,
),
);
const victimEntry =
await server.stores.fsEntry.getEntryByPath(victimPath);
const { actor } = await makeUser();
const app = await (
server.stores.app.create as unknown as (
fields: Record<string, unknown>,
opts: { ownerUserId: number },
) => Promise<{ uid: string }>
)(
{
name: `cacl-${uuidv4()}`,
title: 'ACL test app',
index_url: 'https://example.test/cacl.html',
},
{ ownerUserId: actor.user!.id! },
);
const errorFor = async (subject: unknown) => {
try {
await withActor(actor, () =>
controller.checkAppAcl(
makeReq({
body: { subject, app: app.uid, mode: 'read' },
actor,
}),
makeRes().res,
),
);
return null;
} catch (e) {
const err = e as HttpError;
return {
statusCode: err.statusCode,
message: err.message,
legacyCode: err.legacyCode,
};
}
};
const missing = await errorFor({
path: `/${victim.user!.username!}/Documents/nope.txt`,
});
expect(missing).toEqual({
statusCode: 404,
message: 'Subject does not exist',
legacyCode: 'subject_does_not_exist',
});
expect(await errorFor({ path: victimPath })).toEqual(missing);
expect(await errorFor({ uid: victimEntry!.uuid })).toEqual(missing);
expect(await errorFor({ id: victimEntry!.id })).toEqual(missing);
expect(await errorFor({ uid: uuidv4() })).toEqual(missing);
});
});
// ── down (validation paths) ─────────────────────────────────────────
@@ -1932,7 +1932,7 @@ export class LegacyFSController extends PuterController {
* access to a subject FS entry.
*/
checkAppAcl = async (req: Request, res: Response): Promise<void> => {
this.#requireActor(req);
const actor = this.#requireActor(req);
const body = asRecord(req.body);
const subjectRef = body.subject;
@@ -1944,10 +1944,28 @@ export class LegacyFSController extends PuterController {
legacyCode: 'bad_request',
});
const subject = await resolveV1Selector(
this.stores.fsEntry,
subjectRef,
);
// A missing subject and one the caller can't see get the same 404,
// so this can't be used to probe for other users' entries.
let subject: import('../../stores/fs/FSEntry.js').FSEntry | null = null;
try {
subject = await resolveV1Selector(this.stores.fsEntry, subjectRef);
} catch (err) {
if (!(err instanceof HttpError && err.statusCode === 404))
throw err;
}
let ancestors: Promise<Array<{ uid: string; path: string }>> | null =
null;
const descriptor = subject && {
path: subject.path,
resolveAncestors: () =>
(ancestors ??= this.services.fs.getAncestorChain(subject.path)),
};
const acl = this.services.acl as ACLService;
if (!descriptor || !(await acl.check(actor, descriptor, 'see')))
throw new HttpError(404, 'Subject does not exist', {
legacyCode: 'subject_does_not_exist',
});
let app: { uid: string } | null = null;
if (typeof appRef === 'string') {
app =
@@ -1961,19 +1979,10 @@ export class LegacyFSController extends PuterController {
// Build an actor-under-user shape for the check.
const actorForApp = makeActor({
user: req.actor!.user,
user: actor.user,
app: { uid: (app as { uid: string }).uid },
});
const descriptor = {
path: subject.path,
resolveAncestors: () =>
this.services.fs.getAncestorChain(subject.path),
};
const allowed = await (this.services.acl as ACLService).check(
actorForApp,
descriptor,
mode,
);
const allowed = await acl.check(actorForApp, descriptor, mode);
res.json({ allowed });
};
+2
View File
@@ -40,11 +40,13 @@ import { WebDAVController } from './webdav/WebDAVController.js';
import { WispController } from './wisp/WispController.js';
import type { IPuterControllerRegistry } from './types.js';
import { PeerController } from './peer/PeerController.js';
import { ProfileController } from './profile/ProfileController.js';
export const puterControllers = {
staticAssets: StaticAssetsController,
staticPages: StaticPagesController,
auth: AuthController,
profile: ProfileController,
apps: AppController,
appFeedback: AppFeedbackController,
desktop: DesktopController,
@@ -58,6 +58,10 @@ let router: PuterRouter;
// override in and out (same pattern as AuthController.test.ts).
type SignupValidateOverride = (data: Record<string, unknown>) => void;
let signupValidateOverride: SignupValidateOverride | null = null;
// Stand-in for the extension that honors signup bonus codes.
let bonusValidateOverride: SignupValidateOverride | null = null;
let bonusCheckOverride: SignupValidateOverride | null = null;
const heardSignupSuccess: Array<Record<string, unknown>> = [];
beforeAll(async () => {
server = await setupTestServer({
@@ -90,6 +94,24 @@ beforeAll(async () => {
}
},
);
server.clients.event.on(
'puter.signup-bonus.check',
(_k: unknown, data: unknown) => {
bonusCheckOverride?.(data as Record<string, unknown>);
},
);
server.clients.event.on(
'puter.signup-bonus.validate',
(_k: unknown, data: unknown) => {
bonusValidateOverride?.(data as Record<string, unknown>);
},
);
server.clients.event.on(
'puter.signup.success',
(_k: unknown, data: unknown) => {
heardSignupSuccess.push(data as Record<string, unknown>);
},
);
});
afterAll(async () => {
@@ -99,6 +121,8 @@ afterAll(async () => {
afterEach(() => {
vi.restoreAllMocks();
signupValidateOverride = null;
bonusValidateOverride = null;
bonusCheckOverride = null;
});
interface CapturedResponse {
@@ -1965,3 +1989,143 @@ describe('OIDCController rate limits', () => {
expect(rateLimitOf('get', '/auth/oidc/providers').limit).toBe(1_200);
});
});
// -- Signup bonus codes ----------------------------------------------
describe('OIDC signup bonus codes', () => {
const rand = () => Math.random().toString(36).slice(2, 8);
const startState = async (query: Record<string, unknown>) => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/:provider/start',
makeReq({ params: { provider: 'custom' }, query }),
res,
);
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
'state',
);
return oidc().verifyState(state!) as Record<string, unknown>;
};
const stubIdp = (sub: string, email: string) => {
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
access_token: 'access',
id_token: 'id',
} as never);
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
sub,
email,
email_verified: true,
} as never);
};
const openCheck = (data: Record<string, unknown>) => {
data.valid = true;
data.display = { title: 'T', description: 'D' };
};
const signupCallback = async (bonusCode: string) => {
const state = oidc().signState({
provider: 'custom',
redirect_uri: TEST_ORIGIN + '/',
bonus_code: bonusCode,
});
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/signup',
makeReq({ query: { code: 'c', state } }),
res,
);
return captured;
};
it('carries a canonical code in the signed state', async () => {
const decoded = await startState({
flow: 'signup',
bonusCode: 'Startup3M-ABCD1234',
});
expect(decoded.bonus_code).toBe('startup3mabcd1234');
});
it('drops a malformed code at start', async () => {
const decoded = await startState({ flow: 'signup', bonusCode: 'a:b' });
expect(decoded).not.toHaveProperty('bonus_code');
});
it('creates the account with the requirements an accepted code raises', async () => {
const email = `bonus-${rand()}@test.local`;
stubIdp(`sub-${rand()}`, email);
bonusCheckOverride = openCheck;
let seen: Record<string, unknown> | null = null;
bonusValidateOverride = (data) => {
seen = { ...data };
data.accepted = true;
data.requires_phone_verification = true;
};
const captured = await signupCallback('startup3mabcd1234');
expect(captured.cookies).toHaveLength(1);
expect(seen).toMatchObject({
code: 'startup3mabcd1234',
source: 'oidc',
email,
});
const user = await server.stores.user.findEmailOwner(email, {
force: true,
});
expect(Boolean(user!.requires_phone_verification)).toBe(true);
const deadline = Date.now() + 2000;
let success: Record<string, unknown> | undefined;
while (!success && Date.now() < deadline) {
success = heardSignupSuccess.find((e) => e.email === email);
if (!success) await new Promise((r) => setTimeout(r, 10));
}
expect(success?.bonus_code).toBe('startup3mabcd1234');
});
it('refuses a dead code before the validate hook runs', async () => {
const email = `bonus-${rand()}@test.local`;
stubIdp(`sub-${rand()}`, email);
let validated = false;
signupValidateOverride = (data) => {
if (data.email === email) validated = true;
};
const captured = await signupCallback('startup3mabcd1234');
expect(captured.redirectUrl).toContain('message=bonus_code_invalid');
expect(validated).toBe(false);
});
it('refuses the account when nothing accepts the code, without echoing it', async () => {
const email = `bonus-${rand()}@test.local`;
stubIdp(`sub-${rand()}`, email);
const captured = await signupCallback('startup3mabcd1234');
expect(captured.redirectUrl).toContain('message=bonus_code_invalid');
expect(captured.redirectUrl).not.toContain('bonusCode=');
expect(captured.cookies).toHaveLength(0);
expect(
await server.stores.user.findEmailOwner(email, { force: true }),
).toBeFalsy();
});
it('keeps the code on the retry redirect when something else failed', async () => {
const email = `bonus-${rand()}@test.local`;
stubIdp(`sub-${rand()}`, email);
bonusCheckOverride = openCheck;
signupValidateOverride = (data) => {
if (data.email === email) data.allow = false;
};
const captured = await signupCallback('startup3mabcd1234');
expect(captured.redirectUrl).toContain('message=signup_blocked');
expect(captured.redirectUrl).toContain('bonusCode=startup3mabcd1234');
});
});
+41 -5
View File
@@ -23,6 +23,7 @@ import { HttpError } from '../../core/http/HttpError.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterController } from '../types.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { normalizeBonusCode } from '../../util/signupBonus.js';
import { parseMaskedSharePath } from '../../services/fs/sharePathMask.js';
import {
SHARE_DEEP_LINK_ITEMS_LIMIT,
@@ -50,6 +51,7 @@ const ALLOWED_ERRORS = [
'account_suspended',
'unauthorized',
'signup_blocked',
'bonus_code_invalid',
] as const;
/**
@@ -69,6 +71,23 @@ function resolutionErrorCode(code: string | undefined): string {
: 'signup_blocked';
}
/** What a new account created by an OIDC callback inherits from its flow. */
interface OIDCSignupOptions {
referrer?: string | null;
bonusCode?: string | null;
}
function signupOptionsFromState(
stateDecoded: Record<string, unknown>,
): OIDCSignupOptions {
return {
referrer: (stateDecoded.referrer as string) ?? null,
// Normalized when the state was signed; re-checked since this is the
// value signup acts on.
bonusCode: normalizeBonusCode(stateDecoded.bonus_code),
};
}
// GUI pages an OIDC flow may return to: the root (where a share email lands),
// /desktop, /dashboard, and direct app landings (/app/<name> and its
// desktop-booted twin /desktop/app/<name>, mirroring APP_NAME_REGEX in
@@ -222,6 +241,13 @@ function buildErrorRedirectUrl(
if (requestCode) {
params.set('request_code', requestCode);
}
// A retry from the error page keeps the bonus code, unless it's what failed.
if (
typeof stateDecoded?.bonus_code === 'string' &&
clamped !== 'bonus_code_invalid'
) {
params.set('bonusCode', stateDecoded.bonus_code);
}
for (const path of sharedPaths) {
params.append(SHARE_DEEP_LINK_PARAM, path);
}
@@ -423,6 +449,15 @@ export class OIDCController extends PuterController {
redirect_uri: appRedirectUri,
};
if (referrer) statePayload.referrer = referrer ?? openerOrigin;
// Login can create an account too, so both flows carry it. A
// malformed code is dropped here and never reaches signup.
const rawBonusCode = Array.isArray(req.query.bonusCode)
? req.query.bonusCode[0]
: req.query.bonusCode;
const bonusCode = normalizeBonusCode(rawBonusCode);
if (bonusCode && (flow === 'login' || flow === 'signup')) {
statePayload.bonus_code = bonusCode;
}
if (embeddedInPopup && msgId) {
statePayload.embedded_in_popup = true;
statePayload.msg_id = msgId;
@@ -507,7 +542,7 @@ export class OIDCController extends PuterController {
const resolved = await this.#resolveOrCreateOIDCUser(
provider,
userinfo,
(stateDecoded.referrer as string) ?? null,
signupOptionsFromState(stateDecoded),
);
if ('error' in resolved) {
console.warn(
@@ -574,7 +609,7 @@ export class OIDCController extends PuterController {
const resolved = await this.#resolveOrCreateOIDCUser(
provider,
userinfo,
(stateDecoded.referrer as string) ?? null,
signupOptionsFromState(stateDecoded),
);
if ('error' in resolved) {
console.warn(
@@ -742,7 +777,7 @@ if (window.opener) {
async #resolveOrCreateOIDCUser(
provider: string,
userinfo: { sub: string; email?: unknown; [k: string]: unknown },
referrer?: string | null,
signup: OIDCSignupOptions = {},
attempt = 0,
): Promise<
| { error: string; code?: string; requestCode?: string }
@@ -792,7 +827,8 @@ if (window.opener) {
const outcome = await this.services.oidc.createUserFromOIDC(
provider,
userinfo as { sub: string; email?: string },
referrer,
signup.referrer ?? null,
{ bonusCode: signup.bonusCode ?? null },
);
// A concurrent callback (a second tab, a provider retry) created the
// account between step 2 and the insert. Nothing went wrong for the
@@ -802,7 +838,7 @@ if (window.opener) {
return this.#resolveOrCreateOIDCUser(
provider,
userinfo,
referrer,
signup,
attempt + 1,
);
}
@@ -0,0 +1,152 @@
/*
* 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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
const PICTURE =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aD1sAAAAASUVORK5CYII=';
describe('profile routes over HTTP', () => {
let env: PuterTestEnv;
let userUuid = '';
/** The plan `env.users.user` is on; see the resolver below. */
let plan: string | null = null;
beforeAll(async () => {
env = await setupPuterTestEnv();
userUuid = (await env.server.stores.user.getByUsername(
env.users.user.username,
))!.uuid;
env.server.services.metering.registerPolicy({
id: 'business',
monthUsageAllowance: 45 * 1_000_000 * 100,
monthlyStorageAllowance: 1024 ** 3,
});
env.server.services.metering.registerSubscriptionResolver((actor) =>
actor.user?.uuid === userUuid ? plan : null,
);
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const withSubscription = async <T>(
id: string | null,
fn: () => Promise<T>,
): Promise<T> => {
plan = id;
env.server.services.metering.invalidateActorSubscription(userUuid);
try {
return await fn();
} finally {
plan = null;
env.server.services.metering.invalidateActorSubscription(userUuid);
}
};
const get = (query: string, token?: string) =>
fetch(new URL(`/profile${query}`, env.apiOrigin), {
headers: token ? { authorization: `Bearer ${token}` } : {},
});
const post = (body: unknown, token: string) =>
fetch(new URL('/profile', env.apiOrigin), {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
it('lets a user write and read their own profile on any plan', async () => {
const write = await post(
{ picture: PICTURE, displayName: 'Test' },
env.users.user.token,
);
expect(write.status).toBe(200);
expect(await write.json()).toEqual({
picture: PICTURE,
displayName: 'Test',
bio: null,
});
const own = await get('', env.users.user.token);
expect(own.status).toBe(200);
expect(await own.json()).toMatchObject({ picture: PICTURE });
const byName = await get(
`?username=${env.users.user.username}`,
env.users.user.token,
);
expect(byName.status).toBe(200);
expect(await byName.json()).toMatchObject({ displayName: 'Test' });
});
it('hides a free user from anonymous and third-party readers as a 404', async () => {
const anonymous = await get(`?username=${env.users.user.username}`);
expect(anonymous.status).toBe(404);
const other = await get(
`?username=${env.users.user.username}`,
env.users.other.token,
);
expect(other.status).toBe(404);
// Same shape as an account that does not exist.
const missing = await get('?username=nobody-here');
expect(missing.status).toBe(404);
});
it('serves a paid user to anyone, and hides them again when the plan ends', async () => {
await withSubscription('business', async () => {
const anonymous = await get(`?username=${env.users.user.username}`);
expect(anonymous.status).toBe(200);
expect(await anonymous.json()).toMatchObject({ picture: PICTURE });
const other = await get(
`?username=${env.users.user.username}`,
env.users.other.token,
);
expect(other.status).toBe(200);
});
expect((await get(`?username=${env.users.user.username}`)).status).toBe(
404,
);
});
it('requires a username or a signed-in caller, and validates the username shape', async () => {
expect((await get('')).status).toBe(401);
expect((await get('?username=not%20valid')).status).toBe(400);
});
it('rejects a bad patch with a stable code and refuses app and anonymous writers', async () => {
const bad = await post({ name: 'x' }, env.users.user.token);
expect(bad.status).toBe(400);
expect(await bad.json()).toMatchObject({
code: 'profile_field_not_allowed',
});
const anonymous = await fetch(new URL('/profile', env.apiOrigin), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ bio: 'x' }),
});
expect(anonymous.status).toBe(401);
});
});
@@ -0,0 +1,103 @@
/*
* 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 { Request, Response } from 'express';
import { Controller, Get, Post } from '../../core/http/decorators.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { ProfileService } from '../../services/profile/ProfileService.js';
import { PuterController } from '../types.js';
// Shape only; the store decides whether the account exists.
const USERNAME_PATTERN = /^[a-z0-9_-]{1,64}$/i;
const notFound = () =>
new HttpError(404, 'Profile not found', { legacyCode: 'not_found' });
/**
* User profiles. Reads are open so an app can show another user's profile, but
* a profile other than the caller's own is served only while its owner is on a
* paid plan (`ProfileService.isPubliclyVisible`); a hidden one 404s like a
* missing account, so the response says nothing about the plan.
*/
@Controller('/profile')
export class ProfileController extends PuterController {
/**
* GET /profile?username=<name> — a user's profile. Without `username`, the
* signed-in user's own.
*/
@Get('/', {
subdomain: 'api',
rateLimit: {
scope: 'profile-read',
limit: 120,
window: 60_000,
key: 'ip',
},
})
async get(req: Request, res: Response): Promise<void> {
const service = this.services.profile as ProfileService;
const caller = req.actor?.user;
const username = req.query.username;
let target;
if (username === undefined) {
if (!caller?.uuid) {
throw new HttpError(401, 'Sign in, or pass a username', {
legacyCode: 'unauthorized',
});
}
target = await this.stores.user.getByUuid(caller.uuid);
} else {
if (
typeof username !== 'string' ||
!USERNAME_PATTERN.test(username)
) {
throw new HttpError(400, 'Invalid username', {
legacyCode: 'bad_request',
});
}
target = await this.stores.user.getByUsername(username);
}
if (!target) throw notFound();
const isOwner = caller?.uuid === target.uuid;
if (!isOwner && !(await service.isPubliclyVisible(target))) {
throw notFound();
}
res.json(await service.getProfile(target));
}
/** POST /profile — update the signed-in user's own profile. */
@Post('/', {
subdomain: 'api',
requireUserActor: true,
rateLimit: {
scope: 'profile-write',
limit: 30,
window: 60_000,
key: 'user',
},
})
async update(req: Request, res: Response): Promise<void> {
const service = this.services.profile as ProfileService;
const user = await this.stores.user.getByUuid(req.actor!.user.uuid!);
if (!user) throw notFound();
res.json(await service.updateProfile(user, req.body));
}
}
@@ -26,6 +26,7 @@ import {
type PuterTestEnv,
} from '../../testUtil.js';
import { ShareController } from './ShareController.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
/**
* Route-level coverage for the sharing endpoints. The service unit tests drive
@@ -1069,4 +1070,156 @@ describe('share endpoints over HTTP', () => {
expect(route.options?.allowFullAccessToken).toBeUndefined();
}
});
describe('an invited address over the wire', () => {
/** An app of the owner's, holding `mode` on one file, with a token. */
const appHolding = async (
owner: { username: string },
uid: string,
permission: (fileUid: string) => string,
) => {
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const actor = makeActor({ user: user! });
const app = await env.server.stores.app.create(
{
name: `peek-app-${crypto.randomUUID().slice(0, 8)}`,
title: 'Peek app',
index_url: `https://peek-${crypto.randomUUID()}.test/`,
},
{ ownerUserId: user!.id },
);
await runWithContext({ actor }, () =>
env.server.services.permission.grantUserAppPermission(
actor,
app.uid,
permission(uid),
),
);
const token = await env.server.services.auth.getUserAppToken(
actor,
app.uid,
);
return { ...app, token };
};
const invitedFile = async (owner: { username: string; token: string }) => {
const file = await makeFile(owner);
const email = `wire-${crypto.randomUUID().slice(0, 8)}@test.local`;
const res = await post('/share', owner.token, {
recipients: [email],
items: [{ uid: file.uid }],
mode: 'read',
});
expect(res.status).toBe(200);
return { file, email };
};
it('reaches the owner but not an app the file was handed to', async () => {
const owner = env.users.user;
const { file, email } = await invitedFile(owner);
// The owner asked, so the owner is told.
const mine = await get('/share/shares', owner.token, {
uid: file.uid,
});
expect(mine.status).toBe(200);
expect(await mine.text()).toContain(email);
// The app is answered, but bounded to the rows it issued — and it
// issued none, so the owner's invite is not among them.
const app = await appHolding(owner, file.uid, (u) => `fs:${u}:read`);
const peeked = await get('/share/shares', app.token, {
uid: file.uid,
});
expect(peeked.status).toBe(200);
const peekedBody = await peeked.text();
expect(peekedBody).not.toContain(email);
expect(JSON.parse(peekedBody).items).toEqual([]);
// `return_shares` is the same listing on an access-token surface.
const stat = await post('/fs/stat', app.token, {
uid: file.uid,
return_shares: true,
});
expect(stat.status).toBe(200);
const body = await stat.text();
expect(body).not.toContain(email);
expect(JSON.parse(body).shares).toEqual([]);
});
// The ticket's second route in: `ShareController` admits no access
// token, but `/fs/stat` does, and `return_shares` is the same listing.
it('withholds the listing from a list-scoped access token', async () => {
const owner = env.users.user;
const { file, email } = await invitedFile(owner);
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const actor = makeActor({ user: user! });
const scoped = await runWithContext({ actor }, () =>
env.server.services.auth.createAccessToken(actor, [
[`fs:${file.uid}:list`],
]),
);
const stat = await post('/fs/stat', scoped, {
uid: file.uid,
return_shares: true,
});
expect(stat.status).toBe(200);
const body = await stat.text();
expect(body).not.toContain(email);
expect(JSON.parse(body).shares).toEqual([]);
});
// Withholding here would buy nothing: `shared-by-me` hands the same
// addresses to the same credential.
it('tells a full-access token what it could read from shared-by-me anyway', async () => {
const owner = env.users.user;
const { file, email } = await invitedFile(owner);
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const actor = makeActor({ user: user! });
const pat = await runWithContext({ actor }, () =>
env.server.services.auth.createAccessToken(actor, [
[FULL_API_ACCESS],
]),
);
const outbound = await get('/share/shared-by-me', pat, {});
expect(outbound.status).toBe(200);
expect(await outbound.text()).toContain(email);
const listed = await get('/share/shares', pat, { uid: file.uid });
expect(listed.status).toBe(200);
expect(await listed.text()).toContain(email);
});
it('answers an app that was handed the item to manage', async () => {
const owner = env.users.user;
const { file, email } = await invitedFile(owner);
const app = await appHolding(
owner,
file.uid,
(u) => `manage:fs:${u}`,
);
const res = await get('/share/shares', app.token, {
uid: file.uid,
});
expect(res.status).toBe(200);
const text = await res.text();
// `manage` buys an app authority over the item, not sight of who
// else the owner invited to it.
expect(
JSON.parse(text).items.some(
(i: { pending?: boolean }) => i.pending,
),
).toBe(false);
expect(text).not.toContain(email);
});
});
});
+7 -1
View File
@@ -54,8 +54,14 @@ export async function toClientShare(
...(share.type === undefined ? {} : { type: share.type }),
...(thumbnail === undefined ? {} : { thumbnail }),
...(share.owner === undefined ? {} : { owner: share.owner.username }),
// Withheld from a listing whose caller it isn't for, hence the omission.
...(share.pending
? { pending: true, recipient_email: share.recipientEmail }
? {
pending: true,
...(share.recipientEmail === undefined
? {}
: { recipient_email: share.recipientEmail }),
}
: {}),
// A link share: no holder of any kind, this is what says so.
...(share.anyone ? { anyone: true } : {}),
@@ -18,6 +18,7 @@
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { makeActor } from '../../core/actor.js';
import type { IConfig } from '../../types';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
@@ -53,6 +54,47 @@ describe('team endpoints over HTTP', () => {
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
/** An app token acting for `owner`, minted the way the dashboard does. */
const makeAppToken = async (owner: { username: string }) => {
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const actor = makeActor({ user: user! });
const app = await env.server.stores.app.create(
{
name: `team-http-app-${crypto.randomUUID()}`,
title: 'Team app',
index_url: `https://team-${crypto.randomUUID()}.test/`,
},
{ ownerUserId: actor.user.id! },
);
return env.server.services.auth.getUserAppToken(actor, app.uid);
};
/** A scoped (non-full-access) access token for `owner`, carrying no app. */
const makeScopedToken = async (owner: {
username: string;
token: string;
}) => {
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const anchor = `/${owner.username}/${crypto.randomUUID()}`;
await env.server.services.fs.mkdir(user!.id, {
path: anchor,
createMissingParents: true,
});
const entry = await env.server.stores.fsEntry.getEntryByPath(anchor);
const { actor } = await env.server.services.auth.authenticate(
owner.token,
);
return env.server.services.auth.createAccessToken(
actor!,
[[`fs:${entry!.uid}:list`]],
{ label: 'teams-scope' },
);
};
/** A team owned by `user`, with one provisioned member. */
const makeTeam = async () => {
const res = await call('POST', '/teams', env.users.user.token, {
@@ -199,6 +241,15 @@ describe('team endpoints over HTTP', () => {
expect(after.status).toBe(200);
const body = (await after.json()) as { items: { username: string }[] };
expect(body.items.some((m) => m.username === env.users.user.username)).toBe(true);
// A scoped token was minted for one thing; a roster is not it.
const scoped = await makeScopedToken(env.users.user);
const narrow = await call(
'GET',
`/teams/${team.uid}/directory`,
typeof scoped === 'string' ? scoped : scoped.token,
);
expect(narrow.status).toBe(403);
});
it('refuses the directory to someone outside the team', async () => {
@@ -215,6 +266,188 @@ describe('team endpoints over HTTP', () => {
expect(res.status).toBe(404);
});
// -- app actors, gated by the same opt-in as the directory --------
//
// Built through the service, not the wire: these routes share a
// rate-limit budget with the mutation tests above, and every HTTP
// create/update here would eat into that same window.
/** A team owned by `user`, created and toggled through the service. */
const makeServiceTeam = async () => {
const owner = (await env.server.stores.user.getByUsername(
env.users.user.username,
))!;
const team = await env.server.services.team.createTeam(owner.id, {
name: 'Acme',
handle: randomHandle(),
});
return { team, ownerId: owner.id };
};
it('omits a team from an app token until the directory opts in', async () => {
const { team, ownerId } = await makeServiceTeam();
const appToken = await makeAppToken(env.users.user);
const before = await call('GET', '/teams', appToken);
expect(before.status).toBe(200);
const beforeBody = (await before.json()) as { items: { uid: string }[] };
expect(beforeBody.items.some((t) => t.uid === team.uid)).toBe(false);
await env.server.services.team.updateTeam(team.uid, ownerId, {
directoryEnabled: true,
});
const after = await call('GET', '/teams', appToken);
expect(after.status).toBe(200);
const afterBody = (await after.json()) as { items: { uid: string }[] };
expect(afterBody.items.some((t) => t.uid === team.uid)).toBe(true);
});
it('still lists a team to its owner session with the directory off', async () => {
const { team } = await makeServiceTeam();
const res = await call('GET', '/teams', env.users.user.token);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: { uid: string }[] };
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
});
it('lists a team to a full-access API token with the directory off', async () => {
const { team } = await makeServiceTeam();
const res = await call('GET', '/teams', env.users.user.apiToken);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: { uid: string }[] };
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
});
it('404s /members to an app token until the directory opts in, then gives usernames only', async () => {
const { team, ownerId } = await makeServiceTeam();
const username = `svc_${Math.random().toString(36).slice(2, 9)}`;
await env.server.services.team.provisionAccount(team.uid, ownerId, {
username,
});
const appToken = await makeAppToken(env.users.user);
const before = await call(
'GET',
`/teams/${team.uid}/members`,
appToken,
);
expect(before.status).toBe(404);
expect(await before.json()).toMatchObject({ code: 'team_not_found' });
await env.server.services.team.updateTeam(team.uid, ownerId, {
directoryEnabled: true,
});
const after = await call(
'GET',
`/teams/${team.uid}/members`,
appToken,
);
expect(after.status).toBe(200);
const body = (await after.json()) as {
items: Record<string, unknown>[];
};
expect(body.items.length).toBeGreaterThan(0);
// An app gets username only — no org_owned, created_at, or uuid.
for (const m of body.items) expect(Object.keys(m)).toEqual(['username']);
});
it('still refuses an app token on GET /teams/:uid', async () => {
const { team } = await makeServiceTeam();
const appToken = await makeAppToken(env.users.user);
const res = await call('GET', `/teams/${team.uid}`, appToken);
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'forbidden' });
});
it('excludes a never-activated or suspended seat from an app`s view of /members', async () => {
const { team, ownerId } = await makeServiceTeam();
const neverActivated = `svc_na_${Math.random().toString(36).slice(2, 9)}`;
await env.server.services.team.provisionAccount(team.uid, ownerId, {
username: neverActivated,
});
const suspendedUsername = `svc_su_${Math.random().toString(36).slice(2, 9)}`;
const suspended = await env.server.services.team.provisionAccount(
team.uid,
ownerId,
{ username: suspendedUsername },
);
await env.server.services.team.disableMember(
team.uid,
ownerId,
suspended.userId,
);
await env.server.services.team.updateTeam(team.uid, ownerId, {
directoryEnabled: true,
});
const appToken = await makeAppToken(env.users.user);
const appView = await call(
'GET',
`/teams/${team.uid}/members`,
appToken,
);
expect(appView.status).toBe(200);
const appItems = (
(await appView.json()) as { items: Record<string, unknown>[] }
).items;
for (const m of appItems) expect(Object.keys(m)).toEqual(['username']);
const appUsernames = appItems.map((m) => m.username);
expect(appUsernames).not.toContain(neverActivated);
expect(appUsernames).not.toContain(suspendedUsername);
// The owner's own seat is active, so it still shows.
expect(appUsernames).toContain(env.users.user.username);
// The owner's own session sees every seat regardless of status.
const ownerView = await call(
'GET',
`/teams/${team.uid}/members`,
env.users.user.token,
);
expect(ownerView.status).toBe(200);
const ownerUsernames = (
(await ownerView.json()) as { items: { username: string }[] }
).items.map((m) => m.username);
expect(ownerUsernames).toContain(neverActivated);
expect(ownerUsernames).toContain(suspendedUsername);
});
it('gives a full-access API token the owner`s uuid on /members', async () => {
const { team } = await makeServiceTeam();
const res = await call(
'GET',
`/teams/${team.uid}/members`,
env.users.user.apiToken,
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
items: { username: string; uuid?: string }[];
};
const owner = body.items.find(
(m) => m.username === env.users.user.username,
);
expect(owner?.uuid).toEqual(expect.any(String));
});
it('refuses a scoped access token on GET /teams and /members', async () => {
const { team } = await makeServiceTeam();
const scoped = await makeScopedToken(env.users.user);
const teamsRes = await call('GET', '/teams', scoped);
expect(teamsRes.status).toBe(403);
expect(await teamsRes.json()).toMatchObject({ code: 'forbidden' });
const membersRes = await call(
'GET',
`/teams/${team.uid}/members`,
scoped,
);
expect(membersRes.status).toBe(403);
expect(await membersRes.json()).toMatchObject({ code: 'forbidden' });
});
it('lists members with org_owned distinguishing the owner', async () => {
const { team, memberUsername } = await makeTeam();
+44 -16
View File
@@ -25,6 +25,7 @@ import {
Post,
Put,
} from '../../core/http/decorators.js';
import { isAccountContext } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { TeamRow } from '../../stores/team/TeamStore.js';
import { PuterController } from '../types.js';
@@ -70,7 +71,9 @@ const toClientTeam = (team: TeamRow, isOwner: boolean) => ({
@Controller('/teams')
export class TeamController extends PuterController {
// `requireUserActor` installs the auth gates; reads need it too.
// Most routes gate with `requireUserActor`; `listTeams` and `listMembers`
// use `requireAuth` so an app can read a team whose owner opted into the
// directory (see `directory_enabled`).
/** Off means `/teams` 404s and no team route is registered at all. */
isEnabled(): boolean {
@@ -100,7 +103,7 @@ export class TeamController extends PuterController {
@Get('', {
subdomain: 'api',
requireUserActor: true,
requireAuth: true,
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
@@ -108,8 +111,13 @@ export class TeamController extends PuterController {
const userId = this.#requireUserId(req);
await this.#requireTeamsAvailable(req, userId);
const teams = await this.stores.team.listTeamsForUser(userId);
// An app only sees a team whose owner opened the directory to apps;
// a team that hasn't is indistinguishable from no team at all.
const visible = isAccountContext(req.actor)
? teams
: teams.filter((t) => this.services.team.isDirectoryOpen(t));
res.json({
items: teams.map((t) =>
items: visible.map((t) =>
toClientTeam(t, t.owner_user_id === userId),
),
});
@@ -178,7 +186,7 @@ export class TeamController extends PuterController {
@Get('/:uid/members', {
subdomain: 'api',
requireUserActor: true,
requireAuth: true,
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
@@ -188,9 +196,13 @@ export class TeamController extends PuterController {
this.#param(req, 'uid'),
userId,
);
// Only the owner: the uuid is what billing keys a seat's plan on, and
// one member has no business identifying another.
const isOwner = team.owner_user_id === userId;
const accountContext = isAccountContext(req.actor);
// Same consent as the directory: an app only reads a team that opted in.
if (!accountContext) this.services.team.assertDirectoryOpen(team);
// uuid only when the team owner calls with their own session or API
// token; an app needing a stable id uses `listDirectory()` instead.
const showUuid = accountContext && team.owner_user_id === userId;
const page = await this.stores.team.listMembers(
this.#param(req, 'uid'),
@@ -200,23 +212,30 @@ export class TeamController extends PuterController {
typeof req.query.cursor === 'string'
? req.query.cursor
: undefined,
// An app's view matches the directory's: active seats only.
activeOnly: !accountContext,
},
);
// An app only ever gets a username; org_owned/created_at are account-only.
res.json({
items: page.items.map((m) => ({
username: m.username,
org_owned: Number(m.org_owned) === 1,
created_at: m.created_at,
...(isOwner ? { uuid: m.uuid } : {}),
})),
items: page.items.map((m) =>
accountContext
? {
username: m.username,
org_owned: Number(m.org_owned) === 1,
created_at: m.created_at,
...(showUuid ? { uuid: m.uuid } : {}),
}
: { username: m.username },
),
...(page.cursor ? { cursor: page.cursor } : {}),
});
}
/**
* The only team route that admits an app actor. It discloses nothing a
* colleague cannot already read through `/members`, and the team has to
* have opted in, so an app cannot enumerate a team by default.
* Admits an app actor, like `listTeams` and `listMembers`, once the team
* has opted in. Discloses nothing a colleague cannot already read through
* `/members`.
*/
@Get('/:uid/directory', {
subdomain: 'api',
@@ -226,6 +245,15 @@ export class TeamController extends PuterController {
async listDirectory(req: Request, res: Response): Promise<void> {
// The membership is the person's, never the app's.
const userId = this.#requireUserId(req);
// A scoped token holds only what it was minted for; a roster is not that.
const token = req.actor?.accessToken;
if (token && token.fullAccess !== true) {
throw new HttpError(
403,
'This endpoint is not available to scoped access tokens',
{ legacyCode: 'forbidden' },
);
}
const page = await this.services.team.listDirectory(
this.#param(req, 'uid'),
userId,
+2 -1
View File
@@ -73,7 +73,8 @@ export const PuterController =
public onServerShutdown() {
return;
}
public getReportedCosts(): // eslint-disable-next-line @typescript-eslint/no-explicit-any
public getReportedCosts():
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Promise<Record<string, any>[]>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
| Record<string, any>[] {
@@ -20,6 +20,7 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { Actor } from '../../actor';
import { PERIOD_ESCAPE } from '../../../services/metering/consts.js';
import type { MeteringService } from '../../../services/metering/MeteringService.js';
import type { UsageByType } from '../../../services/metering/types';
import { setupPuterTestEnv, type PuterTestEnv } from '../../../testUtil.js';
@@ -31,12 +32,24 @@ import { setupPuterTestEnv, type PuterTestEnv } from '../../../testUtil.js';
*/
describe('egress metering over HTTP', () => {
let env: PuterTestEnv;
let originalCacheMs: number;
beforeAll(async () => {
env = await setupPuterTestEnv();
// A sharded month caches getActorCurrentMonthUsageDetails's detail
// read for USAGE_DETAIL_CACHE_MS — these tests read before and after
// one request a moment apart, well inside that window, so the cache
// would mask the very update they're checking for.
const ctor = env.server.services.metering
.constructor as typeof MeteringService;
originalCacheMs = ctor.USAGE_DETAIL_CACHE_MS;
ctor.USAGE_DETAIL_CACHE_MS = 0;
}, 120_000);
afterAll(async () => {
(
env.server.services.metering.constructor as typeof MeteringService
).USAGE_DETAIL_CACHE_MS = originalCacheMs;
await env?.shutdown();
});
@@ -679,6 +679,27 @@ describe('resolveOwnedAppForHostedSite', () => {
expect(out).toBeNull();
});
it('prefers the private app over an older public bootstrap stub on an alternate host', async () => {
const owner = await makeUser();
await server.stores.app.createFromOrigin(
`app-${uuidv4()}`,
'http://beans.host.puter.localhost',
{ ownerUserId: owner.id },
);
const app = await createPrivateApp(
owner.id,
'http://beans.app.puter.localhost/',
);
const out = await resolveOwnedAppForHostedSite({
req: reqOf('beans.app.puter.localhost'),
site: { user_id: owner.id },
db: server.clients.db,
config: baseConfig(),
});
expect(out?.uid).toBe(app.uid);
expect(Boolean(out?.is_private)).toBe(true);
});
it('returns null when the site has no owner', async () => {
const out = await resolveOwnedAppForHostedSite({
req: reqOf('beans.site.puter.localhost'),
@@ -249,8 +249,10 @@ export async function resolveOwnedAppForHostedSite(opts: {
? `AND \`is_private\` = ${opts.db.booleanLiteral(true)} `
: '';
const placeholders = uniqueCandidates.map(() => '?').join(', ');
// Ambiguity fails closed: a private row wins; `id` keeps it deterministic.
const orderClause = `ORDER BY CASE WHEN \`is_private\` = ${opts.db.booleanLiteral(true)} THEN 0 ELSE 1 END, \`id\``;
const rows = await opts.db.read(
`SELECT * FROM apps WHERE owner_user_id = ? ${privateFilter}AND index_url IN (${placeholders}) LIMIT 2`,
`SELECT * FROM apps WHERE owner_user_id = ? ${privateFilter}AND index_url IN (${placeholders}) ${orderClause} LIMIT 2`,
[opts.site.user_id, ...uniqueCandidates],
);
if (rows.length === 0) return null;
@@ -876,6 +876,63 @@ describe('createPuterSiteMiddleware — file serving', () => {
// FSService so we exercise parsing, path resolution, and the loop-
// prevention guard together.
describe('createPuterSiteMiddleware — site.access.check', () => {
const serveUnder = async (listener?: (data: unknown) => void) => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
const sub = `gate-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: homeEntry!.id,
});
await writeFile(
owner.id,
`${homePath}/secret.txt`,
Buffer.from('shh'),
'text/plain',
);
const seen: unknown[] = [];
server.clients.event.on('site.access.check', (_key, data) => {
if (data.subdomain !== sub) return;
seen.push(data);
listener?.(data);
});
const { out } = await runMiddleware(
buildMiddleware(),
makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/secret.txt',
}),
);
await new Promise<void>((resolve) => setImmediate(resolve));
return { out, seen, sub };
};
it('asks listeners before streaming and serves when nobody objects', async () => {
const { out, seen, sub } = await serveUnder();
expect(out.statusCode).toBe(200);
expect(seen).toHaveLength(1);
expect(seen[0]).toMatchObject({
subdomain: sub,
host: `${sub}.site.puter.localhost`,
requestPath: '/secret.txt',
entry: { name: 'secret.txt' },
result: { allowed: true },
});
});
it('returns the plain file 404 when a listener withholds the entry', async () => {
const { out } = await serveUnder((data) => {
(data as { result: { allowed: boolean } }).result.allowed = false;
});
expect(out.statusCode).toBe(404);
expect(String(out.body)).toContain('Not Found');
expect(out.headers['Content-Type']).toBeUndefined();
});
});
describe('createPuterSiteMiddleware — .puter_site_config', () => {
const setupSiteWithConfig = async (config: unknown) => {
const owner = await makeUserWithHome();
@@ -631,6 +631,33 @@ export const createPuterSiteMiddleware = (
return;
}
// A listener owning the site may withhold this entry (a system site
// that publishes per-user files conditionally). Denied looks exactly
// like missing, and a listener failure denies rather than serves.
const accessCheck = {
subdomain,
host,
requestPath: req.path,
entry,
result: { allowed: true },
};
try {
await layers.clients.event.emitAndWait(
'site.access.check',
accessCheck,
{},
);
} catch (e) {
console.error('[puter-site] site.access.check threw', e);
accessCheck.result.allowed = false;
}
if (!accessCheck.result.allowed) {
res.status(404)
.type('text/html; charset=UTF-8')
.send('<h1>404</h1><p>Not Found</p>');
return;
}
// Stream the file. `fsEntry.readContent` honours Range + emits
// ETag/Last-Modified when the S3 layer returns them. Range
// requests are suppressed when serving a custom error page so
+2 -9
View File
@@ -27,12 +27,7 @@ import type { Actor } from '../actor';
* it instead of accepting any token that authenticates.
*/
export type TokenSource =
| 'body'
| 'header'
| 'x-api-key'
| 'cookie'
| 'query'
| 'handshake';
'body' | 'header' | 'x-api-key' | 'cookie' | 'query' | 'handshake';
/** Express router methods plus the WebDAV verbs some endpoints use. */
export type RouteMethod =
@@ -340,9 +335,7 @@ export type AuthRequired<O extends RouteOptions> = O extends {
? true
: O extends {
requireSubscription:
| true
| readonly string[]
| string[];
true | readonly string[] | string[];
}
? true
: O extends { requireReputation: string }
@@ -257,6 +257,126 @@ describe('ChatCompletionDriver provider registration', () => {
// -- Failure classification ------------------------------------------
describe('ChatCompletionDriver exhausted-chain classification', () => {
const attemptsFrom = (err: HttpError) =>
(
err as unknown as {
fields: {
attempts: Array<{
error: string;
status?: number;
code?: string;
}>;
};
}
).fields.attempts;
it('maps an upstream 402 to alerted credit exhaustion and redacts its URL', async () => {
const warn = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
const err = await errorFor(
Object.assign(
new Error(
'Insufficient credits. Add more using https://openrouter.ai/settings/credits',
),
{ status: 402 },
),
);
expect(err).toMatchObject({
statusCode: 503,
legacyCode: 'upstream_credits_exhausted',
message: 'AI provider out of credits',
});
expect(err.noAlarm).toBeFalsy();
expect(attemptsFrom(err)[0]!.error).toBe(
'Insufficient credits. Add more using',
);
expect(JSON.stringify(attemptsFrom(err))).not.toContain('http');
const failureLog = warn.mock.calls.find((call) =>
String(call[0]).startsWith('[ai-chat] all routes failed'),
);
expect(failureLog).toBeDefined();
expect(String(failureLog![0])).not.toContain('http');
});
it('classifies a 403 used-up credit message before authentication failures', async () => {
const err = await errorFor(
Object.assign(
new Error(
'Your current credits have been used up and we are unable to process further requests. Please visit https://openrouter.ai/settings/credits to add credits.',
),
{ status: 403 },
),
);
expect(err).toMatchObject({
statusCode: 503,
legacyCode: 'upstream_credits_exhausted',
});
expect(err.legacyCode).not.toBe('upstream_auth_failed');
});
it('alerts on a free model whose required team balance is missing', async () => {
const warn = vi
.spyOn(console, 'warn')
.mockImplementation(() => undefined);
const err = await errorFor(
Object.assign(
new Error(
'Free model requires Team balance greater than $4.999999. (request id: 20260921210512505339070jYB)',
),
{ status: 429 },
),
);
expect(err).toMatchObject({
statusCode: 503,
legacyCode: 'upstream_credits_exhausted',
});
expect(err.noAlarm).toBeFalsy();
expect(attemptsFrom(err)[0]!.error).toBe(
'Free model requires Team balance greater than $4.999999.',
);
expect(JSON.stringify(attemptsFrom(err))).not.toMatch(/request id/i);
expect(
warn.mock.calls.some((call) =>
String(call[0]).startsWith('[ai-chat] all routes failed'),
),
).toBe(true);
});
it('classifies the OpenAI insufficient_quota code before rate limits', async () => {
const err = await errorFor({
status: 429,
message: 'quota exhausted',
error: { code: 'insufficient_quota' },
});
expect(err).toMatchObject({
statusCode: 503,
legacyCode: 'upstream_credits_exhausted',
});
expect(err.legacyCode).not.toBe('upstream_rate_limited');
});
it('strips HTML, URLs and request ids from serialized attempt details', async () => {
const err = await errorFor(
Object.assign(
new Error(
'<html><body>Insufficient credits. Visit https://vendor.test/top-up (request id: req-secret)</body></html>',
),
{ status: 402 },
),
);
const serialized = JSON.stringify(attemptsFrom(err));
expect(err.legacyCode).toBe('upstream_credits_exhausted');
expect(serialized).not.toContain('<');
expect(serialized).not.toContain('http');
expect(serialized).not.toMatch(/request id/i);
});
it('maps an upstream 429 to 429 upstream_rate_limited', async () => {
const err = await errorFor(
Object.assign(new Error('slow down'), { status: 429 }),
@@ -307,6 +427,16 @@ describe('ChatCompletionDriver exhausted-chain classification', () => {
expect(err).toMatchObject({ legacyCode: 'upstream_rate_limited' });
});
it('keeps a plain 429 in the rate-limit bucket', async () => {
const err = await errorFor(
Object.assign(new Error('Rate limit exceeded'), { status: 429 }),
);
expect(err).toMatchObject({
statusCode: 429,
legacyCode: 'upstream_rate_limited',
});
});
it('maps an upstream 401 to a 500 upstream_auth_failed — our misconfiguration, not the callerdispute', async () => {
const err = await errorFor(
Object.assign(new Error('invalid api key'), { statusCode: 401 }),
@@ -488,7 +618,9 @@ describe('ChatCompletionDriver streaming failure handling', () => {
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValue({
stream: true,
init_chat_stream: async () => {
throw new Error('populator exploded');
throw new Error(
'populator exploded; see https://vendor.test/request/secret',
);
},
finally_fn: cleanup,
} as never);
@@ -503,7 +635,7 @@ describe('ChatCompletionDriver streaming failure handling', () => {
const events = await collect(result.stream);
expect(events).toEqual([
{ type: 'error', message: 'populator exploded' },
{ type: 'error', message: 'populator exploded; see' },
]);
// The provider's cleanup hook still runs on the failure path.
expect(cleanup).toHaveBeenCalledTimes(1);
@@ -202,8 +202,11 @@ afterAll(async () => {
* reject is what makes the whole chain observable: the driver records every
* attempt on the thrown error, and `attempts[0]` is who it chose first.
*/
const attemptsFor = async (model: string) => {
createMock.mockRejectedValue(new Error('upstream down'));
const attemptsFor = async (
model: string,
thrown: unknown = new Error('upstream down'),
) => {
createMock.mockRejectedValue(thrown);
let caught: HttpError | undefined;
try {
await withTestActor(() =>
@@ -405,6 +408,74 @@ describe('ChatCompletionDriver unhealthy-route skipping', () => {
expect(next[0].provider).not.toBe('deepseek');
});
it('marks a credit-exhausted 402 route unhealthy for the next caller', async () => {
const first = await attemptsFor(
'deepseek-v4-pro',
Object.assign(
new Error(
'Insufficient credits. Add more using https://openrouter.ai/settings/credits',
),
{ status: 402 },
),
);
const next = await attemptsFor('deepseek-v4-pro');
expect(next[0]).not.toMatchObject({
provider: first[0]!.provider,
model: first[0]!.model,
});
});
it('classifies differing credit-exhaustion statuses as one exhausted chain', async () => {
createMock
.mockRejectedValueOnce(
Object.assign(
new Error(
'Insufficient credits. Add more using https://openrouter.ai/settings/credits',
),
{ status: 402 },
),
)
.mockRejectedValueOnce(
Object.assign(
new Error(
'Your current credits have been used up and we are unable to process further requests. Please visit https://openrouter.ai/settings/credits to add credits.',
),
{ status: 403 },
),
)
.mockRejectedValueOnce(
Object.assign(
new Error(
'Free model requires Team balance greater than $4.999999. (request id: 20260921210512505339070jYB)',
),
{ status: 429 },
),
);
const err = await withTestActor(() =>
driver
.complete({
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'hi' }],
})
.catch((e: unknown) => e as HttpError),
);
expect(err).toMatchObject({
statusCode: 503,
legacyCode: 'upstream_credits_exhausted',
});
const attempts = (
err as unknown as {
fields: { attempts: Array<{ status?: number }> };
}
).fields.attempts;
expect(attempts.map((attempt) => attempt.status)).toEqual([
402, 403, 429,
]);
});
it('still serves a marked route when it is the only one left', async () => {
// gemini-2.5-flash-image-preview has a single route; marking it must
// degrade to trying it anyway rather than failing with no attempt.
@@ -191,16 +191,33 @@ describe('ChatCompletionDriver.complete auth and model resolution', () => {
).rejects.toMatchObject({ statusCode: 401 });
});
it('throws 400 when the requested model is unknown', async () => {
await expect(
withTestActor(() =>
driver.complete({
model: 'totally-not-a-model',
messages: [{ role: 'user', content: 'hi' }],
}),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it.each(['totally-not-a-model', '__proto__', 'constructor'])(
'throws 400 when the requested model is unknown: %s',
async (model) => {
await expect(
withTestActor(() =>
driver.complete({
model,
messages: [{ role: 'user', content: 'hi' }],
}),
),
).rejects.toMatchObject({ statusCode: 400 });
},
);
it.each(['__proto__', 'constructor'])(
'rejects inherited object keys as unknown providers: %s',
async (provider) => {
await expect(
withTestActor(() =>
driver.complete({
provider,
messages: [{ role: 'user', content: 'hi' }],
} as ICompleteArguments),
),
).rejects.toMatchObject({ statusCode: 400 });
},
);
it('falls back to the provider default model when neither model nor provider is given (azure-openai is the hard-coded default provider)', async () => {
// Without `azure-openai` in providers config, the driver tries
@@ -446,6 +463,53 @@ describe('ChatCompletionDriver.complete events and cost emission', () => {
expect(data.service_used).toBe('fake-chat');
});
it('charges cached prompt tokens only at their cache rate', async () => {
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
{
id: 'cached-pricing',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
promptTokensIncludeCached: true,
costs: {
tokens: 1_000_000,
prompt_tokens: 100,
completion_tokens: 200,
cached_tokens: 10,
},
max_tokens: 8192,
},
]);
const driver = await makeDriver();
const costEvents = captureEvent('ai.prompt.cost-calculated');
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce({
message: {
role: 'assistant',
content: [{ type: 'text', text: 'ok' }],
},
usage: {
prompt_tokens: 100,
completion_tokens: 1,
cached_tokens: 40,
},
finish_reason: 'stop',
} as never);
await withTestActor(() =>
driver.complete({
model: 'cached-pricing',
messages: [{ role: 'user', content: 'hello' }],
}),
);
const data = costEvents[0]![1] as {
input_ucents: number;
output_ucents: number;
};
expect(data.input_ucents).toBe(60 * 100 + 40 * 10);
expect(data.output_ucents).toBe(200);
});
it('injects `usd_cents` on the usage object derived from the cost map', async () => {
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
{
@@ -478,6 +542,43 @@ describe('ChatCompletionDriver.complete events and cost emission', () => {
expect(res.usage.usd_cents).toBe(expectedMicroCents / 1_000_000);
});
it('prices `usd_cents` at long-context rates once input passes the threshold', async () => {
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
{
id: 'priced',
aliases: [],
costs_currency: 'usd-cents',
costs: { input_tokens: 1000, output_tokens: 2000 },
long_context_pricing: {
threshold: 5,
input_multiplier: 2,
output_multiplier: 1.5,
},
max_tokens: 8192,
},
]);
const d = await makeDriver();
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce({
message: {
role: 'assistant',
content: [{ type: 'text', text: 'ok' }],
},
usage: { input_tokens: 10, output_tokens: 7 },
finish_reason: 'stop',
} as never);
const res = (await withTestActor(() =>
d.complete({
model: 'priced',
messages: [{ role: 'user', content: 'hi' }],
}),
)) as { usage: Record<string, number> };
const expectedMicroCents = 10 * 1000 * 2 + 7 * 2000 * 1.5;
expect(res.usage.usd_cents).toBe(expectedMicroCents / 1_000_000);
});
it('does not override `usd_cents` when the provider already returned one (e.g. OpenRouter)', async () => {
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce({
message: {
@@ -647,6 +748,54 @@ describe('ChatCompletionDriver.complete credit gate and max_tokens cap', () => {
expect(passed.max_tokens!).toBeGreaterThan(0);
});
it('caps `max_tokens` at the long-context output rate for a prompt past the threshold', async () => {
vi.spyOn(FakeChatProvider.prototype, 'models').mockResolvedValueOnce([
{
id: 'capme',
aliases: [],
costs_currency: 'usd-cents',
costs: { input_tokens: 1000, output_tokens: 2000 },
long_context_pricing: {
threshold: 10,
input_multiplier: 2,
output_multiplier: 1.5,
},
max_tokens: 8192,
},
]);
const d = await makeDriver();
// A ~100-token prompt is past the threshold. 1_000_000 microcents at
// 2000 * 1.5 per output token leaves at most 333 output tokens before
// the prompt is paid for; the standard rate would allow ~450 after it.
vi.spyOn(server.services.metering, 'getRemainingUsage').mockResolvedValue(
1_000_000,
);
const completeSpy = vi
.spyOn(FakeChatProvider.prototype, 'complete')
.mockResolvedValueOnce({
message: {
role: 'assistant',
content: [{ type: 'text', text: 'ok' }],
},
usage: { input_tokens: 1, output_tokens: 1 },
finish_reason: 'stop',
} as never);
await withTestActor(() =>
d.complete({
model: 'capme',
messages: [{ role: 'user', content: 'x'.repeat(400) }],
max_tokens: 10_000,
}),
);
const passed = completeSpy.mock.calls[0]![0] as ICompleteArguments;
expect(passed.max_tokens!).toBeLessThanOrEqual(333);
expect(passed.max_tokens!).toBeGreaterThan(0);
});
it('throws 402 instead of leaving `max_tokens` unset when credits cannot afford one output token', async () => {
// Regression: previously a sub-1 cap set max_tokens to `undefined`,
// which let the provider run to the model's full output limit and
@@ -30,13 +30,18 @@ import type { MeteringService } from '../../services/metering/MeteringService.js
import type { DriverStreamResult } from '../meta.js';
import { PuterDriver } from '../types.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
import { isUpstreamTimeoutError } from '../util/upstreamErrors.js';
import {
isCreditExhaustion as isUpstreamCreditExhaustion,
isUpstreamTimeoutError,
sanitizeUpstreamMessage,
} from '../util/upstreamErrors.js';
import { AlibabaProvider } from './providers/alibaba/AlibabaProvider.js';
import { AzureChatProvider } from './providers/azure/AzureChatProvider.js';
import { AzureResponsesProvider } from './providers/azure/AzureResponsesProvider.js';
import { BytePlusProvider } from './providers/byteplus/BytePlusProvider.js';
import { ClaudeProvider } from './providers/claude/ClaudeProvider.js';
import { DeepSeekProvider } from './providers/deepseek/DeepSeekProvider.js';
import { deepSeekPricing } from './providers/deepseek/pricing.js';
import { FakeChatProvider } from './providers/FakeChatProvider.js';
import { GeminiChatProvider } from './providers/gemini/GeminiChatProvider.js';
import { GroqAIProvider } from './providers/groq/GroqAIProvider.js';
@@ -82,7 +87,13 @@ import {
normalizeResultToOpenAI,
shouldPresentAsOpenAI,
} from './utils/normalizeToOpenAI.js';
import { costKeys, isFreeModel } from './utils/pricing.js';
import {
costKeys,
isFreeModel,
isOutputCostKey,
longContextMultipliers,
trackedInputTokens,
} from './utils/pricing.js';
import {
isRouteUnhealthy,
markRouteUnhealthy,
@@ -153,11 +164,14 @@ const toAttempt = (
provider: providerId,
status,
code: e?.error?.code ?? e?.code,
error: message,
error: sanitizeUpstreamMessage(message),
...(isUpstreamTimeoutError(err) ? { timedOut: true } : {}),
};
};
const isCreditExhaustion = (a: ProviderAttempt) =>
isUpstreamCreditExhaustion(a.status, a.code, a.error);
const isRateLimit = (a: ProviderAttempt) =>
a.status === 429 ||
/rate[\s_-]?limit|too many requests|quota/i.test(a.error);
@@ -184,6 +198,7 @@ const isUpstream5xx = (a: ProviderAttempt) =>
*/
const isRouteLevelFailure = (a: ProviderAttempt) =>
a.status === undefined ||
isCreditExhaustion(a) ||
isRateLimit(a) ||
isAuthFailure(a) ||
isUpstream5xx(a);
@@ -197,6 +212,7 @@ const routeId = (provider: string, modelId: string) => `${provider}:${modelId}`;
*
* Per-class rules (see also alarm gate in server.ts):
*
* - All credit-exhausted → 503 `upstream_credits_exhausted` (alerted)
* - All rate-limited → 429 `upstream_rate_limited` (alerted, unless every attempt
* was on a free model — see `allModelsFree`)
* - All auth failures → 500 `upstream_auth_failed` (paged: our config)
@@ -217,6 +233,12 @@ const classifyAttempts = (
});
}
if (attempts.every(isCreditExhaustion)) {
return new HttpError(503, 'AI provider out of credits', {
legacyCode: 'upstream_credits_exhausted',
fields,
});
}
if (attempts.every(isRateLimit)) {
return new HttpError(429, 'AI provider rate limit exceeded', {
legacyCode: 'upstream_rate_limited',
@@ -301,8 +323,8 @@ export class ChatCompletionDriver extends PuterDriver {
readonly rateLimit = AI_RATE_LIMIT;
readonly concurrent = AI_CONCURRENT;
#providers: Record<string, IChatProvider> = {};
#modelIdMap: Record<string, IChatModel[]> = {};
#providers: Record<string, IChatProvider> = Object.create(null);
#modelIdMap: Record<string, IChatModel[]> = Object.create(null);
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
get #aiMetering(): MeteringService {
@@ -659,7 +681,9 @@ export class ChatCompletionDriver extends PuterDriver {
passthrough.write(
`${JSON.stringify({
type: 'error',
message: (e as Error).message,
message: sanitizeUpstreamMessage(
e instanceof Error ? e.message : String(e),
),
})}\n`,
);
passthrough.end();
@@ -796,11 +820,14 @@ export class ChatCompletionDriver extends PuterDriver {
? outputRateRaw
: undefined;
const isOutputKey = (key: string) =>
key === outputKey ||
key === 'output_tokens' ||
key === 'completion_tokens' ||
key === 'thinking_tokens';
const isOutputKey = (key: string) => isOutputCostKey(key, outputKey);
const multipliers = longContextMultipliers(
model,
trackedInputTokens(usage, model),
);
const cachedTokens = Number.isFinite(usage.cached_tokens)
? Math.max(0, usage.cached_tokens)
: 0;
let inputMicroCents = 0;
let outputMicroCents = 0;
@@ -814,6 +841,11 @@ export class ChatCompletionDriver extends PuterDriver {
if (key === 'usd_cents') continue;
if (key === 'tokens') continue;
const amount =
model.promptTokensIncludeCached && key === inputKey
? Math.max(0, rawAmount - cachedTokens)
: rawAmount;
// thinking_tokens → output rate fallback
let rate = costs[key];
if (typeof rate !== 'number' || !Number.isFinite(rate)) {
@@ -834,12 +866,11 @@ export class ChatCompletionDriver extends PuterDriver {
}
}
const sub = rawAmount * rate;
sawAnyRate = true;
if (isOutputKey(key)) {
outputMicroCents += sub;
outputMicroCents += amount * rate * multipliers.output;
} else {
inputMicroCents += sub;
inputMicroCents += amount * rate * multipliers.input;
}
}
@@ -906,9 +937,14 @@ export class ChatCompletionDriver extends PuterDriver {
const metering = this.services.metering;
const { promptTokenEstimate, requestedMaxTokens } = estimates;
const { inputKey, outputKey } = costKeys(model);
// A prompt estimated past a long-context threshold pays the raised
// rates on input and output alike.
const multipliers = longContextMultipliers(model, promptTokenEstimate);
// `|| 0` also catches NaN from a malformed cost table.
const inputTokenCost = Number(model.costs?.[inputKey] ?? 0) || 0;
const outputTokenCost = Number(model.costs?.[outputKey] ?? 0) || 0;
const inputTokenCost =
(Number(model.costs?.[inputKey] ?? 0) || 0) * multipliers.input;
const outputTokenCost =
(Number(model.costs?.[outputKey] ?? 0) || 0) * multipliers.output;
const approximateInputCost = promptTokenEstimate * inputTokenCost;
const minimumCredits = Number(model.minimumCredits || 1);
@@ -1249,6 +1285,15 @@ export class ChatCompletionDriver extends PuterDriver {
const deepseekKey = readKey(providers['deepseek']);
if (deepseekKey) {
deepSeekPricing.onRefreshError = () => {
this.clients.alarm.create(
'deepseek_pricing_refresh_failed',
'DeepSeek pricing refresh failed; using last verified rates',
{},
'warning',
{ dedup: true },
);
};
this.#providers['deepseek'] = new DeepSeekProvider(
{ apiKey: deepseekKey },
metering,
@@ -46,11 +46,16 @@ import {
type MockInstance,
} from 'vitest';
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import {
expectedIdentifierFields,
makeActorMatrix,
sentIdentifierFields,
withTestActor,
} from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { AzureChatProvider } from './AzureChatProvider.js';
import { AZURE_MODELS } from './models.js';
@@ -389,31 +394,82 @@ describe('AzureChatProvider.complete request shape', () => {
const provider = makeProvider();
createMock.mockResolvedValueOnce(okCompletion);
await withTestActor(() =>
provider.complete({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'hi' }],
}),
await withTestActor(
() =>
provider.complete({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'hi' }],
}),
makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' } }),
);
const [args] = createMock.mock.calls[0]!;
expect(args.user).toBe('puter-u42');
expect('safety_identifier' in args).toBe(true);
expect(args.safety_identifier).toBe(args.user);
});
it('strips safety_identifier for Grok deployments, which 400 on unknown args', async () => {
it('sends the actor uuid and effective app uid as user/safety_identifier', async () => {
const provider = makeProvider();
createMock.mockResolvedValue(okCompletion);
for (const actor of makeActorMatrix()) {
await withTestActor(
() =>
provider.complete({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'hello' }],
}),
actor,
);
}
const fields = ['user', 'safety_identifier', 'prompt_cache_key'];
expect(sentIdentifierFields(createMock.mock.calls, fields)).toEqual(
expectedIdentifierFields(fields),
);
});
it('forwards a caller-supplied prompt_cache_key instead of the derived identifier', async () => {
const provider = makeProvider();
createMock.mockResolvedValueOnce(okCompletion);
await withTestActor(() =>
provider.complete({
model: 'grok-4-20-non-reasoning',
messages: [{ role: 'user', content: 'hi' }],
await withTestActor(
() =>
provider.complete({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'hi' }],
prompt_cache_key: 'caller-key',
}),
makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' } }),
);
const [args] = createMock.mock.calls[0]!;
expect(args.prompt_cache_key).toBe('caller-key');
expect(args.safety_identifier).toBe('puter-u42');
});
it('strips safety_identifier/prompt_cache_key for Grok deployments, which 400 on unknown args', async () => {
const provider = makeProvider();
createMock.mockResolvedValueOnce(okCompletion);
// Runs under a real user actor so `user` would be present; only the
// Grok branch may drop `safety_identifier`/`prompt_cache_key`.
await withTestActor(
() =>
provider.complete({
model: 'grok-4-20-non-reasoning',
messages: [{ role: 'user', content: 'hi' }],
}),
makeActor({
user: { id: 42, uuid: 'u42', username: 'alice' },
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.user).toBe('puter-u42');
expect('safety_identifier' in args).toBe(false);
expect('prompt_cache_key' in args).toBe(false);
expect(args.model).toBe('grok-4-20-non-reasoning');
});
@@ -125,6 +125,7 @@ export class AzureChatProvider implements IChatProvider {
reasoning_effort,
temperature,
text,
prompt_cache_key,
} = params;
let { messages, model } = params;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -175,6 +176,8 @@ export class AzureChatProvider implements IChatProvider {
// })
const userIdentifier = upstreamUserIdentifier(actor);
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
const cacheKey = prompt_cache_key ?? userIdentifier;
// Resolve any `puter_path` content parts into inline base64 data URLs.
// Chat Completions doesn't support file uploads, so this is the only
@@ -203,13 +206,20 @@ export class AzureChatProvider implements IChatProvider {
const supportsReasoningControls =
typeof model === 'string' && model.startsWith('gpt-5');
// `safety_identifier` is an OpenAI-specific param. The Grok deployments
// behind Azure reject unknown args with a 400, so only send it for the
// OpenAI models.
// `safety_identifier`/`prompt_cache_key` are OpenAI-specific params.
// The Grok deployments behind Azure reject unknown args with a 400,
// so only send them for the OpenAI models.
const completionParams: ChatCompletionCreateParams = {
user: userIdentifier,
...(isGrok ? {} : { safety_identifier: userIdentifier }),
...(isGrok
? {}
: {
safety_identifier: userIdentifier,
...(cacheKey !== undefined
? { prompt_cache_key: cacheKey }
: {}),
}),
messages: messages,
model: modelUsed.id,
...(tools ? { tools } : {}),
@@ -40,11 +40,16 @@ import {
type MockInstance,
} from 'vitest';
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import {
expectedIdentifierFields,
makeActorMatrix,
sentIdentifierFields,
withTestActor,
} from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { AzureResponsesProvider } from './AzureResponsesProvider.js';
import { AZURE_MODELS } from './models.js';
@@ -206,17 +211,19 @@ describe('AzureResponsesProvider.complete argument validation', () => {
// -- Request shape ---------------------------------------------------
describe('AzureResponsesProvider.complete request shape', () => {
it('sends messages as `input`, renames max_tokens, and always sets safety_identifier', async () => {
it('sends messages as `input`, renames max_tokens, and sets safety_identifier from the actor', async () => {
const provider = makeProvider();
responsesCreateMock.mockResolvedValueOnce(okResponse);
await withTestActor(() =>
provider.complete({
model: 'gpt-5.3-codex',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 256,
temperature: 0.3,
}),
await withTestActor(
() =>
provider.complete({
model: 'gpt-5.3-codex',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 256,
temperature: 0.3,
}),
makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' } }),
);
const [args] = responsesCreateMock.mock.calls[0]!;
@@ -224,9 +231,31 @@ describe('AzureResponsesProvider.complete request shape', () => {
expect(args.input).toEqual([{ role: 'user', content: 'hello' }]);
expect(args.max_output_tokens).toBe(256);
expect(args.temperature).toBe(0.3);
expect(args.user).toBe('puter-u42');
expect(args.safety_identifier).toBe(args.user);
});
it('sends the actor uuid and effective app uid as user/safety_identifier', async () => {
const provider = makeProvider();
responsesCreateMock.mockResolvedValue(okResponse);
for (const actor of makeActorMatrix()) {
await withTestActor(
() =>
provider.complete({
model: 'gpt-5.3-codex',
messages: [{ role: 'user', content: 'hello' }],
}),
actor,
);
}
const fields = ['user', 'safety_identifier', 'prompt_cache_key'];
expect(
sentIdentifierFields(responsesCreateMock.mock.calls, fields),
).toEqual(expectedIdentifierFields(fields));
});
it('resolves an alias against the unrestricted catalog', async () => {
const provider = makeProvider();
responsesCreateMock.mockResolvedValueOnce(okResponse);
@@ -143,6 +143,8 @@ export class AzureResponsesProvider implements IChatProvider {
)!;
const userIdentifier = upstreamUserIdentifier(actor);
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
const cacheKey = prompt_cache_key ?? userIdentifier;
// Resolve any `puter_path` content parts into inline base64 data URLs
// before the Responses API sees them.
@@ -206,7 +208,7 @@ export class AzureResponsesProvider implements IChatProvider {
...(instructions !== undefined ? { instructions } : {}),
...(metadata !== undefined ? { metadata } : {}),
...(prompt !== undefined ? { prompt } : {}),
...(prompt_cache_key !== undefined ? { prompt_cache_key } : {}),
...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}),
...(prompt_cache_retention !== undefined
? { prompt_cache_retention }
: {}),
@@ -790,42 +790,69 @@ describe('ClaudeProvider.complete request shape', () => {
});
});
it('omits temperature for fable 5.1 (rejects non-default sampling)', async () => {
it.each(['claude-fable-5-1', 'claude-opus-5-5', 'claude-sonnet-5'])(
'omits temperature for %s',
async (model) => {
const { provider } = makeProvider();
messagesCreateMock.mockResolvedValueOnce(baseResponse);
await withTestActor(() =>
provider.complete({
model,
messages: [{ role: 'user', content: 'hi' }],
temperature: 0.5,
}),
);
const [args] = messagesCreateMock.mock.calls[0]!;
expect('temperature' in args).toBe(false);
},
);
it.each(['claude-fable-5-1', 'claude-opus-5-5', 'claude-sonnet-5'])(
'uses adaptive thinking and output effort on %s',
async (model) => {
const { provider } = makeProvider();
messagesCreateMock.mockResolvedValueOnce(baseResponse);
await withTestActor(() =>
provider.complete({
model,
messages: [{ role: 'user', content: 'hi' }],
reasoning_effort: 'high',
} as never),
);
const [args] = messagesCreateMock.mock.calls[0]!;
expect(args.thinking).toEqual({
type: 'adaptive',
display: 'summarized',
});
expect(args.output_config).toEqual({ effort: 'high' });
expect('temperature' in args).toBe(false);
},
);
it('forwards reasoning_effort as adaptive thinking + output_config effort on opus 5.5', async () => {
const { provider } = makeProvider();
messagesCreateMock.mockResolvedValueOnce(baseResponse);
await withTestActor(() =>
provider.complete({
model: 'claude-fable-5-1',
model: 'claude-opus-5-5',
messages: [{ role: 'user', content: 'hi' }],
reasoning_effort: 'low',
temperature: 0.5,
}),
);
const [args] = messagesCreateMock.mock.calls[0]!;
expect('temperature' in args).toBe(false);
});
it('forwards reasoning_effort as adaptive thinking + output_config effort on fable 5.1', async () => {
const { provider } = makeProvider();
messagesCreateMock.mockResolvedValueOnce(baseResponse);
await withTestActor(() =>
provider.complete({
model: 'claude-fable-5-1',
messages: [{ role: 'user', content: 'hi' }],
reasoning_effort: 'high',
} as never),
);
const [args] = messagesCreateMock.mock.calls[0]!;
// Fable 5.1 rejects `budget_tokens` and thinking is always on, so the
// only accepted config is adaptive; effort rides in output_config.
// Opus 5.5 rejects disabled thinking, `budget_tokens`, and sampling params.
expect(args.thinking).toEqual({
type: 'adaptive',
display: 'summarized',
});
expect(args.output_config).toEqual({ effort: 'high' });
expect(args.output_config).toEqual({ effort: 'low' });
expect('temperature' in args).toBe(false);
});
@@ -898,6 +925,22 @@ describe('ClaudeProvider model resolution', () => {
);
});
it('routes the bare claude-opus alias to opus 5.5 rather than opus 5', async () => {
const { provider } = makeProvider();
messagesCreateMock.mockResolvedValueOnce(baseResponse);
await withTestActor(() =>
provider.complete({
model: 'claude-opus',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(messagesCreateMock.mock.calls[0]![0].model).toBe(
'claude-opus-5-5',
);
});
it('falls back to the default model when given an unknown id', async () => {
const { provider } = makeProvider();
messagesCreateMock.mockResolvedValueOnce(baseResponse);
@@ -918,6 +961,60 @@ describe('ClaudeProvider model resolution', () => {
// ── Non-stream completion ───────────────────────────────────────────
describe('ClaudeProvider.complete non-stream output', () => {
it.each([
['claude-opus-5-5', 'claude-opus-5-5', 400, 500, 800, 20, 2000],
[
'anthropic/claude-opus-5-5',
'claude-opus-5-5',
400,
500,
800,
20,
2000,
],
['claude-opus', 'claude-opus-5-5', 400, 500, 800, 20, 2000],
['claude-opus-latest', 'claude-opus-5-5', 400, 500, 800, 20, 2000],
['claude-opus-5-latest', 'claude-opus-5', 500, 625, 1000, 50, 2500],
['claude-sonnet-5', 'claude-sonnet-5', 200, 250, 400, 20, 1000],
])(
'resolves and meters %s at its current rates',
async (model, canonicalId, input, write5m, write1h, cached, output) => {
const { provider } = makeProvider();
messagesCreateMock.mockResolvedValueOnce({
content: [{ type: 'text', text: 'ok' }],
usage: {
input_tokens: 100,
output_tokens: 50,
cache_creation_input_tokens: 30,
cache_creation: {
ephemeral_5m_input_tokens: 10,
ephemeral_1h_input_tokens: 20,
},
cache_read_input_tokens: 1000,
},
});
await withTestActor(() =>
provider.complete({
model,
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(await provider.list()).toContain(model);
const [args] = messagesCreateMock.mock.calls[0]!;
expect(args.model).toBe(canonicalId);
expect(args.max_tokens).toBe(128_000);
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
expect(prefix).toBe(`claude:${canonicalId}`);
expect(overrides).toMatchObject({
input_tokens: 100 * Number(input),
output_tokens: 50 * Number(output),
ephemeral_5m_input_tokens: 10 * Number(write5m),
ephemeral_1h_input_tokens: 20 * Number(write1h),
cache_read_input_tokens: 1000 * Number(cached),
});
},
);
it('returns the message verbatim and meters input/output/cache token costs', async () => {
const { provider } = makeProvider();
const msg = {
@@ -367,6 +367,7 @@ export class ClaudeProvider implements IChatProvider {
'claude-opus-4-7',
'claude-opus-4-8',
'claude-opus-5',
'claude-opus-5-5',
].includes(modelUsed.id);
const resolvedTemperature = omitsTemperature
? undefined
@@ -377,6 +378,7 @@ export class ClaudeProvider implements IChatProvider {
'claude-fable-5-1',
'claude-fable-5',
'claude-sonnet-5',
'claude-opus-5-5',
'claude-opus-5',
'claude-opus-4-8',
'claude-opus-4-7',
@@ -804,14 +806,13 @@ export class ClaudeProvider implements IChatProvider {
}) {
if (!reasoningEffort) return undefined;
// Fable 5/5.1, Opus 4.7+, 4.6, and Sonnet 4.6 use adaptive thinking
// (`budget_tokens` is deprecated on 4.6/Sonnet 4.6, removed on
// Fable 5+ and Opus 4.7+). Fable 5/5.1 and Opus 4.7+ omit thinking
// content by default; `display: 'summarized'` restores visible
// reasoning in the stream.
// These models reject manual thinking budgets; summarized display
// keeps reasoning visible in the stream.
if (
modelId === 'claude-fable-5-1' ||
modelId === 'claude-fable-5' ||
modelId === 'claude-sonnet-5' ||
modelId === 'claude-opus-5-5' ||
modelId === 'claude-opus-5' ||
modelId === 'claude-opus-4-8' ||
modelId === 'claude-opus-4-7'
@@ -44,7 +44,7 @@ export const CLAUDE_MODELS: IChatModel[] = [
input_tokens: 1000,
ephemeral_5m_input_tokens: 1000 * 1.25,
ephemeral_1h_input_tokens: 1000 * 2,
// Fable 5.1 bills cache reads at 0.025x input; every other Claude model is 0.1x.
// 0.025x input, not the usual 0.1x.
cache_read_input_tokens: 1000 * 0.025,
output_tokens: 5000,
},
@@ -80,6 +80,7 @@ export const CLAUDE_MODELS: IChatModel[] = [
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2026-01',
release_date: '2026-06-30',
aliases: [
'claude-sonnet',
@@ -93,14 +94,45 @@ export const CLAUDE_MODELS: IChatModel[] = [
output_cost_key: 'output_tokens',
costs: {
tokens: 1_000_000,
input_tokens: 300,
ephemeral_5m_input_tokens: 300 * 1.25,
ephemeral_1h_input_tokens: 300 * 2,
cache_read_input_tokens: 300 * 0.1,
output_tokens: 1500,
input_tokens: 200,
ephemeral_5m_input_tokens: 200 * 1.25,
ephemeral_1h_input_tokens: 200 * 2,
cache_read_input_tokens: 200 * 0.1,
output_tokens: 1000,
},
context: 1000000,
max_tokens: 64000,
max_tokens: 128000,
},
{
puterId: 'anthropic:anthropic/claude-opus-5-5',
id: 'claude-opus-5-5',
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2026-06',
release_date: '2026-09-22',
aliases: [
'claude-opus',
'claude-opus-latest',
'claude-opus-5-5-latest',
'claude-opus-5.5',
'anthropic/claude-opus-5-5',
],
name: 'Claude Opus 5.5',
costs_currency: 'usd-cents',
input_cost_key: 'input_tokens',
output_cost_key: 'output_tokens',
costs: {
tokens: 1_000_000,
input_tokens: 400,
ephemeral_5m_input_tokens: 400 * 1.25,
ephemeral_1h_input_tokens: 400 * 2,
// 0.05x input, not the usual 0.1x.
cache_read_input_tokens: 400 * 0.05,
output_tokens: 2000,
},
context: 1000000,
max_tokens: 128000,
},
{
puterId: 'anthropic:anthropic/claude-opus-5',
@@ -110,12 +142,7 @@ export const CLAUDE_MODELS: IChatModel[] = [
tool_call: true,
knowledge: '2026-05',
release_date: '2026-07-24',
aliases: [
'claude-opus',
'claude-opus-latest',
'claude-opus-5-latest',
'anthropic/claude-opus-5',
],
aliases: ['claude-opus-5-latest', 'anthropic/claude-opus-5'],
name: 'Claude Opus 5',
costs_currency: 'usd-cents',
input_cost_key: 'input_tokens',
@@ -20,7 +20,7 @@
/**
* Integration test for the DeepSeek provider.
*
* Uses `deepseek-chat` (the cheap V3 chat model, provider default).
* Uses `deepseek-chat` (the Flash legacy alias).
* Skipped when `PUTER_TEST_AI_DEEPSEEK_API_KEY` is unset.
*/
@@ -17,17 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Offline unit tests for DeepSeekProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs DeepSeekProvider directly against the live
* wired `MeteringService` so the recording side is exercised end-to-
* end. DeepSeek is OpenAI-compatible so the OpenAI SDK is mocked at
* the module boundary; that's the real network egress point. The
* companion integration test (DeepSeekProvider.integration.test.ts)
* exercises the real DeepSeek endpoint.
*/
/** Offline provider tests with an in-memory server and mocked OpenAI network boundary. */
import { Writable } from 'node:stream';
import {
@@ -145,9 +135,9 @@ describe('DeepSeekProvider construction', () => {
// ── Model catalog ───────────────────────────────────────────────────
describe('DeepSeekProvider model catalog', () => {
it('returns deepseek-v4-flash as the default', () => {
it('returns deepseek-flash as the default', () => {
const { provider } = makeProvider();
expect(provider.getDefaultModel()).toBe('deepseek-v4-flash');
expect(provider.getDefaultModel()).toBe('deepseek-flash');
});
it('exposes the static DEEPSEEK_MODELS list verbatim from models()', () => {
@@ -164,8 +154,10 @@ describe('DeepSeekProvider model catalog', () => {
expect(ids).toContain(a);
}
}
expect(ids).toContain('deepseek-v4-flash');
expect(ids).toContain('deepseek-v4-pro');
expect(ids).toContain('deepseek-flash');
expect(ids).toContain('deepseek-v4-flash');
expect(ids).toContain('deepseek-v4-flash-vision-exp');
expect(ids).toContain('deepseek-chat');
expect(ids).toContain('deepseek/deepseek-v4-pro');
});
@@ -190,13 +182,13 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-v4-flash',
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'hello' }],
}),
);
const [args] = createMock.mock.calls[0]!;
expect(args.model).toBe('deepseek-v4-flash');
expect(args.model).toBe('deepseek-v4-pro');
expect(args.messages).toEqual([{ role: 'user', content: 'hello' }]);
expect(args.max_tokens).toBe(1000);
});
@@ -207,7 +199,7 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-v4-flash',
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 256,
}),
@@ -222,7 +214,7 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-v4-flash',
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'hi' }],
max_tokens: 0,
}),
@@ -237,7 +229,7 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-v4-flash',
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'hi' }],
}),
);
@@ -261,7 +253,7 @@ describe('DeepSeekProvider.complete request shape', () => {
];
await withTestActor(() =>
provider.complete({
model: 'deepseek-v4-flash',
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'hi' }],
tools,
}),
@@ -275,7 +267,7 @@ describe('DeepSeekProvider.complete request shape', () => {
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'deepseek-v4-flash',
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'hi' }],
stream: false,
}),
@@ -287,7 +279,7 @@ describe('DeepSeekProvider.complete request shape', () => {
createMock.mockReturnValueOnce(asAsyncIterable([]));
await withTestActor(() =>
provider.complete({
model: 'deepseek-v4-flash',
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}),
@@ -303,7 +295,7 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-v4-flash',
model: 'deepseek-v4-pro',
messages: [
{
role: 'assistant',
@@ -344,7 +336,7 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-v4-flash',
model: 'deepseek-v4-pro',
messages: [
{
role: 'assistant',
@@ -381,7 +373,7 @@ describe('DeepSeekProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-v4-flash',
model: 'deepseek-v4-pro',
messages: [
{ role: 'user', content: 'do tool call' },
{
@@ -429,16 +421,16 @@ describe('DeepSeekProvider model resolution', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-v4-flash',
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-pro');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'deepseek:deepseek-v4-flash',
'deepseek:deepseek-v4-pro',
expect.any(Object),
);
});
@@ -449,17 +441,17 @@ describe('DeepSeekProvider model resolution', () => {
await withTestActor(() =>
provider.complete({
// `deepseek/deepseek-v4-flash` is an alias of `deepseek-v4-flash`.
model: 'deepseek/deepseek-v4-flash',
// `deepseek/deepseek-v4-pro` is an alias of `deepseek-v4-pro`.
model: 'deepseek/deepseek-v4-pro',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-pro');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'deepseek:deepseek-v4-flash',
'deepseek:deepseek-v4-pro',
expect.any(Object),
);
});
@@ -475,26 +467,34 @@ describe('DeepSeekProvider model resolution', () => {
}),
);
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-flash');
const flash = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-flash')!;
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
{ prompt_tokens: 1, completion_tokens: 1, cached_tokens: 0 },
expect.anything(),
'deepseek:deepseek-v4-flash',
expect.any(Object),
'deepseek:deepseek-flash',
{
prompt_tokens: Number(flash.costs.prompt_tokens),
completion_tokens: Number(flash.costs.completion_tokens),
cached_tokens: 0,
},
);
});
// The legacy DeepSeek chat/reasoner ids (and their `deepseek/…` and
// `deepseek:deepseek/…` variants) are aliased onto deepseek-v4-flash
// so callers using the old names get transparently upgraded — and
// metered against the v4-flash canonical prefix.
it.each([
'deepseek-v4-flash',
'deepseek/deepseek-v4-flash',
'deepseek:deepseek/deepseek-v4-flash',
'deepseek-v4-flash-vision-exp',
'deepseek/deepseek-v4-flash-vision-exp',
'deepseek:deepseek/deepseek-v4-flash-vision-exp',
'deepseek-chat',
'deepseek/deepseek-chat',
'deepseek:deepseek/deepseek-chat',
'deepseek-reasoner',
'deepseek/deepseek-reasoner',
'deepseek:deepseek/deepseek-reasoner',
])('maps legacy alias %s onto deepseek-v4-flash', async (alias) => {
])('maps Flash and legacy alias %s onto deepseek-flash', async (alias) => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
@@ -505,11 +505,11 @@ describe('DeepSeekProvider model resolution', () => {
}),
);
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-v4-flash');
expect(createMock.mock.calls[0]![0].model).toBe('deepseek-flash');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'deepseek:deepseek-v4-flash',
'deepseek:deepseek-flash',
expect.any(Object),
);
});
@@ -536,7 +536,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'deepseek-v4-flash',
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'hi' }],
}),
);
@@ -551,24 +551,91 @@ describe('DeepSeekProvider.complete non-stream output', () => {
cached_tokens: 10,
});
// deepseek-v4-flash costs: prompt=14, completion=28, cached=0.28
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-v4-flash')!;
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-v4-pro')!;
expect(recordSpy).toHaveBeenCalledTimes(1);
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
expect(usage).toEqual({
prompt_tokens: 100,
prompt_tokens: 90,
completion_tokens: 50,
cached_tokens: 10,
});
expect(actor).toBe(SYSTEM_ACTOR);
expect(prefix).toBe('deepseek:deepseek-v4-flash');
expect(prefix).toBe('deepseek:deepseek-v4-pro');
expect(overrides).toEqual({
prompt_tokens: 100 * Number(chat.costs.prompt_tokens),
prompt_tokens: 90 * Number(chat.costs.prompt_tokens),
completion_tokens: 50 * Number(chat.costs.completion_tokens),
cached_tokens: 10 * Number(chat.costs.cached_tokens ?? 0),
});
});
it('charges a fully cached prompt only at the cache rate', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: { content: 'ok', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 100,
completion_tokens: 0,
prompt_tokens_details: { cached_tokens: 100 },
},
});
await withTestActor(() =>
provider.complete({
model: 'deepseek-flash',
messages: [{ role: 'user', content: 'hi' }],
}),
);
const flash = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-flash')!;
expect(recordSpy).toHaveBeenCalledWith(
{ prompt_tokens: 0, completion_tokens: 0, cached_tokens: 100 },
expect.anything(),
'deepseek:deepseek-flash',
{
prompt_tokens: 0,
completion_tokens: 0,
cached_tokens: 100 * Number(flash.costs.cached_tokens),
},
);
});
it('uses DeepSeek cache hit and miss counts when provided', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: { content: 'ok', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 100,
completion_tokens: 1,
prompt_cache_hit_tokens: 40,
prompt_cache_miss_tokens: 60,
},
});
await withTestActor(() =>
provider.complete({
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(recordSpy).toHaveBeenCalledWith(
{ prompt_tokens: 60, completion_tokens: 1, cached_tokens: 40 },
expect.anything(),
'deepseek:deepseek-v4-pro',
expect.any(Object),
);
});
it('preserves OpenAI-shaped tool_calls on the assistant response', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
@@ -596,7 +663,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
const result = (await withTestActor(() =>
provider.complete({
model: 'deepseek-v4-flash',
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'do a tool call' }],
tools: [
{
@@ -631,7 +698,7 @@ describe('DeepSeekProvider.complete non-stream output', () => {
await withTestActor(() =>
provider.complete({
model: 'deepseek-v4-flash',
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'hi' }],
}),
);
@@ -664,7 +731,7 @@ describe('DeepSeekProvider.complete streaming', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'deepseek-v4-flash',
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'say hi' }],
stream: true,
}),
@@ -689,12 +756,12 @@ describe('DeepSeekProvider.complete streaming', () => {
cached_tokens: 1,
});
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-v4-flash')!;
const chat = DEEPSEEK_MODELS.find((m) => m.id === 'deepseek-v4-pro')!;
expect(recordSpy).toHaveBeenCalledTimes(1);
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
expect(prefix).toBe('deepseek:deepseek-v4-flash');
expect(prefix).toBe('deepseek:deepseek-v4-pro');
expect(overrides).toEqual({
prompt_tokens: 4 * Number(chat.costs.prompt_tokens),
prompt_tokens: 3 * Number(chat.costs.prompt_tokens),
completion_tokens: 2 * Number(chat.costs.completion_tokens),
cached_tokens: 1 * Number(chat.costs.cached_tokens ?? 0),
});
@@ -745,7 +812,7 @@ describe('DeepSeekProvider.complete streaming', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'deepseek-v4-flash',
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'do tool call' }],
tools: [
{
@@ -784,7 +851,7 @@ describe('DeepSeekProvider.complete error mapping', () => {
await expect(
withTestActor(() =>
provider.complete({
model: 'deepseek-v4-flash',
model: 'deepseek-v4-pro',
messages: [{ role: 'user', content: 'boom' }],
}),
),
@@ -26,6 +26,7 @@ import type { IChatProvider, ICompleteArguments } from '../../types.js';
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
import { DEEPSEEK_MODELS } from './models.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import { deepSeekPricing } from './pricing.js';
export class DeepSeekProvider implements IChatProvider {
#openai: OpenAI;
@@ -38,10 +39,11 @@ export class DeepSeekProvider implements IChatProvider {
baseURL: 'https://api.deepseek.com',
});
this.#meteringService = meteringService;
deepSeekPricing.start();
}
getDefaultModel() {
return 'deepseek-v4-flash';
return 'deepseek-flash';
}
models() {
@@ -60,6 +62,7 @@ export class DeepSeekProvider implements IChatProvider {
max_tokens,
temperature,
}: ICompleteArguments): ReturnType<IChatProvider['complete']> {
await deepSeekPricing.refreshIfStale();
const actor = Context.get('actor');
const availableModels = this.models();
const modelUsed =
@@ -122,14 +125,29 @@ export class DeepSeekProvider implements IChatProvider {
return OpenAIUtil.handle_completion_output({
usage_calculator: ({ usage }) => {
const deepSeekUsage = usage as typeof usage & {
prompt_cache_hit_tokens?: number;
prompt_cache_miss_tokens?: number;
};
const cachedTokens =
deepSeekUsage.prompt_cache_hit_tokens ??
usage.prompt_tokens_details?.cached_tokens ??
0;
const trackedUsage = OpenAIUtil.extractMeteredUsage(usage);
const meteredUsage = {
prompt_tokens:
deepSeekUsage.prompt_cache_miss_tokens ??
Math.max(0, (usage.prompt_tokens ?? 0) - cachedTokens),
completion_tokens: usage.completion_tokens ?? 0,
cached_tokens: cachedTokens,
};
const costsOverrideFromModel = Object.fromEntries(
Object.entries(trackedUsage).map(([k, v]) => {
Object.entries(meteredUsage).map(([k, v]) => {
return [k, v * modelUsed.costs[k]];
}),
);
this.#meteringService.utilRecordUsageObject(
trackedUsage,
meteredUsage,
actor!,
`deepseek:${modelUsed.id}`,
costsOverrideFromModel,
@@ -18,61 +18,50 @@
*/
import type { IChatModel } from '../../types.js';
import { deepSeekPricing, type DeepSeekModelId } from './pricing.js';
const dynamicCosts = (modelId: DeepSeekModelId) => ({
tokens: 1_000_000,
get prompt_tokens() {
return deepSeekPricing.costs(modelId).prompt;
},
get completion_tokens() {
return deepSeekPricing.costs(modelId).completion;
},
get cached_tokens() {
return deepSeekPricing.costs(modelId).cached;
},
});
// Hardcoded from https://models.dev/api.json
export const DEEPSEEK_MODELS: IChatModel[] = [
{
puterId: 'deepseek:deepseek/deepseek-v4-flash',
id: 'deepseek-v4-flash',
modalities: { input: ['text'], output: ['text'] },
puterId: 'deepseek:deepseek/deepseek-flash',
id: 'deepseek-flash',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2026-04',
release_date: '2026-04-24',
name: 'DeepSeek Chat',
name: 'DeepSeek Flash',
aliases: [
'deepseek/deepseek-flash',
'deepseek-v4-flash',
'deepseek/deepseek-v4-flash',
'deepseek:deepseek/deepseek-v4-flash',
'deepseek-v4-flash-vision-exp',
'deepseek/deepseek-v4-flash-vision-exp',
'deepseek:deepseek/deepseek-v4-flash-vision-exp',
'deepseek-chat',
'deepseek/deepseek-chat',
'deepseek:deepseek/deepseek-chat',
'deepseek-reasoner',
'deepseek/deepseek-reasoner',
'deepseek:deepseek/deepseek-reasoner',
'deepseek:deepseek/deepseek-chat',
],
context: 1_000_000,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 14,
completion_tokens: 28,
cached_tokens: 0.28,
},
max_tokens: 384_000,
},
{
// Priced identically to deepseek-v4-flash per DeepSeek's launch note
// (https://api-docs.deepseek.com/news/news260821/) and the pricing
// page, which lists the same rates for both models; images are
// tokenized (up to 384 tokens each) and billed as input tokens.
puterId: 'deepseek:deepseek/deepseek-v4-flash-vision-exp',
id: 'deepseek-v4-flash-vision-exp',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
release_date: '2026-08-21',
name: 'DeepSeek V4 Flash Vision (Experimental)',
aliases: ['deepseek/deepseek-v4-flash-vision-exp'],
context: 1_000_000,
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 14,
completion_tokens: 28,
cached_tokens: 0.28,
},
promptTokensIncludeCached: true,
costs: dynamicCosts('deepseek-flash'),
max_tokens: 384_000,
},
{
@@ -89,12 +78,8 @@ export const DEEPSEEK_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 174,
completion_tokens: 348,
cached_tokens: 1.45,
},
promptTokensIncludeCached: true,
costs: dynamicCosts('deepseek-v4-pro'),
max_tokens: 384_000,
},
];
@@ -0,0 +1,33 @@
/*
* 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 { DeepSeekPricing } from './pricing.js';
describe.skipIf(!process.env.PUTER_TEST_AI_DEEPSEEK_PRICING)(
'DeepSeek pricing page (integration)',
() => {
it('fetches and parses current official rates', async () => {
const pricing = new DeepSeekPricing();
await pricing.refresh();
expect(pricing.costs('deepseek-flash').prompt).toBeGreaterThan(0);
expect(pricing.costs('deepseek-v4-pro').cached).toBeGreaterThan(0);
}, 15_000);
},
);
@@ -0,0 +1,125 @@
/*
* 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, vi } from 'vitest';
import { DEEPSEEK_MODELS } from './models.js';
import {
DeepSeekPricing,
isDeepSeekPeak,
parseDeepSeekPrices,
} from './pricing.js';
const pricePage = (flashInput = '$0.15') => `
<table><tr><td>MODEL</td><td>deepseek-flash<sup>(1)</sup></td><td>deepseek-v4-pro</td></tr>
<tr><td>PRICING</td><td>1M INPUT TOKENS<br>(CACHE HIT)</td><td>OFF-PEAK</td><td>$0.003</td><td>$0.022</td></tr>
<tr><td>PEAK</td><td>$0.006</td><td>$0.044</td></tr>
<tr><td>1M INPUT TOKENS<br>(CACHE MISS)</td><td>OFF-PEAK</td><td>${flashInput}</td><td>$0.66</td></tr>
<tr><td>PEAK</td><td>$0.30</td><td>$1.32</td></tr>
<tr><td>1M OUTPUT TOKENS</td><td>OFF-PEAK</td><td>$0.60</td><td>$1.98</td></tr>
<tr><td>PEAK</td><td>$1.20</td><td>$3.96</td></tr></table>
<p>Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese public holidays.</p>`;
describe('DeepSeek pricing', () => {
it('parses both models, periods, and cache rates from the official table shape', () => {
const prices = parseDeepSeekPrices(pricePage());
expect(prices['deepseek-flash'].offPeak).toEqual({
prompt: 0.15,
completion: 0.6,
cached: 0.003,
});
expect(prices['deepseek-v4-pro'].peak).toEqual({
prompt: 1.32,
completion: 3.96,
cached: 0.044,
});
});
it('rejects incomplete tables and changed peak rules', () => {
expect(() =>
parseDeepSeekPrices(
pricePage().replace('excluding Chinese public holidays', ''),
),
).toThrow();
expect(() =>
parseDeepSeekPrices(pricePage().replace('<td>$3.96</td>', '')),
).toThrow();
});
it.each([
['2026-09-28T00:59:00Z', false],
['2026-09-28T01:00:00Z', true],
['2026-09-28T04:00:00Z', false],
['2026-09-28T06:00:00Z', true],
['2026-09-28T10:00:00Z', false],
['2026-09-27T07:00:00Z', false],
['2026-10-01T07:00:00Z', false],
['2026-05-04T07:00:00Z', false],
['2027-09-28T07:00:00Z', false],
])('classifies %s peak=%s', (iso, expected) => {
expect(isDeepSeekPeak(new Date(iso))).toBe(expected);
});
it('updates catalog rates at UTC peak boundaries even after model routing copies an entry', () => {
const flash = {
...DEEPSEEK_MODELS.find((model) => model.id === 'deepseek-flash')!,
};
vi.useFakeTimers();
try {
vi.setSystemTime(new Date('2026-09-28T05:59:59Z'));
expect(flash.costs.prompt_tokens).toBe(15);
vi.setSystemTime(new Date('2026-09-28T06:00:00Z'));
expect(flash.costs.prompt_tokens).toBe(30);
expect(flash.costs.cached_tokens).toBe(0.6);
} finally {
vi.useRealTimers();
}
});
it('refreshes rates and keeps the last good schedule after a bad response', async () => {
const fetchPage = vi
.fn()
.mockResolvedValueOnce(new Response(pricePage('$0.18')))
.mockResolvedValueOnce(new Response('<html>bad prices</html>'));
const pricing = new DeepSeekPricing(fetchPage);
const offPeak = new Date('2026-09-28T05:00:00Z');
await pricing.refresh();
expect(pricing.costs('deepseek-flash', offPeak).prompt).toBe(18);
await expect(pricing.refresh()).rejects.toThrow();
expect(pricing.costs('deepseek-flash', offPeak).prompt).toBe(18);
expect(fetchPage).toHaveBeenCalledWith(
'https://api-docs.deepseek.com/quick_start/pricing/',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it('reports a failed automatic refresh without replacing rates', async () => {
const reportError = vi.fn();
const pricing = new DeepSeekPricing(
vi.fn().mockRejectedValue(new Error('offline')),
reportError,
);
await expect(pricing.refreshIfStale()).resolves.toBeUndefined();
expect(reportError).toHaveBeenCalledTimes(1);
expect(
pricing.costs('deepseek-flash', new Date('2026-09-28T05:00:00Z'))
.prompt,
).toBe(15);
});
});
@@ -0,0 +1,217 @@
/*
* 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/>.
*/
export type DeepSeekModelId = 'deepseek-flash' | 'deepseek-v4-pro';
type Period = 'offPeak' | 'peak';
type TokenRates = { prompt: number; completion: number; cached: number };
type PriceSchedule = Record<DeepSeekModelId, Record<Period, TokenRates>>;
const PRICING_URL = 'https://api-docs.deepseek.com/quick_start/pricing/';
const REFRESH_MS = 60 * 60 * 1000;
// Last verified official rates, in USD per million tokens. Used until a valid
// refresh succeeds; the page remains the source of subsequent changes.
const FALLBACK_RATES: PriceSchedule = {
'deepseek-flash': {
offPeak: { prompt: 0.15, completion: 0.6, cached: 0.003 },
peak: { prompt: 0.3, completion: 1.2, cached: 0.006 },
},
'deepseek-v4-pro': {
offPeak: { prompt: 0.66, completion: 1.98, cached: 0.022 },
peak: { prompt: 1.32, completion: 3.96, cached: 0.044 },
},
};
// State Council's 2026 public holiday arrangement:
// https://en.bjhd.gov.cn/workinginhaidian/supportingservices/publicholidays/202512/t20251211_4797062.shtml
const HOLIDAYS_2026 = [
['2026-01-01', '2026-01-03'],
['2026-02-15', '2026-02-23'],
['2026-04-04', '2026-04-06'],
['2026-05-01', '2026-05-05'],
['2026-06-19', '2026-06-21'],
['2026-09-25', '2026-09-27'],
['2026-10-01', '2026-10-07'],
];
const isChinesePublicHoliday = (date: Date): boolean => {
const day = date.toISOString().slice(0, 10);
return HOLIDAYS_2026.some(([start, end]) => day >= start && day <= end);
};
export const isDeepSeekPeak = (date: Date): boolean => {
// An unpublished year's holiday calendar must not cause peak overbilling.
if (date.getUTCFullYear() !== 2026) return false;
const weekday = date.getUTCDay();
const hour = date.getUTCHours();
return (
weekday >= 1 &&
weekday <= 5 &&
!isChinesePublicHoliday(date) &&
((hour >= 1 && hour < 4) || (hour >= 6 && hour < 10))
);
};
const plainText = (html: string): string =>
html
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const readDollars = (cell: string): number => {
if (!/^\$\d+(?:\.\d+)?$/.test(cell))
throw new Error('Invalid DeepSeek price');
const value = Number(cell.slice(1));
if (!Number.isFinite(value) || value <= 0 || value > 100) {
throw new Error('DeepSeek price outside expected range');
}
return value;
};
export const parseDeepSeekPrices = (html: string): PriceSchedule => {
if (
!plainText(html).includes(
'Peak hours are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese public holidays.',
)
) {
throw new Error('DeepSeek peak schedule changed');
}
const table = [...html.matchAll(/<table\b[^>]*>[\s\S]*?<\/table>/gi)]
.map(([match]) => match)
.find(
(value) =>
value.includes('deepseek-flash') && value.includes('PRICING'),
);
if (!table) throw new Error('DeepSeek pricing table missing');
const rows = [...table.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map(
([, row]) =>
[...row.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)].map(([, cell]) =>
plainText(cell),
),
);
if (
rows[0]?.at(-2) !== 'deepseek-flash (1)' ||
rows[0]?.at(-1) !== 'deepseek-v4-pro'
) {
throw new Error('DeepSeek model columns changed');
}
const rates = structuredClone(FALLBACK_RATES);
const seen = new Set<string>();
let category: keyof TokenRates | undefined;
let pricingStarted = false;
for (const cells of rows) {
if (cells.some((cell) => cell.startsWith('PRICING')))
pricingStarted = true;
if (!pricingStarted) continue;
const label = cells.join(' ');
if (label.includes('CACHE HIT')) category = 'cached';
else if (label.includes('CACHE MISS')) category = 'prompt';
else if (label.includes('OUTPUT TOKENS')) category = 'completion';
const periodCell = cells.at(-3);
if (periodCell !== 'PEAK' && periodCell !== 'OFF-PEAK') continue;
if (!category) throw new Error('DeepSeek price category missing');
const period = periodCell === 'PEAK' ? 'peak' : 'offPeak';
const key = `${period}:${category}`;
if (seen.has(key)) throw new Error('Duplicate DeepSeek price');
rates['deepseek-flash'][period][category] = readDollars(cells.at(-2)!);
rates['deepseek-v4-pro'][period][category] = readDollars(cells.at(-1)!);
seen.add(key);
}
if (seen.size !== 6) throw new Error('Incomplete DeepSeek prices');
for (const model of Object.values(rates)) {
for (const category of ['prompt', 'completion', 'cached'] as const) {
if (model.peak[category] < model.offPeak[category]) {
throw new Error('DeepSeek peak price below off-peak price');
}
}
if (model.offPeak.cached > model.offPeak.prompt) {
throw new Error('DeepSeek cache price above input price');
}
}
return rates;
};
export class DeepSeekPricing {
#rates: PriceSchedule = structuredClone(FALLBACK_RATES);
#refreshPromise?: Promise<void>;
#lastAttempt = 0;
#timer?: ReturnType<typeof setInterval>;
constructor(
private readonly fetchPage: typeof fetch = fetch,
public onRefreshError: (error: unknown) => void = (error) =>
console.warn('[deepseek] pricing refresh failed', error),
) {}
costs(modelId: DeepSeekModelId, date = new Date()): TokenRates {
const period = isDeepSeekPeak(date) ? 'peak' : 'offPeak';
const rates = this.#rates[modelId][period];
return {
prompt: rates.prompt * 100,
completion: rates.completion * 100,
cached: rates.cached * 100,
};
}
async refresh(): Promise<void> {
if (this.#refreshPromise) return this.#refreshPromise;
this.#lastAttempt = Date.now();
this.#refreshPromise = (async () => {
const response = await this.fetchPage(PRICING_URL, {
signal: AbortSignal.timeout(5000),
});
if (!response.ok)
throw new Error(`DeepSeek pricing HTTP ${response.status}`);
this.#rates = parseDeepSeekPrices(await response.text());
})();
try {
await this.#refreshPromise;
} finally {
this.#refreshPromise = undefined;
}
}
async refreshIfStale(): Promise<void> {
if (
!this.#refreshPromise &&
Date.now() - this.#lastAttempt < REFRESH_MS
)
return;
try {
await this.refresh();
} catch (error) {
try {
this.onRefreshError(error);
} catch {
console.warn('[deepseek] pricing refresh failed', error);
}
}
}
start(): void {
if (this.#timer) return;
void this.refreshIfStale();
this.#timer = setInterval(() => void this.refreshIfStale(), REFRESH_MS);
this.#timer.unref();
}
}
export const deepSeekPricing = new DeepSeekPricing();
@@ -361,7 +361,7 @@ describe('MetaProvider.complete request shape', () => {
it('derives safety_identifier from the actor and truncates it to 64 chars', async () => {
createMock.mockResolvedValueOnce(OK_COMPLETION);
const userActor: Actor = makeActor({
const userActor = makeActor({
user: { id: 42, uuid: 'u42', username: 'alice' },
app: { id: 7, uid: 'a'.repeat(80) },
});
@@ -369,29 +369,60 @@ describe('MetaProvider.complete request shape', () => {
await complete(makeProvider(), {}, userActor);
const identifier = createMock.mock.calls[0]![0].safety_identifier;
expect(identifier.startsWith('puter-42-a')).toBe(true);
expect(identifier.startsWith('puter-u42-a')).toBe(true);
expect(identifier.length).toBe(64);
});
it('prefers an explicit custom.safety_identifier over the actor-derived one', async () => {
it('attributes the app through effectiveApp for access-token actors', async () => {
createMock.mockResolvedValueOnce(OK_COMPLETION);
const userActor: Actor = { user: { id: 42, uuid: 'u42' } };
const tokenActor = makeActor({
user: { id: 42, uuid: 'u42', username: 'alice' },
accessToken: {
uid: 'tok-1',
issuer: makeActor({
user: { id: 42, uuid: 'u42', username: 'alice' },
app: { id: 7, uid: 'app-abc' },
}),
},
});
await complete(makeProvider(), {}, tokenActor);
expect(createMock.mock.calls[0]![0].safety_identifier).toBe(
'puter-u42-app-abc',
);
});
it('ignores a caller-supplied custom.safety_identifier', async () => {
createMock.mockResolvedValueOnce(OK_COMPLETION);
const userActor = makeActor({ user: { id: 42, uuid: 'u42' } });
await complete(
makeProvider(),
{ custom: { safety_identifier: 'caller-supplied' } },
userActor,
);
expect(createMock.mock.calls[0]![0].safety_identifier).toBe(
'caller-supplied',
'puter-u42',
);
});
it('omits safety_identifier for the system actor (no user.id)', async () => {
it('omits safety_identifier for the system actor', async () => {
createMock.mockResolvedValueOnce(OK_COMPLETION);
await complete(makeProvider());
expect('safety_identifier' in createMock.mock.calls[0]![0]).toBe(false);
});
it('defaults prompt_cache_key to the actor identifier when not supplied', async () => {
createMock.mockResolvedValueOnce(OK_COMPLETION);
const userActor = makeActor({
user: { id: 42, uuid: 'u42', username: 'alice' },
});
await complete(makeProvider(), {}, userActor);
expect(createMock.mock.calls[0]![0].prompt_cache_key).toBe('puter-u42');
});
it('only sets stream_options.include_usage when streaming', async () => {
const provider = makeProvider();
createMock.mockResolvedValueOnce(OK_COMPLETION);
@@ -31,12 +31,10 @@ import { buildCostsOverride } from '../../utils/pricing.js';
import { processPuterPathUploads } from '../openai/fileUpload.js';
import { META_MODELS, MUSE_SPARK_DEFAULT_MODEL } from './models.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
const DEFAULT_API_BASE_URL = 'https://api.meta.ai/v1';
// `safety_identifier` is capped at 64 characters by the Model API.
const SAFETY_IDENTIFIER_MAX_LENGTH = 64;
type MetaConfig = {
apiBaseUrl?: string;
apiKey: string;
@@ -50,7 +48,6 @@ type MetaCustomParams = {
frequency_penalty?: number;
presence_penalty?: number;
response_format?: unknown;
safety_identifier?: string;
seed?: number;
};
@@ -154,8 +151,7 @@ export class MetaProvider implements IChatProvider {
// Reasoning is always on for Muse Spark — `reasoning_effort: 'none'`
// is a 400 — so a request to switch it off is dropped, not forwarded.
const requestedEffort = (reasoning_effort ?? reasoning?.effort) as
| string
| undefined;
string | undefined;
const effort =
requestedEffort && requestedEffort !== 'none'
? requestedEffort
@@ -168,14 +164,10 @@ export class MetaProvider implements IChatProvider {
? 'in_memory'
: prompt_cache_retention;
const safetyIdentifier =
customParams.safety_identifier ??
(actor?.user?.id
? `puter-${actor.user.id}${actor.effectiveApp?.uid ? `-${actor.effectiveApp?.uid}` : ''}`.slice(
0,
SAFETY_IDENTIFIER_MAX_LENGTH,
)
: undefined);
// The identifier is Puter's abuse attribution, so `custom` can't
// override it. Cache key defaults to it; see upstreamUserIdentifier.
const userIdentifier = upstreamUserIdentifier(actor);
const cacheKey = prompt_cache_key ?? userIdentifier;
const completionParams = {
messages,
@@ -190,13 +182,11 @@ export class MetaProvider implements IChatProvider {
...(temperature !== undefined ? { temperature } : {}),
...(top_p !== undefined ? { top_p } : {}),
...(effort ? { reasoning_effort: effort } : {}),
...(prompt_cache_key !== undefined ? { prompt_cache_key } : {}),
...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}),
...(cacheRetention !== undefined
? { prompt_cache_retention: cacheRetention }
: {}),
...(safetyIdentifier
? { safety_identifier: safetyIdentifier }
: {}),
...(userIdentifier ? { safety_identifier: userIdentifier } : {}),
...(customParams.response_format
? { response_format: customParams.response_format }
: {}),
@@ -53,35 +53,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
completion_tokens: 750,
},
},
{
puterId: 'mistralai:mistralai/mistral-large-2512',
id: 'mistral-large-2512',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: true,
tool_call: true,
knowledge: '2025-12',
release_date: '2025-12-02',
name: 'Mistral Large 3',
aliases: [
'mistral-large-latest',
'mistral-large',
'mistralai/mistral-large-latest',
'mistralai/mistral-large-2512',
],
context: 262_144,
max_tokens: 262_144,
description:
'State-of-the-art, open-weight, general-purpose multimodal model.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 50,
completion_tokens: 150,
},
},
{
puterId: 'mistralai:mistralai/mistral-small-2603',
id: 'mistral-small-2603',
@@ -111,29 +82,6 @@ export const MISTRAL_MODELS: IChatModel[] = [
completion_tokens: 60,
},
},
{
puterId: 'mistralai:mistralai/zai-glm-5-2',
id: 'zai-glm-5-2',
modalities: { input: ['text'], output: ['text'] },
open_weights: true,
tool_call: true,
release_date: '2026-08-06',
name: 'Z.ai GLM 5.2',
aliases: ['glm-5-2'],
context: 1_000_000,
max_tokens: 128_000,
description:
'Third-party open-source model from Z.ai, hosted by Mistral for long-context coding and agentic workflows.',
provider: 'mistral',
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 140,
completion_tokens: 440,
},
},
{
puterId: 'mistralai:mistralai/codestral-2508',
id: 'codestral-2508',
@@ -42,11 +42,16 @@ import {
type MockInstance,
} from 'vitest';
import { SYSTEM_ACTOR } from '../../../../core/actor.js';
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import {
expectedIdentifierFields,
makeActorMatrix,
sentIdentifierFields,
withTestActor,
} from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { OPEN_AI_MODELS } from './models.js';
import { OpenAiChatProvider } from './OpenAiChatCompletionsProvider.js';
@@ -166,6 +171,8 @@ describe('OpenAiChatProvider model catalog', () => {
// gpt-5-nano is a Chat-Completions model, must be present.
expect(ids).toContain('gpt-5-nano-2025-08-07');
expect(ids).toContain('gpt-6-astra');
expect(ids).toContain('gpt-6-sol');
expect(ids).toContain('gpt-6-luna');
});
it('list() flattens canonical ids and aliases', () => {
@@ -270,26 +277,71 @@ describe('OpenAiChatProvider.complete request shape', () => {
expect(args.temperature).toBe(0.4);
});
it('resolves the namespaced GPT-6 Astra alias', async () => {
it('sends the actor uuid and effective app uid as user/safety_identifier', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValue(baseCompletion);
for (const actor of makeActorMatrix()) {
await withTestActor(
() =>
provider.complete({
model: 'gpt-5-nano',
messages: [{ role: 'user', content: 'hello' }],
}),
actor,
);
}
const fields = ['user', 'safety_identifier', 'prompt_cache_key'];
expect(sentIdentifierFields(createMock.mock.calls, fields)).toEqual(
expectedIdentifierFields(fields),
);
});
it('forwards a caller-supplied prompt_cache_key instead of the derived identifier', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'openai/gpt-6-astra',
messages: [{ role: 'user', content: 'hello' }],
}),
await withTestActor(
() =>
provider.complete({
model: 'gpt-5-nano',
messages: [{ role: 'user', content: 'hello' }],
prompt_cache_key: 'caller-key',
}),
makeActor({ user: { id: 42, uuid: 'u42', username: 'alice' } }),
);
expect(createMock.mock.calls[0]![0].model).toBe('gpt-6-astra');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'openai:gpt-6-astra',
expect.any(Object),
);
const [args] = createMock.mock.calls[0]!;
expect(args.prompt_cache_key).toBe('caller-key');
expect(args.safety_identifier).toBe('puter-u42');
});
it.each(['gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna'])(
'resolves the namespaced %s alias',
async (model) => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: `openai/${model}`,
messages: [{ role: 'user', content: 'hello' }],
reasoning_effort: 'low',
}),
);
expect(createMock.mock.calls[0]![0].model).toBe(model);
expect(createMock.mock.calls[0]![0].reasoning_effort).toBe('low');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
`openai:${model}`,
expect.any(Object),
);
},
);
it('forwards temperature 0 and max_tokens 0 instead of dropping them', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
@@ -469,6 +521,48 @@ describe('OpenAiChatProvider.complete non-stream output', () => {
).toBeGreaterThan(0);
});
it('splits cache writes out of prompt_tokens and bills them at 1.25x input', async () => {
const luna = OPEN_AI_MODELS.find((m) => m.id === 'gpt-6-luna')!;
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: { content: 'hi', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 5000,
completion_tokens: 12,
prompt_tokens_details: {
cached_tokens: 1000,
cache_write_tokens: 3000,
},
},
});
await withTestActor(() =>
provider.complete({
model: 'gpt-6-luna',
messages: [{ role: 'user', content: 'hi' }],
}),
);
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
expect(usage).toEqual({
prompt_tokens: 1000,
completion_tokens: 12,
cached_tokens: 1000,
cache_write_tokens: 3000,
});
expect(overrides).toEqual({
prompt_tokens: 1000 * Number(luna.costs.prompt_tokens),
completion_tokens: 12 * Number(luna.costs.completion_tokens),
cached_tokens: 1000 * Number(luna.costs.cached_tokens),
cache_write_tokens: 3000 * Number(luna.costs.cache_write_tokens),
});
});
it('zeroes cached_tokens when prompt_tokens_details is missing', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
@@ -109,6 +109,7 @@ export class OpenAiChatProvider implements IChatProvider {
reasoning_effort,
temperature,
text,
prompt_cache_key,
} = params;
let { messages, model } = params;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -159,6 +160,8 @@ export class OpenAiChatProvider implements IChatProvider {
// })
const userIdentifier = upstreamUserIdentifier(actor);
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
const cacheKey = prompt_cache_key ?? userIdentifier;
// Resolve any `puter_path` content parts into inline base64 data URLs.
// Chat Completions doesn't support file uploads, so this is the only
@@ -183,6 +186,7 @@ export class OpenAiChatProvider implements IChatProvider {
const completionParams: ChatCompletionCreateParams = {
user: userIdentifier,
safety_identifier: userIdentifier,
...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}),
messages: messages,
model: modelUsed.id,
...(tools ? { tools } : {}),
@@ -213,13 +217,26 @@ export class OpenAiChatProvider implements IChatProvider {
return OpenAiUtil.handle_completion_output({
usage_calculator: ({ usage }) => {
const cachedTokens =
usage.prompt_tokens_details?.cached_tokens ?? 0;
// GPT-5.6 and later bill cache writes at 1.25x input. They're
// reported inside `prompt_tokens`, like cached reads.
// The SDK doesn't type `cache_write_tokens` yet.
const cacheWriteTokens =
(
usage.prompt_tokens_details as
{ cache_write_tokens?: number } | undefined
)?.cache_write_tokens ?? 0;
const trackedUsage = {
prompt_tokens:
(usage.prompt_tokens ?? 0) -
(usage.prompt_tokens_details?.cached_tokens ?? 0),
cachedTokens -
cacheWriteTokens,
completion_tokens: usage.completion_tokens ?? 0,
cached_tokens:
usage.prompt_tokens_details?.cached_tokens ?? 0,
cached_tokens: cachedTokens,
...(cacheWriteTokens
? { cache_write_tokens: cacheWriteTokens }
: {}),
};
const costsOverrideFromModel = buildCostsOverride(
@@ -48,7 +48,12 @@ 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';
import { withTestActor } from '../../../integrationTestUtil.js';
import {
expectedIdentifierFields,
makeActorMatrix,
sentIdentifierFields,
withTestActor,
} from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { OPEN_AI_MODELS } from './models.js';
import { OpenAiResponsesChatProvider } from './OpenAiChatResponsesProvider.js';
@@ -188,26 +193,58 @@ describe('OpenAiResponsesChatProvider model catalog', () => {
it('models() exposes Responses-only and dual-API entries', () => {
const { provider } = makeProvider();
const ids = provider.models().map((m: { id: string }) => m.id);
expect(ids).toContain('o3-pro');
expect(ids).toContain('gpt-5.6-sol');
expect(ids).toContain('gpt-6-astra');
expect(ids).not.toContain('gpt-5-nano-2025-08-07');
});
it.each([
['gpt-6-sol', '2026-04-20', 200, 20, 1000],
['gpt-6-luna', '2026-05-18', 10, 1, 50],
])(
'exposes %s with current pricing and limits',
(id, knowledge, input, cached, output) => {
const { provider } = makeProvider();
expect(
provider.models().find((model) => model.id === id),
).toMatchObject({
puterId: `openai:openai/${id}`,
aliases: [`openai/${id}`],
knowledge,
release_date: '2026-09-22',
modalities: { input: ['text', 'image'], output: ['text'] },
costs_currency: 'usd-cents',
costs: {
tokens: 1_000_000,
prompt_tokens: input,
cached_tokens: cached,
completion_tokens: output,
},
context: 1_050_000,
max_tokens: 128_000,
responses_api: true,
});
expect(provider.list()).toEqual(
expect.arrayContaining([id, `openai/${id}`]),
);
},
);
it('models({ no_restrictions: true }) returns the entire catalog (used by complete())', () => {
const { provider } = makeProvider();
const ids = provider
.models({ no_restrictions: true })
.map((m: { id: string }) => m.id);
// Both responses-only AND chat-only ids should be present.
expect(ids).toContain('o3-pro');
expect(ids).toContain('gpt-5.6-sol');
expect(ids).toContain('gpt-5-nano-2025-08-07');
});
it('list() flattens canonical ids and aliases for Responses models', () => {
const { provider } = makeProvider();
const ids = provider.list();
expect(ids).toContain('o3-pro');
expect(ids).toContain('openai/o3-pro');
expect(ids).toContain('gpt-5.6-sol');
expect(ids).toContain('openai/gpt-5.6-sol');
expect(ids).toContain('gpt-6-astra');
expect(ids).toContain('openai/gpt-6-astra');
});
@@ -221,7 +258,7 @@ describe('OpenAiResponsesChatProvider.complete argument validation', () => {
await expect(
withTestActor(() =>
provider.complete({
model: 'o3-pro',
model: 'gpt-5.6-sol',
messages: 'hello' as unknown as never,
}),
),
@@ -246,7 +283,7 @@ describe('OpenAiResponsesChatProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'o3-pro',
model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'hello' }],
max_tokens: 256,
temperature: 0.4,
@@ -254,20 +291,41 @@ describe('OpenAiResponsesChatProvider.complete request shape', () => {
);
const [args] = responsesCreateMock.mock.calls[0]!;
expect(args.model).toBe('o3-pro');
expect(args.model).toBe('gpt-5.6-sol');
// Responses API takes `input`, not `messages`.
expect(args.input).toEqual([{ role: 'user', content: 'hello' }]);
expect(args.max_output_tokens).toBe(256);
expect(args.temperature).toBe(0.4);
});
it('sends the actor uuid and effective app uid as user/safety_identifier', async () => {
const { provider } = makeProvider();
responsesCreateMock.mockResolvedValue(baseResponse);
for (const actor of makeActorMatrix()) {
await withTestActor(
() =>
provider.complete({
model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'hello' }],
}),
actor,
);
}
const fields = ['user', 'safety_identifier', 'prompt_cache_key'];
expect(
sentIdentifierFields(responsesCreateMock.mock.calls, fields),
).toEqual(expectedIdentifierFields(fields));
});
it('unravels function tools into the flat Responses API shape', async () => {
const { provider } = makeProvider();
responsesCreateMock.mockResolvedValueOnce(baseResponse);
await withTestActor(() =>
provider.complete({
model: 'o3-pro',
model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'hi' }],
tools: [
{
@@ -305,7 +363,7 @@ describe('OpenAiResponsesChatProvider.complete request shape', () => {
await withTestActor(() =>
provider.complete({
model: 'o3-pro',
model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'hi' }],
tool_choice: 'auto',
parallel_tool_calls: false,
@@ -346,11 +404,11 @@ describe('OpenAiResponsesChatProvider.complete request shape', () => {
expect('reasoning_effort' in gpt5Args).toBe(false);
expect('verbosity' in gpt5Args).toBe(false);
// o3-pro: not gpt-5 → forwards both.
// o3: not gpt-5 → forwards both.
responsesCreateMock.mockResolvedValueOnce(baseResponse);
await withTestActor(() =>
provider.complete({
model: 'o3-pro',
model: 'o3',
messages: [{ role: 'user', content: 'hi' }],
reasoning_effort: 'medium',
verbosity: 'low',
@@ -360,6 +418,58 @@ describe('OpenAiResponsesChatProvider.complete request shape', () => {
expect(o3Args.reasoning_effort).toBe('medium');
expect(o3Args.verbosity).toBe('low');
});
it.each(['gpt-6-astra', 'gpt-6-sol', 'gpt-6-luna'])(
'maps flat controls to Responses options for %s aliases',
async (model) => {
const { provider } = makeProvider();
responsesCreateMock.mockResolvedValueOnce(baseResponse);
await withTestActor(() =>
provider.complete({
model: `openai/${model}`,
messages: [{ role: 'user', content: 'hi' }],
reasoning_effort: 'high',
verbosity: 'low',
}),
);
const [args] = responsesCreateMock.mock.calls[0]!;
expect(args.model).toBe(model);
expect(args.reasoning).toEqual({ effort: 'high' });
expect(args.text).toEqual({ verbosity: 'low' });
expect(args).not.toHaveProperty('reasoning_effort');
expect(args).not.toHaveProperty('verbosity');
expect(recordSpy.mock.calls[0]![2]).toBe(`openai:${model}`);
},
);
it('preserves nested GPT-6 controls and gives flat options precedence', async () => {
const { provider } = makeProvider();
const reasoning = { effort: 'medium', summary: 'auto' };
const text = { verbosity: 'high', format: { type: 'text' } };
for (const flat of [false, true]) {
responsesCreateMock.mockResolvedValueOnce(baseResponse);
await withTestActor(() =>
provider.complete({
model: 'gpt-6-sol',
messages: [{ role: 'user', content: 'hi' }],
reasoning,
text,
...(flat
? { reasoning_effort: 'low', verbosity: 'low' }
: {}),
} as never),
);
const [args] = responsesCreateMock.mock.lastCall!;
expect(args.reasoning).toEqual({
...reasoning,
effort: flat ? 'low' : 'medium',
});
expect(args.text).toEqual({
...text,
verbosity: flat ? 'low' : 'high',
});
}
});
});
// ── Model resolution ────────────────────────────────────────────────
@@ -399,17 +509,17 @@ describe('OpenAiResponsesChatProvider model resolution', () => {
await withTestActor(() =>
provider.complete({
// openai/o3-pro is an alias of o3-pro.
model: 'openai/o3-pro',
// openai/gpt-5.6-sol is an alias of gpt-5.6-sol.
model: 'openai/gpt-5.6-sol',
messages: [{ role: 'user', content: 'hi' }],
}),
);
expect(responsesCreateMock.mock.calls[0]![0].model).toBe('o3-pro');
expect(responsesCreateMock.mock.calls[0]![0].model).toBe('gpt-5.6-sol');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
'openai:o3-pro',
'openai:gpt-5.6-sol',
expect.any(Object),
);
});
@@ -455,7 +565,7 @@ describe('OpenAiResponsesChatProvider.complete non-stream output', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'o3-pro',
model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'hi' }],
}),
);
@@ -474,24 +584,107 @@ describe('OpenAiResponsesChatProvider.complete non-stream output', () => {
cached_tokens: 10,
});
// o3-pro costs: prompt=2000, completion=8000, cached=50.
const o3pro = OPEN_AI_MODELS.find((m) => m.id === 'o3-pro')!;
// gpt-5.6-sol costs: prompt=400, completion=2000, cached=40.
const sol = OPEN_AI_MODELS.find((m) => m.id === 'gpt-5.6-sol')!;
expect(recordSpy).toHaveBeenCalledTimes(1);
const [usage, actor, prefix, overrides] = recordSpy.mock.calls[0]!;
expect(actor).toBe(SYSTEM_ACTOR);
expect(prefix).toBe('openai:o3-pro');
expect(prefix).toBe('openai:gpt-5.6-sol');
expect(usage).toEqual({
prompt_tokens: 90,
completion_tokens: 50,
cached_tokens: 10,
});
expect(overrides).toEqual({
prompt_tokens: 90 * Number(o3pro.costs.prompt_tokens),
completion_tokens: 50 * Number(o3pro.costs.completion_tokens),
cached_tokens: 10 * Number(o3pro.costs.cached_tokens ?? 0),
prompt_tokens: 90 * Number(sol.costs.prompt_tokens),
completion_tokens: 50 * Number(sol.costs.completion_tokens),
cached_tokens: 10 * Number(sol.costs.cached_tokens ?? 0),
});
});
it('splits cache writes out of input and bills them at 1.25x input', async () => {
const luna = OPEN_AI_MODELS.find((m) => m.id === 'gpt-6-luna')!;
const { provider } = makeProvider();
responsesCreateMock.mockResolvedValueOnce({
output: [{ role: 'assistant' }],
output_text: 'hi',
usage: {
input_tokens: 5000,
output_tokens: 20,
input_tokens_details: {
cached_tokens: 1000,
cache_write_tokens: 3000,
},
},
});
await withTestActor(() =>
provider.complete({
model: 'gpt-6-luna',
messages: [{ role: 'user', content: 'hi' }],
}),
);
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
expect(usage).toEqual({
prompt_tokens: 1000,
completion_tokens: 20,
cached_tokens: 1000,
cache_write_tokens: 3000,
});
expect(luna.costs.cache_write_tokens).toBe(
Number(luna.costs.prompt_tokens) * 1.25,
);
expect(overrides).toEqual({
prompt_tokens: 1000 * Number(luna.costs.prompt_tokens),
completion_tokens: 20 * Number(luna.costs.completion_tokens),
cached_tokens: 1000 * Number(luna.costs.cached_tokens),
cache_write_tokens: 3000 * Number(luna.costs.cache_write_tokens),
});
});
it('bills the whole request at long-context rates past 272K input tokens', async () => {
const sol = OPEN_AI_MODELS.find((m) => m.id === 'gpt-6-sol')!;
const { provider } = makeProvider();
responsesCreateMock.mockResolvedValueOnce({
output: [{ role: 'assistant' }],
output_text: 'hi',
usage: {
input_tokens: 300_000,
output_tokens: 10_000,
input_tokens_details: {
cached_tokens: 50_000,
cache_write_tokens: 20_000,
},
},
});
await withTestActor(() =>
provider.complete({
model: 'gpt-6-sol',
messages: [{ role: 'user', content: 'hi' }],
}),
);
const [, , , overrides] = recordSpy.mock.calls[0]!;
// $0.92 + $0.02 + $0.10 + $0.15 = $1.19, against $0.62 at the
// standard rates.
expect(overrides).toEqual({
prompt_tokens: 230_000 * Number(sol.costs.prompt_tokens) * 2,
cached_tokens: 50_000 * Number(sol.costs.cached_tokens) * 2,
cache_write_tokens:
20_000 * Number(sol.costs.cache_write_tokens) * 2,
completion_tokens:
10_000 * Number(sol.costs.completion_tokens) * 1.5,
});
const totalCents =
Object.values(overrides as Record<string, number>).reduce(
(a, b) => a + b,
0,
) / 1_000_000;
expect(totalCents).toBeCloseTo(119);
});
it('bills cached tokens at the input rate when the model prices no cache read', async () => {
// gpt-5.4-pro is responses-API-only and its catalogue entry has no
// cached_tokens rate. Cached tokens are subtracted out of the input
@@ -549,7 +742,7 @@ describe('OpenAiResponsesChatProvider.complete non-stream output', () => {
const result = (await withTestActor(() =>
provider.complete({
model: 'o3-pro',
model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'do a tool call' }],
}),
)) as { message: { tool_calls?: unknown[] } };
@@ -575,7 +768,7 @@ describe('OpenAiResponsesChatProvider.complete non-stream output', () => {
await expect(
withTestActor(() =>
provider.complete({
model: 'o3-pro',
model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'silence' }],
}),
),
@@ -607,7 +800,7 @@ describe('OpenAiResponsesChatProvider.complete streaming', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'o3-pro',
model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'say hi' }],
stream: true,
}),
@@ -633,15 +826,15 @@ describe('OpenAiResponsesChatProvider.complete streaming', () => {
cached_tokens: 1,
});
// o3-pro costs: prompt=2000, completion=8000, cached=50.
const o3pro = OPEN_AI_MODELS.find((m) => m.id === 'o3-pro')!;
// gpt-5.6-sol costs: prompt=400, completion=2000, cached=40.
const sol = OPEN_AI_MODELS.find((m) => m.id === 'gpt-5.6-sol')!;
expect(recordSpy).toHaveBeenCalledTimes(1);
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
expect(prefix).toBe('openai:o3-pro');
expect(prefix).toBe('openai:gpt-5.6-sol');
expect(overrides).toEqual({
prompt_tokens: 3 * Number(o3pro.costs.prompt_tokens),
completion_tokens: 2 * Number(o3pro.costs.completion_tokens),
cached_tokens: 1 * Number(o3pro.costs.cached_tokens ?? 0),
prompt_tokens: 3 * Number(sol.costs.prompt_tokens),
completion_tokens: 2 * Number(sol.costs.completion_tokens),
cached_tokens: 1 * Number(sol.costs.cached_tokens ?? 0),
});
});
@@ -670,7 +863,7 @@ describe('OpenAiResponsesChatProvider.complete streaming', () => {
const result = await withTestActor(() =>
provider.complete({
model: 'o3-pro',
model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'tool call' }],
stream: true,
}),
@@ -735,7 +928,7 @@ describe('OpenAiResponsesChatProvider.complete error mapping', () => {
await expect(
withTestActor(() =>
provider.complete({
model: 'o3-pro',
model: 'gpt-5.6-sol',
messages: [{ role: 'user', content: 'boom' }],
}),
),
@@ -142,6 +142,8 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
// })
const userIdentifier = upstreamUserIdentifier(actor);
// Cache key defaults to the actor identifier; see upstreamUserIdentifier.
const cacheKey = prompt_cache_key ?? userIdentifier;
// Resolve any `puter_path` content parts into inline base64 data URLs
// before the Responses API sees them.
@@ -173,8 +175,10 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
const requestedReasoningEffort = reasoning_effort ?? reasoning?.effort;
const requestedVerbosity = verbosity ?? text?.verbosity;
const isGpt6Model = modelUsed.id.startsWith('gpt-6-');
const supportsReasoningControls =
typeof model === 'string' && model.startsWith('gpt-5');
isGpt6Model ||
(typeof model === 'string' && model.startsWith('gpt-5'));
// Translate the neutral compaction opt-in (or pass a raw
// `context_management` payload through) to OpenAI's Responses shape.
@@ -204,7 +208,7 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
...(instructions !== undefined ? { instructions } : {}),
...(metadata !== undefined ? { metadata } : {}),
...(prompt !== undefined ? { prompt } : {}),
...(prompt_cache_key !== undefined ? { prompt_cache_key } : {}),
...(cacheKey !== undefined ? { prompt_cache_key: cacheKey } : {}),
...(prompt_cache_retention !== undefined
? { prompt_cache_retention }
: {}),
@@ -230,6 +234,17 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
: {}),
}),
...(supportsReasoningControls && reasoning ? { reasoning } : {}),
...(isGpt6Model && requestedReasoningEffort !== undefined
? {
reasoning: {
...reasoning,
effort: requestedReasoningEffort,
},
}
: {}),
...(isGpt6Model && requestedVerbosity !== undefined
? { text: { ...text, verbosity: requestedVerbosity } }
: {}),
} as unknown as ResponseCreateParams;
// console.log("completion params: ", completionParams)
@@ -238,14 +253,23 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
// console.log("Completion: ", completion)
return OpenAiUtil.handle_completion_output_responses_api({
usage_calculator: ({ usage }) => {
const cachedTokens =
(usage as any).input_tokens_details?.cached_tokens ?? 0;
// GPT-5.6 and later bill cache writes at 1.25x input. They're
// reported inside `input_tokens`, like cached reads.
const cacheWriteTokens =
(usage as any).input_tokens_details?.cache_write_tokens ??
0;
const trackedUsage = {
prompt_tokens:
((usage as any).input_tokens ?? 0) -
((usage as any).input_tokens_details?.cached_tokens ??
0),
cachedTokens -
cacheWriteTokens,
completion_tokens: (usage as any).output_tokens ?? 0,
cached_tokens:
(usage as any).input_tokens_details?.cached_tokens ?? 0,
cached_tokens: cachedTokens,
...(cacheWriteTokens
? { cache_write_tokens: cacheWriteTokens }
: {}),
};
const costsOverrideFromModel = buildCostsOverride(
@@ -21,8 +21,64 @@
import type { IChatModel } from '../../types.js';
// Prompts over 272K input tokens are billed at 2x input (cached reads and
// cache writes included) and 1.5x output for the full request.
const GPT_LONG_CONTEXT_PRICING = {
threshold: 272_000,
input_multiplier: 2,
output_multiplier: 1.5,
};
// Hardcoded from https://models.dev/api.json
export const OPEN_AI_MODELS: IChatModel[] = [
{
puterId: 'openai:openai/gpt-6-sol',
id: 'gpt-6-sol',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2026-04-20',
release_date: '2026-09-22',
aliases: ['openai/gpt-6-sol'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 200,
cached_tokens: 20,
cache_write_tokens: 200 * 1.25,
completion_tokens: 1000,
},
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
context: 1_050_000,
max_tokens: 128_000,
responses_api: true,
},
{
puterId: 'openai:openai/gpt-6-luna',
id: 'gpt-6-luna',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2026-05-18',
release_date: '2026-09-22',
aliases: ['openai/gpt-6-luna'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 10,
cached_tokens: 1,
cache_write_tokens: 10 * 1.25,
completion_tokens: 50,
},
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
context: 1_050_000,
max_tokens: 128_000,
responses_api: true,
},
{
puterId: 'openai:openai/gpt-6-astra',
id: 'gpt-6-astra',
@@ -39,8 +95,10 @@ export const OPEN_AI_MODELS: IChatModel[] = [
tokens: 1_000_000,
prompt_tokens: 1000,
cached_tokens: 100,
cache_write_tokens: 1000 * 1.25,
completion_tokens: 5000,
},
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
context: 1_050_000,
max_tokens: 128_000,
responses_api: true,
@@ -56,12 +114,15 @@ export const OPEN_AI_MODELS: IChatModel[] = [
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
// OpenAI's promotional pricing, guaranteed only through 2026-11-21.
costs: {
tokens: 1_000_000,
prompt_tokens: 500,
cached_tokens: 50,
completion_tokens: 3000,
prompt_tokens: 400,
cached_tokens: 40,
cache_write_tokens: 400 * 1.25,
completion_tokens: 2000,
},
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
context: 1_050_000,
max_tokens: 128_000,
responses_api_only: true,
@@ -81,8 +142,10 @@ export const OPEN_AI_MODELS: IChatModel[] = [
tokens: 1_000_000,
prompt_tokens: 200,
cached_tokens: 20,
cache_write_tokens: 200 * 1.25,
completion_tokens: 1200,
},
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
context: 1_050_000,
max_tokens: 128_000,
responses_api_only: true,
@@ -102,8 +165,10 @@ export const OPEN_AI_MODELS: IChatModel[] = [
tokens: 1_000_000,
prompt_tokens: 20,
cached_tokens: 2,
cache_write_tokens: 20 * 1.25,
completion_tokens: 120,
},
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
context: 1_050_000,
max_tokens: 128_000,
responses_api_only: true,
@@ -126,6 +191,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
cached_tokens: 50,
completion_tokens: 3000,
},
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
context: 1_050_000,
max_tokens: 128_000,
},
@@ -169,6 +235,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
cached_tokens: 25,
completion_tokens: 1500,
},
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
context: 1_050_000,
max_tokens: 1_050_000,
},
@@ -189,6 +256,7 @@ export const OPEN_AI_MODELS: IChatModel[] = [
prompt_tokens: 3000,
completion_tokens: 18000,
},
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
context: 1_050_000,
max_tokens: 128_000,
responses_api_only: true,
@@ -488,28 +556,6 @@ export const OPEN_AI_MODELS: IChatModel[] = [
context: 200_000,
max_tokens: 100000,
},
{
puterId: 'openai:openai/o3-pro',
id: 'o3-pro',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2024-05',
release_date: '2025-06-10',
aliases: ['o3-pro-2025-06-10', 'openai/o3-pro'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 2000,
cached_tokens: 50,
completion_tokens: 8000,
},
context: 200_000,
max_tokens: 100000,
responses_api_only: true,
},
{
puterId: 'openai:openai/o3-mini',
id: 'o3-mini',
@@ -20,13 +20,12 @@
/**
* Offline unit tests for ZAIProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs ZAIProvider directly against the live wired
* `MeteringService` so the recording side is exercised end-to-end.
* The OpenAI SDK is mocked at the module boundary — Z.AI is OpenAI-
* compatible so the provider talks to it through the same client —
* so the provider never reaches the network. The companion
* integration test (ZAIProvider.integration.test.ts) exercises the
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
* constructs ZAIProvider directly against the live wired `MeteringService` so
* the recording side is exercised end-to-end. The OpenAI SDK is mocked at the
* module boundary — Z.AI is OpenAI- compatible so the provider talks to it
* through the same client — so the provider never reaches the network. The
* companion integration test (ZAIProvider.integration.test.ts) exercises the
* real Z.AI endpoint.
*/
@@ -43,7 +42,6 @@ import {
type MockInstance,
} from 'vitest';
import type { Actor } from '../../../../core/actor.js';
import { SYSTEM_ACTOR, makeActor } from '../../../../core/actor.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
@@ -326,7 +324,7 @@ describe('ZAIProvider.complete request shape', () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
const userActor: Actor = makeActor({
const userActor = makeActor({
user: { id: 42, uuid: 'u42', username: 'alice' },
app: { id: 7, uid: 'app-uid' },
});
@@ -341,16 +339,42 @@ describe('ZAIProvider.complete request shape', () => {
);
const [args] = createMock.mock.calls[0]!;
expect(args.user_id).toBe('puter-42-app-uid');
expect(args.user_id).toBe('puter-u42-app-uid');
});
it('prefers an explicit custom.user_id over the actor-derived one', async () => {
it('attributes the app through effectiveApp for access-token actors', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
const userActor: Actor = {
user: { id: 42, uuid: 'u42' },
};
const tokenActor = makeActor({
user: { id: 42, uuid: 'u42', username: 'alice' },
accessToken: {
uid: 'tok-1',
issuer: makeActor({
user: { id: 42, uuid: 'u42', username: 'alice' },
app: { id: 7, uid: 'app-uid' },
}),
},
});
await withTestActor(
() =>
provider.complete({
model: 'glm-4.6',
messages: [{ role: 'user', content: 'hi' }],
}),
tokenActor,
);
const [args] = createMock.mock.calls[0]!;
expect(args.user_id).toBe('puter-u42-app-uid');
});
it('ignores a caller-supplied custom.user_id', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
const userActor = makeActor({ user: { id: 42, uuid: 'u42' } });
await withTestActor(
() =>
@@ -363,10 +387,10 @@ describe('ZAIProvider.complete request shape', () => {
);
const [args] = createMock.mock.calls[0]!;
expect(args.user_id).toBe('caller-supplied');
expect(args.user_id).toBe('puter-u42');
});
it('omits user_id entirely for the system actor (no user.id)', async () => {
it('omits user_id entirely for the system actor', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
@@ -378,7 +402,7 @@ describe('ZAIProvider.complete request shape', () => {
);
const [args] = createMock.mock.calls[0]!;
// SYSTEM_ACTOR has no user.id — provider should leave the key off.
// SYSTEM_ACTOR is excluded by isSystemActor() — the provider should leave the key off.
expect('user_id' in args).toBe(false);
});
@@ -25,6 +25,10 @@ import type { IChatProvider, ICompleteArguments } from '../../types.js';
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
import { ZAI_MODELS } from './models.js';
import { modelLookupNames } from '../../utils/modelRouting.js';
import { upstreamUserIdentifier } from '../../../util/upstreamIdentifier.js';
// Z.AI documents `user_id` as 6-128 characters.
const USER_ID_MAX_LENGTH = 128;
type ZAIConfig = {
apiBaseUrl?: string;
@@ -41,7 +45,6 @@ type ZAICustomParams = {
clear_thinking?: boolean;
};
tool_stream?: boolean;
user_id?: string;
};
const asRecord = (value: unknown): Record<string, unknown> =>
@@ -103,14 +106,8 @@ export class ZAIProvider implements IChatProvider {
});
const customParams = asRecord(custom) as ZAICustomParams;
const userId =
customParams.user_id ??
(actor?.user?.id
? `puter-${actor.user.id}${actor.effectiveApp?.uid ? `-${actor.effectiveApp?.uid}` : ''}`.slice(
0,
128,
)
: undefined);
// Puter's abuse attribution; `custom` can't override it.
const userId = upstreamUserIdentifier(actor, USER_ID_MAX_LENGTH);
const completionParams: ChatCompletionCreateParams = {
messages,
+13
View File
@@ -43,6 +43,19 @@ export interface IChatModel<T extends ModelCost = ModelCost> extends Record<
input_cost_key?: keyof T;
output_cost_key?: keyof T;
costs: T;
/** The prompt count includes cached tokens, which have their own rate. */
promptTokensIncludeCached?: boolean;
/**
* A request whose input exceeds `threshold` tokens is billed at raised
* rates for the whole request, not only the tokens past the threshold:
* every input-side rate (uncached, cached, cache writes) is multiplied by
* `input_multiplier` and every output-side rate by `output_multiplier`.
*/
long_context_pricing?: {
threshold: number;
input_multiplier: number;
output_multiplier: number;
};
context?: number;
max_tokens: number;
subscriberOnly?: boolean;
@@ -19,7 +19,12 @@
import { describe, expect, it } from 'vitest';
import type { IChatModel } from '../types.js';
import { buildCostsOverride, isFreeModel, usdPerMToken } from './pricing.js';
import {
buildCostsOverride,
isFreeModel,
longContextMultipliers,
usdPerMToken,
} from './pricing.js';
const model = (costs: Record<string, number>): IChatModel =>
({
@@ -166,3 +171,91 @@ describe('buildCostsOverride', () => {
});
});
});
describe('long-context pricing', () => {
const longContext = (costs: Record<string, number>): IChatModel => ({
...model(costs),
long_context_pricing: {
threshold: 272_000,
input_multiplier: 2,
output_multiplier: 1.5,
},
});
const rates = {
prompt_tokens: 200,
cached_tokens: 20,
cache_write_tokens: 250,
completion_tokens: 1000,
};
it('bills a request at or under the threshold at standard rates', () => {
const overrides = buildCostsOverride(
{
prompt_tokens: 222_000,
cached_tokens: 50_000,
completion_tokens: 10,
},
longContext(rates),
);
expect(overrides).toEqual({
prompt_tokens: 222_000 * 200,
cached_tokens: 50_000 * 20,
completion_tokens: 10 * 1000,
});
});
it('raises every rate for the whole request once input passes the threshold', () => {
// 230K uncached + 50K cached + 20K cache writes = 300K input. No
// single key crosses 272K; their sum does.
const overrides = buildCostsOverride(
{
prompt_tokens: 230_000,
cached_tokens: 50_000,
cache_write_tokens: 20_000,
completion_tokens: 10_000,
},
longContext(rates),
);
expect(overrides).toEqual({
prompt_tokens: 230_000 * 200 * 2,
cached_tokens: 50_000 * 20 * 2,
cache_write_tokens: 20_000 * 250 * 2,
completion_tokens: 10_000 * 1000 * 1.5,
});
});
it('raises the fallback rate of an unpriced key too', () => {
const overrides = buildCostsOverride(
{ prompt_tokens: 300_000, thinking_tokens: 10 },
longContext({ prompt_tokens: 200, completion_tokens: 1000 }),
);
expect(overrides.thinking_tokens).toBe(10 * 1000 * 1.5);
});
it('leaves a model without long-context pricing at standard rates', () => {
const overrides = buildCostsOverride(
{ prompt_tokens: 900_000, completion_tokens: 10 },
model(rates),
);
expect(overrides).toEqual({
prompt_tokens: 900_000 * 200,
completion_tokens: 10 * 1000,
});
});
it('applies the multipliers strictly above the threshold', () => {
const m = longContext(rates);
expect(longContextMultipliers(m, 272_000)).toEqual({
input: 1,
output: 1,
});
expect(longContextMultipliers(m, 272_001)).toEqual({
input: 2,
output: 1.5,
});
});
});
+58 -7
View File
@@ -54,6 +54,56 @@ export const costKeys = (
outputKey: (model.output_cost_key as string | undefined) ?? 'output_tokens',
});
/**
* Whether a usage key is priced at the output rate when the model has no rate
* of its own for it.
*/
export const isOutputCostKey = (key: string, outputKey: string): boolean =>
key === outputKey ||
key === 'output_tokens' ||
key === 'completion_tokens' ||
key === 'thinking_tokens';
/**
* The rate multipliers a request pays given how many input tokens it sent —
* cached reads and cache writes included. `1`/`1` unless the model has
* long-context pricing and the request is past its threshold.
*/
export const longContextMultipliers = (
model: IChatModel,
inputTokens: number,
): { input: number; output: number } => {
const pricing = model.long_context_pricing;
if (!pricing || !(inputTokens > pricing.threshold)) {
return { input: 1, output: 1 };
}
return {
input: pricing.input_multiplier,
output: pricing.output_multiplier,
};
};
/**
* The input tokens a tracked-usage object carries: every key that isn't
* output-side. Providers split one prompt into uncached, cached-read and
* cache-write keys; the long-context threshold is measured on their sum.
*/
export const trackedInputTokens = (
trackedUsage: Record<string, unknown>,
model: IChatModel,
): number => {
const { outputKey } = costKeys(model);
let total = 0;
for (const [key, amount] of Object.entries(trackedUsage)) {
if (key === 'tokens' || key === 'usd_cents') continue;
if (isOutputCostKey(key, outputKey)) continue;
if (typeof amount === 'number' && Number.isFinite(amount)) {
total += amount;
}
}
return total;
};
/**
* Whether a model costs the user nothing to run.
*
@@ -90,11 +140,10 @@ export const buildCostsOverride = (
const inputRate = isRate(costs[inputKey]) ? costs[inputKey] : undefined;
const outputRate = isRate(costs[outputKey]) ? costs[outputKey] : undefined;
const isOutputKey = (key: string) =>
key === outputKey ||
key === 'output_tokens' ||
key === 'completion_tokens' ||
key === 'thinking_tokens';
const multipliers = longContextMultipliers(
model,
trackedInputTokens(trackedUsage, model),
);
const overrides: Record<string, number> = {};
for (const [key, amount] of Object.entries(trackedUsage)) {
@@ -102,11 +151,13 @@ export const buildCostsOverride = (
// not a per-unit rate.
if (key === 'tokens') continue;
const isOutput = isOutputCostKey(key, outputKey);
const rate = isRate(costs[key])
? costs[key]
: ((isOutputKey(key) ? outputRate : inputRate) ?? 0);
: ((isOutput ? outputRate : inputRate) ?? 0);
overrides[key] = amount * rate;
overrides[key] =
amount * rate * (isOutput ? multipliers.output : multipliers.input);
}
return overrides;
@@ -20,12 +20,12 @@
/**
* Offline unit tests for ImageGenerationDriver.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) with API keys for every image provider so the driver
* registers and indexes them all. Then drives `server.drivers.aiImage`
* directly. Provider SDKs are mocked at the module boundary so the
* driver routes are exercised without real network egress. Aligns
* with AGENTS.md: "Prefer test server over mocking deps."
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) with
* API keys for every image provider so the driver registers and indexes them
* all. Then drives `server.drivers.aiImage` directly. Provider SDKs are mocked
* at the module boundary so the driver routes are exercised without real
* network egress. Aligns with AGENTS.md: "Prefer test server over mocking
* deps."
*/
import {
@@ -50,7 +50,8 @@ import { OPEN_AI_IMAGE_GENERATION_MODELS } from './providers/openai/models.js';
import { REPLICATE_IMAGE_GENERATION_MODELS } from './providers/replicate/models.js';
import { TOGETHER_IMAGE_GENERATION_MODELS } from './providers/together/models.js';
import { XAI_IMAGE_GENERATION_MODELS } from './providers/xai/models.js';
import type { ImageGenerationDriver } from './ImageGenerationDriver.js';
import { ImageGenerationDriver } from './ImageGenerationDriver.js';
import { ReplicateImageGenerationProvider } from './providers/replicate/ReplicateImageGenerationProvider.js';
// ── SDK mocks ──────────────────────────────────────────────────────
//
@@ -119,15 +120,15 @@ vi.mock('together-ai', () => {
return { Together, default: Together };
});
const { replicateRunMock } = vi.hoisted(() => ({
replicateRunMock: vi.fn(),
const { createPredictionMock } = vi.hoisted(() => ({
createPredictionMock: vi.fn(),
}));
vi.mock('replicate', () => {
const Replicate = vi.fn().mockImplementation(function (
this: Record<string, unknown>,
) {
this.run = replicateRunMock;
this.predictions = { create: createPredictionMock, cancel: vi.fn() };
});
return { default: Replicate };
});
@@ -173,7 +174,7 @@ beforeEach(() => {
googleAIGenerateContentMock.mockReset();
googleAIGenerateImagesMock.mockReset();
togetherImagesGenerateMock.mockReset();
replicateRunMock.mockReset();
createPredictionMock.mockReset();
secureFetchMock.mockReset();
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
eventEmitSpy = vi.spyOn(server.clients.event, 'emit') as MockInstance<
@@ -199,7 +200,7 @@ describe('ImageGenerationDriver.generate authentication', () => {
it('throws 401 when no actor is on the request context', async () => {
await expect(
driver.generate({
model: 'gpt-image-1-mini',
model: 'gpt-image-2',
prompt: 'hi',
ratio: { w: 1024, h: 1024 },
} as never),
@@ -254,8 +255,8 @@ describe('ImageGenerationDriver model catalog', () => {
const all = await driver.models();
// Every catalog id from at least one provider must be reachable.
const ids = all.map((m) => m.id);
// OpenAI catalog: gpt-image-1-mini should be present (lowercased by buildModelMap).
expect(ids).toContain('gpt-image-1-mini');
// OpenAI catalog: gpt-image-2 should be present.
expect(ids).toContain('gpt-image-2');
// xAI catalog: grok-imagine-image should be present.
expect(ids).toContain('grok-imagine-image');
});
@@ -272,16 +273,16 @@ describe('ImageGenerationDriver model catalog', () => {
costValue: number;
source: string;
}>;
// gpt-image-1-mini has a low:1024x1024 cost line — must surface in reportedCosts.
// gpt-image-2 has a low:1024x1024 cost line — must surface in reportedCosts.
const gptLine = reported.find(
(r) =>
r.usageType ===
'openai-image-generation:gpt-image-1-mini:low:1024x1024',
'openai-image-generation:gpt-image-2:low:1024x1024',
);
expect(gptLine).toBeDefined();
expect(gptLine?.costValue).toBe(
OPEN_AI_IMAGE_GENERATION_MODELS.find(
(m) => m.id === 'gpt-image-1-mini',
(m) => m.id === 'gpt-image-2',
)!.costs['low:1024x1024'],
);
expect(gptLine?.source).toBe('driver:aiImage/openai-image-generation');
@@ -290,15 +291,388 @@ describe('ImageGenerationDriver model catalog', () => {
// ── Provider routing ────────────────────────────────────────────────
describe('image model data policy', () => {
it('omits excluded routes from discovery and reported costs', async () => {
expect((await driver.models()).some((model) => model.excludedForDataPolicy)).toBe(false);
expect((await driver.list()).some((id) => id.startsWith('togetherai:'))).toBe(false);
expect(driver.getReportedCosts().some((cost) =>
String(cost.usageType).startsWith('together-image-generation:'),
)).toBe(false);
});
it.each(TOGETHER_IMAGE_GENERATION_MODELS)(
'blocks the excluded route $id and its aliases before calling the provider',
async (model) => {
for (const id of [model.id, ...(model.aliases ?? [])]) {
await expect(withActor(() => driver.generate({
provider: 'together', model: id, prompt: 'hi', test_mode: true,
}))).rejects.toMatchObject({
statusCode: 400,
message: expect.stringContaining('required third-party data sharing'),
});
}
expect(togetherImagesGenerateMock).not.toHaveBeenCalled();
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
expect(createPredictionMock).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
},
);
it.each(['together', ' TOGETHER-IMAGE-GENERATION '])(
'rejects the excluded default for provider %s', async (provider) => {
await expect(withActor(() => driver.generate({
provider, prompt: 'hi', test_mode: true,
}))).rejects.toThrow('required third-party data sharing');
},
);
it('rejects an excluded default selected through the legacy driver name', async () => {
await expect(withDriverName('together-image-generation', () =>
driver.generate({ prompt: 'hi', test_mode: true }),
)).rejects.toThrow('required third-party data sharing');
});
it('keeps a shared alias available through an allowed route', async () => {
openaiImagesGenerateMock.mockResolvedValueOnce({ data: [{ url: 'https://oai/img.png' }] });
await withActor(() => driver.generate({ model: 'gpt-image-2', prompt: 'hi' }));
expect(openaiImagesGenerateMock).toHaveBeenCalledOnce();
expect(togetherImagesGenerateMock).not.toHaveBeenCalled();
});
it('skips an excluded provider when choosing the deployment default', async () => {
const limitedDriver = new ImageGenerationDriver({
providers: {
'together-image-generation': { apiKey: 'tg-key' },
'cloudflare-image-generation': { apiToken: 'cf-token', accountId: 'acct' },
},
} as never, server.clients, server.stores, server.services);
await limitedDriver.onServerStart();
await withActor(() => limitedDriver.generate({ prompt: 'hi', test_mode: true }));
expect(eventEmitSpy).toHaveBeenCalledWith('ai.log.image', expect.objectContaining({
service_used: 'cloudflare-image-generation',
}), {});
});
it('also blocks routes that train on customer content', async () => {
const fixture = {
...OPEN_AI_IMAGE_GENERATION_MODELS[0],
id: 'training-fixture',
aliases: ['training-fixture-alias'],
excludedForDataPolicy: 'training' as const,
};
OPEN_AI_IMAGE_GENERATION_MODELS.push(fixture);
try {
const isolatedDriver = new ImageGenerationDriver(
{ providers: { 'openai-image-generation': { apiKey: 'oai-key' } } } as never,
server.clients, server.stores, server.services,
);
await isolatedDriver.onServerStart();
await expect(withActor(() => isolatedDriver.generate({
model: 'training-fixture-alias', prompt: 'hi', test_mode: true,
}))).rejects.toThrow('training on customer content');
expect(await isolatedDriver.list()).not.toContain('training-fixture');
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
} finally {
OPEN_AI_IMAGE_GENERATION_MODELS.pop();
}
});
});
describe('ImageGenerationDriver.generate provider routing', () => {
it('routes a known gpt-image-1-mini model id to the OpenAI image provider', async () => {
it('uses the default provider when called through the main driver', async () => {
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://oai/default.png' }],
});
await withDriverName('ai-image', () =>
driver.generate({ prompt: 'a landscape' }),
);
expect(openaiImagesGenerateMock).toHaveBeenCalledWith(
expect.objectContaining({ model: 'gpt-image-2' }),
);
});
it('rejects an excluded model regardless of caller casing', async () => {
await expect(withActor(() =>
driver.generate({
model: 'togetherai:qwen/qwen-image',
prompt: 'a landscape',
}),
)).rejects.toThrow('required third-party data sharing');
expect(togetherImagesGenerateMock).not.toHaveBeenCalled();
});
it.each(['openai', 'gemini', 'cloudflare', 'xai', 'replicate'])(
'resolves the short provider name %s with its default model',
async (provider) => {
await withDriverName('ai-image', () =>
driver.generate({
provider,
prompt: 'a landscape',
test_mode: true,
}),
);
expect(eventEmitSpy).toHaveBeenCalledWith(
'ai.log.image',
expect.objectContaining({
service_used: `${provider}-image-generation`,
}),
{},
);
},
);
it('keeps legacy driver hints as preferences for a known model', async () => {
openaiImagesGenerateMock.mockResolvedValue({
data: [{ url: 'https://xai/image.png' }],
});
await withDriverName('openai-image-generation', () =>
driver.generate({ model: 'grok-imagine-image', prompt: 'hi' }),
);
expect(openaiImagesGenerateMock.mock.calls[0][0].model).toBe(
'grok-imagine-image',
);
});
it.each(['AI-Image', ' ai-image '])(
'normalizes the generic provider hint %s',
async (provider) => {
await withActor(() =>
driver.generate({ provider, prompt: 'hi', test_mode: true }),
);
expect(eventEmitSpy).toHaveBeenCalledWith(
'ai.log.image',
expect.objectContaining({
service_used: 'openai-image-generation',
}),
{},
);
},
);
it('falls back to a known model when the provider preference does not offer it', async () => {
await withActor(() =>
driver.generate({
provider: 'gemini',
model: 'gpt-image-2',
prompt: 'hi',
test_mode: true,
}),
);
expect(eventEmitSpy).toHaveBeenCalledWith(
'ai.log.image',
expect.objectContaining({
service_used: 'openai-image-generation',
}),
{},
);
});
it.each([
'black-forest-labs/FLUX.1-schnell',
'FLUX.1-schnell',
'togetherai:black-forest-labs/FLUX.1-schnell',
])(
'does not redirect the retired Together alias %s to another provider',
async (model) => {
await expect(
withActor(() =>
driver.generate({ model, prompt: 'hi', test_mode: true }),
),
).rejects.toThrow(
'no longer available through together-image-generation',
);
expect(fetchSpy).not.toHaveBeenCalled();
},
);
it('does not let a provider hint resurrect a retired alias', async () => {
await expect(
withActor(() =>
driver.generate({
provider: 'cloudflare',
model: 'black-forest-labs/FLUX.1-schnell',
prompt: 'hi',
test_mode: true,
}),
),
).rejects.toThrow('no longer available through together-image-generation');
expect(eventEmitSpy).not.toHaveBeenCalledWith(
'ai.log.image',
expect.anything(),
expect.anything(),
);
});
it('does not redirect unavailable Replicate Phoenix callers to another provider', async () => {
await expect(withActor(() => driver.generate({
model: 'phoenix-1.0', provider: 'cloudflare', prompt: 'hi', test_mode: true,
}))).rejects.toThrow('no longer available through replicate-image-generation');
expect(fetchSpy).not.toHaveBeenCalled();
});
it.each(['gpt-image-1', 'gpt-image-1-mini', 'GPT-Image-1.5'])(
'keeps the deprecated OpenAI id %s routable until its shutdown date',
async (model) => {
openaiImagesGenerateMock.mockResolvedValueOnce({ data: [{ url: 'https://oai/img.png' }] });
await withActor(() => driver.generate({ model, prompt: 'hi' }));
expect(openaiImagesGenerateMock).toHaveBeenCalledOnce();
expect(openaiImagesGenerateMock.mock.calls[0]![0].model).toBe(model.toLowerCase());
},
);
it('hides delisted models from discovery but still reports their costs', async () => {
const listed = await driver.list();
const ids = (await driver.models()).map((m) => m.id);
for (const id of ['gpt-image-1', 'gpt-image-1-mini', 'gpt-image-1.5']) {
expect(ids).not.toContain(id);
expect(listed).not.toContain(`openai:openai/${id}`);
}
expect(driver.getReportedCosts().some((cost) =>
String(cost.usageType).startsWith('openai-image-generation:gpt-image-1-mini:'),
)).toBe(true);
});
it('reports an id Puter never offered as not found', async () => {
await expect(withActor(() => driver.generate({
model: 'chatgpt-image-latest', prompt: 'hi', test_mode: true,
}))).rejects.toThrow('Model not found: chatgpt-image-latest');
});
it.each(['openai/gpt-image-2', 'openai/gpt-image-1.5'])(
'routes the vendor-prefixed id %s to OpenAI unless Replicate is asked for',
async (model) => {
eventEmitSpy.mockClear();
await withActor(() => driver.generate({ model, prompt: 'hi', test_mode: true }));
expect(eventEmitSpy).toHaveBeenCalledWith('ai.log.image', expect.objectContaining({
service_used: 'openai-image-generation',
}), {});
for (const pinned of [{ model, provider: 'replicate' }, { model: `replicate:${model}` }]) {
eventEmitSpy.mockClear();
await withActor(() => driver.generate({ ...pinned, prompt: 'hi', test_mode: true }));
expect(eventEmitSpy, JSON.stringify(pinned)).toHaveBeenCalledWith('ai.log.image', expect.objectContaining({
service_used: 'replicate-image-generation',
}), {});
}
},
);
it('explains why an unavailable Replicate model is refused', async () => {
await expect(withActor(() => driver.generate({
model: 'quiverai/arrow-1.1', prompt: 'hi', test_mode: true,
}))).rejects.toThrow('Replicate generation fails even with the minimal input schema.');
});
it('names a retired provider default instead of "undefined"', async () => {
const schnell = REPLICATE_IMAGE_GENERATION_MODELS.find((m) => m.id === 'black-forest-labs/flux-schnell')!;
REPLICATE_IMAGE_GENERATION_MODELS.push({ ...schnell, unavailableReason: 'fixture outage.' });
const defaultSpy = vi.spyOn(ReplicateImageGenerationProvider.prototype, 'getDefaultModel')
.mockReturnValue('Black-Forest-Labs/FLUX-Schnell');
try {
const isolatedDriver = new ImageGenerationDriver(
{ providers: { 'replicate-image-generation': { apiKey: 'rp-key' } } } as never,
server.clients, server.stores, server.services,
);
await isolatedDriver.onServerStart();
const error = await withActor(() => isolatedDriver.generate({
provider: 'replicate', prompt: 'hi', test_mode: true,
})).then(() => null, (e: Error) => e);
expect(error?.message).toMatch(/flux-schnell is no longer available through replicate-image-generation; fixture outage\./i);
expect(error?.message).not.toContain('undefined');
} finally {
defaultSpy.mockRestore();
REPLICATE_IMAGE_GENERATION_MODELS.pop();
}
});
it('omits the aliases key from catalog entries that declare none', async () => {
const entries = await driver.models();
const withoutAliases = entries.filter((m) => !Object.hasOwn(m, 'aliases'));
expect(withoutAliases.length).toBeGreaterThan(0);
for (const entry of entries) {
if (Object.hasOwn(entry, 'aliases')) expect(Array.isArray(entry.aliases)).toBe(true);
}
});
it('keeps Cloudflare Schnell reachable through its own id and puterId', async () => {
for (const model of [
'@cf/black-forest-labs/flux-1-schnell',
'workers-ai:black-forest-labs/flux.1-schnell',
]) {
eventEmitSpy.mockClear();
await withActor(() =>
driver.generate({ model, prompt: 'hi', test_mode: true }),
);
expect(eventEmitSpy, model).toHaveBeenCalledWith(
'ai.log.image',
expect.objectContaining({
service_used: 'cloudflare-image-generation',
}),
{},
);
}
});
it('never lists a retired alias on any provider entry', async () => {
for (const model of await driver.models()) {
for (const alias of model.aliases ?? []) {
expect(alias.toLowerCase()).not.toBe(
'black-forest-labs/flux.1-schnell',
);
}
}
});
it('keeps canonical and provider-prefixed model IDs independent from shared aliases', async () => {
const catalogs = {
'openai-image-generation': OPEN_AI_IMAGE_GENERATION_MODELS,
'gemini-image-generation': GEMINI_IMAGE_GENERATION_MODELS,
'together-image-generation': TOGETHER_IMAGE_GENERATION_MODELS.filter((model) => !model.excludedForDataPolicy),
'cloudflare-image-generation': CLOUDFLARE_IMAGE_GENERATION_MODELS,
'xai-image-generation': XAI_IMAGE_GENERATION_MODELS,
'replicate-image-generation': REPLICATE_IMAGE_GENERATION_MODELS.filter((model) => !model.unavailableReason),
};
// A vendor's own spelling (`openai/x`) beats a reseller's exact id, so
// Replicate's `openai/*` entries route to OpenAI without a hint.
const firstParty = new Map(OPEN_AI_IMAGE_GENERATION_MODELS.map((model) => [
model.puterId!.replace(/^[^:/]+:/, '').toLowerCase(),
{ model_used: model.id, service_used: 'openai-image-generation' },
]));
for (const [provider, models] of Object.entries(catalogs)) {
for (const model of models) {
for (const id of new Set(
[model.id, model.puterId].filter(Boolean),
)) {
eventEmitSpy.mockClear();
await withDriverName('ai-image', () =>
driver.generate({
model: id,
prompt: 'a landscape',
test_mode: true,
}),
);
expect(eventEmitSpy, id).toHaveBeenCalledWith(
'ai.log.image',
expect.objectContaining(
firstParty.get(id.toLowerCase()) ?? {
model_used: model.id,
service_used: provider,
},
),
{},
);
}
}
}
});
it('routes a known gpt-image-2 model id to the OpenAI image provider', async () => {
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://oai/img.png' }],
});
const result = await withActor(() =>
driver.generate({
model: 'gpt-image-1-mini',
model: 'gpt-image-2',
prompt: 'hi',
ratio: { w: 1024, h: 1024 },
} as never),
@@ -308,7 +682,7 @@ describe('ImageGenerationDriver.generate provider routing', () => {
expect(openaiImagesGenerateMock).toHaveBeenCalledTimes(1);
// Other provider mocks must NOT have been touched.
expect(togetherImagesGenerateMock).not.toHaveBeenCalled();
expect(replicateRunMock).not.toHaveBeenCalled();
expect(createPredictionMock).not.toHaveBeenCalled();
});
it('routes a known grok-imagine-image id to the xAI image provider (also OpenAI-SDK shaped)', async () => {
@@ -329,14 +703,14 @@ describe('ImageGenerationDriver.generate provider routing', () => {
expect(sent.prompt).toBe('hi');
});
it('lowercases model lookups so case variants resolve (GPT-Image-1-Mini → gpt-image-1-mini)', async () => {
it('lowercases model lookups so case variants resolve (GPT-Image-2 → gpt-image-2)', async () => {
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://oai/img.png' }],
});
await withActor(() =>
driver.generate({
model: 'GPT-Image-1-Mini',
model: 'GPT-Image-2',
prompt: 'hi',
ratio: { w: 1024, h: 1024 },
} as never),
@@ -347,7 +721,11 @@ describe('ImageGenerationDriver.generate provider routing', () => {
it('falls through to the requested provider via Context.driverName when args.provider is not supplied', async () => {
// We're not setting args.provider; Context.driverName takes its place.
replicateRunMock.mockResolvedValueOnce(['https://rp/img.png']);
createPredictionMock.mockResolvedValueOnce({
id: 'prediction-1',
status: 'succeeded',
output: ['https://rp/img.png'],
});
// Replicate's flux-schnell is the only registered model under id
// `black-forest-labs/flux-schnell` matched solely by Replicate's catalog.
@@ -359,7 +737,7 @@ describe('ImageGenerationDriver.generate provider routing', () => {
);
expect(result).toBe('https://rp/img.png');
expect(replicateRunMock).toHaveBeenCalledTimes(1);
expect(createPredictionMock).toHaveBeenCalledTimes(1);
});
});
@@ -373,7 +751,7 @@ describe('ImageGenerationDriver.generate ratio normalization', () => {
await withActor(() =>
driver.generate({
model: 'gpt-image-1-mini',
model: 'gpt-image-2',
prompt: 'hi',
width: 1024,
height: 1536,
@@ -386,27 +764,24 @@ describe('ImageGenerationDriver.generate ratio normalization', () => {
});
it('parses aspect_ratio "w:h" into ratio when width/height are absent', async () => {
// Use a per-tier Together model that consults `ratio` directly.
// The id is shared with Gemini via an alias collision (`gemini-3-pro-image`),
// so disambiguate explicitly with `args.provider`.
togetherImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://tg/img.png' }],
googleAIGenerateContentMock.mockResolvedValueOnce({
candidates: [{ content: { parts: [{ inlineData: {
data: 'aW1n', mimeType: 'image/png',
} }] } }],
});
await withActor(() =>
driver.generate({
provider: 'together-image-generation',
model: 'togetherai:google/gemini-3-pro-image',
provider: 'gemini',
model: 'gemini-3-pro-image',
prompt: 'hi',
aspect_ratio: '16:9',
quality: '1K',
} as never),
);
// Together's resolution_map for 16:9 + 1K is 1376×768.
const sent = togetherImagesGenerateMock.mock.calls[0]![0];
expect(sent.width).toBe(1376);
expect(sent.height).toBe(768);
const sent = googleAIGenerateContentMock.mock.calls[0]![0];
expect(sent.config.imageConfig.aspectRatio).toBe('16:9');
});
});
@@ -420,7 +795,7 @@ describe('ImageGenerationDriver.generate audit log', () => {
await withActor(() =>
driver.generate({
model: 'gpt-image-1-mini',
model: 'gpt-image-2',
prompt: 'hi',
ratio: { w: 1024, h: 1024 },
} as never),
@@ -432,7 +807,7 @@ describe('ImageGenerationDriver.generate audit log', () => {
expect(aiLogCall).toBeDefined();
const [, payload] = aiLogCall!;
const p = payload as Record<string, unknown>;
expect(p.model_used).toBe('gpt-image-1-mini');
expect(p.model_used).toBe('gpt-image-2');
expect(p.service_used).toBe('openai-image-generation');
// completionId is a fresh uuid-style string per call.
expect(typeof p.completionId).toBe('string');
@@ -446,7 +821,7 @@ describe('ImageGenerationDriver.generate audit log', () => {
await expect(
withActor(() =>
driver.generate({
model: 'gpt-image-1-mini',
model: 'gpt-image-2',
prompt: 'hi',
ratio: { w: 1024, h: 1024 },
} as never),
@@ -475,7 +850,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await expect(
withTestUser(() =>
driver.generate({
model: 'gpt-image-1-mini',
model: 'gpt-image-2',
prompt: 'hi',
puter_output_path: '/',
} as never),
@@ -489,7 +864,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await expect(
withTestUser(() =>
driver.generate({
model: 'gpt-image-1-mini',
model: 'gpt-image-2',
prompt: 'hi',
puter_output_path: '/image.png',
} as never),
@@ -506,7 +881,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await expect(
withTestUser(() =>
driver.generate({
model: 'gpt-image-1-mini',
model: 'gpt-image-2',
prompt: 'hi',
puter_output_path: '/testuser/somedir/image.png',
} as never),
@@ -531,7 +906,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await expect(
withTestUser(() =>
driver.generate({
model: 'gpt-image-1-mini',
model: 'gpt-image-2',
prompt: 'hi',
puter_output_path: '/testuser/dir/img.png',
} as never),
@@ -560,7 +935,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await withTestUser(() =>
driver.generate({
model: 'gpt-image-1-mini',
model: 'gpt-image-2',
prompt: 'hi',
puter_output_path: '~/images/out.png',
} as never),
@@ -592,7 +967,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
const result = await withTestUser(() =>
driver.generate({
model: 'gpt-image-1-mini',
model: 'gpt-image-2',
prompt: 'hi',
puter_output_path: '/testuser/photos/out.png',
} as never),
@@ -642,7 +1017,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
await withTestUser(() =>
driver.generate({
model: 'gpt-image-1-mini',
model: 'gpt-image-2',
prompt: 'hi',
puter_output_path: '/testuser/dir/img.png',
} as never),
@@ -660,7 +1035,7 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
Promise.resolve(
runWithContext({ actor: noIdActor }, () =>
driver.generate({
model: 'gpt-image-1-mini',
model: 'gpt-image-2',
prompt: 'hi',
puter_output_path: '/noone/dir/img.png',
} as never),
@@ -671,3 +1046,188 @@ describe('ImageGenerationDriver.generate puter_output_path', () => {
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
});
});
describe('image dimension parsing at the driver boundary', () => {
it.each([
{ w: 2048, h: 1024, kind: 'pixels' },
{ w: 'invalid', h: 1024, kind: 'pixels' },
])('ignores caller-supplied internal imageSize: %j', async (imageSize) => {
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://oai/img.png' }],
});
const args = {
model: 'gpt-image-2',
prompt: 'hi',
imageSize,
};
const snapshot = structuredClone(args);
await withActor(() => driver.generate(args as never));
expect(openaiImagesGenerateMock).toHaveBeenCalledWith(
expect.objectContaining({ size: '1024x1024' }),
);
expect(args).toEqual(snapshot);
const logged = eventEmitSpy.mock.calls.find(
(call) => call[0] === 'ai.log.image',
)![1] as { parameters: Record<string, unknown> };
expect(logged.parameters).not.toHaveProperty('imageSize');
});
it.each([
{ ratio: { w: 'invalid', h: 100 } },
{ ratio: { w: 0, h: 1 } },
{ quality: 42 },
{ resolution: [] },
{ width: 1024 },
])('rejects invalid options before a provider request: %j', (options) =>
expect(
withActor(() =>
driver.generate({
model: 'grok-imagine-image',
prompt: 'hi',
...options,
} as never),
),
).rejects.toMatchObject({ statusCode: 400 }),
);
});
it.each(['__proto__', 'constructor'])(
'rejects inherited object keys as unknown models: %s',
async (model) => {
await expect(
withActor(() =>
driver.generate({ provider: 'openai', model, prompt: 'hi' }),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
message: `Model not found: ${model}`,
});
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
},
);
it('rejects an unavailable provider without a model before dispatch', async () => {
await expect(
withActor(() =>
driver.generate({ provider: 'byteplus', prompt: 'hi' }),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
message: 'Image provider not available: byteplus',
});
expect(eventEmitSpy).not.toHaveBeenCalledWith(
'ai.log.image',
expect.anything(),
expect.anything(),
);
});
it('only advertises aliases that route to that entry without a provider hint', async () => {
for (const model of await driver.models()) {
for (const alias of model.aliases ?? []) {
eventEmitSpy.mockClear();
await expect(
withActor(() =>
driver.generate({
model: alias,
prompt: 'hi',
test_mode: true,
}),
),
).resolves.toBe(
'https://puter-sample-data.puter.site/image_example.png',
);
expect(eventEmitSpy, `${model.provider} ${alias}`).toHaveBeenCalledWith(
'ai.log.image',
expect.objectContaining({
model_used: model.id,
service_used: model.provider,
}),
{},
);
}
}
});
it('lists a shared alias only on the provider that wins it', async () => {
const owners = (await driver.models())
.filter((model) => model.aliases?.includes('leonardo/lucid-origin'))
.map((model) => model.provider);
expect(owners).toEqual(['cloudflare-image-generation']);
});
it('rejects an excluded provider alias instead of falling back to another provider', async () => {
await expect(withActor(() =>
driver.generate({
provider: 'together',
model: 'gpt-image-2',
prompt: 'hi',
}),
)).rejects.toThrow('required third-party data sharing');
expect(togetherImagesGenerateMock).not.toHaveBeenCalled();
expect(openaiImagesGenerateMock).not.toHaveBeenCalled();
});
it.each([
{ width: null, height: null },
{ aspect_ratio: null },
{ ratio: null, width: null, height: null, aspect_ratio: '' },
])('treats null dimension fields %j as absent', async (dimensions) => {
await expect(
withActor(() =>
driver.generate({
model: 'gpt-image-2',
prompt: 'hi',
test_mode: true,
...dimensions,
} as never),
),
).resolves.toBe('https://puter-sample-data.puter.site/image_example.png');
});
it('leaves the caller args untouched and logs the normalized request', async () => {
const args = {
model: 'gpt-image-2',
prompt: 'hi',
width: 1024,
height: 768,
test_mode: true,
};
const snapshot = structuredClone(args);
await withActor(() => driver.generate(args as never));
expect(args).toEqual(snapshot);
expect(eventEmitSpy).toHaveBeenCalledWith(
'ai.log.image',
expect.objectContaining({
parameters: expect.objectContaining({
model: 'gpt-image-2',
provider: 'openai-image-generation',
imageSize: { w: 1024, h: 768, kind: 'pixels' },
ratio: { w: 1024, h: 768 },
}),
}),
{},
);
const logged = eventEmitSpy.mock.calls.find(
(call) => call[0] === 'ai.log.image',
)![1] as { parameters: Record<string, unknown> };
expect(logged.parameters).not.toHaveProperty('width');
expect(logged.parameters).not.toHaveProperty('height');
});
it.each([undefined, null, '', ' ', 42, {}])(
'rejects an invalid prompt %j before routing',
async (prompt) => {
await expect(
withActor(() =>
driver.generate({ prompt, model: 'no-such-model' } as never),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
message: '`prompt` must be a non-empty string',
});
},
);
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { assertImagePrompt } from './imageValidation.js';
import crypto from 'node:crypto';
import { posix as pathPosix } from 'node:path';
import { assertNormalized } from '../../services/fs/resolveNode.js';
@@ -33,6 +34,7 @@ import { CloudflareImageProvider } from './providers/cloudflare/CloudflareImageP
import { GeminiImageProvider } from './providers/gemini/GeminiImageProvider.js';
import { OpenAiImageProvider } from './providers/openai/OpenAiImageProvider.js';
import { ReplicateImageGenerationProvider } from './providers/replicate/ReplicateImageGenerationProvider.js';
import { resolveImageSize } from './imageDimensions.js';
import { TogetherImageProvider } from './providers/together/TogetherImageProvider.js';
import { XAIImageProvider } from './providers/xai/XAIImageProvider.js';
import type { IGenerateParams, IImageModel, IImageProvider } from './types.js';
@@ -71,17 +73,22 @@ export class ImageGenerationDriver extends PuterDriver {
readonly rateLimit = AI_RATE_LIMIT;
readonly concurrent = AI_CONCURRENT;
#providers: Record<string, IImageProvider> = {};
#modelIdMap: Record<string, IImageModel[]> = {};
#providers: Record<string, IImageProvider> = Object.create(null);
#modelIdMap: Record<string, IImageModel[]> = Object.create(null);
#excludedModelIdMap: Record<string, IImageModel[]> = Object.create(null);
#retiredModelAliases = new Map<
string,
{ provider: string; reason?: string }
>();
/** Metering scoped to this driver. Lazy: services wire up after drivers. */
get #aiMetering(): MeteringService {
return this.services.metering.withAiCostFactor(this.driverName);
}
override onServerStart() {
override async onServerStart() {
this.#registerProviders();
this.#buildModelMap();
await this.#buildModelMap();
}
async models() {
@@ -89,10 +96,27 @@ export class ImageGenerationDriver extends PuterDriver {
return Object.values(this.#modelIdMap)
.flat()
.filter((m) => {
if (seen.has(m.id)) return false;
seen.add(m.id);
if (m.delisted) return false;
const key = `${m.provider}:${m.id}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.map((model) => {
if (!model.aliases) return model;
// Advertise only aliases that reach this entry without a
// provider hint; an alias another provider wins would route a
// caller who picked it from this entry somewhere else.
const aliases = model.aliases.filter((alias) => {
const resolved = this.#resolveModel(alias);
return (
resolved != null &&
resolved.provider === model.provider &&
resolved.id === model.id
);
});
return { ...model, aliases };
})
.sort((a, b) => {
if (a.provider === b.provider) return a.id.localeCompare(b.id);
return (a.provider ?? '').localeCompare(b.provider ?? '');
@@ -137,10 +161,20 @@ export class ImageGenerationDriver extends PuterDriver {
// Every provider reads these off `args`, and several index into them
// directly rather than through the shared helpers, so the shape is
// settled here — once — before any provider runs.
assertImagePrompt(args.prompt);
assertInputImagesShape(args, 'image generation');
for (const option of ['quality', 'resolution'] as const) {
if (args[option] != null && typeof args[option] !== 'string') {
throw new HttpError(400, `${option} must be a string`, {
legacyCode: 'bad_request',
});
}
}
const puterOutputPath = args.puter_output_path;
delete args.puter_output_path;
// Providers and lifecycle listeners each get their own view: the
// caller's `args` is re-emitted by reference in `.after`/`.error`
// payloads, so it is never mutated here.
const { puter_output_path: puterOutputPath, ...request } = args;
// Validate the output path early — before spending credits.
let resolvedOutputPath: string | undefined;
@@ -165,22 +199,85 @@ export class ImageGenerationDriver extends PuterDriver {
typeof args.model === 'string'
? args.model.trim().toLowerCase()
: undefined;
const providerHint = args.provider ?? Context.get('driverName');
let intendedProvider =
args.provider ?? (Context.get('driverName') as string | undefined);
typeof providerHint === 'string'
? providerHint.trim().toLowerCase()
: undefined;
if (intendedProvider === this.driverName) intendedProvider = undefined;
if (
intendedProvider &&
!intendedProvider.endsWith('-image-generation')
) {
intendedProvider += '-image-generation';
}
if (
!modelId &&
intendedProvider &&
!this.#providers[intendedProvider]
) {
throw new HttpError(
400,
`Image provider not available: ${providerHint}`,
{
legacyCode: 'bad_request',
},
);
}
// Default: first registered provider's default model if none given
// Pick the first provider whose default model is available.
if (!modelId && !intendedProvider) {
intendedProvider = Object.keys(this.#providers)[0];
intendedProvider = Object.keys(this.#providers).find((name) => {
const defaultModel = this.#providers[name].getDefaultModel();
return (
this.#resolveModel(defaultModel, name)?.provider === name
);
});
}
if (!modelId && intendedProvider) {
modelId = this.#providers[intendedProvider]?.getDefaultModel();
modelId = this.#providers[intendedProvider]
?.getDefaultModel()
.trim()
.toLowerCase();
}
if (!modelId)
throw new HttpError(400, 'Missing `model`', {
legacyCode: 'bad_request',
});
const excludedModels =
this.#excludedModelIdMap[modelId.trim().toLowerCase()] ?? [];
const dataPolicyError = (excluded: IImageModel) =>
new HttpError(
400,
`Image model excluded by data policy: ${excluded.id} (${excluded.excludedForDataPolicy === 'training' ? 'training on customer content' : 'required third-party data sharing'}).`,
{ legacyCode: 'bad_request' },
);
// A hinted provider answers for its own excluded route, even when
// another provider retired the same bare name.
const hintedExcludedModel = excludedModels.find(
(entry) => entry.provider === intendedProvider,
);
if (hintedExcludedModel) throw dataPolicyError(hintedExcludedModel);
// Retired aliases are never registered in the model map, so this gate
// mostly turns "Model not found" into a message that names the
// provider; it also keeps a retired name from reaching a provider
// whose exact id happens to spell the same thing.
const retired = this.#retiredModelAliases.get(modelId);
if (retired) {
throw new HttpError(
400,
`${args.model ?? modelId} is no longer available through ${retired.provider}; ${retired.reason ?? 'choose an available model.'}`,
{
legacyCode: 'bad_request',
},
);
}
const model = this.#resolveModel(modelId, intendedProvider);
if (!model && excludedModels[0])
throw dataPolicyError(excludedModels[0]);
if (!model) {
throw new HttpError(400, `Model not found: ${args.model}`, {
legacyCode: 'bad_request',
@@ -196,19 +293,33 @@ export class ImageGenerationDriver extends PuterDriver {
);
}
// `width`/`height` or `aspect_ratio` -> `ratio: {w,h}`
this.#normalizeRatio(args);
// Every caller-facing size field collapses into `imageSize`; the
// `ratio` mirror carries plain dimensions for providers that need no
// aspect-versus-pixels intent.
const imageSize = resolveImageSize(request, model);
delete request.width;
delete request.height;
delete request.aspect_ratio;
delete request.ratio;
delete request.imageSize;
if (imageSize) {
request.imageSize = imageSize;
request.ratio = { w: imageSize.w, h: imageSize.h };
}
request.model = model.id;
request.provider = model.provider;
// Audit log for abuse / billing. Fired before the upstream call
// so a failed generate still shows up in the log (prompt_block
// uses this to track user-by-user image prompts).
// uses this to track user-by-user image prompts). Logs the
// normalized request so the row records the resolved model and size.
const completionId = crypto.randomUUID();
this.clients.event.emit(
'ai.log.image',
{
actor,
completionId,
parameters: args,
parameters: request,
intended_service: model.id,
model_used: model.id,
service_used: model.provider!,
@@ -216,11 +327,7 @@ export class ImageGenerationDriver extends PuterDriver {
{},
);
const result = await provider.generate({
...args,
model: model.id,
provider: model.provider,
});
const result = await provider.generate(request);
if (resolvedOutputPath) {
await this.#saveToFS(actor, result, resolvedOutputPath);
@@ -229,34 +336,6 @@ export class ImageGenerationDriver extends PuterDriver {
return result;
}
#normalizeRatio(parameters: IGenerateParams) {
if (parameters.ratio) return;
const w = parameters.width as number | undefined;
const h = parameters.height as number | undefined;
if (typeof w === 'number' && typeof h === 'number') {
parameters.ratio = { w, h };
delete parameters.width;
delete parameters.height;
return;
}
const ar = parameters.aspect_ratio as string | undefined;
if (typeof ar === 'string' && ar.includes(':')) {
const [aw, ah] = ar.split(':').map(Number);
if (
Number.isFinite(aw) &&
Number.isFinite(ah) &&
aw > 0 &&
ah > 0
) {
parameters.ratio = { w: aw, h: ah };
delete parameters.aspect_ratio;
return;
}
}
}
#registerProviders() {
const providers = this.config.providers ?? {};
const m = this.#aiMetering;
@@ -305,8 +384,7 @@ export class ImageGenerationDriver extends PuterDriver {
const cloudflare = (providers['cloudflare-image-generation'] ??
providers['cloudflare-workers-ai-image'] ??
providers['cloudflare-workers-ai']) as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const cfToken =
(cloudflare?.apiToken as string | undefined) ??
(cloudflare?.apiKey as string | undefined) ??
@@ -321,8 +399,7 @@ export class ImageGenerationDriver extends PuterDriver {
apiToken: cfToken,
accountId: cfAccount,
apiBaseUrl: cloudflare?.apiBaseUrl as
| string
| undefined,
string | undefined,
},
m,
);
@@ -354,11 +431,9 @@ export class ImageGenerationDriver extends PuterDriver {
// pair its missing apiBaseUrl with the shared block's key (or vice
// versa) and point a region-scoped key at the wrong endpoint.
const byteplusImageCfg = providers['byteplus-image-generation'] as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const byteplusSharedCfg = providers['byteplus'] as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
const byteplusKey = readKey(byteplusImageCfg, byteplusSharedCfg);
if (byteplusKey) {
this.#providers['byteplus-image-generation'] =
@@ -367,8 +442,7 @@ export class ImageGenerationDriver extends PuterDriver {
apiKey: byteplusKey,
apiBaseUrl: (byteplusImageCfg?.apiBaseUrl ??
byteplusSharedCfg?.apiBaseUrl) as
| string
| undefined,
string | undefined,
},
m,
);
@@ -376,47 +450,66 @@ export class ImageGenerationDriver extends PuterDriver {
}
async #buildModelMap() {
for (const providerName in this.#providers) {
const provider = this.#providers[providerName];
for (const entry of await provider.models()) {
// Catalogs are module-level constants that providers hand
// back by reference, so they are read and never written:
// normalizing the id or appending puterId in place would
// accumulate across map builds. Work on a copy instead.
const model = { ...entry };
model.id = model.id.trim().toLowerCase();
if (!this.#modelIdMap[model.id]) {
this.#modelIdMap[model.id] = [];
}
this.#modelIdMap[model.id].push({
...model,
this.#modelIdMap = Object.create(null);
this.#excludedModelIdMap = Object.create(null);
this.#retiredModelAliases.clear();
const models: IImageModel[] = [];
for (const [providerName, provider] of Object.entries(
this.#providers,
)) {
for (const alias of provider.retiredModelAliases ?? []) {
this.#retiredModelAliases.set(alias.trim().toLowerCase(), {
provider: providerName,
reason: provider.retiredModelReasons?.[alias],
});
}
for (const entry of await provider.models()) {
models.push({ ...entry, provider: providerName });
}
}
if (model.puterId) {
model.aliases = model.aliases
? [...model.aliases, model.puterId]
: [model.puterId];
}
if (model.aliases) {
for (let alias of model.aliases) {
alias = alias.trim().toLowerCase();
if (!this.#modelIdMap[alias]) {
this.#modelIdMap[alias] =
this.#modelIdMap[model.id];
} else if (
this.#modelIdMap[alias] !==
this.#modelIdMap[model.id]
) {
this.#modelIdMap[alias].push({
...model,
provider: providerName,
});
this.#modelIdMap[model.id] =
this.#modelIdMap[alias];
}
}
}
const register = (id: string, model: IImageModel) => {
const key = id.trim().toLowerCase();
const modelMap = model.excludedForDataPolicy
? this.#excludedModelIdMap
: this.#modelIdMap;
const bucket = (modelMap[key] ??= []);
if (
!bucket.some(
(entry) =>
entry.id === model.id &&
entry.provider === model.provider,
)
) {
bucket.push(model);
}
};
// Exact IDs take precedence over another provider's shorthand aliases.
// A puterId minus its provider prefix (`openai:openai/x` → `openai/x`)
// is that provider's own spelling as well, so a reseller's exact id
// never outranks it; ties keep provider registration order.
const retired = (name: string) =>
this.#retiredModelAliases.has(name.trim().toLowerCase());
for (const model of models) {
register(model.id, model);
if (!model.puterId) continue;
register(model.puterId, model);
const ownSpelling = model.puterId.replace(/^[^:/]+:/, '');
if (model.excludedForDataPolicy || !retired(ownSpelling))
register(ownSpelling, model);
}
for (const model of models) {
for (const alias of model.aliases ?? []) {
// A retired name never routes anywhere, whichever catalog
// still spells it, so the gate in generate() cannot depend on
// which providers a deployment configures. Excluded routes
// never route either, so they keep the alias for their error.
if (
!model.excludedForDataPolicy &&
this.#retiredModelAliases.has(alias.trim().toLowerCase())
)
continue;
register(alias, model);
}
}
}
@@ -526,7 +619,7 @@ export class ImageGenerationDriver extends PuterDriver {
}
#resolveModel(modelId: string, provider?: string): IImageModel | null {
const models = this.#modelIdMap[modelId];
const models = this.#modelIdMap[modelId.trim().toLowerCase()];
if (!models || models.length === 0) return null;
if (!provider) return models[0];
return models.find((m) => m.provider === provider) ?? models[0];
@@ -0,0 +1,159 @@
/*
* 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 {
isAspectRatio,
expandAspectRatio,
resolveImageSize,
closestAspectRatio,
formatAspectRatio,
} from './imageDimensions.js';
describe('isAspectRatio', () => {
it.each([
[16, 9, true],
[32, 18, true],
[64, 36, false],
[1024, 1024, false],
[0, 1, false],
[1, -1, false],
])('classifies %s by %s', (w, h, expected) => {
expect(isAspectRatio({ w: Number(w), h: Number(h) })).toBe(expected);
});
});
describe('resolveImageSize', () => {
it.each([
[{ ratio: { w: '16', h: '9' } }, { w: 16, h: 9, kind: 'aspect' }],
[
{ width: '4096', height: '3072' },
{ w: 4096, h: 3072, kind: 'pixels' },
],
[
{ width: ' 512 ', height: ' 512 ' },
{ w: 512, h: 512, kind: 'pixels' },
],
[{ aspect_ratio: '170:100' }, { w: 170, h: 100, kind: 'aspect' }],
[
{ ratio: { w: 4, h: 3 }, width: 1024, height: 1024 },
{ w: 4, h: 3, kind: 'aspect' },
],
[
{ width: 1024, height: 768, aspect_ratio: '1:1' },
{ w: 1024, h: 768, kind: 'pixels' },
],
])('preserves dimension intent for %j', (params, expected) => {
expect(resolveImageSize({ prompt: 'hi', ...params } as never)).toEqual(
expected,
);
});
it('retains the BytePlus legacy pixel threshold', () => {
expect(
resolveImageSize(
{ prompt: 'hi', width: 512, height: 512 },
{ pixelSizeThreshold: 921_600 },
),
).toEqual({ w: 512, h: 512, kind: 'aspect' });
});
it('does not manufacture a size when none is requested', () => {
expect(resolveImageSize({ prompt: 'hi' })).toBeUndefined();
});
it.each([
{ ratio: null },
{ width: null, height: null },
{ aspect_ratio: null },
{ aspect_ratio: '' },
{ aspect_ratio: ' ' },
{ ratio: null, width: null, height: null, aspect_ratio: null },
])('treats null and blank dimension fields %j as absent', (params) => {
expect(
resolveImageSize({ prompt: 'hi', ...params } as never),
).toBeUndefined();
});
it('names the missing partner when only one pixel dimension is set', () => {
expect(() =>
resolveImageSize({ prompt: 'hi', width: 1024 } as never),
).toThrow(/width and height must be set together/);
});
it('still rejects a lone dimension when its partner is null', () => {
expect(() =>
resolveImageSize({ prompt: 'hi', width: 1024, height: null } as never),
).toThrow(expect.objectContaining({ statusCode: 400 }));
});
it.each([
{ ratio: '16:9' },
{ ratio: {} },
{ ratio: { w: 0, h: 10 } },
{ ratio: { w: Infinity, h: 1 } },
{ ratio: { w: true, h: 1 } },
{ width: 'invalid', height: 1 },
{ width: 1 },
{ aspect_ratio: '1:2:3' },
{ aspect_ratio: 42 },
{ aspect_ratio: '-1:2' },
{ width: '0x10', height: '0x10' },
{ ratio: { w: '1e3', h: '1e3' } },
])('rejects malformed dimensions %j', (params) => {
expect(() =>
resolveImageSize({ prompt: 'hi', ...params } as never),
).toThrow(expect.objectContaining({ statusCode: 400 }));
});
});
describe('aspect helpers', () => {
it('chooses the closest supported shape symmetrically', () => {
const shapes = [
{ w: 16, h: 9 },
{ w: 9, h: 16 },
{ w: 1, h: 1 },
];
expect(closestAspectRatio({ w: 17, h: 10 }, shapes)).toEqual(shapes[0]);
expect(closestAspectRatio({ w: 10, h: 17 }, shapes)).toEqual(shapes[1]);
});
it.each([
[1920, 1080, '16:9'],
[16.5, 9.5, '33:19'],
[1.1, 1, '11:10'],
[0.1, 0.3, '1:3'],
[1024, 768, '4:3'],
[0, 1, undefined],
])('formats %s by %s', (w, h, expected) => {
expect(formatAspectRatio({ w: Number(w), h: Number(h) })).toBe(
expected,
);
});
it('omits a missing ratio', () =>
expect(formatAspectRatio()).toBeUndefined());
});
it.each([
{ w: 4, h: 1 },
{ w: 1e-200, h: 1e-200 },
])('expands an aspect hint without overflowing: %j', (ratio) => {
const result = expandAspectRatio(ratio);
expect(result.w / result.h).toBeCloseTo(ratio.w / ratio.h);
expect(result.w * result.h).toBeCloseTo(1024 * 1024);
});
it('rejects an aspect ratio whose quotient overflows', () => {
expect(() =>
resolveImageSize({ prompt: 'hi', ratio: { w: 16, h: 1e-320 } }),
).toThrow(expect.objectContaining({ statusCode: 400 }));
});
@@ -0,0 +1,165 @@
/*
* 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 { HttpError } from '../../core/http/HttpError.js';
import type {
IGenerateParams,
IImageModel,
ImageDimensions,
ImageSize,
} from './types.js';
// Decimal-only, so `'0x10'`, `'1e3'` and similar number-literal coercions
// don't sneak into what is meant to be a plain image dimension.
const DECIMAL_RE = /^\d+(\.\d+)?$/;
function dimension(value: unknown): number {
const number =
typeof value === 'number'
? value
: typeof value === 'string' && DECIMAL_RE.test(value.trim())
? Number(value)
: NaN;
if (
!Number.isFinite(number) ||
number <= 0 ||
number > Number.MAX_SAFE_INTEGER
) {
throw new HttpError(
400,
'Image dimensions must be positive, finite numbers',
{ legacyCode: 'bad_request' },
);
}
return number;
}
/**
* Null and blank strings mean "not set": form state and spread option objects
* carry them, and JSON preserves them.
*/
function present<T>(value: T): value is NonNullable<T> {
return value != null && (typeof value !== 'string' || value.trim() !== '');
}
export function isAspectRatio(ratio: ImageDimensions): boolean {
return ratio.w > 0 && ratio.h > 0 && Math.max(ratio.w, ratio.h) <= 32;
}
/** Preserve dimension-source information before adapting to a model's sizes. */
export function resolveImageSize(
params: IGenerateParams,
model?: Pick<IImageModel, 'pixelSizeThreshold'>,
): ImageSize | undefined {
let pair: { w?: unknown; h?: unknown };
let explicitAspect = false;
if (present(params.ratio)) {
if (typeof params.ratio !== 'object' || Array.isArray(params.ratio)) {
throw new HttpError(400, 'ratio must contain w and h', {
legacyCode: 'bad_request',
});
}
pair = params.ratio;
} else if (present(params.width) || present(params.height)) {
if (!present(params.width) || !present(params.height)) {
throw new HttpError(400, 'width and height must be set together', {
legacyCode: 'bad_request',
});
}
pair = { w: params.width, h: params.height };
} else if (present(params.aspect_ratio)) {
if (
typeof params.aspect_ratio !== 'string' ||
params.aspect_ratio.split(':').length !== 2
) {
throw new HttpError(400, 'aspect_ratio must use w:h format', {
legacyCode: 'bad_request',
});
}
const [w, h] = params.aspect_ratio.split(':');
pair = { w, h };
explicitAspect = true;
} else return undefined;
const ratio = { w: dimension(pair.w), h: dimension(pair.h) };
const quotient = ratio.w / ratio.h;
if (!Number.isFinite(quotient) || quotient <= 0) {
throw new HttpError(
400,
'Image aspect ratio is outside the supported numeric range',
{ legacyCode: 'bad_request' },
);
}
const aspect =
explicitAspect ||
(model?.pixelSizeThreshold
? ratio.w * ratio.h < model.pixelSizeThreshold
: isAspectRatio(ratio));
return { ...ratio, kind: aspect ? 'aspect' : 'pixels' };
}
export function closestAspectRatio<T extends ImageDimensions>(
ratio: ImageDimensions,
candidates: T[],
): T {
const target = ratio.w / ratio.h;
return candidates.reduce((best, candidate) =>
Math.abs(Math.log(candidate.w / candidate.h / target)) <
Math.abs(Math.log(best.w / best.h / target))
? candidate
: best,
);
}
const isWhole = (value: number) => Math.abs(value - Math.round(value)) < 1e-6;
export function formatAspectRatio(ratio?: ImageDimensions): string | undefined {
if (
!ratio ||
!Number.isFinite(ratio.w) ||
!Number.isFinite(ratio.h) ||
ratio.w <= 0 ||
ratio.h <= 0
)
return undefined;
// Euclid needs integers: `%` on doubles turns 1.1:1 into sixteen-digit
// terms, so scale decimal pairs up first (16.5:9.5 → 165:95).
let scale = 1;
while (
scale < 1e6 &&
!(isWhole(ratio.w * scale) && isWhole(ratio.h * scale))
)
scale *= 10;
const w = Math.round(ratio.w * scale);
const h = Math.round(ratio.h * scale);
if (!w || !h) return undefined;
let a = w;
let b = h;
while (b !== 0) [a, b] = [b, a % b];
return `${w / a}:${h / a}`;
}
/** Expand a shape without multiplying tiny dimension pairs first. */
export function expandAspectRatio(
ratio: ImageDimensions,
pixels = 1024 * 1024,
): ImageDimensions {
const side = Math.sqrt(pixels);
const aspect = Math.sqrt(ratio.w / ratio.h);
return { w: side * aspect, h: side / aspect };
}
@@ -0,0 +1,53 @@
/*
* 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 { imageDataUri } from './imageOutput.js';
describe('imageDataUri', () => {
it.each([
['jpeg', Buffer.from([0xff, 0xd8, 0xff, 0xe0])],
['png', Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])],
['webp', Buffer.from('RIFF0000WEBP')],
])('labels %s bytes without changing the payload', (format, bytes) => {
const base64 = Buffer.from(bytes).toString('base64');
expect(imageDataUri(base64)).toBe(
`data:image/${format};base64,${base64}`,
);
});
it('uses the model fallback for an unknown signature', () => {
expect(imageDataUri('AAAA', 'image/jpeg')).toBe(
'data:image/jpeg;base64,AAAA',
);
});
it('labels svg when the root sits beyond the first bytes', () => {
// `<?xml ...?>` pushes `<svg` past fixed-offset magic-number range, so
// the sniff has to scan the full 8 KB window rather than the head only.
const svg =
'<?xml version="1.0" encoding="UTF-8"?>' +
'<svg xmlns="http://www.w3.org/2000/svg">' +
'<rect width="10" height="10"/></svg>';
const base64 = Buffer.from(svg).toString('base64');
expect(imageDataUri(base64)).toBe(
`data:image/svg+xml;base64,${base64}`,
);
});
});
@@ -0,0 +1,37 @@
/*
* 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 { SVG_SNIFF_WINDOW, sniffImageMime } from '../../util/appIcon.js';
// Base64 chars needed to recover SVG_SNIFF_WINDOW decoded bytes.
const SNIFF_BASE64_CHARS = Math.ceil((SVG_SNIFF_WINDOW * 4) / 3);
/** Label generated bytes with the existing content-based MIME detector. */
export function imageDataUri(
base64: string,
fallbackMime = 'image/png',
): string {
// Sniff enough of the payload to see an SVG root element; fixed-offset
// signatures (PNG/JPEG/GIF/WEBP) appear in the first bytes regardless.
const mime =
sniffImageMime(
Buffer.from(base64.slice(0, SNIFF_BASE64_CHARS), 'base64'),
) ?? fallbackMime;
return `data:${mime};base64,${base64}`;
}
@@ -0,0 +1,28 @@
/*
* 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 { HttpError } from '../../core/http/HttpError.js';
export function assertImagePrompt(prompt: unknown): asserts prompt is string {
if (typeof prompt !== 'string' || !prompt.trim()) {
throw new HttpError(400, '`prompt` must be a non-empty string', {
legacyCode: 'bad_request',
});
}
}
@@ -32,6 +32,7 @@ import type { IGenerateParams } from './types.js';
export {
assertInputImageString,
fetchImageAsBase64,
fetchImageBytes,
isHttpUrl,
parseDataUri,
toBase64DataUri,
@@ -20,12 +20,11 @@
/**
* Offline unit tests for BytePlusImageProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs BytePlusImageProvider directly against the
* live wired `MeteringService` so the recording side is exercised
* end-to-end. Ark's image API is OpenAI-compatible so the OpenAI SDK
* is mocked at the module boundary; that's the real network egress
* point.
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
* constructs BytePlusImageProvider directly against the live wired
* `MeteringService` so the recording side is exercised end-to-end. Ark's image
* API is OpenAI-compatible so the OpenAI SDK is mocked at the module boundary;
* that's the real network egress point.
*/
import {
@@ -261,7 +260,7 @@ describe('BytePlusImageProvider.generate request shape', () => {
quality: '1.5K',
}),
);
expect(generateMock.mock.calls[0]![0].size).toBe('1.5K');
expect(generateMock.mock.calls[0]![0].size).toBe('2K');
});
it('resolves an aspect ratio + tier to the documented pixel size', async () => {
@@ -271,7 +270,7 @@ describe('BytePlusImageProvider.generate request shape', () => {
model: 'seedream-4-0',
prompt: 'hi',
quality: '1k',
ratio: { w: 16, h: 9 },
imageSize: { w: 16, h: 9, kind: 'aspect' },
}),
);
expect(generateMock.mock.calls[0]![0].size).toBe('1424x800');
@@ -296,19 +295,38 @@ describe('BytePlusImageProvider.generate request shape', () => {
makeProvider().generate({
prompt: 'hi',
quality: '1.5k',
ratio: { w: 16, h: 9 },
imageSize: { w: 16, h: 9, kind: 'aspect' },
}),
);
expect(generateMock.mock.calls[1]![0].size).toBe('2816x1584');
expect(generateMock.mock.calls[1]![0].size).toBe('2848x1600');
});
it.each([
['seedream-4-0', 512, 512, '2048x2048'],
['seedream-5-0-lite', 640, 360, '2848x1600'],
['dola-seedream-5-0-pro', 640, 360, '2816x1584'],
])(
'preserves small pixel pairs as aspect hints for %s',
async (model, w, h, size) => {
generateMock.mockResolvedValueOnce(sampleResponse);
await withTestActor(() =>
makeProvider().generate({
model: String(model),
prompt: 'hi',
imageSize: { w: Number(w), h: Number(h), kind: 'aspect' },
}),
);
expect(generateMock.mock.calls[0][0].size).toBe(size);
},
);
it('passes explicit pixel dimensions straight through', async () => {
generateMock.mockResolvedValueOnce(sampleResponse);
await withTestActor(() =>
makeProvider().generate({
model: 'seedream-4-0',
prompt: 'hi',
ratio: { w: 2048, h: 1024 },
imageSize: { w: 2048, h: 1024, kind: 'pixels' },
}),
);
expect(generateMock.mock.calls[0]![0].size).toBe('2048x1024');
@@ -322,7 +340,7 @@ describe('BytePlusImageProvider.generate request shape', () => {
withTestActor(() =>
makeProvider().generate({
prompt: 'hi',
ratio: { w: 2048, h: 1024 },
imageSize: { w: 2048, h: 1024, kind: 'pixels' },
}),
),
).rejects.toMatchObject({ statusCode: 400 });
@@ -336,7 +354,7 @@ describe('BytePlusImageProvider.generate request shape', () => {
model: 'seedream-4-0',
prompt: 'hi',
quality: '1k',
ratio: { w: 32, h: 18 },
imageSize: { w: 32, h: 18, kind: 'aspect' },
}),
);
expect(generateMock.mock.calls[0]![0].size).toBe('1424x800');
@@ -347,7 +365,7 @@ describe('BytePlusImageProvider.generate request shape', () => {
withTestActor(() =>
makeProvider().generate({
prompt: 'hi',
ratio: { w: 6000, h: 6000 },
imageSize: { w: 6000, h: 6000, kind: 'pixels' },
}),
),
).rejects.toMatchObject({ statusCode: 400 });
@@ -548,3 +566,95 @@ describe('BytePlusImageProvider.generate response handling', () => {
expect(batchIncrementUsagesSpy).not.toHaveBeenCalled();
});
});
describe('BytePlusImageProvider model-specific output sizes', () => {
it.each([
['seedream-5-0-lite-260128', '3k', '3072x3072'],
['seedream-5-0-lite-260128', '4k', '4096x4096'],
['seedream-4-5-251128', '4k', '4096x4096'],
['seedream-4-0-250828', '4k', '4096x4096'],
])('supports %s at %s', async (model, quality, size) => {
generateMock.mockResolvedValueOnce({
data: [{ url: 'https://example.com/image.png' }],
});
await withTestActor(() =>
makeProvider().generate({
model,
quality,
prompt: 'a landscape',
imageSize: { w: 1, h: 1, kind: 'aspect' },
}),
);
expect(generateMock).toHaveBeenCalledWith(
expect.objectContaining({ size }),
);
});
it('accepts explicit 4K pixels for Lite but rejects them for Pro', async () => {
generateMock.mockResolvedValueOnce({
data: [{ url: 'https://example.com/image.png' }],
});
await withTestActor(() =>
makeProvider().generate({
model: 'seedream-5-0-lite-260128',
prompt: 'a landscape',
imageSize: { w: 4096, h: 4096, kind: 'pixels' },
}),
);
expect(generateMock).toHaveBeenCalledWith(
expect.objectContaining({ size: '4096x4096' }),
);
await expect(
withTestActor(() =>
makeProvider().generate({
model: 'dola-seedream-5-0-pro-260628',
prompt: 'a landscape',
imageSize: { w: 4096, h: 4096, kind: 'pixels' },
}),
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
it('labels BytePlus base64 by its image signature', async () => {
generateMock.mockResolvedValueOnce({
data: [{ b64_json: 'iVBORw0KGgo=' }],
});
const result = await withTestActor(() =>
makeProvider().generate({ prompt: 'hi' }),
);
expect(result).toBe('data:image/png;base64,iVBORw0KGgo=');
});
it.each([
{ w: 2048.5, h: 2048 },
{ w: 2048, h: 2048.5 },
])('rejects fractional pixel dimensions %j before billing', async (ratio) => {
await expect(
withTestActor(() =>
makeProvider().generate({
prompt: 'hi',
model: 'seedream-4-5-251128',
imageSize: { ...ratio, kind: 'pixels' },
}),
),
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
expect(hasCreditsSpy).not.toHaveBeenCalled();
expect(generateMock).not.toHaveBeenCalled();
});
it('maps a nearby aspect hint to the closest supported BytePlus aspect', async () => {
generateMock.mockResolvedValueOnce({
data: [{ url: 'https://example.com/out.jpg' }],
});
await withTestActor(() =>
makeProvider().generate({
prompt: 'hi',
model: 'dola-seedream-5-0-pro-260628',
imageSize: { w: 17, h: 10, kind: 'aspect' },
}),
);
expect(generateMock).toHaveBeenCalledWith(
expect.objectContaining({ size: '2816x1584' }),
);
});
@@ -17,7 +17,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { assertImagePrompt } from '../../imageValidation.js';
import { OpenAI } from 'openai';
import { imageDataUri } from '../../imageOutput.js';
import { closestAspectRatio } from '../../imageDimensions.js';
import type { ImageSize } from '../../types.js';
import { Context } from '../../../../core/context.js';
import { HttpError } from '../../../../core/http/HttpError.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
@@ -29,24 +33,17 @@ import type {
import { toUrlOrDataUri } from '../../inputImage.js';
import {
BYTEPLUS_IMAGE_GENERATION_MODELS,
SEEDREAM_RESOLUTION_MAP,
type BytePlusImageModel,
} from './models.js';
const DEFAULT_MODEL = 'seedream-5-0-lite-260128';
// Ark's explicit-pixel `size` bounds ("method 2"): total pixels within
// [1280x720, 2048x2048x1.1025] and aspect ratio within [1/16, 16].
const MIN_TOTAL_PIXELS = 921_600;
const MAX_TOTAL_PIXELS = 4_624_220;
// Models restricted to the 2K tier (see SEEDREAM_2K_ONLY in models.ts)
// enforce this higher minimum on explicit sizes too.
const MIN_TOTAL_PIXELS_2K_ONLY = 3_686_400;
// dola-seedream-5-0-pro's price break: ≤ 2.61MP bills the "1.5K or lower"
// rate, above it the higher rate.
const PRO_TIER_BREAK_PIXELS = 2_610_000;
// Ark's `size` tiers, smallest first.
const TIERS = ['1k', '1.5k', '2k'] as const;
const TIERS = ['1k', '1.5k', '2k', '3k', '4k'] as const;
type Tier = (typeof TIERS)[number];
const isTier = (v: string): v is Tier =>
(TIERS as readonly string[]).includes(v);
@@ -95,7 +92,7 @@ export class BytePlusImageProvider implements IImageProvider {
});
}
models(): IImageModel[] {
models(): BytePlusImageModel[] {
return BYTEPLUS_IMAGE_GENERATION_MODELS;
}
@@ -104,7 +101,7 @@ export class BytePlusImageProvider implements IImageProvider {
}
async generate(params: IGenerateParams): Promise<string> {
const { prompt, test_mode, model, ratio, quality } = params;
const { prompt, test_mode, model, quality } = params;
let { input_images } = params;
const { input_image, input_image_mime_type } = params;
@@ -115,11 +112,7 @@ export class BytePlusImageProvider implements IImageProvider {
return 'https://puter-sample-data.puter.site/image_example.png';
}
if (typeof prompt !== 'string' || prompt.trim().length === 0) {
throw new HttpError(400, '`prompt` must be a non-empty string', {
legacyCode: 'bad_request',
});
}
assertImagePrompt(prompt);
// Backwards compat: fold singular `input_image` into `input_images`.
if (input_image && (!input_images || input_images.length === 0)) {
@@ -138,7 +131,8 @@ export class BytePlusImageProvider implements IImageProvider {
const inputImageCount = input_images?.length ?? 0;
const tier = this.#normalizeTier(quality, selectedModel);
const size = this.#resolveSize(tier, selectedModel, ratio);
// The driver resolves every caller-facing size field into `imageSize`.
const size = this.#resolveSize(tier, selectedModel, params.imageSize);
// The pro model bills by output pixel count; everything else is a
// flat per-image rate.
@@ -215,7 +209,10 @@ export class BytePlusImageProvider implements IImageProvider {
const url =
first?.url ||
(first?.b64_json
? `data:image/${first.output_format ?? 'jpeg'};base64,${first.b64_json}`
? imageDataUri(
first.b64_json,
`image/${first.output_format ?? 'jpeg'}`,
)
: undefined);
if (!url) {
throw new Error(
@@ -279,49 +276,48 @@ export class BytePlusImageProvider implements IImageProvider {
* - `ratio` holding real pixel dimensions → explicit `WxH` (method 2)
* - `ratio` holding an aspect ratio with a known tier mapping → the
* documented `WxH` for (aspect, tier)
* - Otherwise → the tier keyword (`1K`/`1.5K`/`2K`, method 1)
* - Otherwise → the tier keyword (`1K` through `4K`, method 1)
*/
#resolveSize(
tier: Tier,
model: IImageModel,
ratio?: { w: number; h: number },
model: BytePlusImageModel,
ratio?: ImageSize,
): string {
if (ratio?.w && ratio?.h) {
const pixels = ratio.w * ratio.h;
if (pixels >= MIN_TOTAL_PIXELS) {
// 2K-only models enforce a higher minimum on explicit sizes
// too — fail fast with the real constraint instead of letting
// Ark 400 the request after the round-trip.
const minPixels = model.allowedQualityLevels?.includes('1k')
? MIN_TOTAL_PIXELS
: MIN_TOTAL_PIXELS_2K_ONLY;
if (ratio.kind === 'pixels') {
const { minPixels, maxPixels } = model;
const aspect = ratio.w / ratio.h;
if (
pixels < minPixels ||
pixels > MAX_TOTAL_PIXELS ||
pixels > maxPixels ||
!Number.isInteger(ratio.w) ||
!Number.isInteger(ratio.h) ||
aspect < 1 / 16 ||
aspect > 16
) {
throw new HttpError(
400,
`Requested size ${ratio.w}x${ratio.h} is outside BytePlus limits ` +
`for ${model.id} (total pixels within [${minPixels}, ${MAX_TOTAL_PIXELS}], ` +
`for ${model.id} (total pixels within [${minPixels}, ${maxPixels}], ` +
'aspect ratio within [1/16, 16])',
{ legacyCode: 'bad_request' },
);
}
return `${ratio.w}x${ratio.h}`;
}
// Reduce w:h to lowest terms so any spelling of a supported
// aspect (8:6, 32:18, ...) finds its documented tier size.
const gcd = (a: number, b: number): number =>
b === 0 ? a : gcd(b, a % b);
const d = gcd(Math.round(ratio.w), Math.round(ratio.h)) || 1;
const key = `${Math.round(ratio.w) / d}:${Math.round(ratio.h) / d}`;
const mapped = SEEDREAM_RESOLUTION_MAP[key]?.[tier];
const aspects = Object.entries(model.resolution_map ?? {}).map(
([key, sizes]) => {
const [w, h] = key.split(':').map(Number);
return { w, h, sizes };
},
);
const mapped = aspects.length
? closestAspectRatio(ratio, aspects).sizes[tier]
: undefined;
if (mapped) return `${mapped.w}x${mapped.h}`;
}
return { '1k': '1K', '1.5k': '1.5K', '2k': '2K' }[tier];
return tier.toUpperCase();
}
/** Pixel count of an explicit `WxH` size; undefined for tier keywords. */
@@ -19,12 +19,16 @@
import type { IImageModel } from '../../types.js';
// Ark's `size` "method 1" tiers and the pixel dimensions each (tier, aspect
// ratio) pair resolves to, from the image generation API reference:
export type BytePlusImageModel = IImageModel & {
minPixels: number;
maxPixels: number;
};
export const BYTEPLUS_PIXEL_SIZE_THRESHOLD = 921_600;
// Output dimensions from the ModelArk image generation API reference.
// https://docs.byteplus.com/en/docs/ModelArk/1541523
// The table is identical for dola-seedream-5-0-pro, seedream-5-0-lite,
// seedream-4-5 and seedream-4-0.
export const SEEDREAM_RESOLUTION_MAP: Record<
export const SEEDREAM_PRO_RESOLUTION_MAP: Record<
string,
Record<string, { w: number; h: number }>
> = {
@@ -70,12 +74,59 @@ export const SEEDREAM_RESOLUTION_MAP: Record<
},
};
const SEEDREAM_QUALITY_LEVELS = ['1k', '1.5k', '2k'];
// seedream-4-5 and the 5.0 series enforce a minimum output of 3,686,400
// pixels, which every 1K and 1.5K entry in the table above falls under —
// they only accept the 2K tier.
const SEEDREAM_2K_ONLY = ['2k'];
export const SEEDREAM_RESOLUTION_MAP: Record<
string,
Record<string, { w: number; h: number }>
> = {
'1:1': {
'1k': { w: 1024, h: 1024 },
'2k': { w: 2048, h: 2048 },
'3k': { w: 3072, h: 3072 },
'4k': { w: 4096, h: 4096 },
},
'4:3': {
'1k': { w: 1152, h: 864 },
'2k': { w: 2304, h: 1728 },
'3k': { w: 3456, h: 2592 },
'4k': { w: 4704, h: 3520 },
},
'3:4': {
'1k': { w: 864, h: 1152 },
'2k': { w: 1728, h: 2304 },
'3k': { w: 2592, h: 3456 },
'4k': { w: 3520, h: 4704 },
},
'16:9': {
'1k': { w: 1424, h: 800 },
'2k': { w: 2848, h: 1600 },
'3k': { w: 4096, h: 2304 },
'4k': { w: 5504, h: 3040 },
},
'9:16': {
'1k': { w: 800, h: 1424 },
'2k': { w: 1600, h: 2848 },
'3k': { w: 2304, h: 4096 },
'4k': { w: 3040, h: 5504 },
},
'3:2': {
'1k': { w: 1248, h: 832 },
'2k': { w: 2496, h: 1664 },
'3k': { w: 3744, h: 2496 },
'4k': { w: 4992, h: 3328 },
},
'2:3': {
'1k': { w: 832, h: 1248 },
'2k': { w: 1664, h: 2496 },
'3k': { w: 2496, h: 3744 },
'4k': { w: 3328, h: 4992 },
},
'21:9': {
'1k': { w: 1568, h: 672 },
'2k': { w: 3136, h: 1344 },
'3k': { w: 4704, h: 2016 },
'4k': { w: 6240, h: 2656 },
},
};
// Costs are in usd-cents per image, hardcoded from
// https://docs.byteplus.com/en/docs/ModelArk/1544106 (pricing) and
@@ -85,10 +136,13 @@ const SEEDREAM_2K_ONLY = ['2k'];
// lower") vs above — plus a per-input-image rate from the second reference
// image onward (the first is free). Every other model is a flat per-image
// rate with free image input.
export const BYTEPLUS_IMAGE_GENERATION_MODELS: IImageModel[] = [
export const BYTEPLUS_IMAGE_GENERATION_MODELS: BytePlusImageModel[] = [
{
puterId: 'byteplus:byteplus/dola-seedream-5-0-pro-260628',
id: 'dola-seedream-5-0-pro-260628',
pixelSizeThreshold: BYTEPLUS_PIXEL_SIZE_THRESHOLD,
minPixels: 921_600,
maxPixels: 4_624_220,
aliases: [
'byteplus/dola-seedream-5-0-pro-260628',
'dola-seedream-5-0-pro',
@@ -105,8 +159,8 @@ export const BYTEPLUS_IMAGE_GENERATION_MODELS: IImageModel[] = [
'output:2k': 9, // $0.09 per image > 2.61MP
input_image: 0.3, // $0.003 per input image from the 2nd on
},
allowedQualityLevels: SEEDREAM_QUALITY_LEVELS,
resolution_map: SEEDREAM_RESOLUTION_MAP,
allowedQualityLevels: ['1k', '1.5k', '2k'],
resolution_map: SEEDREAM_PRO_RESOLUTION_MAP,
},
{
// The catalog lists `seedream-5-0-260128` as the same service
@@ -114,6 +168,9 @@ export const BYTEPLUS_IMAGE_GENERATION_MODELS: IImageModel[] = [
// under the -lite id, so that's the primary id here.
puterId: 'byteplus:byteplus/seedream-5-0-lite-260128',
id: 'seedream-5-0-lite-260128',
pixelSizeThreshold: BYTEPLUS_PIXEL_SIZE_THRESHOLD,
minPixels: 3_686_400,
maxPixels: 16_777_216,
aliases: [
'byteplus/seedream-5-0-lite-260128',
'seedream-5-0-lite',
@@ -126,12 +183,15 @@ export const BYTEPLUS_IMAGE_GENERATION_MODELS: IImageModel[] = [
pricing_unit: 'per-image',
index_cost_key: 'per-image',
costs: { 'per-image': 3.5 },
allowedQualityLevels: SEEDREAM_2K_ONLY,
allowedQualityLevels: ['2k', '3k', '4k'],
resolution_map: SEEDREAM_RESOLUTION_MAP,
},
{
puterId: 'byteplus:byteplus/seedream-4-5-251128',
id: 'seedream-4-5-251128',
pixelSizeThreshold: BYTEPLUS_PIXEL_SIZE_THRESHOLD,
minPixels: 3_686_400,
maxPixels: 16_777_216,
aliases: [
'byteplus/seedream-4-5-251128',
'seedream-4-5',
@@ -142,12 +202,15 @@ export const BYTEPLUS_IMAGE_GENERATION_MODELS: IImageModel[] = [
pricing_unit: 'per-image',
index_cost_key: 'per-image',
costs: { 'per-image': 4 },
allowedQualityLevels: SEEDREAM_2K_ONLY,
allowedQualityLevels: ['2k', '4k'],
resolution_map: SEEDREAM_RESOLUTION_MAP,
},
{
puterId: 'byteplus:byteplus/seedream-4-0-250828',
id: 'seedream-4-0-250828',
pixelSizeThreshold: BYTEPLUS_PIXEL_SIZE_THRESHOLD,
minPixels: 921_600,
maxPixels: 16_777_216,
aliases: [
'byteplus/seedream-4-0-250828',
'seedream-4-0',
@@ -158,7 +221,7 @@ export const BYTEPLUS_IMAGE_GENERATION_MODELS: IImageModel[] = [
pricing_unit: 'per-image',
index_cost_key: 'per-image',
costs: { 'per-image': 3 },
allowedQualityLevels: SEEDREAM_QUALITY_LEVELS,
allowedQualityLevels: ['1k', '2k', '4k'],
resolution_map: SEEDREAM_RESOLUTION_MAP,
},
];
@@ -20,12 +20,11 @@
/**
* Offline unit tests for CloudflareImageProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs CloudflareImageProvider directly against the
* live wired `MeteringService` so the recording side is exercised
* end-to-end. Cloudflare has no SDK — the provider hits the REST API
* directly via global `fetch`, which we stub. That's the real network
* egress point.
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
* constructs CloudflareImageProvider directly against the live wired
* `MeteringService` so the recording side is exercised end-to-end. Cloudflare
* has no SDK — the provider hits the REST API directly via global `fetch`,
* which we stub. That's the real network egress point.
*/
import {
@@ -47,14 +46,14 @@ import { withTestActor } from '../../../integrationTestUtil.js';
import { CLOUDFLARE_IMAGE_GENERATION_MODELS } from './models.js';
import { CloudflareImageProvider } from './CloudflareImageProvider.js';
// Stub the URL→base64 fetch so URL inputs stay offline; keep the rest real.
const { fetchImageAsBase64Mock } = vi.hoisted(() => ({
fetchImageAsBase64Mock: vi.fn(),
// Keep URL downloads offline; other input parsing stays real.
const { fetchImageBytesMock } = vi.hoisted(() => ({
fetchImageBytesMock: vi.fn(),
}));
vi.mock('../../inputImage.js', async (orig) => ({
...(await orig<typeof import('../../inputImage.js')>()),
fetchImageAsBase64: fetchImageAsBase64Mock,
fetchImageBytes: fetchImageBytesMock,
}));
// ── Test harness ────────────────────────────────────────────────────
@@ -91,7 +90,7 @@ const makeProvider = (
);
beforeEach(() => {
fetchImageAsBase64Mock.mockReset();
fetchImageBytesMock.mockReset();
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
batchIncrementUsagesSpy = vi.spyOn(
@@ -181,7 +180,7 @@ describe('CloudflareImageProvider.generate credit gate', () => {
// ── Request shape ──────────────────────────────────────────────────
describe('CloudflareImageProvider.generate request shape', () => {
it('POSTs JSON with width/height/steps to the account-scoped /ai/run/<model> endpoint', async () => {
it('sends only fields supported by Schnell to its account-scoped endpoint', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(
okJsonResponse({
@@ -195,7 +194,7 @@ describe('CloudflareImageProvider.generate request shape', () => {
provider.generate({
model: '@cf/black-forest-labs/flux-1-schnell',
prompt: 'a tiny red dot',
ratio: { w: 1024, h: 1024 },
imageSize: { w: 1024, h: 1024, kind: 'pixels' },
} as never),
);
@@ -209,13 +208,7 @@ describe('CloudflareImageProvider.generate request shape', () => {
expect(headers.Authorization).toBe('Bearer cf-test-token');
expect(headers['Content-Type']).toBe('application/json');
const body = JSON.parse(init?.body as string);
expect(body.prompt).toBe('a tiny red dot');
expect(body.width).toBe(1024);
expect(body.height).toBe(1024);
// Schnell defaults to 4 steps; provider sends both steps + num_steps
// for compatibility with both naming conventions.
expect(body.steps).toBe(4);
expect(body.num_steps).toBe(4);
expect(body).toEqual({ prompt: 'a tiny red dot', steps: 4 });
});
it('honours an apiBaseUrl override', async () => {
@@ -253,7 +246,7 @@ describe('CloudflareImageProvider.generate request shape', () => {
// flux-2-dev has requiresMultipart=true.
model: '@cf/black-forest-labs/flux-2-dev',
prompt: 'hi',
ratio: { w: 1024, h: 1024 },
imageSize: { w: 1024, h: 1024, kind: 'pixels' },
} as never),
);
@@ -268,7 +261,7 @@ describe('CloudflareImageProvider.generate request shape', () => {
expect(form.get('height')).toBe('1024');
});
it('clamps user-supplied steps to [1,50]', async () => {
it('clamps Schnell steps to [1,8]', async () => {
const provider = makeProvider();
// High clamp.
@@ -282,9 +275,9 @@ describe('CloudflareImageProvider.generate request shape', () => {
steps: 999,
} as never),
);
expect(JSON.parse(fetchSpy.mock.calls[0]![1]!.body as string).steps).toBe(
50,
);
expect(
JSON.parse(fetchSpy.mock.calls[0]![1]!.body as string).steps,
).toBe(8);
// Low clamp.
fetchSpy.mockResolvedValueOnce(
@@ -297,9 +290,9 @@ describe('CloudflareImageProvider.generate request shape', () => {
steps: 0,
} as never),
);
expect(JSON.parse(fetchSpy.mock.calls[1]![1]!.body as string).steps).toBe(
1,
);
expect(
JSON.parse(fetchSpy.mock.calls[1]![1]!.body as string).steps,
).toBe(1);
});
});
@@ -341,7 +334,7 @@ describe('CloudflareImageProvider.generate output extraction', () => {
} as never),
);
expect(result).toBe('data:image/webp;base64,AAAA');
expect(result).toBe('data:image/jpeg;base64,AAAA');
});
it('passes through an http(s) URL or data URL straight from the JSON envelope', async () => {
@@ -426,7 +419,7 @@ describe('CloudflareImageProvider.generate cost components', () => {
provider.generate({
model: '@cf/black-forest-labs/flux-1-schnell',
prompt: 'hi',
ratio: { w: 1024, h: 1024 }, // 2x2 tiles = 4
imageSize: { w: 1024, h: 1024, kind: 'pixels' }, // 2x2 tiles = 4
} as never),
);
@@ -462,7 +455,7 @@ describe('CloudflareImageProvider.generate cost components', () => {
model: '@cf/black-forest-labs/flux-2-klein-9b',
prompt: 'hi',
// 2 MP image to exercise both first_mp and subsequent_mp.
ratio: { w: 2000, h: 1000 },
imageSize: { w: 2000, h: 1000, kind: 'pixels' },
image: 'data:image/png;base64,AAAA', // hasInputImage=true
} as never),
);
@@ -497,7 +490,7 @@ describe('CloudflareImageProvider.generate input_images', () => {
provider.generate({
model: '@cf/black-forest-labs/flux-2-klein-9b',
prompt: 'edit it',
ratio: { w: 2000, h: 1000 },
imageSize: { w: 2000, h: 1000, kind: 'pixels' },
...extra,
} as never),
);
@@ -521,12 +514,12 @@ describe('CloudflareImageProvider.generate input_images', () => {
});
it('fetches an http(s) URL input via secureFetch and uses it as the input image', async () => {
fetchImageAsBase64Mock.mockResolvedValueOnce({
base64: 'AAAA',
fetchImageBytesMock.mockResolvedValueOnce({
bytes: Buffer.from('AAAA', 'base64'),
mime: 'image/png',
});
await klein9bWith({ input_images: ['https://example.com/in.png'] });
expect(fetchImageAsBase64Mock).toHaveBeenCalledWith(
expect(fetchImageBytesMock).toHaveBeenCalledWith(
'https://example.com/in.png',
);
expect(hasInputCostLine()).toBe(true);
@@ -539,12 +532,221 @@ describe('CloudflareImageProvider.generate input_images', () => {
provider.generate({
model: '@cf/black-forest-labs/flux-2-klein-9b',
prompt: 'edit it',
ratio: { w: 1024, h: 1024 },
input_images: ['data:image/png;base64,AAAA', 'data:image/png;base64,BBBB'],
imageSize: { w: 1024, h: 1024, kind: 'pixels' },
input_images: [
'data:image/png;base64,AAAA',
'data:image/png;base64,BBBB',
],
} as never),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
expect(fetchImageAsBase64Mock).not.toHaveBeenCalled();
expect(fetchImageBytesMock).not.toHaveBeenCalled();
});
});
describe('CloudflareImageProvider reference transport', () => {
it('labels a FLUX.2 JPEG response with its actual MIME type', async () => {
fetchSpy.mockResolvedValueOnce(
okJsonResponse({ result: { image: '/9j/4A==' } }),
);
const result = await withTestActor(() =>
makeProvider().generate({
model: '@cf/black-forest-labs/flux-2-klein-4b',
prompt: 'a landscape',
}),
);
expect(result).toBe('data:image/jpeg;base64,/9j/4A==');
});
it('uploads decoded image bytes using the FLUX.2 multipart field', async () => {
fetchSpy.mockResolvedValueOnce(
okJsonResponse({ result: { image: 'AAAA' } }),
);
await withTestActor(() =>
makeProvider().generate({
model: '@cf/black-forest-labs/flux-2-klein-4b',
prompt: 'a landscape',
input_images: ['data:image/png;base64,AQID'],
steps: 40,
}),
);
const form = fetchSpy.mock.calls[0][1]!.body as FormData;
const input = form.get('input_image_0') as File;
expect(input.type).toBe('image/png');
expect([...new Uint8Array(await input.arrayBuffer())]).toEqual([
1, 2, 3,
]);
expect(form.has('image')).toBe(false);
expect(form.has('steps')).toBe(false);
});
it('rejects image inputs on text-only models before a provider request', async () => {
await expect(
withTestActor(() =>
makeProvider().generate({
prompt: 'a landscape',
input_image: 'AAAA',
}),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
});
describe('Cloudflare legacy image option', () => {
it.each([{ url: 'https://example.com/in.png' }, 123, ['AAAA']])(
'rejects a non-string image %j as 400 before any upstream call',
async (image) => {
await expect(
withTestActor(() =>
makeProvider().generate({
model: '@cf/black-forest-labs/flux-2-dev',
prompt: 'hi',
image,
} as never),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
});
expect(fetchSpy).not.toHaveBeenCalled();
expect(hasCreditsSpy).not.toHaveBeenCalled();
},
);
it('rejects an input that decodes to zero bytes instead of uploading it', async () => {
await expect(
withTestActor(() =>
makeProvider().generate({
model: '@cf/black-forest-labs/flux-2-dev',
prompt: 'hi',
input_images: ['data:image/png;base64,'],
}),
),
).rejects.toMatchObject({
statusCode: 400,
message: 'Invalid input image (empty or not base64)',
});
expect(fetchSpy).not.toHaveBeenCalled();
expect(hasCreditsSpy).not.toHaveBeenCalled();
});
});
describe('Cloudflare size bounds', () => {
it.each([
{ w: 4, h: 1 },
{ w: 1, h: 4 },
{ w: 32, h: 1 },
])('fits an aspect hint %j inside FLUX.2 side limits', async (ratio) => {
fetchSpy.mockResolvedValueOnce(
okJsonResponse({ result: { image: 'AAAA' } }),
);
await withTestActor(() =>
makeProvider().generate({
model: '@cf/black-forest-labs/flux-2-klein-4b',
prompt: 'hi',
imageSize: { ...ratio, kind: 'aspect' },
}),
);
const body = fetchSpy.mock.calls[0][1]!.body as FormData;
for (const side of ['width', 'height']) {
expect(Number(body.get(side))).toBeGreaterThanOrEqual(256);
expect(Number(body.get(side))).toBeLessThanOrEqual(1920);
}
});
});
it.each([
{ model: '@cf/leonardo/lucid-origin', negative: false },
{ model: '@cf/leonardo/phoenix-1.0', negative: true },
])(
'sends the supported JSON fields for $model',
async ({ model, negative }) => {
fetchSpy.mockResolvedValueOnce(
okJsonResponse({ result: { image: 'AAAA' } }),
);
await withTestActor(() =>
makeProvider().generate({
model,
prompt: 'hi',
imageSize: { w: 1024, h: 768, kind: 'pixels' },
steps: 12,
seed: 42,
guidance: 4,
negative_prompt: 'blur',
}),
);
expect(JSON.parse(fetchSpy.mock.calls[0][1]!.body as string)).toEqual({
prompt: 'hi',
width: 1024,
height: 768,
num_steps: 12,
seed: 42,
guidance: 4,
...(negative ? { negative_prompt: 'blur' } : {}),
});
},
);
it('meters the bounded FLUX.2 dimensions sent upstream', async () => {
fetchSpy.mockResolvedValueOnce(
okJsonResponse({ result: { image: 'AAAA' } }),
);
await withTestActor(() =>
makeProvider().generate({
model: '@cf/black-forest-labs/flux-2-klein-4b',
prompt: 'hi',
imageSize: { w: 32, h: 1, kind: 'aspect' },
}),
);
const body = fetchSpy.mock.calls[0][1]!.body as FormData;
expect(body.get('width')).toBe('1920');
expect(body.get('height')).toBe('256');
expect(batchIncrementUsagesSpy.mock.calls[0][1]).toEqual([
expect.objectContaining({
usageType: expect.stringContaining(':input_tile_512'),
usageAmount: 4,
costOverride: 23600,
}),
expect.objectContaining({
usageType: expect.stringContaining(':output_tile_512'),
usageAmount: 4,
costOverride: 114800,
}),
]);
});
describe('Cloudflare priced legacy models', () => {
it('sends image and mask bytes to inpainting and preserves a published zero price', async () => {
fetchSpy.mockResolvedValueOnce(new Response(new Uint8Array([1, 2, 3]), { headers: { 'content-type': 'image/png' } }));
await withTestActor(() => makeProvider().generate({
model: '@cf/runwayml/stable-diffusion-v1-5-inpainting', prompt: 'A cup',
input_images: ['data:image/png;base64,AQID'], maskImage: 'data:image/png;base64,BAUG',
imageSize: { kind: 'pixels', w: 512, h: 512 },
}));
expect(JSON.parse(fetchSpy.mock.calls[0]![1]!.body as string)).toMatchObject({ image: [1, 2, 3], mask: [4, 5, 6], num_steps: 20, width: 512, height: 512 });
expect(hasCreditsSpy).toHaveBeenCalledWith(expect.anything(), 0);
});
it('rejects missing inpainting inputs before credit checks', async () => {
await expect(withTestActor(() => makeProvider().generate({ model: '@cf/runwayml/stable-diffusion-v1-5-inpainting', prompt: 'A cup' }))).rejects.toMatchObject({ statusCode: 400 });
expect(hasCreditsSpy).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
});
it.each(['@cf/bytedance/stable-diffusion-xl-lightning', '@cf/stabilityai/stable-diffusion-xl-base-1.0'])(
'rejects an input image on %s, which Cloudflare serves text-to-image only', async (model) => {
await expect(withTestActor(() => makeProvider().generate({
model, prompt: 'A cup', input_image: 'data:image/png;base64,AQID',
}))).rejects.toMatchObject({ statusCode: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
},
);
it('charges Lucid at the price returned by the model API', async () => {
fetchSpy.mockResolvedValueOnce(new Response(new Uint8Array([1, 2, 3]), { headers: { 'content-type': 'image/png' } }));
await withTestActor(() => makeProvider().generate({ model: '@cf/leonardo/lucid-origin', prompt: 'A cup', imageSize: { kind: 'pixels', w: 512, h: 512 }, steps: 1 }));
expect(hasCreditsSpy).toHaveBeenCalledWith(expect.anything(), 713200);
});
});
@@ -17,6 +17,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { assertImagePrompt } from '../../imageValidation.js';
import { expandAspectRatio } from '../../imageDimensions.js';
import { imageDataUri } from '../../imageOutput.js';
import { HttpError } from '@heyputer/backend/src/core/http/HttpError.js';
import { Context } from '../../../../core/context.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
@@ -30,9 +33,11 @@ import {
CloudflareImageModel,
} from './models.js';
import {
fetchImageAsBase64,
assertInputImageString,
fetchImageBytes,
isHttpUrl,
resolveSingleInputImage,
parseDataUri,
} from '../../inputImage.js';
type CloudflareGenerateParams = IGenerateParams & {
@@ -41,8 +46,9 @@ type CloudflareGenerateParams = IGenerateParams & {
seed?: number;
guidance?: number;
negative_prompt?: string;
output_format?: 'jpeg' | 'png' | 'webp';
image?: string;
maskImage?: string;
strength?: number;
};
interface CostComponent {
@@ -84,20 +90,18 @@ export class CloudflareImageProvider implements IImageProvider {
}
async generate(params: IGenerateParams): Promise<string> {
const options = params as CloudflareGenerateParams;
const options = { ...params } as CloudflareGenerateParams;
const { prompt, test_mode } = options;
const ratio = this.#normalizeRatio(options.ratio);
const selectedModel = this.#getModel(options.model);
const ratio = selectedModel.fixedRatio
? { ...selectedModel.fixedRatio }
: this.#normalizeRatio(options, selectedModel);
if (test_mode) {
return 'https://puter-sample-data.puter.site/image_example.png';
}
if (typeof prompt !== 'string' || prompt.trim().length === 0) {
throw new HttpError(400, '`prompt` must be a non-empty string', {
legacyCode: 'bad_request',
});
}
assertImagePrompt(prompt);
const actor = Context.get('actor');
if (!actor) {
@@ -106,21 +110,82 @@ export class CloudflareImageProvider implements IImageProvider {
});
}
// Canonical `input_images`/`input_image` → Cloudflare's `image` field.
// Cloudflare accepts a single input image; a URL is fetched to base64
// server-side (SSRF-guarded) since the API has no URL field.
const singleInput = resolveSingleInputImage(options, 'Cloudflare');
// The legacy `image` option bypasses the driver's input_images shape
// check, so it gets the same string validation here before anything
// reaches the base64 decoder.
const legacyImage =
options.image == null
? undefined
: assertInputImageString(options.image, 'Cloudflare');
const singleInput =
resolveSingleInputImage(options, 'Cloudflare') ?? legacyImage;
let inputImage: { bytes: Buffer; mime: string } | undefined;
if (singleInput) {
options.image ??= isHttpUrl(singleInput)
? (await fetchImageAsBase64(singleInput)).base64
: singleInput;
if (
!selectedModel.requiresMultipart &&
!selectedModel.supportsImageBytes
) {
throw new HttpError(
400,
`${selectedModel.id} does not support input images`,
{ legacyCode: 'bad_request' },
);
}
if (isHttpUrl(singleInput))
inputImage = await fetchImageBytes(singleInput);
else {
const data = parseDataUri(singleInput);
inputImage = {
bytes: Buffer.from(data?.base64 ?? singleInput, 'base64'),
mime:
data?.mime ??
options.input_image_mime_type ??
'image/png',
};
}
if (inputImage.bytes.length === 0) {
throw new HttpError(
400,
'Invalid input image (empty or not base64)',
{ legacyCode: 'bad_request' },
);
}
}
if (selectedModel.requiresInputImage && !inputImage) {
throw new HttpError(
400,
`${selectedModel.id} requires an input image`,
{
legacyCode: 'bad_request',
},
);
}
let maskBytes: Buffer | undefined;
if (options.maskImage != null) {
const mask = assertInputImageString(options.maskImage, 'maskImage');
if (!selectedModel.supportsImageBytes || !inputImage) {
throw new HttpError(
400,
'maskImage requires a model supporting masks and an input image',
{
legacyCode: 'bad_request',
},
);
}
maskBytes = isHttpUrl(mask)
? (await fetchImageBytes(mask)).bytes
: Buffer.from(parseDataUri(mask)?.base64 ?? mask, 'base64');
if (!maskBytes.length) {
throw new HttpError(400, 'maskImage must contain image data', {
legacyCode: 'bad_request',
});
}
}
const steps = this.#resolveSteps(selectedModel, options);
const costComponents = this.#estimateCost(selectedModel, ratio, steps, {
hasInputImage:
typeof options.image === 'string' &&
options.image.trim() !== '',
hasInputImage: !!inputImage,
});
const totalCostInMicroCents = costComponents.reduce(
(acc, component) => acc + component.totalCostMicroCents,
@@ -142,6 +207,8 @@ export class CloudflareImageProvider implements IImageProvider {
...options,
ratio,
steps,
inputImage,
maskBytes,
});
this.#meteringService.batchIncrementUsages(
@@ -170,33 +237,39 @@ export class CloudflareImageProvider implements IImageProvider {
return found || models.find((m) => m.id === DEFAULT_MODEL)!;
}
#normalizeRatio(ratio?: { w: number; h: number }) {
const width = Number(ratio?.w);
const height = Number(ratio?.h);
if (
Number.isFinite(width) &&
Number.isFinite(height) &&
width > 0 &&
height > 0
) {
return {
w: Math.max(64, Math.round(width)),
h: Math.max(64, Math.round(height)),
};
}
return { ...DEFAULT_RATIO };
#normalizeRatio(params: IGenerateParams, model: CloudflareImageModel) {
// The driver resolves every caller-facing size field into `imageSize`.
const size = params.imageSize;
const { w, h } =
size?.kind === 'aspect'
? expandAspectRatio(size)
: (size ?? DEFAULT_RATIO);
const min = model.minDimension ?? 64;
const max = model.maxDimension ?? 2500;
const scale =
size?.kind === 'aspect'
? Math.min(
max / Math.max(w, h),
Math.max(min / Math.min(w, h), 1),
)
: 1;
return {
w: Math.min(max, Math.max(min, Math.round(w * scale))),
h: Math.min(max, Math.max(min, Math.round(h * scale))),
};
}
#resolveSteps(
model: CloudflareImageModel,
options: CloudflareGenerateParams,
): number {
if (model.fixedSteps) return model.fixedSteps;
const input = Number(
options.steps ?? options.num_steps ?? model.defaultSteps ?? 25,
);
const fallback = model.defaultSteps ?? 25;
if (!Number.isFinite(input)) return fallback;
return Math.max(1, Math.min(50, Math.round(input)));
return Math.max(1, Math.min(model.maxSteps ?? 50, Math.round(input)));
}
// Cloudflare models have *really exact* billing needs. They pretty much bill based on exactly what the model does
@@ -329,6 +402,8 @@ export class CloudflareImageProvider implements IImageProvider {
params: CloudflareGenerateParams & {
ratio: { w: number; h: number };
steps: number;
inputImage?: { bytes: Buffer; mime: string };
maskBytes?: Buffer;
},
) {
const endpoint = `${this.#apiBaseUrl}/accounts/${this.#accountId}/ai/run/${model.id}`;
@@ -342,7 +417,8 @@ export class CloudflareImageProvider implements IImageProvider {
formData.append('prompt', params.prompt);
formData.append('width', String(params.ratio.w));
formData.append('height', String(params.ratio.h));
formData.append('steps', String(params.steps));
if (!model.fixedSteps)
formData.append('steps', String(params.steps));
if (Number.isFinite(params.seed))
formData.append(
@@ -351,34 +427,43 @@ export class CloudflareImageProvider implements IImageProvider {
);
if (Number.isFinite(params.guidance))
formData.append('guidance', String(params.guidance));
if (typeof params.negative_prompt === 'string')
formData.append('negative_prompt', params.negative_prompt);
if (typeof params.output_format === 'string')
formData.append('output_format', params.output_format);
if (typeof params.image === 'string')
formData.append('image', params.image);
if (params.inputImage) {
formData.append(
'input_image_0',
new Blob([new Uint8Array(params.inputImage.bytes)], {
type: params.inputImage.mime,
}),
'input-image',
);
}
body = formData;
} else {
headers['Content-Type'] = 'application/json';
body = JSON.stringify({
prompt: params.prompt,
width: params.ratio.w,
height: params.ratio.h,
steps: params.steps,
num_steps: params.steps,
...(Number.isFinite(params.seed)
? { seed: Math.round(params.seed as number) }
: {}),
...(Number.isFinite(params.guidance)
? { guidance: params.guidance }
: {}),
...(typeof params.negative_prompt === 'string'
? { negative_prompt: params.negative_prompt }
: {}),
...(typeof params.output_format === 'string'
? { output_format: params.output_format }
: {}),
});
const request: Record<string, unknown> = { prompt: params.prompt };
if (model.fixedRatio) {
request.steps = params.steps;
} else {
request.width = params.ratio.w;
request.height = params.ratio.h;
request.num_steps = params.steps;
if (model.supportsImageBytes && params.inputImage) {
request.image = [...params.inputImage.bytes];
if (params.maskBytes) request.mask = [...params.maskBytes];
if (Number.isFinite(params.strength))
request.strength = params.strength;
}
if (Number.isFinite(params.seed))
request.seed = Math.round(params.seed!);
if (Number.isFinite(params.guidance))
request.guidance = params.guidance;
if (
model.supportsNegativePrompt &&
typeof params.negative_prompt === 'string'
) {
request.negative_prompt = params.negative_prompt;
}
}
body = JSON.stringify(request);
}
const response = await fetch(endpoint, {
@@ -439,8 +524,8 @@ export class CloudflareImageProvider implements IImageProvider {
return imageString;
}
const mime = this.#mimeForFormat(params.output_format);
return `data:${mime};base64,${imageString}`;
const mime = model.outputMime ?? 'image/png';
return imageDataUri(imageString, mime);
}
#extractImageString(payload: unknown): string | undefined {
@@ -502,12 +587,6 @@ export class CloudflareImageProvider implements IImageProvider {
return (w * h) / 1_000_000;
}
#mimeForFormat(format?: string) {
if (format === 'jpeg') return 'image/jpeg';
if (format === 'webp') return 'image/webp';
return 'image/png';
}
#costForUnits(units: number, microCentsPerUnit?: number) {
if (!Number.isFinite(units) || units <= 0) return 0;
if (
@@ -30,15 +30,70 @@ export type CloudflareImageModel = IImageModel & {
billingScheme: CloudflareBillingScheme;
defaultSteps?: number;
requiresMultipart?: boolean;
maxSteps?: number;
fixedSteps?: number;
outputMime?: string;
minDimension?: number;
maxDimension?: number;
fixedRatio?: { w: number; h: number };
supportsNegativePrompt?: boolean;
supportsImageBytes?: boolean;
requiresInputImage?: boolean;
};
// Source: Cloudflare Workers AI docs and model pages.
// Pricing values are in USD microcents for billing units.
export const CLOUDFLARE_IMAGE_GENERATION_MODELS: CloudflareImageModel[] = [
{
id: '@cf/bytedance/stable-diffusion-xl-lightning',
puterId: 'workers-ai:bytedance/stable-diffusion-xl-lightning',
name: 'SDXL Lightning',
costs_currency: 'usd-microcents',
index_cost_key: 'step',
costs: { step: 0 },
billingScheme: 'step-only',
defaultSteps: 20,
maxSteps: 20,
minDimension: 256,
maxDimension: 2048,
supportsNegativePrompt: true,
},
{
id: '@cf/stabilityai/stable-diffusion-xl-base-1.0',
puterId: 'workers-ai:stabilityai/stable-diffusion-xl-base-1.0',
name: 'Stable Diffusion XL Base 1.0',
costs_currency: 'usd-microcents',
index_cost_key: 'step',
costs: { step: 0 },
billingScheme: 'step-only',
defaultSteps: 20,
maxSteps: 20,
minDimension: 256,
maxDimension: 2048,
supportsNegativePrompt: true,
},
{
id: '@cf/runwayml/stable-diffusion-v1-5-inpainting',
puterId: 'workers-ai:runwayml/stable-diffusion-v1-5-inpainting',
name: 'Stable Diffusion 1.5 Inpainting',
costs_currency: 'usd-microcents',
index_cost_key: 'step',
costs: { step: 0 },
billingScheme: 'step-only',
defaultSteps: 20,
maxSteps: 20,
minDimension: 256,
maxDimension: 2048,
supportsNegativePrompt: true,
supportsImageBytes: true,
requiresInputImage: true,
},
{
puterId: 'workers-ai:black-forest-labs/flux.1-schnell',
id: '@cf/black-forest-labs/flux-1-schnell',
aliases: ['black-forest-labs/flux.1-schnell'],
// No bare `black-forest-labs/flux.1-schnell` alias: it is the retired
// Together spelling, and a shared alias would route Together callers
// here whenever Together is not configured.
name: 'FLUX.1 Schnell',
costs_currency: 'usd-microcents',
index_cost_key: 'step',
@@ -48,16 +103,21 @@ export const CLOUDFLARE_IMAGE_GENERATION_MODELS: CloudflareImageModel[] = [
},
billingScheme: 'tile-plus-step',
defaultSteps: 4,
maxSteps: 8,
outputMime: 'image/jpeg',
fixedRatio: { w: 1024, h: 1024 },
},
{
puterId: 'workers-ai:leonardo/lucid-origin',
id: '@cf/leonardo/lucid-origin',
aliases: ['leonardo/lucid-origin'],
name: 'Lucid Origin',
maxDimension: 2500,
maxSteps: 40,
costs_currency: 'usd-microcents',
index_cost_key: 'step',
costs: {
tile_512: 699600,
tile_512: 700000,
step: 13200,
},
billingScheme: 'tile-plus-step',
@@ -66,8 +126,10 @@ export const CLOUDFLARE_IMAGE_GENERATION_MODELS: CloudflareImageModel[] = [
{
puterId: 'workers-ai:leonardo/phoenix-1.0',
id: '@cf/leonardo/phoenix-1.0',
supportsNegativePrompt: true,
aliases: ['leonardo/phoenix-1.0'],
name: 'Phoenix 1.0',
maxDimension: 2048,
costs_currency: 'usd-microcents',
index_cost_key: 'step',
costs: {
@@ -91,6 +153,8 @@ export const CLOUDFLARE_IMAGE_GENERATION_MODELS: CloudflareImageModel[] = [
billingScheme: 'flux2-dev-tile-step',
defaultSteps: 25,
requiresMultipart: true,
minDimension: 256,
maxDimension: 1920,
},
{
puterId: 'workers-ai:black-forest-labs/flux.2-klein-4b',
@@ -104,7 +168,10 @@ export const CLOUDFLARE_IMAGE_GENERATION_MODELS: CloudflareImageModel[] = [
output_tile_512: 28700,
},
billingScheme: 'flux2-klein-4b-tile',
fixedSteps: 4,
requiresMultipart: true,
minDimension: 256,
maxDimension: 1920,
},
{
puterId: 'workers-ai:black-forest-labs/flux.2-klein-9b',
@@ -119,6 +186,9 @@ export const CLOUDFLARE_IMAGE_GENERATION_MODELS: CloudflareImageModel[] = [
input_image_mp: 200000,
},
billingScheme: 'flux2-klein-9b-mp',
fixedSteps: 4,
requiresMultipart: true,
minDimension: 256,
maxDimension: 1920,
},
];
@@ -20,11 +20,10 @@
/**
* Offline unit tests for GeminiImageProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs GeminiImageProvider directly against the
* live wired `MeteringService` so the recording side runs end-to-end.
* The Google GenAI SDK is mocked at the module boundary — that's the
* real network egress point.
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock redis) and
* constructs GeminiImageProvider directly against the live wired
* `MeteringService` so the recording side runs end-to-end. The Google GenAI SDK
* is mocked at the module boundary — that's the real network egress point.
*/
import {
@@ -197,6 +196,90 @@ describe('GeminiImageProvider.generate argument validation', () => {
).rejects.toMatchObject({ statusCode: 400 });
expect(generateContentMock).not.toHaveBeenCalled();
});
it.each([
['gemini-3-pro-image', 'high'],
['gemini-3-pro-image', ' 8K '],
['gemini-3.1-flash-lite-image', '2K'],
])(
'rejects an unsupported quality tier %s/%j before pricing',
async (model, quality) => {
await expect(
withTestActor(() =>
makeProvider().generate({ model, prompt: 'hi', quality }),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
message: expect.stringContaining('Unsupported quality tier'),
});
expect(hasCreditsSpy).not.toHaveBeenCalled();
expect(generateContentMock).not.toHaveBeenCalled();
},
);
it('resolves quality case-insensitively to the catalog tier', async () => {
generateContentMock.mockResolvedValueOnce({
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: 'image/png',
data: 'BASE64IMG',
},
},
],
},
},
],
usageMetadata: { promptTokenCount: 12 },
});
await withTestActor(() =>
makeProvider().generate({
model: 'gemini-3-pro-image',
prompt: 'hi',
quality: ' 2k ',
}),
);
expect(generateContentMock).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({
imageConfig: expect.objectContaining({ imageSize: '2K' }),
}),
}),
);
});
it('ignores quality on a model without tiers', async () => {
generateContentMock.mockResolvedValueOnce({
candidates: [
{
content: {
parts: [
{
inlineData: {
mimeType: 'image/png',
data: 'BASE64IMG',
},
},
],
},
},
],
usageMetadata: { promptTokenCount: 12 },
});
await withTestActor(() =>
makeProvider().generate({
model: 'gemini-2.5-flash-image',
prompt: 'hi',
quality: 'high',
}),
);
const config = generateContentMock.mock.calls[0][0].config;
expect(config.imageConfig).not.toHaveProperty('imageSize');
});
});
// ── generateContent (Flash) path ────────────────────────────────────
@@ -227,6 +310,30 @@ describe('GeminiImageProvider.generate Flash path (generateContent)', () => {
},
};
it.each(['gemini-3-pro-image-preview', 'gemini-3.1-flash-image-preview'])(
'routes %s to its stable endpoint and accepts equivalent pixel ratios',
async (model) => {
generateContentMock.mockResolvedValueOnce(inlineImageResponse);
await withTestActor(() =>
makeProvider().generate({
model,
prompt: 'a landscape',
ratio: { w: 1600, h: 900 },
}),
);
expect(generateContentMock).toHaveBeenCalledWith(
expect.objectContaining({
model: model.replace('-preview', ''),
config: expect.objectContaining({
imageConfig: expect.objectContaining({
aspectRatio: '16:9',
}),
}),
}),
);
},
);
it('forwards prompt + aspectRatio config and routes to generateContent', async () => {
const provider = makeProvider();
generateContentMock.mockResolvedValueOnce(inlineImageResponse);
@@ -246,7 +353,7 @@ describe('GeminiImageProvider.generate Flash path (generateContent)', () => {
expect(sent.config.imageConfig.aspectRatio).toBe('16:9');
});
it('falls back to the first allowedRatio when an invalid ratio is supplied', async () => {
it('snaps an unlisted ratio to the nearest allowed aspect', async () => {
const provider = makeProvider();
generateContentMock.mockResolvedValueOnce(inlineImageResponse);
@@ -367,3 +474,47 @@ describe('GeminiImageProvider.generate Flash path (generateContent)', () => {
expect(imageEntry?.usageAmount).toBe(1290);
});
});
it('maps a nearby aspect hint to the closest Gemini aspect', async () => {
generateContentMock.mockResolvedValueOnce({
candidates: [
{
content: {
parts: [
{ inlineData: { mimeType: 'image/png', data: 'AAAA' } },
],
},
},
],
});
await withTestActor(() =>
makeProvider().generate({
prompt: 'hi',
model: 'gemini-2.5-flash-image',
ratio: { w: 17, h: 10 },
}),
);
expect(
generateContentMock.mock.calls[0][0].config.imageConfig.aspectRatio,
).toBe('16:9');
});
describe('Gemini image standard pricing', () => {
it('meters Flash Image 3.1 input and text at standard rather than batch rates', async () => {
generateContentMock.mockResolvedValueOnce({
candidates: [{ content: { parts: [{ inlineData: { mimeType: 'image/png', data: 'AAAA' } }] } }],
usageMetadata: {
promptTokenCount: 100,
candidatesTokenCount: 1200,
candidatesTokensDetails: [{ modality: 'IMAGE', tokenCount: 1120 }, { modality: 'TEXT', tokenCount: 80 }],
thoughtsTokenCount: 20,
},
});
await withTestActor(() => makeProvider().generate({ model: 'gemini-3.1-flash-image', prompt: 'A cup' }));
expect(batchIncrementUsagesSpy).toHaveBeenCalledWith(expect.anything(), expect.arrayContaining([
expect.objectContaining({ usageType: 'gemini:gemini-3.1-flash-image:input', costOverride: 5000 }),
expect.objectContaining({ usageType: 'gemini:gemini-3.1-flash-image:output:text', costOverride: 30000 }),
expect.objectContaining({ usageType: 'gemini:gemini-3.1-flash-image:output:image', costOverride: 6720000 }),
]));
});
});
@@ -17,6 +17,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { closestAspectRatio } from '../../imageDimensions.js';
import { assertImagePrompt } from '../../imageValidation.js';
import { GenerateContentResponse, GoogleGenAI } from '@google/genai';
import { Context } from '../../../../core/context.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
@@ -74,26 +76,22 @@ export class GeminiImageProvider implements IImageProvider {
let { ratio, input_images, quality } = params;
const selectedModel =
this.models().find((m) => m.id === model) ||
this.models().find((m) => m.id === this.getDefaultModel())!;
this.models().find(
(m) => m.id === model || m.aliases?.includes(model ?? ''),
) || this.models().find((m) => m.id === this.getDefaultModel())!;
if (test_mode) {
return 'https://puter-sample-data.puter.site/image_example.png';
}
if (typeof prompt !== 'string' || prompt.trim().length === 0) {
throw new HttpError(400, '`prompt` must be a non-empty string', {
legacyCode: 'bad_request',
});
}
assertImagePrompt(prompt);
const allowedRatios = selectedModel.allowedRatios ?? [
GEMINI_DEFAULT_RATIO,
];
ratio =
ratio && this.#isValidRatio(ratio, allowedRatios)
? ratio
: allowedRatios[0];
ratio = ratio
? closestAspectRatio(ratio, allowedRatios)
: allowedRatios[0];
// Backwards compat: merge singular input_image into input_images
if (input_image && (!input_images || input_images.length === 0)) {
@@ -127,6 +125,11 @@ export class GeminiImageProvider implements IImageProvider {
}
const actor = Context.get('actor');
if (!actor) {
throw new HttpError(401, 'actor not found in context', {
legacyCode: 'unauthorized',
});
}
// --- Pre-flight cost estimation ---
const inputImageCount = input_images?.length ?? 0;
@@ -138,10 +141,29 @@ export class GeminiImageProvider implements IImageProvider {
selectedModel.costs.input,
);
if (!quality) {
quality = selectedModel.allowedQualityLevels?.[0] ?? '';
params.quality = quality;
// Tiered models resolve `quality` case-insensitively against their
// catalog levels, as Together and xAI do; a tier the model lacks is
// the caller's mistake, not a missing token-table entry.
const tiers = (selectedModel.allowedQualityLevels ?? []).filter(
Boolean,
);
if (quality) {
const wanted = quality.trim().toLowerCase();
const requestedTier = tiers.find(
(tier) => tier.toLowerCase() === wanted,
);
if (tiers.length && !requestedTier) {
throw new HttpError(
400,
`Unsupported quality tier: ${quality}. Expected ${tiers.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
quality = requestedTier ?? '';
} else {
quality = tiers[0] ?? '';
}
params.quality = quality;
// Estimate output image tokens
const imageTokenKey = quality
@@ -345,7 +367,7 @@ export class GeminiImageProvider implements IImageProvider {
if (!Number.isFinite(centsPerMillion) || (centsPerMillion ?? 0) <= 0)
return 0;
return (tokenCount / 1_000_000) * (centsPerMillion as number);
return (tokenCount * (centsPerMillion as number)) / 1_000_000;
}
#toMicroCents(cents: number): number {
@@ -405,11 +427,4 @@ export class GeminiImageProvider implements IImageProvider {
return { mimeType, base64: data.substring(commaIdx + 1) };
}
#isValidRatio(
ratio: { w: number; h: number },
allowedRatios: { w: number; h: number }[],
) {
return allowedRatios.some((r) => r.w === ratio.w && r.h === ratio.h);
}
}
Loaded 100 of 338 files, more files were not shown because too many files have changed in this diff. Show more