* docs(agents): no credits or bug-report mentions in commits and PRs
* fix(ai): chat credit gate ceiling, in-flight holds and disconnects
- Cap output at min(max_tokens, context - prompt). The gate used
max_tokens - prompt, so any prompt longer than a model's output limit
was refused with 402 regardless of balance. Gemini 2.5 Pro's output
limit corrected to 65,536.
- Abort the upstream stream when the caller disconnects (driver calls and
the OpenAI/Anthropic-compatible routes), which ends the credit hold.
- A request refused only because the account's own in-flight requests
hold its balance gets 429 `credits_reserved` instead of 402; puter.js
retries it with exponential backoff (1s to 30s, about 2 minutes).
- When the balance caps a completion and it uses the whole cap, the
response carries `usage_limited` (non-stream `metadata`, stream `usage`
line), which puter.js already turns into the upgrade prompt.
* perf(ai): read the balance and its holds once per credit gate
The gate re-read holds, and the whole balance when any were held, to tell
spent from committed. getUsageHeadroom returns both halves from the same
reads getRemainingUsage already did.
The /metering/usage/:app handler repeated the "an app reads only its own
usage" check while the service still let any app read the global bucket.
The service now refuses an app actor anything but its own app, and the
handler only resolves names. Status codes for app actors are unchanged; a
user reading os-global gets their global usage instead of a name-lookup 404.
whoami no longer sets taskbar_items to undefined for app actors, and only
keys with a value count as built, so a listener can't fill one in for an
app. getMonthlyUsage's JSDoc notes allowanceInfo is account-wide.
* fix: keep launch tokens out of URLs and storage (PUT-2040)
- Strip the consumed launch token param from the app URL after load
- Never persist user session (godmode) tokens to localStorage; purge any stored one at boot
- Worker log socket sends the token as its first message instead of in the URL path
* fix: strip the private-app sign-in token from top-level URLs, keep godmode tokens across reloads (PUT-2040)
- Private-app gate: a top-level GET carrying ?puter.auth.token= that passes the gate gets the sticky cookie and a 302 to the same URL without the token. Framed launches are untouched.
- Godmode session tokens go to sessionStorage (never localStorage), so a frame reload stays signed in; cleared on sign-out or when an app token replaces it.
Remove models providers have deprecated or retired, add newly released
ones, and move provider defaults off retired models. Gemini video (Veo
via the Gemini API) is removed; Infron and OpenRouter skip models their
listings mark deprecated. xAI bills cached prompt tokens only at the
cached rate. Claude Sonnet 4.6 max output raised to 128K.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A page the browser gives no origin to — one opened straight from disk, or an
iframe sandboxed without `allow-same-origin` — serialises its origin as
`"null"`. `appUidFromOrigin` logged that at error level on every attempt and
the GUI sent it on three endpoints, so each refusal cost a 400 and an
error-level line while the user got nothing useful.
An opaque origin cannot name an app to mint a token for, and is not a valid
`postMessage` target to deliver one to, so these clients are refused rather
than supported. Make the refusal quiet, early and legible instead:
- AuthService: drop the `console.error` and say what the caller should do.
The accept/reject set is unchanged; a 400 is already counted per route.
- puter.js: `signIn()` rejects with `unsupported_origin` before opening
anything, which covers implicit auth too since it routes through `signIn`.
The load-time warning now covers sandboxed iframes, console-only — a modal
belongs in nobody else's embed.
- GUI: a popup whose opener has no attested origin says so and closes,
instead of rendering a blank window and wedging on a failed exchange.
Also fixes two faults this made reachable: `PuterDialog.open()` prefers the
popup branch, which reaches the `puter` global before the constructor that
assigns it has returned (`puter is not defined`, no SDK at all); and
`showModal()` throws where modals are blocked. `openNotice()` does neither.
Ordinary http(s) sign-in is unaffected.
* fix: misc events and kv issues found during recipes
* test: cover handler depth on narrowed actors and created notifications
* test: fix stale legacy batch directory upload test and seat user-agent flake
* test: wait for the refusal to be recorded before claiming its gap marker
Adds the Mistral and document-annotation img2txt examples to the
playground, states that Textract takes no options, and drops the
normalize mentions from the img2txt docs and types.
The SDK now checks Mistral inline sources against the size a base64
upload can reach inside the 50 MB JSON body (36 MB) instead of the
10 MB Textract limit; 50 MB let 38-50 MB inputs through to a body-parser
413. Retired-model messages no longer cite vendor dates.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Model sync against Mistral's listing, docs and a live call per id:
mistral-ocr-latest now serves OCR 4.1, so the catalog pins it (with
mistral-ocr-4 and the retired-but-redirected 2503) to mistral-ocr-4-1,
adds mistral-ocr-4-0 and OCR 3 (mistral-ocr-2512 / -3 / -3-0), and
rejects the retired mistral-ocr-2505 before any upstream call. Mistral
usage is metered per model at vendor rates: $4/1000 pages for OCR 4.x
with $5 annotations, $2 and $3 for OCR 3, replacing the flat $1 and $3.
Textract now calls DetectDocumentText instead of AnalyzeDocument with
LAYOUT. The layout feature cost $4/1000 pages while we billed the
$1.50 detect rate, and it only added text-less LAYOUT_* blocks that put
blank lines into img2txt output. Multi-page PDFs and unsupported
formats become a 400 that names Mistral as the alternative.
Request shape: a model name alone now selects its provider, so calls
differ only by model; an explicit provider still picks its default and
must agree with the model. Mistral options gain tableFormat,
extractHeader, extractFooter and documentAnnotationPrompt; annotation
formats accept the REST (json_schema.schema) or SDK
(jsonSchema.schemaDefinition) spelling and are validated before
billing. Non-image documents such as DOCX go as document_url.
Response shape: both providers return model, text and blocks with a
0-based page; Mistral also returns document_annotation, which was
billed but dropped. img2txt resolves to that annotation when
documentAnnotationFormat is set and to the text otherwise; the return
is a string for every model, so normalize has no effect on it.
Input size: OCR input is capped at the provider limit (10 MB Textract,
50 MB Mistral), and web inputs in loadFileInput are streamed under the
cap instead of buffered whole, which also bounds speech2txt URLs.
Docs: img2txt models, options, input limits, return value and errors;
OCR limits in the quotas page.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An owner toggle beside the directory one, confirmed on the way on since
it locks out every account without 2FA until they enrol, and unconfirmed
on the way off since that only lifts a requirement.
A per-member Reset 2FA action, shown only while the rule is on — with it
off a member can clear their own factor and needs nobody's help. The
confirmation says what it does and what it leaves alone: signed out
everywhere, told about it, password unchanged.
`require2fa` rides the Team shape and `update()`, and `resetTwoFactor()`
joins the module beside `resetPassword()`, whose docblock already says
2FA is left alone — the two now read as the pair they are.
* 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
- 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
- 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
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
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
* feat(email): inline cid attachments and Puter mailbox delivery for sendTransactional
EmailAttachment gains cid/contentDisposition so the transactional driver can send inline images. The SDK's EmailAttachment typedef now comes from types.js, which already carried cid. Docs describe delivery to <username>@puter.email recipients and the not_found code.
* feat: share with anyone with the link (PUT-1580); gate sharing on a verified phone or card
feat: the team seat experience — no email required, forced password change, team label, and plan-based limits (PUT-1792)
Note: Bypassing the code owners rule, since there are couple approvals in place for this.
From the adversarial review of this PR.
The forced password-change gate could deadlock: it POSTed to the
cookie-only route with a bare fetch, and both initgui call sites open it
before update_auth_data mints the session cookie — a fresh browser with a
token URL 401'd every submit inside a non-dismissible loop. It uses the
session-cookie retry wrapper now, taking the caller's token because
window.auth_token does not exist yet on that path. It also gains the
logout footer its sibling gates have; a lost temporary password was a
hard lock with devtools as the only exit.
Password recovery refused for seats: the address is admin-supplied and
never verified, so whoever holds that inbox could take the seat over at
any later time. A seat's recovery channel is its admin's reset.
change-email gets the same seat guard as change-username and deletion —
the address is where admin-issued credentials go.
The login response now carries `team` alongside requires_password_change:
no-reload logins store that payload as window.user verbatim, and every
seat restriction keys on it.
Smaller: the team-badge tooltip no longer double-encodes; the create-token
hint for an emailless account stops pointing at a verification it can
never perform; the quotas doc records the halved org_seat_free allowance;
the config template tells upgrading operators how to keep the old flat
cap; the SDK suite covers emailless provisioning and the owner-only uuid.
* puter_signed_upload_error_correction
* docs: update signed upload behavior
* fix(puter-js): complete a bodiless response in the XHR shim
A response with no body at all (`fetch` gives `null`) threw inside the
shim's own `then`, the same way a missing content-type did: no 'load',
no 'error', the request hangs forever. Signed storage PUTs answer in
exactly that shape.
Adds the regression tests the content-type fix was missing — all three
hang against the shim as it was.
* refactor(puter-js): drop the signed batch-write env allowlist
With `nodejs`, `service-worker` and `web-worker` added, the list held
every value `puter.env` can take, so the gate decided nothing — and a
new env would have been silently routed to the legacy path.
The backend capability check (`signedBatchWriteSupported`, set from a
404/405/501 on `startBatchWrite`) is the one that still does work.
* test: run the directory-upload suite on node and workers
Both were pinned to browser because the legacy `/batch` fallback cannot
create a directory tree. Now that every platform takes the signed path
they are the regression test for it, and the stale comment goes with
them.
---------
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
The per-account subscription button did nothing. `listMembers` never
returned a member's uuid, the SDK's `toMember` dropped it, and the Plan
column read `seatTiers[undefined]`, so every seat rendered as Free and
the action dispatched with no seat.
The uuid is owner-only: it is what billing keys a seat's plan on, and one
member has no business identifying another.
PUT-1792. Two separate problems, both from treating a provisioned account like
a self-registered one.
`email` was required, so an admin creating ten seats had to invent ten addresses
and then keep track of ten uniqueness constraints -- for accounts that sign in
by username and never use the address. It is now optional at every layer, and
the add-account form does not ask for it at all: username is the only thing a
seat needs.
`requires_email_confirmation` was set to true, with the reasoning that an
admin-supplied address is unverified. True, but `requireVerifiedAccount` turns
away on exactly `requires_email_confirmation && !email_confirmed`, so a
freshly created seat was asked to confirm an address it may not hold and could
not use the product until it did. The team creating the account is the trust
anchor, not the mailbox, so this is now false either way.
An address is still accepted and still stored when given, because the notices
are worth delivering. `#notifyUser` already returned early on a missing
address, so `team_account_created`, `team_account_disabled`,
`team_password_reset` and `team_closed` degrade quietly with no new branching --
the temporary password is in the API response, which is the documented delivery.
`idx_user_owned_email` is partial and skips password-null rows, so omitting the
address sidesteps it rather than creating a collision surface. Two seats with no
address do not conflict, and there is a test for it.
Docs now say an emailless seat is recoverable only through its team's owner.
That falls out of the design rather than being a limitation of this change, but
it should be written down rather than discovered.
Falsified: putting `requires_email_confirmation: true` back fails
"never demands confirmation, with or without an address" with
`expected true to be false`, and nothing else.
164 team tests, 40 SDK tests, typecheck clean.
OpenAI shuts the Sora Videos API down on 2026-09-24 and sora-2 was the
default txt2vid model, so the default moves to Veo 3.1 Lite on Gemini
and the OpenAI video provider goes. While there, the video catalogs are
brought in line with what each vendor serves today, the request options
are unified across providers, and the txt2vid docs are rewritten.
- driver: default provider gemini-video-generation with
veo-3.1-lite-generate-preview; a request under the generic `ai-video`
driver name lands on the default instead of the first-registered
provider; `WIDTHxHEIGHT` sizes map onto tier catalogs by the shorter
side and fill width/height
- openai video provider, the `openai-video-generation` alias, its
config template and migration entries, and Together's openai/sora-2*
rows removed
- gemini: Veo 3.1 Fast rates 10/12/30 cents per second for
720p/1080p/4K, Veo 3.1 Lite accepts reference images, URL image
inputs are fetched server-side through the SSRF-guarded fetch
- together: drop nine models retired upstream, add eighteen from the
live listing; per-second models are estimated from the catalog rate,
clamped to remaining credit and billed at the cost Together reports
on the job; tier-sized models take resolution/ratio;
input_reference/last_frame map onto keyframes; generate_audio is
forwarded
- byteplus: Seedance 2.5 (dreamina-seedance-2-5-260628) with per-model
reference-image caps
- util/imageInput: string-level image helpers shared by the image and
video drivers; ai-image/inputImage re-exports them unchanged
- puter.js types: provider and generate_audio options; docs: txt2vid
page rewritten with per-provider model tables, unified options and
four new playground examples
Known follow-up: Veo returns a key-protected Google file URL, so the
default clip cannot be played directly by a browser until the provider
fetches it server-side.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Members can already enumerate each other: `/teams/:uid/members` needs a user
actor and nothing more. The only thing this adds is admitting an app actor to
the same names, so an app can offer colleagues without the member driving it.
That is the whole risk, so it is off until the team owner turns it on.
`group.directory_enabled` defaults to 0, and a team that has not opted in
answers 404 rather than 403 -- whether a team has this on is not something an
app should be able to probe for either.
Three things bound what an app sees. The membership tested is always the
person's, never the app's, so an app installed by a member of one team can
never read another's. The page carries username and uuid and nothing else --
no email, activation state, usage or role. And suspended accounts and ones
that never took up their credential are left out, since offering someone who
cannot sign in is noise and their existence is not this list's to disclose.
Activation is the forced-change flag clearing, not the password existing: a
provisioned seat holds its temporary password from birth, so testing
`password IS NOT NULL` would have leaked exactly the accounts meant to be
excluded. A test covers that distinction.
Turning the directory on or off writes an audit row, because it changes who
can read the member list and that is not something a team should be able to
alter silently. Setting it to the value it already has records nothing.
The toggle lives in TabTeams, and turning it on asks for confirmation while
turning it off does not -- one grants access, the other only takes it away.
Closes PUT-1736.