`useCredentialChain: true` in `dynamo.aws`, `providers['aws-polly']` or
`providers['aws-textract']` takes credentials from the default AWS chain
instead of static keys, as `s3.s3Config` already does. One provider is shared
and refreshes temporary credentials itself.
- Providers send upstream calls with a driver-owned `upstreamSignal`
instead of the caller's `abortSignal`. A disconnect detaches the chat
stream: output stops, the provider keeps reading until the upstream
reports usage, and that usage is metered.
- A detached stream gets SDK_TIMEOUT_MS to finish. After that the driver
aborts the upstream, and the stream is metered like any other stream
that ends without usage.
- The disconnect check also runs right before dispatch.
- The OpenAI stream handlers report usage that arrived before an abort.
- Docs: stopping a stream stops the output, and a request that reached
the model is billed in full. Detached streams get 10 more minutes.
Image, video, TTS, speech-to-text and voice-changer driver tests run one
request at factor 1 and again with every `ai.cost.factor.*` hook set to
2, and check that both the credit hold and the recorded cost double.
Chat and OCR already had this coverage. A driver that hands a provider
the raw metering service instead of its factored view now fails its
test.
`withAiCostFactorHook` in integrationTestUtil keeps the hook in place
for a callback, since providers record their usage unawaited.
Infron (a new-api gateway) answers "bad response status code N" with the
upstream's status when the upstream replied without a JSON error, e.g. a
model the upstream no longer serves. That 4xx was classified as the
caller's bad request, so the request skipped fallback, the dead route
stayed in rotation, and the caller got upstream_bad_request. It now counts
as an upstream failure: fallback runs, the route is marked unhealthy, and
an exhausted chain reports upstream_provider_unavailable. A gateway error
that carries the vendor's own message still stays request-level.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* chore: move to Node 26
Docker image and CI run on Node 26 (bookworm-slim, same Debian base as before).
Node 26's bundled undici reads the global dispatcher from the
`undici.globalDispatcher.2` slot, so the guard's 10-minute timeout wrap on
`.1` no longer reached plain fetch() and provider calls fell back to the
5-minute headers timeout. The guard now wraps every present slot and tracks
wrapped dispatchers in a set, since compose() can return a Proxy that writes
a marker property through to the original dispatcher.
* test(puter-js): disable Node's web storage in test workers
Node 26 defines its own localStorage global, undefined without a storage
file, and vitest's jsdom environment doesn't replace globals that already
exist, so jsdom tests saw localStorage as undefined.
index_url may not point at a system host: the origin, the api host, or a
subdomain an extension has claimed. App create and update now apply the
same refusal to `<claimed>.<domain>` (any case, any port) that they apply
to the origin and api hosts.
The host-to-claim match is a new exported `subdomainLabelOf` in
hostRedirects, also used by the claim dispatch middleware, so both
decide the same way.
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
* feat(ai): add Kling for video, image and speech generation
One `kling` API key now registers a provider on three drivers:
- txt2vid: Kling 3.0 Turbo, 3.0, 3.0 Omni, O1, 2.6 and 2.5 Turbo,
with first/last frames, reference images on the omni models, native
audio, and editing a source video on 3.0 Omni and O1. Billed per
second at the resolution, audio and video-input rate; an edit is held
at the longest source and billed for the clip Kling returns.
- txt2img: Kling Image 3.0, 3.0 Omni, O1 and 2.1, billed per image.
- txt2speech: Kling TTS with its English and Chinese voices, billed per
request.
Kling's business error codes map onto the driver error contract, so an
exhausted resource pack reports as credit exhaustion and a submit-time
content block as `moderation_flagged`. "Risk control" failures now
count as content-filter refusals.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): treat Kling's unknown-model codes as a Puter-side failure
Kling answers 1202/1203 when the method or model isn't one it serves.
Puter picks both, so these meant catalog drift but surfaced as a 400 that
blamed the caller and raised no alarm. They now return 502 upstream_failed
with a warning alarm.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): tighten Kling input checks and error mapping
- 3.0 Turbo takes at most 2,500 prompt characters with a first frame,
the image-to-video endpoint's limit.
- O1 refuses reference images together with a first and last frame.
- 3.0 Omni accepts a source of up to 15.5 seconds, and an edit is held
at 16 seconds to cover it.
- A source video Kling can't decode fails as 400 upstream_bad_request
instead of a Puter-side 502.
- A 1201 about the model counts as catalog drift, like 1202/1203.
- Auth failures no longer pass Kling's console wording to the caller.
Adds integration tests for the image, video and TTS providers, gated
on PUTER_TEST_AI_KLING_API_KEY.
* feat(ai): wait up to 14 minutes for a video job
Video jobs, Kling edits in particular, can take over 10 minutes. The
poll window and the Gemini Omni deadline move to 14 minutes, under the
SDK's 15-minute idle timeout, with holds and signed source links
lengthened to match. The keep-alive default moves to 920 seconds so it
stays above a 15-minute proxy idle timeout.
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
* feat(ai): edit a source video with txt2vid on Gemini Omni
txt2vid takes an `inputVideo` option (sent as `input_video`): a URL, data
URI or Puter file path to an MP4, QuickTime or WebM video of up to 32 MiB.
Gemini Omni receives it inline next to the prompt, which describes the
edit or extension. Google enforces the 10-second limit.
Models declare `videoInput` in the catalog, and the driver rejects a
source video for any other model instead of letting the provider drop it.
The credit hold adds 16,000 input tokens for a source video, since
countTokens reports far fewer tokens than the reply bills.
loadFileInput takes an optional `timeoutMs` for web URLs, and cancels
the body of a failed response.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(ai): edit a source video with FLUX 3 on Together AI
FLUX 3 takes `input_video` as Together's `media.source_video`. A web URL
goes through for Together to fetch; a data URI or Puter file is read with
the caller's access, capped at 32 MiB, sniffed as a video and sent inline.
Editing is estimated and billed at the 1080p rate of $0.54/s, since FLUX 3
jobs report no cost. Image inputs can't be combined with a source video.
Seedance 2.0 ignores `source_video`, treats `reference_videos` as a loose
reference rather than an edit, and Seedance 2.5 fails on reference videos,
so neither declares `videoInput`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(ai): name FLUX 3 as a video editor in the AI overview
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): match Hailuo 02 to Together's price and document estimate billing
Together quotes $0.56 for a Hailuo 02 clip; the catalog had $0.49.
Together's video jobs no longer report what they cost, so every Together
clip is billed at the catalog's highest published rate. The docs said the
charge was the amount Together reports; they now describe the estimate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: enforce video edit durations and propagate source fetch cancellation
* fix(ai): pass media inputs as Puter files and stream source videos
Source videos were read whole into the API process, base64-encoded and
sent twice to Gemini (countTokens and the interaction), which held about
128 MiB per in-flight edit for up to ten minutes.
- Video inputs are a URL, a Puter file path or a data URI of at most
1 MiB. Together gets a Puter file as a 15-minute presigned S3 URL and
fetches it itself; Gemini streams it into the request body with an
exact Content-Length, so it's never held as one string.
- Gemini counts only the prompt and image for a source video, and both
providers read the source only after the credit hold.
- txt2img and txt2vid image inputs accept Puter file paths, read within
the caller's access right before the provider call. Existing URL,
data URI and base64 inputs are unchanged.
- puter.js writes a Blob, File or data URI over 1 MiB to a temporary
file under .tmp, sends its path, and deletes it once the call settles.
- FLUX 3 edits bill at the 720p editing rate, matching their 1280x704
output.
* fix(ai): stage inputs after website sign-in, keep images working without a write
- puter.js chose the temporary file path before signing a website visitor
in, so it fell outside the app's data directory and the upload was
refused. It now signs in first, then picks the path.
- An image that can't be written (no access, storage full) is sent inline
as before instead of failing the call. Videos still fail, since a
large inline video isn't accepted.
- Telling raw JPEG base64 from a path decodes only the first 24
characters instead of the whole input.
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: 404oops <oops404@util.404oops.com>
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
* fix(ai): validate large base64 without anchored regexp loops
Gemini TTS replies over ~4 MB of base64 (~65 s of audio) failed with
"Maximum call stack size exceeded". Once a process holds enough compiled
code, V8 compiles new regexps unoptimized, and `^[...]+={0,2}$` then
pushes a backtrack entry per character until its stack overflows.
TTS now searches for a character outside the alphabet instead. The raw
base64 icon/reference-image check drops its own anchored regexp;
decodeStrictBase64 already rejects the same inputs.
* perf(ai): validate base64 by decoded length instead of regexp passes
Unoptimized regexp scans dominated large payloads: 186 ms for max-length
TTS audio, 880 ms in decodeStrictBase64 for 30 MB of video. Buffer.from
skips characters outside the alphabet, so comparing the decoded length
with the one the input length predicts catches them for free.
decodeStrictBase64 now does that plus rejecting the URL-safe alphabet
(~10 ms for 30 MB), and TTS reuses it instead of its own check.
Non-canonical trailing bits now decode instead of being rejected.
* fix(ai): reject non-ASCII base64 that Node decodes by its low byte
Node reads code units above 0xFF by their low byte, so U+012B decodes
as '+' and keeps the decoded length the input predicts. Require ASCII
(~1-2 ms for 43M chars) before the length check.
Pipeline
- CORS preflights send Access-Control-Max-Age: browsers stop re-preflighting authed calls every 5 s.
- `req.ua` is parsed on first read instead of on every request.
- JSON `rawBody` reuses the parsed buffer instead of copying it.
- `emitAndWait` returns early when nothing listens: no span or closure per unlistened driver call.
- SQLBatcher sends a lone query on the next tick while fewer than 8 flushes are in flight, and
waits `maxTimeInQueue` as before otherwise. Lone reads skip the 10/30 ms wait; batching under
load is unchanged.
Auth and permissions
- Token-to-actor auth starts the user, session and app lookups together and checks them in the
same order: fewer sequential Redis round trips per authed request.
- get-user-app-token and check-app reuse the resolved app row. getOrCreateApp skips the re-select
after its own insert and doesn't wait on the cache write for replica rows.
- getOrCreateApp reads the race winner from the primary when its insert loses, so it no longer
hands out a session that was never written.
- whoami runs the email check and the OIDC/taskbar/team lookups in parallel; key order unchanged.
- OIDC providers are memoized once every discovery succeeds, with one discovery fetch in flight
per URL, and the Apple key is parsed once.
- gui-token and sync-cookie use `req.actor.user` instead of re-reading the user.
- listSessions batches app lookups. App icon probes run together, and the original-icon probe
skips the lineage fallback.
- Default-granted permissions are answered from config before the scan cache, and exploded
options are deduped: one less Redis read per /drivers/call.
- ACL resolves ancestors lazily (owners and full-access tokens skip them) and scans the node
first, then the ancestors concurrently on a miss.
Filesystem
- Missing-path lineage lookups run 10 at a time: batch-upload path resolution drops from about
1.3 s to about 40 ms per 100 new files.
- MySQL single-file inserts return the row built from the written values, falling back to the
primary read: one less primary round trip per new file.
- Batch thumbnails are stored with one UPDATE per chunk (200 rows or 4M chars), with a per-row
fallback.
- storage.quota.bonus listeners run concurrently next to the usage SUM, and the payload carries
`userUuid`: quota checks and /df no longer serialize the listener reads.
- readdir cache backfill is one pipeline instead of 3xN SETEX.
- Profile reads skip the lineage fallback; the own profile is read alongside the user lookup.
- Legacy /stat runs is_empty (new `hasChildrenByParentUid`), owner and thumbnail concurrently.
- Downloads and the video proxy pipe through `pipeToResponse`: aborts release the storage stream
at once with no extra 'close' listeners, and the video proxy no longer crashes on a mid-stream
upstream error.
- mkdir looks up target and parent together.
- Upload completion reuses the session from the access check (status is still re-read before
completing), and multipart signing reads it once.
- Moving a shared file to Trash skips two lookups that always returned the entry itself.
Drivers and events
- Subscription resolvers run concurrently and are folded in registration order.
- SubdomainDriver and WorkerDriver overlap independent lookups.
- The events "anyone subscribed?" check is one Redis pipeline when the region is warm.
- Handler publish overlaps the count with the size check.
- Broadcast flushes peers concurrently over a keep-alive agent, so a slow peer no longer delays
cross-region invalidations to the others.
Sites, share and AI
- Hosted sites resolve the owner first, then the app and root together. Site config loads only
on 404s. The hosted-app query selects only the columns it uses and moves into AppStore.
- shareFlags asks about the plan only when an item is shared by link only.
- Share listings run the count, teams and plan reads alongside their page query.
- Claude attachment cleanup no longer blocks the response.
- New util `awaitLater` for promises started early and awaited later.
* Add Gemini Omni video generation with token metering
* Keep explicit Gemini video model selection within provider
* Settle reported Gemini video usage before validating output
---------
Co-authored-by: 404oops <oops404@util.404oops.com>
* feat(ai): add Gemini unary transcription
* Clarify transcription language results and omit pricing
* style(ai): keep Gemini transcription sources prettier-clean
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai-speech2txt): charge Gemini transcriptions that fail after Google answers
Google bills an interaction it processed whether or not it completed, so an
incomplete, failed or malformed reply was free to the caller and paid by
Puter; audio whose transcript overran the output cap could be resubmitted at
no cost. Usage is now recorded before the reply is checked, falling back to
the counted audio tokens when the reported usage can't be read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Preserve transcript text with optional malformed annotations
* Keep speech provider spacing consistent
---------
Co-authored-by: 404oops <oops404@util.404oops.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): keep Gemini speech instructions separate from transcript
* Clarify Gemini speech instructions
* style(ai-tts): keep Gemini cost comment prettier-clean
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Validate speech requests and settle reported Gemini usage
* Clarify speech settlement for unreadable responses
* Reserve the published Gemini speech token budget
* Clarify speech accounting for bounded responses
* Limit only Gemini TTS text to 3000 characters; charge overrun replies
The 3000-character text cap moves from the TTS driver into the Gemini
provider, so other providers keep their own limits.
The Gemini reply cap rises to 64 MiB, which fits the full 16,384-token
audio budget. A reply that still overruns it or times out reports no
usage, so it is charged the held budget and the error tells the caller
to split the text.
---------
Co-authored-by: 404oops <oops404@util.404oops.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
* feat(ai): restore available Gemini models and align pricing
* Exclude deprecated Flash Image additions and share scheduled pricing
* Bill Gemini 2.5 Pro at its current list rates
Cache reads are $0.125/M, and prompts over 200k tokens bill every rate at
the long-context tier ($2.50 in, $15 out, $0.25 cached).
---------
Co-authored-by: 404oops <oops404@util.404oops.com>
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
* feat(ai): add Gemini 4 Argon with native usage metering
* test(ai): isolate Argon HTTP smoke test from optional extensions
* fix(ai): preserve Gemini native completion and refusal reasons
* Refer model specifications and pricing to the directory
* Bound unary Argon requests and reject unsupported content
* Keep Gemini tool metadata out of Claude requests
* Describe provider-specific chat timeout boundaries
* Keep Gemini extra_content off other providers; report Gemini timeouts as 504
Replayed tool calls only carry extra_content for providers that opt in
(Gemini), so OpenAI-compatible and Responses vendors don't see an unknown
field. The native Gemini client now owns its request deadline: the SDK's
own timeout aborted without a reason and read as an internal error. A
deadline timeout returns 504 upstream_timeout and leaves the route in
rotation.
---------
Co-authored-by: 404oops <oops404@util.404oops.com>
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
* Handle model access denials and filter batch chat variants
* Raise a low alarm when a provider denies model access
A 403 model_not_found still returns 400 upstream_bad_request, but now
alarms at info so a lost provider entitlement gets noticed. HttpError
gains an alarmSeverity option the terminal alarm gate honors.
---------
Co-authored-by: 404oops <oops404@util.404oops.com>
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
- relay sign-in popups take the opener origin from the referrer only
- session revokes read the primary, batch per user, and tombstone caches in every region
- wisp relay tokens bound to the minting session; verify rechecks the account
- hosted origins keep app identity with the site owner; deleted app uids recorded in deleted_apps
- subdomain cache tombstoned on delete, update, reclaim and app/account cascades
- email lookups require an exact address match after the SQL lookup
- workers deploy only from files the deployer owns
- share_app_data:false enforced at ACL time, plus copy/move/rename/share/recursive listing
- kv remove returns no value without read; kv:expire op grant usable
- fs errors name the entry instead of the owner's path
- shared-by-me drops rows a delegate no longer has authority over
- private apps can't move index_url onto another user's origin
- GUI: default apps saved and launched by uid; save picker accepts only copy/move; permission dialog waits for focus; Publish as Worker hidden on others' files
getObjectStream now attaches an 'error' listener to every body it returns.
A body that fails before its consumer attaches (socket timeout, reset, or a
checksum mismatch on a truncated read) used to emit 'error' with no listener
and exit the process, failing every in-flight request on the task. It now
logs a warning; consumers that attach later still receive the error.
* PUT-2012 fix broadcast service drops
* Revert pre-commit lint churn on files merged from main
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix test bug
* Remove other test
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- TTS sends output_format as the query parameter ElevenLabs reads; the body
field was ignored, so every request came back as MP3. Unknown formats are
rejected before credits are reserved, and the OpenAI-style names mp3, opus,
pcm and wav map to native formats.
- Voice IDs go straight to synthesis. The library listing omits voices that
still synthesize, the default 21m00Tcm4TlvDq8ikWAM among them, and runs to
9 MB per call. Names resolve through /v2/voices search, and premade voices
("Bella - Professional, Bright, Warm") also match their short name.
- speech2speech accepts HTTPS audio URLs through secureFetch, as documented.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Catalog gemini-nano-banana-2.1 (aliases google/gemini-nano-banana-2.1 and
nano-banana-2.1) at Google's standard rates: $1.50/1M input, $7.50/1M text
and thinking output, $30/1M image output, with 1K/2K/4K tiers at
1120/1680/3780 image tokens. 3.1 Flash Image stays the provider default.
Pre-flight credit checks assumed 50 text/thinking output tokens per image
for every Gemini model; live samples run 270-1122 depending on the model, so
each model now carries its own estimate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(rate-limit): count the newest KV rate rows first
The KV backend's rate check, peek and concurrent acquire list the key's
rows with a limit + 1 cap that the store applies before dropping expired
rows, so unswept rows from past windows (oldest keys) could fill the fetch
and the live count read as zero. They now list newest first.
* fix(broadcast): split webhook flushes under a byte budget, per-peer backlogs
Flushes go out in bodies of at most `broadcast.outboundMaxBodyBytes` (default
4 MiB) instead of one POST, and each peer keeps its own backlog of what it
missed, capped by count and four bodies' worth of bytes, oldest dropped first,
so one failing peer can no longer grow every peer's body past their 50 MB limit.
* fix(ai-chat): classify dropped upstream connections as provider failures
A connection reset or refusal (SDK APIConnectionError, Mistral's
ConnectionError, an undici reset on the cause) carries no status, so a chain
of them fell through to 500 internal_error. Attempts now record
connectionFailed; a chain of only resets and 5xx answers 400
upstream_provider_unavailable, and a reset counts as an upstream signal in a
mixed chain.
* fix(ai-chat): keep SDK retries on Anthropic count_tokens
The Claude client is built with SDK retries off so the driver can fall back,
but count_tokens has no fallback route. It now passes sdkRetryOptions, like
the OpenAI moderation call, so a transient 429 or reset is retried.
* fix(ai-chat): mark a route unhealthy for 60 s after a transient failure
With SDK retries off, one 5xx, 429, timeout or dropped connection kept a
route marked for 10 minutes, sending that model's traffic to resellers. Those
failures now mark the route for 60 s; bad credentials, an exhausted account
and other route-level failures keep 10 minutes.
* fix(metering): don't memoise spent addons inside the force-exact window
The spent-addons memo could cache a pre-grant record read from a lagging
replica right after a credits-changed announcement, so overage skipped the
new credit and requireCredits kept answering 402 for up to 15 s. A spent
record is now only cached once the actor's force-exact window has ended.
* fix(share): send the digest entries a flush claimed when another take fails
Digest entries are taken concurrently, and a single rejected take threw
away every entry the other takes had already removed from KV. The flush
now logs the failed takes and sends what it claimed; an entry whose take
failed stays queued for a later flush.
* fix(fs): announce an overwriting move's replaced entry only when it took its name
/move and v2 /fs/move looked up the entry at the destination name before
moving and always announced its removal. A move into someone else's Trash
renames instead of replacing, so the owner's desktop hid a live entry and
legacy /move reported it as `overwritten`. Both routes now pass the target
on only when the moved entry kept the name it was looked up by.
* fix(ai): name the vendor in upstream error fields from media providers
upstreamFetch now takes the provider id plus a display label, and
UpstreamResponseError carries the id. translateProviderError copies a
string `provider` off the error into `fields`, and the BytePlus video and
Cloudflare image errors set one, so upstream_auth_failed and
upstream_credits_exhausted alarms say which vendor failed again.
* fix(teams): delete a provisioned seat whose home folders could not be created
provisionUser logs a failed default-folder creation and still returns the
account, so provisionAccount went on to add the membership, issue a password,
mail the seat and emit team.account.created for an account with no home. It
now cascade-deletes the new user and returns 500 internal_error before any of
that.
* fix(ai-chat): load model catalogs without holding up server start
onServerStart awaited every gateway catalog (up to its 10s fetch timeout)
while HTTP was already listening, so drivers started after it had no
providers yet and chat requests found an empty model map. The first build
now runs in the background; complete(), models() and token counting wait
on it instead of failing.
* fix(ai-chat): serve direct models while gateway catalogs load at boot
The first model-map build now publishes a fresh map as each provider's
catalog lands, so static catalogs are routable at once. complete() and
token counting wait for the full first build only when the model, or the
provider asked for, isn't in the map yet. Refreshes still swap once.
* fix(ai-chat): split gateway-billed cost across input and output
A completion billed at the cost OpenRouter, Infron or Neuralwatt reports
was quoted to `ai.prompt.cost-calculated` as all input and no output. The
ledger still records one `billedUsage` line; the reported per-key costs now
spread that bill over the usage keys at the catalog's relative rates, in
whole µ¢ that sum to it. Neuralwatt energy units take no share.
* fix(ai-image): hold Replicate credits for the whole prediction
The output and input-surcharge holds took the default 10-minute TTL, the
same as the prediction window alone, so a slow prediction could outlive
them once creation and input measurement were added. They now last
15 minutes, like the video job holds.
* feat(ai-chat): report what a completion was charged as usage.cost
Chat usage (the non-stream result and the stream's final usage line) now
carries `cost`: the µ¢ the ledger records for the request after the cost
factor, from the request's recorded charges and the gate's factor lookup.
`usd_cents` is unchanged. A hooked recording now copies the usage object
first, so keys the driver adds later never land in the ledger.
* fix(ai): charge streamed speech once its first byte arrives
Per-character TTS and the voice changer recorded their charge when the
upstream's response headers arrived, so a body that failed before any audio
was billed in full. A shared relay now charges on the first byte, or when
the caller hangs up, and charges nothing for an upstream that fails or ends
before sending audio.
* fix(ai): release credit holds only after the request's usage lands
A hold was released as soon as its operation returned, while the usage it
stood for was still being written (behind the cost-factor lookup), so a
concurrent request briefly saw neither. AI metering now tracks the
request's usage writes and its holds (chat included) release once those
settle, without delaying the response; a streamed charge chains its own.
A failed write still releases the hold, and the hold's TTL backstops it.
* test(ai-chat): wait for a stream's credit hold to come back after its usage lands
* perf(fs): page subtree delete and copy by parent link, not by depth over the path range
Each page of a recursive delete or copy sorted the whole remaining subtree by a
computed depth, so a walk cost grew with the square of its size. It now pages
the children of up to 100 sibling directories at a time by parent_uid, depth
first, keyset on (parent_uid, id), so a page reads only its own rows; rows with
no stored path are reached too. SQLite gets the parent_uid index the others have.
* fix(fs): find children by parent_uid in the empty check and supplementary-name lookup
Rows the old backend wrote carry parent_uid and no parent_id, so a non-recursive
delete of a directory holding only such rows passed the empty check and orphaned
them, and on MySQL the supplementary-character child lookup missed them, leaving
their subtrees out of listings, counts and renames. Both now key on parent_uid;
the empty check still probes parent_id too, which the delete cascade follows.
* docs(fs): drop the single-POST note from the entry broadcast cap
* perf(ai-chat): cache gateway catalogs with one remote-catalog helper
cachedRemoteCatalog serves a fetched catalog for 15 minutes, shares one
in-flight fetch between callers, abandons a fetch after 10 s, remembers a
failure for 30 s and keeps the last good value through a failed refresh.
Together, OpenRouter, Infron, Neuralwatt (models and quota) and Ollama use it
in place of their kv-singleton caches. The cached value is the converted
catalog, so a completion no longer re-coerces the whole gateway catalog
(OpenRouter did it twice per request). Ollama now refreshes instead of caching
its first non-empty list forever, and Together's catalog failure no longer
rejects models().
* refactor(ai-chat): build every chat SDK client with retries off
sdkClientOptions() gives each OpenAI, Groq, Together and Anthropic client
maxRetries: 0 and an explicit timeout. The SDK default of two retries, each up
to the full timeout, stacked under the driver's three-route fallback chain; a
failed attempt now goes straight back to the driver, which marks the route
and moves to the next provider for the model.
Timeouts keep what each provider already had: ten minutes, Groq's SDK default
of one minute, and Claude's 10 m 0.6 s. Non-streaming Mistral calls, which had
no timeout once a cancellation signal was passed, are bounded at ten minutes.
The Azure providers are left for after the open Azure fixes land.
* refactor(ai-chat): meter every OpenAI-family completion through one pricing path
meterChatUsage records a completion's usage at buildCostsOverride's rates, or
as one billedUsage line when the gateway reports what it billed, and returns
the per-key costs it recorded. Providers hand those to the driver through the
usageCosts channel, now carried by the Chat Completions handlers too, so the
usd_cents and cost-calculated figures equal the ledger. The driver's own
#computeCost is replaced by pricing.ts usageCost, which shares the rate
resolution with buildCostsOverride.
Billing changes, all toward what the upstream charges:
- DeepSeek, Moonshot, Alibaba, Groq, MiniMax, Z.AI, Hoonify, BytePlus and the
Neuralwatt catalog fallback took cached reads out of prompt_tokens nowhere,
so a cached token was billed at input + cache rate. splitCachedPrompt bills
it once, at the cache rate; usage.prompt_tokens on those routes now excludes
cached reads, as it already did for OpenAI, Gemini and Meta.
- Catalog entries that listed cached_tokens: 0 (38 Alibaba, 5 Groq) relied on
that double count to bill cached reads at the input rate. The zero is
dropped, so they keep exactly that price through the input-rate fallback.
Neuralwatt models with no published cache price likewise.
- An unpriced key falls back to the input/output rate instead of NaN (0), e.g.
OpenRouter cached reads on its no-cost fallback path.
- A gateway-reported cost of 0 records 0 µ¢ rather than 1 µ¢.
Claude, Azure and xAI keep their metering; xAI's cached-token handling waits
on the open Grok decision.
* refactor(ai-chat): one OpenAICompatProvider for the OpenAI-compatible vendors
DeepSeek, Moonshot, Alibaba, Groq, MiniMax, Z.AI, Hoonify, BytePlus, Together,
Gemini, Meta, Ollama and the complete() paths of OpenRouter, Infron and
Neuralwatt were copies of one Chat Completions call. Each is now an
OpenAICompatOptions entry (client, catalog, metering prefix, wire-id prefix,
max-token param and default, passthrough fields, openAICompatParams scope,
message cleanup, image inlining, stream usage, context-length retry) plus the
hooks its API needs: prepareMessages (DeepSeek, Groq, Gemini), vendorParams
(MiniMax, Hoonify, Z.AI, BytePlus, Meta, OpenRouter, Infron, Neuralwatt),
meteredUsage (Gemini and the gateways) and reportedUsage (Neuralwatt). xAI is
left until the open xAI fix lands.
Deliberate changes:
- Tools go through make_openai_tools for every vendor (six did, ten sent them
raw): Anthropic-only tool extras and server tools no longer reach these
upstreams, and an empty tool list is omitted.
- Model lookup is one indexed, case-insensitive pass per catalog for every
vendor (Together and Neuralwatt already ignored case).
- Per-provider console logging of upstream errors is gone; the driver logs
every failed attempt.
- Every vendor honours a configured apiBaseUrl.
tool_choice forwarding stays as each vendor had it (MiniMax, Hoonify, Z.AI
auto-only, BytePlus, Meta auto-only, OpenRouter); widening it needs each
vendor's support confirmed.
* perf(ai-chat): resolve the model once in the driver and keep its map fresh
The driver hands each provider its own catalog entry for the route it chose,
so providers stop re-resolving the id per request; the OpenAI-compatible
providers only look an id up (indexed, case-insensitively) for a direct
caller, and an unknown id is a 400 instead of silently becoming the default
model. Gateways with mixed-case ids (Infron, Neuralwatt) used to miss the
driver's lowercased id and fall through to that default.
The model map is now built from every provider's catalog in parallel, with a
failing catalog left out rather than aborting the build, swapped in whole,
awaited in onServerStart, and rebuilt every 15 minutes so gateway catalog
changes reach routing without a restart.
* refactor(ai-chat): drop provider surface and OpenAIUtil code nothing uses
- list() leaves IChatProvider and every provider that isn't Azure or xAI
(both wait on the open fix for those files); only tests called it.
- OpenAI Chat, OpenAI Responses and Meta no longer inline puter_path parts:
the driver does it before every attempt, so theirs found nothing to do. The
stores and FS service they took for it go too.
- providers/ChatProvider.ts had no subclasses.
- OpenAIUtil: one hoistToolUseBlocks for the Chat and Responses message
passes (Responses keeps canonical_id), and the dead create_usage_calculator,
unused Responses deviations and constant mode/toolblock are gone.
checkModeration stays on IChatProvider: the open Azure moderation fix calls it
through that interface.
* refactor(ai-chat): register keyed providers from one table
Sixteen near-identical blocks in #registerProviders, with three constructor
argument orders, become a { name, configKey, Class } table read by one loop;
every entry takes (config, metering) and gets apiBaseUrl when configured.
Claude, Azure, OpenAI (sibling wiring), Ollama (enabled unless turned off) and
the fake provider stay explicit. Gemini's constructor takes config first like
the rest.
* fix(ai-chat): send Ollama the model name it listed
Ollama catalog ids are ollama:ollama/<name>, built from /api/tags. Only the
ollama: part was stripped, so the local server was asked for ollama/<name>, a
model it doesn't have. Strip the whole ollama:ollama/ namespace.
* fix(ai-chat): keep SDK retries on the OpenAI moderation call
The completion clients run with SDK retries off so the driver can fall back.
A moderation check has no fallback, so it passes the SDK's default two
retries per request.
* refactor(ai): route TTS, STT and voice-changer REST calls through upstreamFetch
upstreamFetch bounds a provider call with AbortSignal.timeout and throws an
UpstreamResponseError carrying the upstream status and code, which the
/drivers/call boundary maps like any SDK error. Replaces five hand-written
status translators (ElevenLabs TTS, xAI TTS, Speechify, xAI STT, voice
changer); caller-facing messages are now sanitized by the shared mapper.
* perf(ai): bound input image fetches and share one data-URI parser
fetchImageBytes now streams under a byte cap (30 MB by default) with a
30-second timeout, via a readBoundedBody helper shared with loadFileInput's web
branch and Replicate's input measuring. assertInputImagesShape caps
input_images at 16. Chat image inlining uses fetchImageBytes instead of
buffering with no timeout. fileInput, imageInput and mediaParts parse data URIs
through one parseDataUri.
* refactor(ai-tts): one per-character metering path for TTS providers
meterPerCharacter holds text length x price, runs the synthesis and meters the
same amount once it succeeds; characterCostReport builds the per-character
cost lines. Both sit on a new withAiCreditHold (reserve, run, release) that
Gemini TTS uses directly. SAMPLE_AUDIO_URL is defined once. The TTSProvider
base class is gone: its methods were stubs every provider overrode, and only
Polly read its untyped providerConfig.
* refactor(ai): one ProviderRegistry and key reader for the AI media drivers
ProviderRegistry holds a driver's provider catalog (ids, default, aliases) and
its configured instances: alias resolution, the not-found 400, default
fallback and parallel 'all' listing. TTS, speech-to-text, OCR and the voice
changer route through it. readProviderKey reads apiKey, secret_key, api_key and
key in that order for every AI media driver, so providers.xai.secret_key now
enables xAI TTS as it already did xAI image and speech-to-text. ElevenLabs
caches its voice list for 10 minutes, as Polly does.
* perf(ai): stream TTS and voice-changer audio instead of buffering it
OpenAI, xAI and ElevenLabs TTS and the voice changer hand the upstream body
straight to the caller through upstreamBodyStream; their costs are known
before the call, so nothing waits on the download. A body that fails
mid-stream ends the response early instead of raising an unhandled stream
error. xAI speech-to-text keeps one copy of the audio (the upload Blob)
instead of up to three. The voice changer and xAI STT hold credits through
withAiCreditHold.
* perf(ai-ocr): read Puter files only when the provider needs the bytes
resolveFileInput resolves and access-checks a Puter file and defers the read
to load(); loadFileInput is that plus the read. OCR resolves first: Textract
reads the object straight from S3 and downloads it only if Textract can't
reach the bucket, and only Mistral loads the bytes. A file over the size cap
is refused from its recorded size before any read. countPdfPages inflates
object streams on the libuv pool instead of the request thread.
* perf(ai): build image and video model catalogs once and share output saving
ModelCatalog holds what the public model routes serve (models, sorted names,
cost lines), computed once after boot instead of on every listing. The video
driver now awaits its model map before serving. resolveOutputPath and
saveGeneratedMediaToFS replace the copies of puter_output_path handling in
both drivers, using expandTildePath and normalizeAbsolutePath. The video
driver normalizes a copy of the caller's args instead of mutating them, and
its never-taken stream branch is gone.
* fix(drivers): abort the response when a streamed driver result fails mid-body
upstreamBodyStream now errors when the provider body fails instead of ending
cleanly, and DriverController pipes stream results through pipeStreamResult
(stream.pipeline): a failed source destroys the response so the client sees
an aborted transfer, the failure is logged once, and nothing is left to raise
an unhandled stream error. A client hanging up stops the source.
* fix(drivers): drop a streamed driver result when the caller already hung up
pipeline() throws synchronously on a closed destination, which surfaced as
a 500 alarm on every early disconnect and left the upstream body open.
* fix(ai): bound streamed upstream calls by time to first byte, not download time
upstreamFetch takes a `stream` option: the timeout covers the request until
the response (or a non-2xx error body) is read, then is cleared so a body
piped to the client drains at the client's pace. ElevenLabs TTS, xAI TTS and
the voice changer pass it; non-streamed calls keep the full-response bound.
A stalled upstream body is still cut by undici's body idle timeout.
* fix(drivers): type pipeStreamResult's response by the members it uses
* perf(workers): skip hot-reload lookups for files no worker is bound to
The fs.write.file / fs.remove.node / fs.move.node listeners listed every
workers.puter.* row of the file's owner on every event, for every user.
The event's FSEntry already carries the worker rows bound to it, so a file
with none returns before any I/O; a bound file reads just its own rows,
batched, from the primary.
* perf(apps): read the uid alias only after a direct lookup misses
Every AppDriver uid lookup fired an uncached system-KV GetItem for the
merge alias in parallel with the direct read, even when the direct read
hit. The alias is now read only on a miss.
* refactor(apps): one app view with the launch gates, built by AppService
AppDriver's serializer and its private-app / hosted-backing gates move to
AppService.views, built on a pure toAppView that is the one place
index_url is withheld. AppDriver, the subdomains driver's embedded apps and
the GUI shell view all use it. Visibility of protected apps
(filterReadable) moves with it.
Subdomain embeds (associated_app, app_owner) used to return index_url with
no gate; they now withhold it from a viewer who may not open a private app
or whose hosted backing is gone, and carry privateAccess, godmode and
feedback_enabled like a direct app read.
* refactor(apps): one toAppSummary; batch the recommended and taskbar lookups
SuggestedApps, RecommendedApps, /get-launch-apps and the taskbar each built
their own launch summary; they now share toAppSummary (the extra
feedback_enabled / external flags appear on the lists that lacked them).
getRecommendedApps awaited getByName once per recommended app (29) and
getTaskbarItems did the same per taskbar entry on every whoami; both now
make one batched call per key through the new AppStore.getByNames, which
keeps getByName's case-insensitive and old-name matching. The taskbar's
raw UPDATE of the user row goes through UserStore.update, and
/get-launch-apps checks hosted backings in one batch and reaches the
stores without casts.
* refactor(apps): app routes read through AppService; /delete-site calls the subdomains driver
AppController used the global driversContainers through a getter whose
boot-order comment no longer held; it uses the injected drivers and
AppService. GET /apps/:name looked each name up, read it again through the
driver and resolved private access a second time, emitting
app.privateAccess.resolveLaunch twice per private app; it now batches the
names and resolves once. POST /query/app did a lookup, a driver read and a
raw app_filetype_association SELECT per selector (up to 200, serially); it
now makes one batched lookup per key, one grant check and one filetype read.
The driver's read shape is a pure toAppReadView both use.
HostingController's /delete-site re-implemented the subdomains driver's
delete (protected check, delete, subdomain.delete event); it keeps its
owner-scoped 404 and calls the driver. Hosting and Desktop controllers drop
constructors that discarded the drivers argument, and trivial getters.
* refactor(drivers): one per-method policy validator and resolver; drop @Driver
Four policy blocks (rateLimit, concurrent, requireSubscription,
requireReputation) share the { default, methods } shape but each had its
own validator and resolver. They become validatePerMethod(value, label,
policy) and resolvePerMethod(cfg, method); error messages are unchanged.
The @Driver class decorator and the prototype keys it wrote had no users
outside its own tests; drivers declare their fields, and resolveDriverMeta
reads only those. DriverController takes the driver name from the meta it
already resolved instead of probing the prototype.
resolveDriverMethodRateLimit/Concurrent stay as deprecated aliases for the
email extension.
* refactor(drivers): shared actor guards and crud-q concurrency policy
The apps, subdomains, workers and notifications drivers each carried a
private #requireActor, two carried the same #requireVerified, and apps and
subdomains the same owner/app_owner write predicate. These become
requireContextActor, requireContextUserActor, assertActorEmailVerified and
actorOwnsRow in core/actor.ts; each driver keeps its own error for the
cases that differed (system actors still read apps; notifications still
answer 403 for an actor without a user). The identical crud-q concurrency
block is one CRUD_CONCURRENT constant.
The metering, installedApps and appTelemetry extensions re-checked an actor
their routes already require; those checks and the tests for them go.
* refactor(extensions): store methods instead of raw SQL in OSS extensions
installedApps and appTelemetry queried user_to_app_permissions directly
and metering read apps by name with its own SELECT. PermissionStore gains
listAppsGrantedByUser, listAppAuthenticatedUsers (with the email-share
check folded in) and countAppUsers; metering uses AppStore.getByName.
* refactor(server): one layer builder and one lifecycle hook runner
PuterServer built each of its five layers with its own copy of the same
loop (built-ins, then extension additions, into the layer and its export
container), and ran each lifecycle hook with five near-identical loops per
hook. Both are now one helper each: #instantiateLayer and
#runHook(order, hook). Construction order, the args each layer receives,
and hook order (start and prepare bottom-up, shutdown top-down) are
unchanged.
* fix(workers): tear down a deleted source's worker after its FK nulls root_dir_id
Azure entries that front an OpenAI or xAI model now come from a `mirror`
helper that copies every non-identity field from the source catalog entry.
The hand-copied entries had drifted: none carried `long_context_pricing`, so
long prompts on Azure-served gpt-5.4 and Grok 4.3/4.20 were billed at 1x;
grok-4.3 had lost `pdf` input and the Grok 4.20 deployments still listed the
old 2M context. Models no longer in the direct catalogs stay inline.
xAI sent a fixed max_tokens of 1000, ignoring both the caller's value and
the credit gate's cap. It now forwards max_tokens like the other
OpenAI-compatible providers and omits it when unset.
Azure chat's checkModeration threw "Method not implemented", so
`moderation: true` on an Azure route metered the completion, then failed it
as a route error: Azure was marked unhealthy and the fallback billed a
second completion. Azure's v1 API has no /moderations endpoint, so both
Azure providers now delegate moderation to the OpenAI chat provider
(Azure chat via its Responses sibling, as it does for web_search).
A moderation call that throws is now a 400 `moderation_unavailable` that
the driver treats like `moderation_flagged`: final, no fallback, and the
route stays healthy.
* fix(stores): read rows back from the primary after writing them
ShareStore (create, upsertPending, upsertActive, upsertActiveGroup,
applyPending, upsertAnyone), AppStore (create, createFromOrigin, update,
claimOwnership), NotificationStore.create, SubdomainStore.update and
NotificationDriver.create returned the row they had just written by
reading it back through `db.read`, which goes to the replica. Under
replica lag that came back null or stale. Those read-backs now go to the
primary, through a `primary` option on the getters. AppStore caches the
primary copy on create, and claimOwnership no longer reads through
getById, which cached the replica's pre-claim row on a miss.
* fix(stores): skip the notify read-back; read OIDC link conflicts from the primary
NotificationStore gains `insert`, which writes and returns the uid;
`create` is now `insert` plus the primary read-back. NotificationService
.notify ignores the row, so it uses `insert` and no longer pays a primary
round trip per recipient. NotificationDriver.create still reads the row
back from the primary.
OIDCStore.link reads the conflicting row from the primary after a unique
violation, so a replica that has not seen another account's link can no
longer make the call report success.
* perf(stores): return written rows instead of reading them back
ShareStore.create, NotificationStore.create, AppStore.create and
AppStore.createFromOrigin build the row they return from the values they
inserted and the insert id, instead of reading it back from the primary.
The DB-defaulted creation timestamp is now written explicitly from unix
seconds (`timestampFromUnixSql`), so the returned value matches what a
read returns (`timestampValue`); postgres INSERTs add RETURNING id. The
AppStore rows fill every column the INSERT leaves to the schema and are
cached as before.
ShareStore.upsertPending's re-invite branch selects the full pending row
in its dedupe read and returns it with the new mode and data; it falls
back to the primary only when the UPDATE matched nothing.
The remaining primary read-backs stay and say why: the share upserts and
the new-invite insert (an upsert's surviving uid/created_at and the
entry-owner subquery are only known to the primary), applyPending and
claimOwnership (only an id or uid in hand), AppStore.update and
SubdomainStore.update (the base row is a replica copy that would be
cached), NotificationDriver.create (notify returns only the uid) and the
OIDC link conflict.