Compare commits

...
Author SHA1 Message Date
Aiden Cline d46c773872 fix(ai): defer Mistral thinking metadata until block end 2026-09-28 23:41:19 -05:00
Aiden Cline 36ef6cc216 fix(ai): finalize Bedrock redacted reasoning at block end (#51999) 2026-09-28 23:37:51 -05:00
opencode f02c30eb55 sync release versions for v2.0.19 2026-09-29 04:31:02 +00:00
Aiden Cline ca084b2430 fix(ai): give provider routes distinct IDs (#51976) 2026-09-28 19:55:06 -05:00
Aiden Cline 3740ec311b feat(core): align shell tool environment with agent conventions (#51975) 2026-09-28 19:53:53 -05:00
opencode-agent[bot]andrekram1-node 35bd8ac442 test(core): update xAI variant expectations for Responses (#51977)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-28 19:48:35 -05:00
Aiden Cline 1fc05ca590 fix(core): share affinity in provider session headers (#51931) 2026-09-28 19:30:24 -05:00
Aiden Cline bda798b167 fix(core): drop session ID and order instructions for prompt-cache reuse (#51960) 2026-09-28 19:29:28 -05:00
Aiden Cline 3babae35c0 fix(core): fall back to Cloudflare environment IDs when not configured (#51963) 2026-09-28 19:28:03 -05:00
Aiden Cline 3ab5c1433c fix(core): request xAI reasoning summaries on Responses variants (#51964) 2026-09-28 19:11:48 -05:00
Aiden Cline 49437a25b1 fix(ai): classify invalid Google API keys as authentication errors (#51950) 2026-09-28 18:49:29 -05:00
Aiden Cline 49403a554f feat(core): cap requested output tokens at 256k (#51962) 2026-09-28 18:49:05 -05:00
opencode-agent[bot]andrekram1-node 87d6f93409 test(cli): isolate run exit codes between tests (#51955)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-28 19:16:01 -04:00
Frank 97d4eaa2ba zen: jev privacy policy 2026-09-28 19:01:46 -04:00
Kit Langton 7827dbe396 fix(tui): deduplicate projects in the open picker (#51924) 2026-09-28 18:13:00 -04:00
James Long 5f9ced439b fix(cli): drop misleading interruption errors after declined prompts in run (#51948) 2026-09-28 17:58:04 -04:00
Aiden Cline 8c1ce954d0 fix(tui): wait for agent and model before auto-submitting --prompt (#51938) 2026-09-28 15:41:14 -05:00
07338c5d48 feat(cli): create the session when --session names one that does not exist (#51405)
Co-authored-by: Alireza Haghdoost <haghdoost@uber.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-09-28 15:09:03 -05:00
James Long 46e53e3f2b fix(ai): expose evaluation confidence (#51930) 2026-09-28 15:24:21 -04:00
Aiden Cline 6cf442b545 fix(core): share child session affinity headers (#51923) 2026-09-28 13:31:17 -05:00
opencode-agent[bot]andrekram1-node f20f5b68ee chore(release): announce V2 releases in Discord (#51880)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-28 12:30:55 -05:00
SebastianandOpenCode Agent 96dd9f77a9 fix(core): attribute one-shot generation requests (#48358)
Co-authored-by: OpenCode Agent <opencode-agent[bot]@users.noreply.github.com>
2026-09-28 11:55:14 -05:00
opencode-agent[bot] dd786c62af chore(core): refresh bundled models.dev snapshot 2026-09-28 12:23:50 +00:00
Frank 87c402a124 docs(go): simplify v2 usage limit explanation 2026-09-28 07:39:42 -04:00
Frank 7076a878a4 docs(www): document Go Plus (#51834) 2026-09-28 07:25:37 -04:00
Frank 45b91eed82 docs(go): sync v2 model list with v1 (#51837) 2026-09-28 11:23:40 +00:00
Niels Kootstra 39e1ce55bc fix(core): reject relative path segments in repository hosts (#51577) 2026-09-28 10:14:35 +05:30
opencode-agent[bot]andrekram1-node d9f54392ba fix(tui): distinguish background shell from interrupted command (#51769)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-27 23:01:00 -05:00
Aiden Cline d73396ab3d fix(ai): preserve Gemini thought signatures on OpenAI Chat tool calls (#51768) 2026-09-27 22:57:22 -05:00
Jérôme BenoitandTest User 0caae608a2 chore(nix): update nixpkgs for Bun 1.4 (#50221)
Co-authored-by: Test User <test@test.com>
2026-09-27 21:34:52 -05:00
Kit Langton 96f23508be refactor(core): remove unused project discovery option (#51729) 2026-09-27 15:05:46 -07:00
Kit Langton 28bb0a7158 refactor(core): drop forwarding shim modules (#51670) 2026-09-27 14:47:19 -07:00
DS 3d109828ff fix(tui): truncate btw question preview (#51713) 2026-09-27 21:05:20 +02:00
Shoubhit Dash c0d49f101c feat(ai): retry transient failures on queued generation reads (#51635) 2026-09-27 19:47:25 +05:30
Shoubhit Dash be2446e188 feat(ai): add ElevenLabs Scribe transcription route (#51641) 2026-09-27 19:34:36 +05:30
Shoubhit Dash 107966eddd fix(ai): reject and throw with signal.reason on abort (#51633) 2026-09-27 19:24:45 +05:30
Kit Langton f5e580cde1 chore(core): remove dead modules and exports (#51667) 2026-09-27 06:38:43 -07:00
Shoubhit Dash 4428a77acd fix(ai): classify terminal generation failures by provider error code (#51632) 2026-09-27 18:44:21 +05:30
183 changed files with 2625 additions and 1000 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@opencode/core": patch
---
Correct directory page headings when the read offset is zero.
+20
View File
@@ -24,6 +24,10 @@ on:
description: "Override version (optional)"
required: false
type: string
release_notes:
description: "Reviewed V2 release notes for the Discord announcement (optional)"
required: false
type: string
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'v2' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }}
@@ -653,3 +657,19 @@ jobs:
OPENCODE_DESKTOP_DIST: /tmp/desktop
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
notify-discord-v2:
needs:
- version
- publish
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2' && needs.version.outputs.release && needs.publish.result == 'success'
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
# Unlike dev, V2 publishes a tag rather than a GitHub Release event.
- name: Announce V2 release in Discord
uses: SethCohen/github-releases-to-discord@24d166886aee4646d448c8a389ff9e1ebcab3682 # v1.20.0
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
release_name: OpenCode V2 ${{ needs.version.outputs.tag }}
release_body: ${{ inputs.release_notes }}
release_html_url: https://github.com/${{ github.repository }}/tree/${{ needs.version.outputs.tag }}
+35 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -112,7 +112,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.18",
"version": "2.0.19",
"bin": {
"opencode": "./bin/opencode.cjs",
"opencode2": "./bin/opencode2.cjs",
@@ -178,7 +178,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -204,7 +204,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -217,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -253,7 +253,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -280,7 +280,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -321,7 +321,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -341,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -409,7 +409,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
@@ -458,7 +458,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -495,7 +495,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -511,7 +511,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -530,7 +530,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -543,7 +543,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -557,7 +557,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -572,7 +572,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -611,7 +611,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -641,7 +641,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -656,7 +656,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -680,7 +680,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -701,7 +701,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -723,7 +723,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -758,7 +758,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -778,7 +778,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -812,7 +812,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -831,7 +831,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -877,7 +877,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -891,7 +891,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -925,7 +925,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -960,7 +960,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -998,7 +998,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1039,7 +1039,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1776683584,
"narHash": "sha256-NuTLMrr10Tng72hurYG8jYQ4XKK8wnpJmOGcPiis96g=",
"lastModified": 1790510107,
"narHash": "sha256-EVMNYv7hYDDD9TGVT/hIyTYgpiXA8y3m5xIEIxuGNU0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9dd5558b06dbdacbf635a3dd36dce1b1a7ee3a89",
"rev": "3181085bfd08663b6b9e60bc7a8395c2aaa741bd",
"type": "github"
},
"original": {
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.18",
"version": "2.0.19",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+2 -2
View File
@@ -98,11 +98,11 @@ When a provider supports multiple physical transports, selection remains executi
Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.inline(...)` / `queued(...)` / `stream(...)` (`src/route/media.ts`) compose a `MediaProtocol` kind with `Endpoint` and `Auth` and own the transport plumbing: `http` option merging, URL/query rendering, auth headers, JSON vs multipart encoding, and handing the response back to the protocol. `MediaProtocol.inline` (`src/route/media-protocol.ts`) is `body.from(request)` plus `response.decode(response, context)`; each protocol declares `const route = MediaProtocol.identity({ id, name, provider })` once and decodes through `route.decodeJson` / `route.text` / `route.decodeStarted` so decode failures retain the raw body and HTTP context, raising `route.unsupported(operation, message)` for requests it cannot lower, and passes `route` as the first argument to `MediaProtocol.inline` / `queued` / `stream`. `Generation` (`src/generation.ts`) is the provider-neutral handle for a queued generation over a `GenerationRoute` (`status`, `result`, `cancel`). Image protocol files follow the same section order as LLM protocols and declare unsupported common fields once through the protocol's `unsupported` list.
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel` (with `activeOnly` when the provider's cancel endpoint deletes finished work, as Runway's does: the route refreshes status first and skips terminal generations), each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added or changed so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel` (with `activeOnly` when the provider's cancel endpoint deletes finished work, as Runway's does: the route refreshes status first and skips terminal generations), each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added or changed so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`; a `failed` generation maps the provider's error code through a per-protocol `FAILURE` table via `MediaProtocol.failure` so rejected inputs are not reported as retryable `ProviderInternal`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
`MediaProtocol.stream` is the incremental kind every speech route uses, with the same discipline as LLM protocols. `MediaRoute.stream` submits the caller's request as `MediaProtocol.Addressed<Request>` (`{ ...request, mode }`, `mode: "generate" | "stream"`), so one provider stays one protocol: `body.from`, the endpoint path, and `frames` read `request.mode` to pick the body, path, and framing. `frames(bytes, context)` returns frames — `Framing.sse`, `Framing.lines`, `Framing.document` (a single-document response shaped like a streamed record), or the raw `bytes` for chunked audio. `initial()` is fresh per-response parser state; `step` folds each frame into it and emits modality events; `finish(state, context)` runs once after the last frame with the request, body, and observed `http` (header-only usage lives there) and emits exactly one terminal event or fails with `route.incomplete()`. Keep parser state to real accumulators and derive anything the request or body determines in `finish`. `generate` runs the same stream and folds it with the modality's `collect`. Request-derived URL parameters go on the body's `query` (array values repeat the parameter), applied before route and caller `http.query`. Decode frames with `route.decodeFrame` and raise stream-time failures with `route.frameError` (the frame stays on `reason.body`); protocols never thread HTTP context, because the route fills `reason.http` on stream errors that lack it. Speech protocols share `protocols/utils/speech-stream.ts` for deltas, timestamps, voice ids, PCM and container descriptions, and the terminal asset.
Every modality route is the inline | stream | queued union (transcription uses all three: OpenAI and Gemini stream, Deepgram is inline, AssemblyAI is queued), every client is `MediaClient.make(Service, { modality, responseEvents })` (`src/media-client.ts`), which dispatches on the route's `kind`, and every model composes through `composeRoute`. fal queue protocols come from `protocols/utils/fal-queue.ts`, bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
Every modality route is the inline | stream | queued union (transcription uses all three: OpenAI and Gemini stream, Deepgram and ElevenLabs are inline, AssemblyAI is queued), every client is `MediaClient.make(Service, { modality, responseEvents })` (`src/media-client.ts`), which dispatches on the route's `kind`, and every model composes through `composeRoute`. fal queue protocols come from `protocols/utils/fal-queue.ts`, bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
### URL Construction
+24 -12
View File
@@ -129,8 +129,9 @@ VercelAIGateway.configure().experimental.evaluation("typesafe-ai/jev")
OpenRouter reads `OPENROUTER_API_KEY`. Vercel reads `AI_GATEWAY_API_KEY`, then `VERCEL_OIDC_TOKEN`.
The common API uses `boolean`; System One routes lower it to native `noul`.
Choice and score confidence plus score legends remain available in provider metadata, and the
provider's rounded probabilities are returned unchanged.
Choice and score answers include `confidence` when the provider returns it, such as
`response.answers.department.confidence`. Score legends remain available in provider metadata, and
the provider's rounded probabilities are returned unchanged.
## Alibaba Cloud Model Studio
@@ -752,7 +753,10 @@ const events = Video.stream({ model: Runway.configure({ apiKey }).video("gen4.5"
Status polls, result fetches, cancels, and asset downloads all run through the same request executor with the route's
auth. `Generation.await` and `Generation.events` fail with a
`Timeout` reason when `poll.timeout` (default 10 minutes) elapses. Failed,
`Timeout` reason when `poll.timeout` (default 10 minutes) elapses. Status polls and result fetches retry transient
failures (rate limits, provider 5xx, network errors) with backoff that honors `retry-after`, always within
`poll.timeout`; submits and cancels never retry. Interrupting a wait (or aborting its `signal`) does not cancel the
provider job, which keeps running and billing: call `cancel()` to stop it. Failed,
cancelled, and expired generations fail typed with the provider's terminal document on `reason.body`; moderation
outcomes (Veo `raiMediaFilteredReasons`, xAI `respect_moderation`, Runway `SAFETY.*` codes) surface as `notices` when
a video is still returned and as a `ContentPolicy` reason when nothing is.
@@ -773,7 +777,9 @@ Provider notes:
The promise client exposes the same surface: `ai.video.start(...)` resolves to a handle with `await`, `events`,
`result`, `refresh`, `cancel`, and `token`; `ai.video.generate`, `ai.video.resume(model, token)`, and
`ai.video.stream` mirror the Effect API. The handle's `status` and `progress` are a snapshot from when it was
created; `refresh()` resolves to a new handle.
created; `refresh()` resolves to a new handle. Every promise method and stream accepts `{ signal }`: like `fetch`,
aborting rejects the Promise or throws from the `for await` loop with `signal.reason` (an `AbortError` `DOMException`
unless `abort(reason)` passed one), while `break` stops a stream without throwing.
```ts
import { ai } from "@opencode/ai/promise"
@@ -871,11 +877,12 @@ for await (const event of ai.speech.stream({ model, text: "Hello from OpenCode."
## Transcription
Transcription (speech-to-text) is the one modality whose providers use every route kind: OpenAI and Gemini stream,
Deepgram answers inline, and AssemblyAI is queued. `Transcription.generate` and `Transcription.stream` work on all of
them; `Transcription.start` / `resume` return a `Generation` on queued routes and fail with `UnsupportedOperation`
elsewhere. Models come from `.transcription(...)` selectors on the `OpenAI`, `Google`, `Deepgram`, and `AssemblyAI`
facades. Common fields (`language`, `prompt`, `timestamps: "none" | "segment" | "word"`, `diarize`, `speakers`) lower
natively or fail with a typed `AIError` before any network call; a route may return more than asked.
Deepgram and ElevenLabs answer inline, and AssemblyAI is queued. `Transcription.generate` and `Transcription.stream`
work on all of them; `Transcription.start` / `resume` return a `Generation` on queued routes and fail with
`UnsupportedOperation` elsewhere. Models come from `.transcription(...)` selectors on the `OpenAI`, `Google`,
`Deepgram`, `ElevenLabs`, and `AssemblyAI` facades. Common fields (`language`, `prompt`,
`timestamps: "none" | "segment" | "word"`, `diarize`, `speakers`) lower natively or fail with a typed `AIError` before
any network call; a route may return more than asked.
```ts
import { Console, Effect, Stream } from "effect"
@@ -887,7 +894,7 @@ const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY })
const program = Effect.gen(function* () {
const audio = yield* Media.file("./call.mp3")
// Speaker-labelled segments; labels are provider-native strings ("A", "0", "spk:0").
// Speaker-labelled segments; labels are provider-native strings ("A", "0", "spk:0", "speaker_0").
const response = yield* Transcription.generate({
model: Deepgram.configure({ apiKey }).transcription("nova-3"),
audio,
@@ -897,7 +904,7 @@ const program = Effect.gen(function* () {
response.text // "Hello from OpenCode."
response.segments // [{ text, startSeconds, endSeconds, speaker: "0" }]
response.words // [{ text, startSeconds, endSeconds, speaker, confidence }]
response.language // the provider's own value, lowercased ("en", "english", "en_us")
response.language // the provider's own value, lowercased ("en", "eng", "english", "en_us")
// Text deltas as the model transcribes, then one finish carrying the whole transcript.
yield* Transcription.stream({ model: openai.transcription("gpt-4o-mini-transcribe"), audio }).pipe(
@@ -921,7 +928,12 @@ Provider notes:
- **OpenAI** takes inline audio only; `diarize` needs `gpt-4o-transcribe-diarize`, timestamps need `whisper-1`, and `whisper-1` does not stream.
- **Gemini** needs a transcribe model (`gemini-3.5-transcribe`); `prompt` and `speakers` fail typed.
- **Deepgram** detects the language unless `language` is set; vocabulary goes in `providerOptions.keyterm`.
- **AssemblyAI** uploads inline audio before submitting and is the only route that accepts `speakers`.
- **ElevenLabs** (`scribe_v2`) uploads inline audio as the multipart `file` and sends a URL as `source_url`. Words
always carry timestamps, and segments are speaker turns, so `diarize`, `timestamps: "segment"`, or `speakers` turns
on diarization. `speakers` is an upper bound (`num_speakers`); `prompt` fails typed (vocabulary goes in
`providerOptions.keyterms`), as do webhook delivery and per-channel output (`use_multi_channel` without
`multichannel_output_style: "combined"`).
- **AssemblyAI** uploads inline audio before submitting and treats `speakers` as the exact speaker count.
The promise client mirrors the Effect API:
+27 -17
View File
@@ -1,7 +1,6 @@
# Media generation in `@opencode/ai` — public API direction
Status: phases 1–4 implemented (through Image queued routes and partial images; ElevenLabs Scribe transcription
pending); phase 5 proposal.
Status: phases 1–4 implemented (through Image queued routes and partial images); phase 5 proposal.
## Goal
@@ -271,8 +270,8 @@ Deferred: `Speech.session(...)` — input-streaming TTS where text arrives incre
#### Transcription (STT)
Shipped as the second half of phase 3 (`src/transcription.ts`, `src/transcription-client.ts`, protocols
`openai-transcription`, `google-transcription`, `deepgram-transcription`, `assemblyai-transcription`; new `AssemblyAI`
facade).
`openai-transcription`, `google-transcription`, `deepgram-transcription`, `elevenlabs-transcription`,
`assemblyai-transcription`; new `AssemblyAI` facade).
```ts
const request = Transcription.request({
@@ -281,7 +280,7 @@ const request = Transcription.request({
language: "en", // provider-native passthrough
timestamps: "segment", // none | segment | word
diarize: true,
speakers: 2, // exact speaker count (AssemblyAI only)
speakers: 2, // speaker count (AssemblyAI exact, ElevenLabs maximum)
providerOptions: { known_speaker_names: ["agent"] },
})
@@ -309,17 +308,23 @@ upload); `packages/ai/AGENTS.md` (Media Routes) describes both.
Settled rules:
- **Timestamps.** A granularity the selected route or model cannot produce fails as `UnsupportedOperation`
(`media.timestamps`), following Speech; a route that returns more than asked (Deepgram and AssemblyAI always return
words) is not stripped. Segments always carry start and end times: Gemini times each transcription part from its
(`media.timestamps`), following Speech; a route that returns more than asked (Deepgram, ElevenLabs, and AssemblyAI
always return words) is not stripped. Segments always carry start and end times: Gemini times each transcription part from its
word offsets, so segment timestamps and diarization also request word offsets there.
- **Diarization.** `diarize` means segments (and words, where the provider labels them) carry `speaker`. Labels are
provider-native strings — OpenAI `A` or a known speaker name, Deepgram `0`, Gemini `spk:0`, AssemblyAI `A` — with no
cross-provider speaker model. `speakers` is the exact number of speakers to label, which AssemblyAI (`speakers_expected`, the only route that
accepts it) treats as a constraint rather than a hint.
provider-native strings — OpenAI `A` or a known speaker name, Deepgram `0`, Gemini `spk:0`, AssemblyAI `A`,
ElevenLabs `speaker_0` — with no cross-provider speaker model. `speakers` is the number of speakers to label:
AssemblyAI (`speakers_expected`) treats it as an exact constraint rather than a hint, and ElevenLabs
(`num_speakers`) as the maximum. Both turn on diarization for it; the other routes reject it.
- **Segments from words.** ElevenLabs returns only a token list (`word`, `spacing`, `audio_event`), so its segments
are speaker turns: consecutive words and spacing with one `speaker_id`, text joined from the provider's own spacing
tokens. `words` drops spacing and audio events. Segments therefore need diarization, which `timestamps: "segment"`
turns on, as AssemblyAI's utterances need speaker labels.
- **Language** is passed through (`language`, OpenAI `gpt-transcribe` `languages[]`, Gemini `languageCodes`,
AssemblyAI `language_code`). `response.language` is the provider's own value, lowercased but not normalized: an
ISO code on most routes (AssemblyAI's detection returns `en`), `english` from whisper-1. Deepgram and AssemblyAI
assume English unless asked to detect, so a missing `language` enables their detection.
AssemblyAI and ElevenLabs `language_code`). `response.language` is the provider's own value, lowercased but not
normalized: an ISO code on most routes (AssemblyAI's detection returns `en`, ElevenLabs ISO 639-3 `eng`), `english`
from whisper-1. Deepgram and AssemblyAI assume English unless asked to detect, so a missing `language` enables their
detection.
- **Gemini** requires a transcribe model; other model ids fail with `UnsupportedOperation` before the call, because
general models ignore `audioTranscriptionConfig` and answer conversationally. Streamed chunks carry whole speaker
turns (one part per turn), which join with a space.
@@ -332,11 +337,12 @@ Settled rules:
| OpenAI | stream (`stream: true` in `stream` mode; `whisper-1` ignores `stream`, so it emits only `finish`) | multipart `file` (inline only) | `whisper-1` (`verbose_json`); diarize model: `segment` | `gpt-4o-transcribe-diarize` (`diarized_json`) | `speakers`; `prompt` on the diarize model | `tokens` or `seconds` |
| Gemini | stream (`generateContent` / `streamGenerateContent`) | `inlineData` or Gemini Files `fileData` | `audioTranscriptionConfig.wordTimestamp` | `audioTranscriptionConfig.diarization` | `prompt`, `speakers` | `tokens` |
| Deepgram | inline | raw body, or JSON `{ url }` | words always; `segment` → `utterances` | `diarize_model=latest` + `utterances` | `prompt`, `speakers` | `seconds` (`metadata.duration`) |
| ElevenLabs | inline | multipart `file`, or `source_url` | words always; `segment` → `diarize` (speaker turns) | `diarize` | `prompt`; `webhook`, per-channel `use_multi_channel` | `seconds` (`audio_duration_secs`) |
| AssemblyAI | queued (upload → submit → poll) | `/v2/upload` then `audio_url`, or a URL | words always; `segment` → `speaker_labels` | `speaker_labels` | — | `seconds` (`audio_duration`) |
Deferred: `Transcription.session(...)` — realtime STT over WebSocket (Deepgram live, AssemblyAI streaming, ElevenLabs
realtime, OpenAI realtime transcription) — is the same future scoped `session` shape as input-streaming TTS and ships
with the realtime work in phase 5. ElevenLabs Scribe is not implemented yet.
with the realtime work in phase 5.
### `Generation` — shared async execution
@@ -361,6 +367,10 @@ Poll = { interval?: Duration; timeout?: Duration }
`Generation` is not video-specific. Image routes on BFL, fal, Replicate, and Stability `upscale()` are queued; `Image.start` exists for them. A route declares itself `inline` or `queued`; `generate` on a queued route is `start` then `await`.
Status polls and result reads retry transient failures (rate limits, provider 5xx, and transport errors, classified by the same `isRetryable` the Session runner uses) inside `MediaRoute.queued`. Only the HTTP exchange retries, never the decoded document: a terminal `failed` generation also surfaces as `ProviderInternal` and must not be re-read. Gaps grow exponentially from 1s with jitter, up to 30s each, honoring a provider `retry-after` up to that cap, for at most 8 retries. `await`, `events`, and `Video.stream` cut retries off at `poll.timeout` and fail with `Timeout`, so retries never extend the caller's deadline; a direct `result()` or `resume` read is bounded by the retry cap alone. `start` and `cancel` never retry: a repeated submit can start and bill a second job. The policy is internal; there is no option for it.
Interrupting `await`, `events`, or `Video.stream` (or aborting the promise API's `signal`) stops waiting only. The provider job keeps running and billing; call `cancel()` explicitly to stop it.
### Usage
```ts
@@ -402,7 +412,7 @@ for await (const event of ai.llm.stream(request)) { … }
await ai.dispose()
```
Streams become `AsyncIterable` via `Stream.toAsyncIterable`. `AIError` is thrown as-is. `AbortSignal` maps to interruption. Nothing in `src/*` except this entrypoint knows about promises.
Streams become `AsyncIterable` via `Stream.toAsyncIterable`. `AIError` is thrown as-is. Aborting an `AbortSignal` interrupts the work and, like `fetch`, rejects the Promise or throws from the stream with `signal.reason` instead of ending the stream as if complete. Nothing in `src/*` except this entrypoint knows about promises.
### Providers
@@ -414,7 +424,7 @@ implemented):
| `OpenAI` | responses (default), chat | Images API (stream) | *Sora skipped (decision 8)* | ✓ | ✓ | |
| `Google` | Gemini | Gemini-native | Veo | Gemini TTS | `gemini-3.5-transcribe` | |
| `XAI` | ✓ | ✓ | ✓ | | | |
| `ElevenLabs` | | | | ✓ | *Scribe (pending)* | *soundEffect, music (phase 5)* |
| `ElevenLabs` | | | | ✓ | Scribe | *soundEffect, music (phase 5)* |
| `Cartesia` | | | | ✓ | | |
| `Deepgram` | | | | Aura | ✓ | |
| `Fal` | | ✓ (queued) | ✓ | | | |
@@ -467,7 +477,7 @@ Foundation + Image ship together as the reference implementation, serially. Vide
1. **Foundation** — per-modality selectors, `Media`, `Generation`, `Poll`, `Usage` union, `MediaProtocol` kinds, `@opencode/ai/promise` with `llm` + `image`. Port the five existing image protocols onto it. Unify `MediaPart` and add the `media` LLM event (fixes Gemini image output being dropped).
2. **Video** — ✅ Veo, xAI, fal, Runway shipped (`MediaProtocol.queued`, `Video.start/generate/resume/stream`, promise `ai.video`). Deferred: `Video.complete` (webhooks), Luma, Kling, MiniMax, Replicate.
3. **Speech + Transcription** — ✅ Speech: OpenAI, Gemini TTS, ElevenLabs, Cartesia, Deepgram shipped (`MediaProtocol.stream`, `Speech.generate/stream`, promise `ai.speech`). ✅ Transcription: OpenAI, Gemini, Deepgram, AssemblyAI shipped across all three route kinds (`Transcription.generate/stream/start/resume`, promise `ai.transcription`). Pending: ElevenLabs Scribe. Deferred: `Speech.session` and `Transcription.session` (WebSocket streaming).
3. **Speech + Transcription** — ✅ Speech: OpenAI, Gemini TTS, ElevenLabs, Cartesia, Deepgram shipped (`MediaProtocol.stream`, `Speech.generate/stream`, promise `ai.speech`). ✅ Transcription: OpenAI, Gemini, Deepgram, ElevenLabs Scribe, AssemblyAI shipped across all three route kinds (`Transcription.generate/stream/start/resume`, promise `ai.transcription`). Deferred: `Speech.session` and `Transcription.session` (WebSocket streaming).
4. **Image queued routes and partials** — ✅ BFL, fal, Replicate, and Stability creative upscale queued; Stability generate inline; OpenAI `partial_images` streaming (`image-partial` restored). Imagen dropped: shut down on the Gemini API and discontinued on Vertex (2026-06-30). Deferred: Stability's synchronous edit and fast/conservative upscale endpoints.
5. **Later** — ElevenLabs music/SFX, Lyria, `Speech.session` / `Transcription.session`, realtime.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.18",
"version": "2.0.19",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+1
View File
@@ -39,6 +39,7 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
// whole policy pass for these — emitting hints would be harmless but pointless.
const RESPECTS_INLINE_HINTS = new Set([
"anthropic-messages",
"anthropic-compatible-messages",
"google-vertex-messages",
"bedrock-converse",
"openrouter",
@@ -63,6 +63,7 @@ export const ChoiceAnswer = Schema.Struct({
type: Schema.Literal("choice"),
choice: Schema.String,
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
confidence: Schema.optional(Probability),
})
export type ChoiceAnswer = Schema.Schema.Type<typeof ChoiceAnswer>
@@ -70,6 +71,7 @@ export const ScoreAnswer = Schema.Struct({
type: Schema.Literal("score"),
score: Schema.Number,
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
confidence: Schema.optional(Probability),
})
export type ScoreAnswer = Schema.Schema.Type<typeof ScoreAnswer>
@@ -92,6 +94,7 @@ export type AnswerFor<Question extends EvaluationQuestion> = Question extends {
readonly type: "choice"
readonly choice: Extract<keyof Criteria, string>
readonly probabilities?: Readonly<Record<Extract<keyof Criteria, string>, number>>
readonly confidence?: number
}
: Question extends { readonly type: "score" }
? ScoreAnswer
+10 -5
View File
@@ -142,32 +142,37 @@ export const model = <Options extends EvaluationOptions = EvaluationOptions>(cfg
Effect.mapError((cause) => fail("System One returned an invalid response", cause, text)),
)
const confidence: Record<string, number> = {}
const legend: Record<string, Record<string, Schema.Json>> = {}
const answers = Object.fromEntries(
Object.entries(data.answers).map(([id, answer]): [string, EvaluationAnswer] => {
if (answer.type === "noul") return [id, { type: "boolean", probability: answer.noul }]
if (answer.type === "choice") {
if (answer.confidence !== undefined) confidence[id] = answer.confidence
return [
id,
{
type: "choice",
choice: answer.choice,
probabilities: answer.probabilities,
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
},
]
}
if (answer.confidence !== undefined) confidence[id] = answer.confidence
if (answer.legend !== undefined) legend[id] = answer.legend
return [id, { type: "score", score: answer.score, probabilities: answer.probabilities }]
return [
id,
{
type: "score",
score: answer.score,
probabilities: answer.probabilities,
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
},
]
}),
)
const meta = {
...(data.id === undefined ? {} : { responseId: data.id }),
...(data.provider === undefined ? {} : { provider: data.provider }),
...data.provider_metadata?.[cfg.providerMetadataKey],
...(Object.keys(confidence).length === 0 ? {} : { confidence }),
...(Object.keys(legend).length === 0 ? {} : { legend }),
}
return new EvaluationResponse({
+47 -28
View File
@@ -102,7 +102,7 @@ export class Generation<Response> {
return settled.pipe(
// Non-completed terminal states also go through `result` so the route can surface its provider failure body.
Effect.flatMap((generation) => generation.result()),
Effect.timeoutOrElse({ duration: timeout, orElse: () => this.timeoutError(timeout) }),
Effect.timeoutOrElse({ duration: timeout, orElse: () => timeoutError(this.id, timeout) }),
)
}
@@ -123,20 +123,7 @@ export class Generation<Response> {
Clock.currentTimeMillis.pipe(
Effect.map((start) => {
const deadline = start + Duration.toMillis(timeout)
// Fail before polling once the deadline has passed: a fast status request could otherwise win the zero-budget
// race and schedule another zero-delay poll.
const refresh = Clock.currentTimeMillis.pipe(
Effect.flatMap((now) =>
now >= deadline
? this.timeoutError(timeout)
: this.refresh().pipe(
Effect.timeoutOrElse({
duration: Duration.millis(deadline - now),
orElse: () => this.timeoutError(timeout),
}),
),
),
)
const refresh = within(this.refresh(), this.id, timeout, deadline)
const schedule = this.schedule(options?.poll).pipe(
Schedule.modifyDelay((meta) =>
Effect.succeed(Duration.min(meta.duration, Duration.millis(Math.max(0, deadline - meta.now)))),
@@ -157,15 +144,6 @@ export class Generation<Response> {
return { type: "generation-progress", id: this.id, progress: this.progress }
}
private timeoutError(timeout: Duration.Duration) {
return new AIError({
reason: new TimeoutError({
message: `Generation ${this.id} did not finish within ${Duration.format(timeout)}`,
timeoutMs: Duration.toMillis(timeout),
}),
})
}
private poll(poll: Poll | undefined) {
return this.refresh().pipe(
Effect.repeat({ schedule: this.schedule(poll), until: (generation) => generation.terminal }),
@@ -177,12 +155,53 @@ export class Generation<Response> {
}
}
/** `events` followed by the expanded result, with the result fetch bounded by the same `poll.timeout` deadline. */
export const resultEvents = <Response, A>(
generation: Generation<Response>,
expand: (response: Response) => ReadonlyArray<A>,
options?: AwaitOptions,
): Stream.Stream<Observation | A, AIError> =>
generation.events(options).pipe(
Stream.filter((event): event is Observation => event.type !== "generation-finished"),
Stream.concat(Stream.fromIterableEffect(Effect.map(generation.result(), expand))),
): Stream.Stream<Observation | A, AIError> => {
const timeout = Duration.fromInputUnsafe(options?.poll?.timeout ?? DEFAULT_POLL_TIMEOUT)
return Stream.unwrap(
Clock.currentTimeMillis.pipe(
Effect.map((start) =>
generation.events(options).pipe(
Stream.filter((event): event is Observation => event.type !== "generation-finished"),
Stream.concat(
Stream.fromIterableEffect(
within(generation.result(), generation.id, timeout, start + Duration.toMillis(timeout)).pipe(
Effect.map(expand),
),
),
),
),
),
),
)
}
/**
* Run `effect` within the time left until `deadline`. Fails before starting once the deadline has passed: a fast
* request could otherwise win the zero-budget race and schedule another zero-delay poll.
*/
const within = <A>(effect: Effect.Effect<A, AIError>, id: string, timeout: Duration.Duration, deadline: number) =>
Clock.currentTimeMillis.pipe(
Effect.flatMap((now) =>
now >= deadline
? Effect.fail(timeoutError(id, timeout))
: effect.pipe(
Effect.timeoutOrElse({
duration: Duration.millis(deadline - now),
orElse: () => Effect.fail(timeoutError(id, timeout)),
}),
),
),
)
const timeoutError = (id: string, timeout: Duration.Duration) =>
new AIError({
reason: new TimeoutError({
message: `Generation ${id} did not finish within ${Duration.format(timeout)}`,
timeoutMs: Duration.toMillis(timeout),
}),
})
+1 -1
View File
@@ -4,7 +4,7 @@ export { ImageClient } from "./image-client.js"
export { Auth } from "./route/auth.js"
export { Provider } from "./provider.js"
export { ProviderPackage } from "./provider-package.js"
export { isContextOverflow, isContextOverflowFailure } from "./provider-error.js"
export { isContextOverflow, isContextOverflowFailure, isRetryable } from "./provider-error.js"
export type {
RouteLanguageModelInput,
RouteRoutedLanguageModelInput,
+7 -6
View File
@@ -42,7 +42,7 @@ export type GenerationHandle<Response> = Snapshot & {
/** Serializable JSON; pass it back to `resume` from another process. */
readonly token: unknown
readonly await: (options?: AwaitOptions & RunOptions) => Promise<Response>
/** Status observations until the first terminal one, polling like `await`; abort ends iteration without throwing. */
/** Status observations until the first terminal one, polling like `await`; abort throws `signal.reason`. */
readonly events: (options?: AwaitOptions & RunOptions) => AsyncIterable<Event>
/** The result without polling; fails when the generation has not completed. */
readonly result: (options?: RunOptions) => Promise<Response>
@@ -50,15 +50,16 @@ export type GenerationHandle<Response> = Snapshot & {
readonly cancel: (options?: RunOptions) => Promise<void>
}
// Fails with `signal.reason` so aborted calls reject and aborted streams throw like `fetch`: an `AbortError` by default.
const abortEffect = (signal: AbortSignal | undefined) =>
signal === undefined
? Effect.never
: Effect.callback<void>((resume) => {
: Effect.callback<never, unknown>((resume) => {
if (signal.aborted) {
resume(Effect.void)
resume(Effect.fail(signal.reason))
return
}
const onAbort = () => resume(Effect.void)
const onAbort = () => resume(Effect.fail(signal.reason))
signal.addEventListener("abort", onAbort, { once: true })
return Effect.sync(() => signal.removeEventListener("abort", onAbort))
})
@@ -68,14 +69,14 @@ export const make = (options: Options = {}) => {
/** Run any package Effect (for example `LLMClient.compact(...)`) inside this runtime. */
const run = <A, E>(effect: Effect.Effect<A, E, Services>, options?: RunOptions) =>
runtime.runPromise(effect, { signal: options?.signal })
runtime.runPromise(Effect.raceFirst(effect, abortEffect(options?.signal)))
const iterate = <A, E>(stream: Stream.Stream<A, E, Services>, options?: RunOptions): AsyncIterable<A> =>
Stream.toAsyncIterable(
Stream.unwrap(
runtime.contextEffect.pipe(
Effect.map(
(context): Stream.Stream<A, E> =>
(context): Stream.Stream<A, unknown> =>
stream.pipe(Stream.interruptWhen(abortEffect(options?.signal)), Stream.provideContext(context)),
),
),
+24 -9
View File
@@ -555,7 +555,7 @@ interface ParserState {
readonly hasToolCalls: boolean
readonly lifecycle: Lifecycle.State
readonly reasoningSignatures: Readonly<Record<number, string>>
readonly reasoningRedactedContent: Readonly<Record<number, ReadonlyArray<Uint8Array>>>
readonly reasoningRedactedContent: Readonly<Record<number, Uint8Array[]>>
}
const encodeRedactedContent = (chunks: ReadonlyArray<Uint8Array>) => Encoding.encodeBase64(concatBytes(chunks))
@@ -605,10 +605,9 @@ const step = (state: ParserState, event: BedrockEvent) =>
const index = event.contentBlockDelta.contentBlockIndex
const reasoning = event.contentBlockDelta.delta.reasoningContent
const events: LLMEvent[] = []
const redactedChunks = yield* (() => {
const redactedChunk = yield* (() => {
if (reasoning.redactedContent === undefined) return Effect.succeed(undefined)
return Effect.fromResult(Encoding.decodeBase64(reasoning.redactedContent)).pipe(
Effect.map((chunk) => [...(state.reasoningRedactedContent[index] ?? []), chunk]),
Effect.mapError((cause) =>
ProviderShared.eventError(
ADAPTER,
@@ -619,17 +618,21 @@ const step = (state: ParserState, event: BedrockEvent) =>
),
)
})()
const redactedData = redactedChunks === undefined ? reasoning.data : encodeRedactedContent(redactedChunks)
const redactedChunks = state.reasoningRedactedContent[index] ?? []
if (redactedChunk !== undefined) redactedChunks.push(redactedChunk)
const metadata = (() => {
if (reasoning.signature) return providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
if (redactedData !== undefined) return providerMetadata(state.providerMetadataKey, { redactedData })
if (redactedChunk === undefined && reasoning.data !== undefined)
return providerMetadata(state.providerMetadataKey, { redactedData: reasoning.data })
})()
const lifecycle = (() => {
if (reasoning.text === undefined && metadata === undefined) return state.lifecycle
return Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
if (reasoning.text !== undefined || metadata !== undefined)
return Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${index}`, reasoning.text ?? "", metadata)
if (redactedChunk !== undefined) return Lifecycle.reasoningStart(state.lifecycle, events, `reasoning-${index}`)
return state.lifecycle
})()
const reasoningRedactedContent = (() => {
if (redactedChunks !== undefined) return { ...state.reasoningRedactedContent, [index]: redactedChunks }
if (redactedChunk !== undefined) return { ...state.reasoningRedactedContent, [index]: redactedChunks }
if (reasoning.data === undefined) return state.reasoningRedactedContent
return Object.fromEntries(
Object.entries(state.reasoningRedactedContent).filter(([key]) => key !== String(index)),
@@ -765,7 +768,19 @@ const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> => {
return state.finishReason.normalized
})()
const events: LLMEvent[] = []
Lifecycle.finish(state.lifecycle, events, {
const lifecycle = Object.entries(state.reasoningRedactedContent).reduce((current, [index, chunks]) => {
const signature = state.reasoningSignatures[Number(index)]
return Lifecycle.reasoningEnd(
current,
events,
`reasoning-${index}`,
providerMetadata(
state.providerMetadataKey,
signature ? { signature } : { redactedData: encodeRedactedContent(chunks) },
),
)
}, state.lifecycle)
Lifecycle.finish(lifecycle, events, {
reason: {
...state.finishReason,
normalized,
@@ -6,6 +6,7 @@ import { mergeJsonRecords, type OpenString } from "../schema/index.js"
import { TranscriptionModel, TranscriptionResponse, type TranscriptionRequestFor } from "../transcription.js"
import { ProviderShared } from "./shared.js"
import { MediaInput } from "./utils/media-input.js"
import { SpeakerTurns } from "./utils/speaker-turns.js"
const route = MediaProtocol.identity({ id: "deepgram-transcription", name: "Deepgram", provider: "deepgram" })
export const DEFAULT_BASE_URL = "https://api.deepgram.com"
@@ -115,16 +116,6 @@ const speaker = (value: number | undefined) => (value === undefined ? undefined
const wordText = (word: typeof Word.Type) => word.punctuated_word ?? word.word
// Utterances split on pauses, not speakers: the v2 diarizer labels a whole utterance with one speaker even when its
// words change speaker, so segments split each utterance at speaker changes.
const speakerTurns = (words: ReadonlyArray<typeof Word.Type>) =>
words.reduce<Array<Array<typeof Word.Type>>>((turns, word) => {
const last = turns.at(-1)
if (last === undefined || last[0].speaker !== word.speaker) return [...turns, [word]]
last.push(word)
return turns
}, [])
const decodeResponse = Effect.fn("DeepgramTranscription.decodeResponse")(function* (
response: HttpClientResponse.HttpClientResponse,
) {
@@ -136,6 +127,8 @@ const decodeResponse = Effect.fn("DeepgramTranscription.decodeResponse")(functio
const requestID = output.value.metadata?.request_id
return new TranscriptionResponse({
text: alternative.transcript,
// Utterances split on pauses, not speakers: the v2 diarizer labels a whole utterance with one speaker even when
// its words change speaker, so segments split each utterance at speaker changes.
segments: output.value.results.utterances?.flatMap((utterance) =>
utterance.words === undefined || utterance.words.length === 0
? [
@@ -146,7 +139,7 @@ const decodeResponse = Effect.fn("DeepgramTranscription.decodeResponse")(functio
speaker: speaker(utterance.speaker),
},
]
: speakerTurns(utterance.words).map((turn) => ({
: SpeakerTurns.group(utterance.words, (word) => word.speaker).map((turn) => ({
text: turn.map(wordText).join(" "),
startSeconds: turn[0].start,
endSeconds: turn[turn.length - 1].end,
@@ -0,0 +1,211 @@
import { Effect, Schema } from "effect"
import type { HttpClientResponse } from "effect/unstable/http"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { mergeJsonRecords, type OpenString } from "../schema/index.js"
import { TranscriptionModel, TranscriptionResponse, type TranscriptionRequestFor } from "../transcription.js"
import { mediaTypeExtension } from "../utils/media-type.js"
import { ProviderShared, optionalNull } from "./shared.js"
import { MediaInput } from "./utils/media-input.js"
import { SpeakerTurns } from "./utils/speaker-turns.js"
const route = MediaProtocol.identity({
id: "elevenlabs-transcription",
name: "ElevenLabs Transcription",
provider: "elevenlabs",
})
export const DEFAULT_BASE_URL = "https://api.elevenlabs.io"
export const PATH = "/v1/speech-to-text"
// ---------------------------------------------------------------------------
// 1. Public model input
// ---------------------------------------------------------------------------
export type ElevenLabsTranscriptionOptions = {
readonly tag_audio_events?: boolean
readonly timestamps_granularity?: OpenString<"none" | "word" | "character">
readonly diarization_threshold?: number
readonly file_format?: OpenString<"pcm_s16le_16" | "other">
readonly temperature?: number
readonly seed?: number
readonly keyterms?: ReadonlyArray<string>
readonly no_verbatim?: boolean
readonly detect_speaker_roles?: boolean
readonly use_speaker_library?: boolean
readonly entity_detection?: string | ReadonlyArray<string>
readonly entity_redaction?: string | ReadonlyArray<string>
readonly entity_redaction_mode?: OpenString<"redacted" | "entity_type" | "enumerated_entity_type">
} & Record<string, unknown>
export type Request = TranscriptionRequestFor<ElevenLabsTranscriptionOptions>
// ---------------------------------------------------------------------------
// 2. Response schema
// ---------------------------------------------------------------------------
/** `type` is `word`, `spacing` (the whitespace between words), or `audio_event` (`(laughter)`). */
const Token = Schema.Struct({
text: Schema.String,
type: Schema.String,
start: optionalNull(Schema.Number),
end: optionalNull(Schema.Number),
speaker_id: optionalNull(Schema.String),
logprob: optionalNull(Schema.Number),
})
type Token = Schema.Schema.Type<typeof Token>
const Transcript = Schema.Struct({
language_code: optionalNull(Schema.String),
text: Schema.String,
words: optionalNull(Schema.Array(Token)),
transcription_id: optionalNull(Schema.String),
audio_duration_secs: optionalNull(Schema.Number),
})
// ---------------------------------------------------------------------------
// 5. Request body construction
// ---------------------------------------------------------------------------
/** Speaker turns are the only segments ElevenLabs can produce, and `num_speakers` only applies to diarization. */
const diarizes = (request: Request) =>
request.diarize === true || request.timestamps === "segment" || request.speakers !== undefined
const RESERVED_FORM_FIELDS = new Set([
"file",
"cloud_storage_url",
"source_url",
"model_id",
"language_code",
"diarize",
"num_speakers",
])
const validate = (request: Request, overlay: Record<string, unknown>) => {
// Webhook requests return 202 with no transcript; the result arrives at a configured webhook instead.
if (overlay.webhook === true)
return Effect.fail(route.unsupported("transcription.webhook", `${route.name} does not deliver to webhooks`))
// Separate multichannel output replaces the transcript with one transcript per channel.
if (overlay.use_multi_channel === true && overlay.multichannel_output_style !== "combined")
return Effect.fail(
route.unsupported(
"transcription.multichannel",
`${route.name} returns a single transcript; set multichannel_output_style: "combined" to merge channels`,
),
)
if (overlay.timestamps_granularity === "none" && (request.timestamps === "word" || diarizes(request)))
return Effect.fail(
route.unsupported(
"media.timestamps",
`${route.name} cannot return word timestamps or speaker turns with timestamps_granularity: "none"`,
),
)
return Effect.void
}
const fromRequest = Effect.fn("ElevenLabsTranscription.fromRequest")(function* (request: Request) {
const overlay = mergeJsonRecords(request.providerOptions, request.http?.body) ?? {}
yield* validate(request, overlay)
const form = new FormData()
const url = ProviderShared.mediaUrl(request.audio)
if (url === undefined) {
const extension = mediaTypeExtension(request.audio.mediaType)
const audio = yield* MediaInput.inlineBytes(route.id, request.audio)
form.append(
"file",
MediaInput.blob(audio, request.audio.mediaType),
extension === undefined ? "audio" : `audio.${extension}`,
)
}
MediaInput.appendFields(
form,
{
model_id: request.model.id,
// `cloud_storage_url` is deprecated in favor of `source_url`, which accepts any hosted audio or video URL.
source_url: url,
language_code: request.language,
diarize: diarizes(request) ? true : undefined,
num_speakers: request.speakers,
},
{ overlay, reserved: RESERVED_FORM_FIELDS, repeatArrays: "key" },
)
return MediaProtocol.multipart(form)
})
// ---------------------------------------------------------------------------
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeTranscript = route.decodeJson(Transcript)
type TimedWord = Token & { readonly start: number; readonly end: number }
const isTimedWord = (token: Token): token is TimedWord =>
token.type === "word" && typeof token.start === "number" && typeof token.end === "number"
/** Turn text keeps the provider's own spacing tokens, so languages written without spaces are not re-spaced. */
const speakerTurns = (tokens: ReadonlyArray<Token>) =>
SpeakerTurns.group(
tokens.filter((token) => token.type === "word" || token.type === "spacing"),
(token) => token.speaker_id,
).flatMap((turn) => {
const words = turn.filter(isTimedWord)
if (words.length === 0) return []
return [
{
text: turn
.map((token) => token.text)
.join("")
.trim(),
startSeconds: words[0].start,
endSeconds: words[words.length - 1].end,
speaker: turn[0].speaker_id ?? undefined,
},
]
})
const decodeResponse = Effect.fn("ElevenLabsTranscription.decodeResponse")(function* (
response: HttpClientResponse.HttpClientResponse,
context: MediaProtocol.DecodeContext<Request>,
) {
const output = yield* decodeTranscript(response)
const transcript = output.value
const tokens = transcript.words ?? []
const duration = transcript.audio_duration_secs ?? undefined
const transcriptionID = transcript.transcription_id ?? undefined
return new TranscriptionResponse({
text: transcript.text,
segments: diarizes(context.request) ? speakerTurns(tokens) : undefined,
words: tokens.filter(isTimedWord).map((word) => ({
text: word.text,
startSeconds: word.start,
endSeconds: word.end,
speaker: word.speaker_id ?? undefined,
confidence: typeof word.logprob === "number" ? Math.exp(word.logprob) : undefined,
})),
language: transcript.language_code?.toLowerCase(),
durationSeconds: duration,
usage: duration === undefined ? undefined : { type: "seconds", seconds: duration },
providerMetadata: transcriptionID === undefined ? undefined : { elevenlabs: { transcriptionId: transcriptionID } },
})
})
// ---------------------------------------------------------------------------
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.inline<Request, TranscriptionResponse>(route, {
unsupported: ["prompt"],
body: { from: fromRequest },
response: { decode: decodeResponse },
})
export const model = (input: MediaRoute.ModelInput) =>
TranscriptionModel.fromRoute<ElevenLabsTranscriptionOptions>(
{ protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
input,
)
export const ElevenLabsTranscription = {
protocol,
model,
} as const
+14 -1
View File
@@ -36,7 +36,9 @@ const StartResponse = Schema.Struct({ name: Schema.String })
const Operation = Schema.Struct({
done: Schema.optional(Schema.Boolean),
error: Schema.optional(Schema.Struct({ message: Schema.optional(Schema.String) })),
error: Schema.optional(
Schema.Struct({ code: Schema.optional(Schema.Number), message: Schema.optional(Schema.String) }),
),
response: Schema.optional(
Schema.Struct({
generateVideoResponse: Schema.optional(
@@ -60,6 +62,16 @@ const Operation = Schema.Struct({
metadata: Schema.optional(Schema.Unknown),
})
// Operation errors are `google.rpc.Status`; unlisted codes (INTERNAL, UNAVAILABLE, ...) are provider-side.
const FAILURE = {
3: "InvalidRequest", // INVALID_ARGUMENT
7: "Authentication", // PERMISSION_DENIED
8: "RateLimit", // RESOURCE_EXHAUSTED
9: "InvalidRequest", // FAILED_PRECONDITION
11: "InvalidRequest", // OUT_OF_RANGE
16: "Authentication", // UNAUTHENTICATED
} as const satisfies Record<number, MediaProtocol.Failure>
// ---------------------------------------------------------------------------
// 5. Request body construction
// ---------------------------------------------------------------------------
@@ -154,6 +166,7 @@ const decodeResult = Effect.fn("GoogleVideo.decodeResult")(function* (
return yield* output.ended(
"failed",
`${route.name} operation failed${operation.error?.message === undefined ? "" : `: ${operation.error.message}`}`,
MediaProtocol.failure(FAILURE, operation.error?.code),
)
const generated = operation.response?.generateVideoResponse
// Downloads require the same API key as the poll; the asset carries it transiently and follows the redirect.
+10 -6
View File
@@ -426,6 +426,7 @@ interface ActiveContent {
readonly type: "text" | "reasoning"
readonly id: string
readonly thinking?: MistralThinkingContent
readonly thinkingUnits?: MistralThinkingUnit[]
}
export interface ParserState {
@@ -502,8 +503,8 @@ const closeActive = (state: ParserState, events: LLMEvent[]) => {
state.lifecycle,
events,
state.active.id,
thinkingMetadata(state.active.thinking ?? { type: "thinking", thinking: [] }),
thinkingText(state.active.thinking?.thinking ?? []),
thinkingMetadata({ ...state.active.thinking, type: "thinking", thinking: state.active.thinkingUnits ?? [] }),
thinkingText(state.active.thinkingUnits ?? []),
)
return { ...state, lifecycle, active: undefined }
}
@@ -524,20 +525,23 @@ const appendThinking = (state: ParserState, events: LLMEvent[], part: MistralOut
const current = state.active?.type === "reasoning" ? state : closeActive(state, events)
const units = thinkingUnits(part.thinking)
const active = current.active ?? { type: "reasoning" as const, id: `reasoning-${current.nextContent}` }
// Keep native units out of streamed events until the block is complete.
const accumulated = active.thinkingUnits ?? []
accumulated.push(...units)
const thinking = {
...active.thinking,
...part,
type: "thinking" as const,
thinking: [...(active.thinking?.thinking ?? []), ...units],
thinking: [],
}
const text = thinkingText(units)
return {
...current,
lifecycle:
text.length > 0
? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text, thinkingMetadata(thinking))
: Lifecycle.reasoningStart(current.lifecycle, events, active.id, thinkingMetadata(thinking)),
active: { ...active, thinking },
? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text)
: Lifecycle.reasoningStart(current.lifecycle, events, active.id),
active: { ...active, thinking, thinkingUnits: accumulated },
nextContent: current.active ? current.nextContent : current.nextContent + 1,
}
}
+26 -9
View File
@@ -64,6 +64,14 @@ const OpenAIChatTool = Schema.Struct({
})
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
// Gemini's OpenAI-compatible surface carries thought signatures in tool call
// `extra_content` and rejects replayed parallel calls without them:
// https://ai.google.dev/gemini-api/docs/thinking#signatures
const ExtraContent = Schema.Struct({
google: Schema.Struct({ thought_signature: Schema.String }),
})
const decodeExtraContent = (value: unknown) => Option.getOrUndefined(Schema.decodeUnknownOption(ExtraContent)(value))
const OpenAIChatAssistantToolCall = Schema.Struct({
id: Schema.String,
type: Schema.tag("function"),
@@ -71,6 +79,7 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
name: Schema.String,
arguments: Schema.String,
}),
extra_content: Schema.optional(ExtraContent),
})
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
@@ -112,12 +121,6 @@ const decodeReasoningDetail = Schema.decodeUnknownOption(ReasoningDetail)
const knownReasoningDetails = (details: ReadonlyArray<unknown>) =>
details.flatMap((detail) => Option.toArray(decodeReasoningDetail(detail)))
// Intentionally omit Gemini's provider-specific `extra_content.google.thought_signature`
// extension until direct Google OpenAI-compatible routing is supported here:
// https://github.com/vercel/ai/issues/11590
// https://github.com/vercel/ai/pull/11745
// https://ai.google.dev/gemini-api/docs/thought-signatures#openai
const OpenAIChatUserContent = Schema.Union([
Schema.Struct({
type: Schema.Literal("text"),
@@ -242,6 +245,7 @@ const OpenAIChatToolCallDelta = Schema.Struct({
index: optionalNull(Schema.Number),
id: optionalNull(Schema.String),
function: optionalNull(OpenAIChatToolCallDeltaFunction),
extra_content: optionalNull(Schema.Unknown),
})
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
@@ -294,6 +298,7 @@ interface PendingToolDelta {
readonly id?: string
readonly name?: string
readonly input: string
readonly extraContent?: Schema.Schema.Type<typeof ExtraContent>
}
export interface ParserState {
@@ -347,13 +352,17 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ type: "function" as const, function: { name } }),
})
const lowerToolCall = (part: ToolCallPart, options: LoweringOptions): OpenAIChatAssistantToolCall => ({
const lowerToolCall = (
part: ToolCallPart,
options: LoweringOptions & { readonly providerMetadataKey: string },
): OpenAIChatAssistantToolCall => ({
id: options.toolCallID?.(part.id) ?? part.id,
type: "function",
function: {
name: part.name,
arguments: ProviderShared.encodeJson(part.input === undefined ? {} : part.input),
},
extra_content: decodeExtraContent(part.providerMetadata?.[options.providerMetadataKey]?.extraContent),
})
const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart) {
@@ -721,7 +730,9 @@ const detectSupportsStore = (provider: string, baseURL: string | undefined): boo
p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
const isAntLing = p === "ant-ling" || url.includes("api.ant-ling.com")
const isOpencode = p === "opencode" || url.includes("opencode.ai")
const isGemini = url.includes("generativelanguage.googleapis.com")
const isNonStandard =
isGemini ||
isNvidia ||
isCerebras ||
isXai ||
@@ -1114,12 +1125,13 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
const extraContent = pending?.extraContent ?? decodeExtraContent(tool.extra_content)
latestToolIndex = index
nextToolIndex = Math.max(nextToolIndex, index + 1)
if (!current && (!id || !name)) {
pendingTools = {
...pendingTools,
[index]: { id: id || undefined, name: name || undefined, input: text },
[index]: { id: id || undefined, name: name || undefined, input: text, extraContent },
}
continue
}
@@ -1131,7 +1143,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
ADAPTER,
tools,
index,
{ id: id || undefined, name: name || undefined, text },
{
id: id || undefined,
name: name || undefined,
text,
providerMetadata: extraContent && { [state.providerMetadataKey]: { extraContent } },
},
"OpenAI Chat tool call delta is missing id or name",
)
if (ToolStream.isError(result))
@@ -193,7 +193,7 @@ const fromRequest = Effect.fn("OpenAITranscription.fromRequest")(function* (requ
{
overlay: mergeJsonRecords(request.providerOptions, request.http?.body),
reserved: RESERVED_FORM_FIELDS,
repeatArrays: true,
repeatArrays: "key[]",
},
)
return MediaProtocol.multipart(form)
+6 -1
View File
@@ -137,7 +137,12 @@ const decodeResult = Effect.fn("RunwayVideo.decodeResult")(function* (
const message = `${route.name} task failed${code === undefined ? "" : ` (${code})`}${task.failure ? `: ${task.failure}` : ""}`
// Runway failure codes are dotted paths; every moderation outcome carries a SAFETY segment.
if (code !== undefined && /(^|\.)SAFETY(\.|$)/.test(code)) return yield* output.contentPolicy(message)
return yield* output.ended("failed", message)
// ASSET.INVALID rejects the caller's input media; Runway documents it as not retryable.
return yield* output.ended(
"failed",
message,
code !== undefined && /^ASSET\.INVALID(\.|$)/.test(code) ? "InvalidRequest" : "ProviderInternal",
)
}
if (status === "cancelled")
return yield* output.ended("cancelled", `${route.name} task ${context.token.taskID} was cancelled`)
@@ -71,8 +71,9 @@ export const imageOutput = (
}
/**
* Append multipart text fields: strings as-is, other values as JSON, or arrays as repeated `key[]` parts with
* `repeatArrays`. `overlay` keys in `reserved` are dropped so `http.body` cannot replace route-owned fields.
* Append multipart text fields: strings as-is, other values as JSON, or scalar arrays as one part per item with
* `repeatArrays`, named `key[]` or `key`. `overlay` keys in `reserved` are dropped so `http.body` cannot replace
* route-owned fields.
*/
export const appendFields = (
form: FormData,
@@ -80,13 +81,13 @@ export const appendFields = (
options: {
readonly overlay?: Record<string, unknown>
readonly reserved: ReadonlySet<string>
readonly repeatArrays?: true
readonly repeatArrays?: "key[]" | "key"
},
) => {
const overlay = Object.entries(options.overlay ?? {}).filter(([key]) => !options.reserved.has(key))
Object.entries(mergeJsonRecords(fields, Object.fromEntries(overlay)) ?? {}).forEach(([key, value]) => {
if (Array.isArray(value) && options.repeatArrays)
return value.forEach((item) => form.append(`${key}[]`, String(item)))
if (Array.isArray(value) && value.every(isScalar) && options.repeatArrays !== undefined)
return value.forEach((item) => form.append(options.repeatArrays === "key[]" ? `${key}[]` : key, String(item)))
form.append(key, typeof value === "string" ? value : encodeJson(value))
})
}
@@ -0,0 +1,10 @@
/** Split an ordered token list into runs of consecutive tokens with the same speaker. */
export const group = <Item>(items: ReadonlyArray<Item>, speaker: (item: Item) => unknown) =>
items.reduce<Array<Array<Item>>>((turns, item) => {
const last = turns.at(-1)
if (last === undefined || speaker(last[0]) !== speaker(item)) return [...turns, [item]]
last.push(item)
return turns
}, [])
export * as SpeakerTurns from "./speaker-turns.js"
@@ -147,7 +147,12 @@ export const appendOrStart = <K extends StreamKey>(
route: string,
tools: State<K>,
key: K,
delta: { readonly id?: string; readonly name?: string; readonly text: string },
delta: {
readonly id?: string
readonly name?: string
readonly text: string
readonly providerMetadata?: ProviderMetadata
},
missingToolMessage: string,
): AppendOutcome<K> | AIError => {
const current = tools[key]
@@ -161,7 +166,7 @@ export const appendOrStart = <K extends StreamKey>(
namespace: current?.namespace,
input: `${current?.input ?? ""}${delta.text}`,
providerExecuted: current?.providerExecuted,
providerMetadata: current?.providerMetadata,
providerMetadata: current?.providerMetadata ?? delta.providerMetadata,
}
if (current && delta.text.length === 0 && current.id === id && current.name === name)
return { tools, tool: current, events: [] }
+8
View File
@@ -65,6 +65,13 @@ const STATUS = {
expired: "expired",
} as const satisfies Record<string, Status>
// Documented video error codes; `service_unavailable`, `internal_error`, and unknown codes are provider-side.
const FAILURE = {
invalid_argument: "InvalidRequest",
failed_precondition: "InvalidRequest",
permission_denied: "Authentication",
} as const satisfies Record<string, MediaProtocol.Failure>
// ---------------------------------------------------------------------------
// 5. Request body construction
// ---------------------------------------------------------------------------
@@ -143,6 +150,7 @@ const decodeResult = Effect.fn("XAIVideo.decodeResult")(function* (
return yield* output.ended(
"failed",
`${route.name} generation failed${code === undefined ? "" : ` (${code})`}${message === undefined ? "" : `: ${message}`}`,
MediaProtocol.failure(FAILURE, code),
)
}
if (status !== "completed")
+47 -1
View File
@@ -58,6 +58,47 @@ export const isContextOverflowFailure = (failure: unknown) =>
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
/**
* Whether a failed call may succeed when sent again: rate limits, provider-side failures, transport failures that did
* not deliver an accepted write, and unrecognized failures. Callers decide which calls are safe to repeat.
*/
export const isRetryable = (error: AIError) => {
const override = error.reason.http?.headers["x-should-retry"]
if (override === "true") return true
if (override === "false") return false
switch (error.reason._tag) {
case "RateLimit":
case "ProviderInternal":
return true
// A WebSocket acknowledgment marks delivery accepted before model output may exist.
// Read failures can still recover; the caller chooses retry versus continuation from durable output.
case "Transport":
return (
error.reason.delivery !== "rejected" &&
(error.reason.delivery !== "accepted" || error.reason.operation === "read")
)
case "InvalidProviderOutput":
return error.reason.classification === "incomplete-stream"
// Unrecognized failures retry: classification records affirmative
// deterministic evidence, and transient failures are exactly the ones
// that arrive in shapes no classifier anticipates.
case "UnknownProvider":
return true
case "Authentication":
case "QuotaExceeded":
case "ContentPolicy":
case "InvalidRequest":
case "UnsupportedOperation":
case "NoRoute":
case "Timeout":
return false
default: {
const exhaustive: never = error.reason
return exhaustive
}
}
}
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
const QUOTA_CODES = new Set([
@@ -68,7 +109,8 @@ const QUOTA_CODES = new Set([
"freeusagelimiterror",
"creditlimitexceeded",
])
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
// Google reports an invalid API key as HTTP 400 INVALID_ARGUMENT with this `details[].reason`.
const AUTH_CODES = new Set(["authentication_error", "permission_error", "api_key_invalid"])
const SERVER_CODES = new Set([
"api_error",
"internal_error",
@@ -218,6 +260,10 @@ function providerCodes(value: unknown) {
error?.type,
error?.status,
error?.error_type,
// Google `google.rpc.ErrorInfo` details carry the specific reason.
...(Array.isArray(error?.details)
? error.details.map((detail) => (isRecord(detail) ? detail.reason : undefined))
: []),
inner?.code,
metadata?.error_type,
responseError?.code,
@@ -28,7 +28,8 @@ export type Settings = ProviderPackage.Settings &
readonly provider?: string
}
export const routes = [AnthropicMessages.route]
const compatibleRoute = AnthropicMessages.route.with({ id: "anthropic-compatible-messages", provider: id })
export const routes = [compatibleRoute]
const auth = (input: ProviderAuthOption<"optional">) => {
if ("auth" in input && input.auth) return input.auth
@@ -43,7 +44,7 @@ export const configure = (input: Config) => {
message: "Anthropic-compatible providers require a baseURL",
})
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
const route = AnthropicMessages.route.with({
const route = (provider === "anthropic" ? AnthropicMessages.route : compatibleRoute).with({
...rest,
provider,
endpoint: { baseURL },
+5
View File
@@ -3,8 +3,10 @@ import type { ProviderAuthOption } from "../route/auth-options.js"
import { MediaRoute } from "../route/media.js"
import { type HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { ElevenLabsSpeech } from "../protocols/elevenlabs-speech.js"
import { ElevenLabsTranscription } from "../protocols/elevenlabs-transcription.js"
export type { ElevenLabsOutputFormat, ElevenLabsSpeechOptions } from "../protocols/elevenlabs-speech.js"
export type { ElevenLabsTranscriptionOptions } from "../protocols/elevenlabs-transcription.js"
export const id = ProviderID.make("elevenlabs")
@@ -24,12 +26,15 @@ const auth = (options: ProviderAuthOption<"optional">) => {
export const configure = (input: Config = {}) => {
const media = MediaRoute.deployment(input, auth(input))
const speech = (modelID: string | ModelID) => ElevenLabsSpeech.model({ ...media, id: modelID })
const transcription = (modelID: string | ModelID) => ElevenLabsTranscription.model({ ...media, id: modelID })
return {
id,
speech,
transcription,
configure,
}
}
export const provider = configure()
export const speech = provider.speech
export const transcription = provider.transcription
+3 -3
View File
@@ -35,13 +35,13 @@ const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
const responsesRoute = Route.make({
compact: { endpoint: XAIResponses.compact },
id: "openai-responses",
id: "xai-responses",
provider: id,
providerMetadataKey: "xai",
protocol: XAIResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL }),
transport: OpenResponsesChannel.transport({
id: "openai-responses",
id: "xai-responses",
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
// xAI continues a chain only from stored responses: with `store: false` (the route default) `previous_response_id`
@@ -53,7 +53,7 @@ const responsesRoute = Route.make({
})
const chatRoute = Route.make({
id: "openai-compatible-chat",
id: "xai-chat",
provider: id,
providerMetadataKey: "xai",
protocol: OpenAIChat.protocol,
+26 -5
View File
@@ -5,12 +5,14 @@ import { Media } from "../media.js"
import type { AuthInput } from "./auth.js"
import {
AIError,
AuthenticationError,
ContentPolicyError,
HttpContext,
InvalidProviderOutputError,
InvalidRequestError,
ProviderID,
ProviderInternalError,
RateLimitError,
UnsupportedOperationError,
} from "../schema/index.js"
@@ -188,6 +190,16 @@ export const stream = <Request, Event, Frame, State>(
// Response helpers
// ---------------------------------------------------------------------------
/** Reasons a provider can report for a `failed` generation; anything it does not classify is `ProviderInternal`. */
const FAILURES = {
InvalidRequest: InvalidRequestError,
Authentication: AuthenticationError,
RateLimit: RateLimitError,
ProviderInternal: ProviderInternalError,
}
export type Failure = keyof typeof FAILURES
const context = (response: HttpClientResponse.HttpClientResponse) =>
new HttpContext({ url: response.request.url, status: response.status, headers: response.headers })
@@ -199,9 +211,10 @@ export const identity = (input: { readonly id: string; readonly name: string; re
/**
* Read a text body while retaining the original payload and HTTP context on every downstream error. `invalid` is a
* malformed provider document; `ended` is a generation that reached a terminal status without output (`failed` is
* provider-side, `cancelled`/`expired` mean the result will never exist); `pending` is a `result()` read before the
* generation finished, which is caller misuse; `contentPolicy` is a moderated result.
* malformed provider document; `ended` is a generation that reached a terminal status without output (`failed`
* carries the provider's classification, defaulting to `ProviderInternal`; `cancelled`/`expired` mean the result
* will never exist); `pending` is a `result()` read before the generation finished, which is caller misuse;
* `contentPolicy` is a moderated result.
*/
const text = Effect.fn("MediaProtocol.text")(function* (response: HttpClientResponse.HttpClientResponse) {
const http = context(response)
@@ -223,11 +236,15 @@ export const identity = (input: { readonly id: string; readonly name: string; re
http,
invalid: (message: string, cause?: unknown) =>
new AIError({ reason: new InvalidProviderOutputError({ route: input.id, message, body, http, cause }) }),
ended: (status: Exclude<Status, "queued" | "running" | "completed">, message: string) =>
ended: (
status: Exclude<Status, "queued" | "running" | "completed">,
message: string,
failure: Failure = "ProviderInternal",
) =>
new AIError({
reason:
status === "failed"
? new ProviderInternalError({ message, body, http })
? new FAILURES[failure]({ message, body, http })
: new InvalidRequestError({ message, body, http }),
}),
pending: (id: string) =>
@@ -303,6 +320,10 @@ export const status = <Table extends Record<string, Status>>(
return Effect.succeed(table[raw])
}
/** Map a provider error code through the protocol's table; missing or unmapped codes are `ProviderInternal`. */
export const failure = (table: Readonly<Record<string, Failure>>, code: string | number | undefined): Failure =>
code !== undefined && Object.hasOwn(table, code) ? table[code] : "ProviderInternal"
/** A `url` asset whose provider-declared retention window starts now. */
export const expiringUrl = (url: string, retention: Duration.Duration, options?: Parameters<typeof Media.url>[1]) =>
Clock.currentTimeMillis.pipe(
+34 -4
View File
@@ -1,4 +1,4 @@
import { Effect, Schema, Stream } from "effect"
import { Duration, Effect, Schedule, Schema, Stream } from "effect"
import { Headers, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
import { Auth, type AuthInput } from "./auth.js"
import { Endpoint } from "./endpoint.js"
@@ -7,6 +7,7 @@ import { RequestExecutor } from "./executor.js"
import { MediaProtocol } from "./media-protocol.js"
import { Generation, isTerminal } from "../generation.js"
import type { Media } from "../media.js"
import { isRetryable } from "../provider-error.js"
import {
AIError,
AIErrorReason,
@@ -137,6 +138,32 @@ export const inline = <Request extends MediaRequest, Response>(
}
}
const READ_RETRY_MAX_DELAY = Duration.seconds(30)
/**
* Status and result reads retry transient failures; `start` and `cancel` never do. Gaps grow exponentially from 1s,
* jittered, up to 30s each, for at most 8 retries (about two minutes when every attempt fails), so a direct
* `Generation.result()` stays bounded; `await` and `events` also cut retries off at `poll.timeout`. A provider
* `retryAfterMs` raises the gap, still capped at 30s.
*/
const READ_RETRY = Schedule.max([
Schedule.min([Schedule.exponential("1 second"), Schedule.spaced(READ_RETRY_MAX_DELAY)]),
Schedule.recurs(8),
]).pipe(
Schedule.jittered,
Schedule.setInputType<AIError>(),
Schedule.modifyDelay(({ input, duration }) =>
Effect.succeed(
Duration.min(
input.reason._tag === "RateLimit" || input.reason._tag === "ProviderInternal"
? Duration.max(duration, Duration.millis(input.reason.retryAfterMs ?? 0))
: duration,
READ_RETRY_MAX_DELAY,
),
),
),
)
/**
* Compose a queued media protocol the same way, adding `start`/`resume` handles whose polls reuse the route's auth,
* deployment headers, and (for `start`) the request's `http` overlay. The token is decoded once at the boundary and
@@ -154,6 +181,8 @@ export const queued = <Request extends MediaRequest, Response, Token>(
const generationRoute = (token: Token, http: HttpOptions | undefined, execute: Execute) => {
const materialize = (asset: Media.Asset) =>
asset.materialize().pipe(Effect.provideService(RequestExecutorService, { execute }))
// Only the GET exchange retries: a decoded terminal failure (`output.ended`) can be a `ProviderInternal` too, and
// re-reading it would spin until the caller's deadline.
const poll = <A>(operation: {
readonly path: (token: Token) => string
readonly decode: (
@@ -161,9 +190,10 @@ export const queued = <Request extends MediaRequest, Response, Token>(
context: MediaProtocol.PollContext<Token>,
) => Effect.Effect<A, AIError>
}) =>
transport
.call("GET", operation.path(token), http, execute)
.pipe(Effect.flatMap((sent) => operation.decode(sent.response, { token, auth: sent.auth, materialize })))
transport.call("GET", operation.path(token), http, execute).pipe(
Effect.retry({ schedule: READ_RETRY, while: isRetryable }),
Effect.flatMap((sent) => operation.decode(sent.response, { token, auth: sent.auth, materialize })),
)
const status = poll(protocol.status)
const cancel = protocol.cancel
const send =
+1 -1
View File
@@ -103,7 +103,7 @@ export type TranscriptionRequestInput<Model extends TranscriptionModel = Transcr
// Response and events
// ---------------------------------------------------------------------------
/** Speaker labels are provider-native (`A`, `0`, `spk:0`, or a known speaker name). */
/** Speaker labels are provider-native (`A`, `0`, `spk:0`, `speaker_0`, or a known speaker name). */
export const TranscriptionSegment = Schema.Struct({
text: Schema.String,
startSeconds: Schema.Number,
+21 -1
View File
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../src/index.js"
import { Auth } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { AmazonBedrock, GoogleVertexMessages } from "../src/providers.js"
import { AmazonBedrock, AnthropicCompatible, GoogleVertexMessages } from "../src/providers.js"
import * as AnthropicMessages from "../src/protocols/anthropic-messages.js"
import * as Gemini from "../src/protocols/gemini.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
@@ -107,6 +107,26 @@ describe("applyCachePolicy", () => {
}),
)
it.effect("'auto' emits Anthropic cache markers on Anthropic-compatible routes", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: AnthropicCompatible.configure({ apiKey: "test", baseURL: "https://messages.example.test/v1" }).model(
"compatible",
),
system: "You are concise.",
prompt: "hi",
}),
)
expect(prepared.route).toBe("anthropic-compatible-messages")
expect(prepared.body).toMatchObject({
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
})
}),
)
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+2 -1
View File
@@ -39,17 +39,18 @@ describe("experimental Evaluation", () => {
type: "choice",
choice: "billing",
probabilities: { billing: 0.9, technical: 0.1 },
confidence: 0.8,
})
expect(response.answers.urgency).toEqual({
type: "score",
score: 1.2,
probabilities: { "0": 0, "1": 0.8, "2": 0.2 },
confidence: 0.6,
})
expect(response.answers.refund).toEqual({ type: "boolean", probability: 0.97 })
expect(response.usage?.totalTokens).toBe(36)
expect(response.providerMetadata).toEqual({
typesafe: {
confidence: { department: 0.8, urgency: 0.6 },
legend: { urgency: { "0": "Can wait", "1": "Needs attention", "2": "Blocking" } },
},
})
+2
View File
@@ -26,8 +26,10 @@ const request = Evaluation.request({
const result = EvaluationClient.evaluate(request)
type Result = Success<typeof result>
type Choice = Assert<Equal<Result["answers"]["topic"]["choice"], "billing" | "support">>
type Confidence = Assert<Equal<Result["answers"]["topic"]["confidence"], number | undefined>>
type ClientRequirements = Assert<Equal<Requirements<typeof result>, Service>>
void (true satisfies Choice)
void (true satisfies Confidence)
void (true satisfies ClientRequirements)
Effect.gen(function* () {
+7 -2
View File
@@ -154,8 +154,8 @@ describe("public exports", () => {
expect(XAI.model).toBeFunction()
expect(XAI.provider.responses).toBe(XAI.responses)
expect(XAI.provider.chat).toBe(XAI.chat)
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("openai-compatible-chat")
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("xai-responses")
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("xai-chat")
expect(OpenAI.configure({ apiKey: "fixture" }).image("gpt-image-2").route.id).toBe("openai-images")
expect(OpenAI.provider.image).toBe(OpenAI.image)
expect(Google.configure({ apiKey: "fixture" }).image("imagen-4.0-generate-001").route.id).toBe("google-images")
@@ -197,6 +197,11 @@ describe("public exports", () => {
expect(Google.configure({ apiKey: "fixture" }).transcription("gemini-3.5-transcribe").route.kind).toBe("stream")
expect(Deepgram.configure({ apiKey: "fixture" }).transcription("nova-3").route.kind).toBe("inline")
expect(AssemblyAI.configure({ apiKey: "fixture" }).transcription("universal-3-5-pro").route.kind).toBe("queued")
expect(ElevenLabs.configure({ apiKey: "fixture" }).transcription("scribe_v2").route.id).toBe(
"elevenlabs-transcription",
)
expect(ElevenLabs.configure({ apiKey: "fixture" }).transcription("scribe_v2").route.kind).toBe("inline")
expect(ElevenLabs.provider.transcription).toBe(ElevenLabs.transcription)
})
test("protocol barrels expose supported low-level routes", () => {
@@ -0,0 +1,32 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:elevenlabs-transcription",
"provider:elevenlabs",
"protocol:elevenlabs-transcription"
],
"name": "elevenlabs-transcription/groups-diarized-words-into-speaker-turns",
"recordedAt": "2026-09-27T09:35:28.265Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.elevenlabs.io/v1/speech-to-text",
"headers": {
"content-type": "multipart/form-data; boundary=----WebKitFormBoundary356bdc14864a477dbacbfcf60d1ecceb"
},
"body": "--BOUNDARY\r\nContent-Disposition: form-data; name=\"file\"; filename=\"audio.mp3\"\r\nContent-Type: audio/mpeg\r\n\r\n[audio]\r\n--BOUNDARY\r\nContent-Disposition: form-data; name=\"model_id\"\r\n\r\nscribe_v2\r\n--BOUNDARY\r\nContent-Disposition: form-data; name=\"diarize\"\r\n\r\ntrue\r\n--BOUNDARY--\r\n"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"language_code\":\"eng\",\"language_probability\":0.9495430588722229,\"text\":\"Did the release ship? Yes, it shipped this morning\",\"words\":[{\"text\":\"Did\",\"start\":0.34,\"end\":0.44,\"type\":\"word\",\"speaker_id\":\"speaker_0\",\"logprob\":-1.7881377516459906e-6},{\"text\":\" \",\"start\":0.44,\"end\":0.48,\"type\":\"spacing\",\"speaker_id\":\"speaker_0\",\"logprob\":-1.1920928244535389e-7},{\"text\":\"the\",\"start\":0.48,\"end\":0.56,\"type\":\"word\",\"speaker_id\":\"speaker_0\",\"logprob\":-1.1920928244535389e-7},{\"text\":\" \",\"start\":0.56,\"end\":0.6,\"type\":\"spacing\",\"speaker_id\":\"speaker_0\",\"logprob\":-7.152531907195225e-6},{\"text\":\"release\",\"start\":0.6,\"end\":0.92,\"type\":\"word\",\"speaker_id\":\"speaker_0\",\"logprob\":-7.152531907195225e-6},{\"text\":\" \",\"start\":0.92,\"end\":0.94,\"type\":\"spacing\",\"speaker_id\":\"speaker_0\",\"logprob\":-8.344646857949556e-7},{\"text\":\"ship?\",\"start\":0.94,\"end\":1.26,\"type\":\"word\",\"speaker_id\":\"speaker_0\",\"logprob\":-7.414704032271402e-6},{\"text\":\" \",\"start\":1.26,\"end\":1.26,\"type\":\"spacing\",\"speaker_id\":\"speaker_0\",\"logprob\":-0.0009363081189803779},{\"text\":\"Yes,\",\"start\":1.68,\"end\":2.02,\"type\":\"word\",\"speaker_id\":\"speaker_1\",\"logprob\":-0.003542040009030245},{\"text\":\" \",\"start\":2.02,\"end\":2.48,\"type\":\"spacing\",\"speaker_id\":\"speaker_1\",\"logprob\":-3.933898824470816e-6},{\"text\":\"it\",\"start\":2.48,\"end\":2.62,\"type\":\"word\",\"speaker_id\":\"speaker_1\",\"logprob\":-3.933898824470816e-6},{\"text\":\" \",\"start\":2.62,\"end\":2.64,\"type\":\"spacing\",\"speaker_id\":\"speaker_1\",\"logprob\":-0.000013589766240329482},{\"text\":\"shipped\",\"start\":2.66,\"end\":2.9,\"type\":\"word\",\"speaker_id\":\"speaker_1\",\"logprob\":-0.000013589766240329482},{\"text\":\" \",\"start\":2.9,\"end\":2.94,\"type\":\"spacing\",\"speaker_id\":\"speaker_1\",\"logprob\":0.0},{\"text\":\"this\",\"start\":2.94,\"end\":3.12,\"type\":\"word\",\"speaker_id\":\"speaker_1\",\"logprob\":0.0},{\"text\":\" \",\"start\":3.12,\"end\":3.18,\"type\":\"spacing\",\"speaker_id\":\"speaker_1\",\"logprob\":-3.576278118089249e-7},{\"text\":\"morning\",\"start\":3.18,\"end\":3.5,\"type\":\"word\",\"speaker_id\":\"speaker_1\",\"logprob\":-3.576278118089249e-7}],\"transcription_id\":\"cs3I2282TH8hjw12brNg\",\"audio_duration_secs\":3.5526875}"
}
}
]
}
@@ -0,0 +1,50 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:elevenlabs-transcription",
"provider:elevenlabs",
"protocol:elevenlabs-transcription"
],
"name": "elevenlabs-transcription/transcribes-audio-with-word-timestamps",
"recordedAt": "2026-09-27T09:35:27.686Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.elevenlabs.io/v1/speech-to-text",
"headers": {
"content-type": "multipart/form-data; boundary=----WebKitFormBoundarye2be7b31e94441bbbeb35a9c890a9d74"
},
"body": "--BOUNDARY\r\nContent-Disposition: form-data; name=\"file\"; filename=\"audio.mp3\"\r\nContent-Type: audio/mpeg\r\n\r\n[audio]\r\n--BOUNDARY\r\nContent-Disposition: form-data; name=\"model_id\"\r\n\r\nscribe_v2\r\n--BOUNDARY--\r\n"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"language_code\":\"eng\",\"language_probability\":0.6618340611457825,\"text\":\"Hello from OpenCode\",\"words\":[{\"text\":\"Hello\",\"start\":0.4,\"end\":0.66,\"type\":\"word\",\"logprob\":-0.000014781842764932662},{\"text\":\" \",\"start\":0.66,\"end\":0.74,\"type\":\"spacing\",\"logprob\":-3.814689989667386e-6},{\"text\":\"from\",\"start\":0.74,\"end\":0.84,\"type\":\"word\",\"logprob\":-3.814689989667386e-6},{\"text\":\" \",\"start\":0.84,\"end\":0.9,\"type\":\"spacing\",\"logprob\":-0.018268775194883347},{\"text\":\"OpenCode\",\"start\":0.9,\"end\":1.44,\"type\":\"word\",\"logprob\":-0.1251817401498556}],\"transcription_id\":\"D4VfnANM2ArCHTujIb9q\",\"audio_duration_secs\":1.54125}"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.elevenlabs.io/v1/speech-to-text",
"headers": {
"content-type": "multipart/form-data; boundary=----WebKitFormBoundaryfb80d0e44d9e44d299416ed546a04056"
},
"body": "--BOUNDARY\r\nContent-Disposition: form-data; name=\"file\"; filename=\"audio.mp3\"\r\nContent-Type: audio/mpeg\r\n\r\n[audio]\r\n--BOUNDARY\r\nContent-Disposition: form-data; name=\"model_id\"\r\n\r\nscribe_v2\r\n--BOUNDARY--\r\n"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"language_code\":\"eng\",\"language_probability\":0.6618340611457825,\"text\":\"Hello from OpenCode\",\"words\":[{\"text\":\"Hello\",\"start\":0.4,\"end\":0.66,\"type\":\"word\",\"logprob\":-0.000023007127310847864},{\"text\":\" \",\"start\":0.66,\"end\":0.74,\"type\":\"spacing\",\"logprob\":-2.3841830625315197e-6},{\"text\":\"from\",\"start\":0.74,\"end\":0.84,\"type\":\"word\",\"logprob\":-2.3841830625315197e-6},{\"text\":\" \",\"start\":0.84,\"end\":0.9,\"type\":\"spacing\",\"logprob\":-0.008306833915412426},{\"text\":\"OpenCode\",\"start\":0.9,\"end\":1.44,\"type\":\"word\",\"logprob\":-0.1075385226868093}],\"transcription_id\":\"SkYplzfq1DW8Ae3bWnoy\",\"audio_duration_secs\":1.54125}"
}
}
]
}
@@ -0,0 +1,54 @@
{
"version": 1,
"metadata": {
"model": "gemini-3.8-flash",
"tags": [
"prefix:openai-compatible-chat",
"provider:google",
"protocol:openai-chat",
"tool",
"tool-loop",
"continuation"
],
"name": "gemini-parallel-tool-signatures",
"recordedAt": "2026-09-28T03:12:05.083Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gemini-3.8-flash\",\"messages\":[{\"role\":\"system\",\"content\":\"Call get_weather for every requested city in parallel, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris and in Tokyo?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"extra_content\":{\"google\":{\"thought_signature\":\"ErYCCrMCAWkUfRNGh+/Zbc8YUSzk1yfWAfROcjA4HyF1x69jz6167w8zd4n6kZQQ5FDeBZ5HZMEbEkQ4ENOpzsQL8roCR6wONkhXpiduWrTD6XwbP8KGNkf6D1tX/JlBh7G5Cl+0rdjiSOl/mdY1lcjbkfyCRFs5T8odNWMG7WD3rCrXJDFQ/5QfOl+tqVTceKGz2yyXBhOhvsQDU33ulR9tHQJo/Fmx3HNyDdwvmyKUXm+kgqHsYZnkdv6y6xZwy9zBXGUO4QwxelMw3Rrc24Mp2rNbEDZS3YEeP72Jn/hIP5a8XWOx6+zop/4/CjYxxSfN/tJRY5d48NAKRNFzUN1Az8SvAs/N5X2I3/5ZMaDnQWW/BVdS9fm2KFYJ2baqtBiRRnJxMq4ELArkKjGZzHpd97XG8YjV6g==\"}},\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"name\":\"get_weather\"},\"id\":\"call_723181\",\"type\":\"function\"}]},\"index\":0}],\"created\":1790565123,\"id\":\"A9u5aoufBp3rz7IPke_3oAo\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":16,\"prompt_tokens\":74,\"total_tokens\":132}}\n\ndata: {\"choices\":[{\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Tokyo\\\"}\",\"name\":\"get_weather\"},\"id\":\"call_723184\",\"type\":\"function\"}]},\"index\":0}],\"created\":1790565123,\"id\":\"A9u5aoufBp3rz7IPke_3oAo\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":32,\"prompt_tokens\":74,\"total_tokens\":148}}\n\ndata: {\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"index\":0}],\"created\":1790565124,\"id\":\"A9u5aoufBp3rz7IPke_3oAo\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":32,\"prompt_tokens\":74,\"total_tokens\":148}}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gemini-3.8-flash\",\"messages\":[{\"role\":\"system\",\"content\":\"Call get_weather for every requested city in parallel, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris and in Tokyo?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_723181\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},\"extra_content\":{\"google\":{\"thought_signature\":\"ErYCCrMCAWkUfRNGh+/Zbc8YUSzk1yfWAfROcjA4HyF1x69jz6167w8zd4n6kZQQ5FDeBZ5HZMEbEkQ4ENOpzsQL8roCR6wONkhXpiduWrTD6XwbP8KGNkf6D1tX/JlBh7G5Cl+0rdjiSOl/mdY1lcjbkfyCRFs5T8odNWMG7WD3rCrXJDFQ/5QfOl+tqVTceKGz2yyXBhOhvsQDU33ulR9tHQJo/Fmx3HNyDdwvmyKUXm+kgqHsYZnkdv6y6xZwy9zBXGUO4QwxelMw3Rrc24Mp2rNbEDZS3YEeP72Jn/hIP5a8XWOx6+zop/4/CjYxxSfN/tJRY5d48NAKRNFzUN1Az8SvAs/N5X2I3/5ZMaDnQWW/BVdS9fm2KFYJ2baqtBiRRnJxMq4ELArkKjGZzHpd97XG8YjV6g==\"}}},{\"id\":\"call_723184\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Tokyo\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_723181\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"},{\"role\":\"tool\",\"tool_call_id\":\"call_723184\",\"content\":\"{\\\"temperature\\\":0,\\\"condition\\\":\\\"unknown\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"It is currently sunny and 22°C in Paris,\",\"role\":\"assistant\"},\"index\":0}],\"created\":1790565125,\"id\":\"BNu5auWeB-2fz7IPxY6l4QY\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":13,\"prompt_tokens\":150,\"total_tokens\":226}}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\" while Tokyo is 0°C.\",\"role\":\"assistant\"},\"index\":0}],\"created\":1790565125,\"id\":\"BNu5auWeB-2fz7IPxY6l4QY\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":21,\"prompt_tokens\":150,\"total_tokens\":234}}\n\ndata: {\"choices\":[{\"delta\":{\"extra_content\":{\"google\":{\"thought_signature\":\"EpcDCpQDAWkUfRM325TAUtfFiOeQIEWn/TsCU9oi2js4VPHFeeLfAu+2k8PJm0fN/OaF0y4ovau7S9QIAsuOPI2w2aIyQ2kMGj1XvUyRTvz30DOZgtq1km6W6YGzZyyTCNSeBcpwtJtziHZVVWq9xEI/HHB8Ta1Ot215xnFyDL7iUGEwgGu45/mInpk+SOCYBy9biDddpDxcDi14BGoleArY9XEFAzYLxXssl7HMWjpfee5095im7gD125Nripq1Jf3nGY/2TxqjgQAdJpQybwct63p74O1szGHQxrkBt7AwphDgbOWtLpUP/QJBRdl8qhrozqRe611NQ6V5lMSwpO7OhQ/IDRtWMwOyrrKblZfmMnnPl2/9xDfZRsYnfmWq+7PeAptJl1cDRlMBKhj5iRn31xvN43EiuvWwWPsSndiWvxrMvVBd89TR4u0+z0pYCYZcsFkYKFlPA7pZsdnh6CON24AA5WUkO4dQNoqYdik6aO5hE9jLHG5FHTdr0W69qgPNozbxnO0ptRcOtkGpB9xYUVoTxcSXXZE=\"}},\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"index\":0}],\"created\":1790565125,\"id\":\"BNu5auWeB-2fz7IPxY6l4QY\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":21,\"prompt_tokens\":192,\"total_tokens\":276}}\n\ndata: [DONE]\n\n"
}
}
]
}
+85 -1
View File
@@ -22,7 +22,8 @@ const chatBody = sseEvents(
/**
* Executor layer that answers chat completions with SSE text, image generations with one base64 PNG, Runway video
* tasks with a queued submission that succeeds on the second poll, speech with raw audio or SSE audio deltas, OpenAI
* transcription with JSON or SSE text deltas, and AssemblyAI transcripts that complete on the first poll.
* transcription with JSON or SSE text deltas, AssemblyAI transcripts that complete on the first poll, and `slow.test`
* chat completions that send one text delta and never finish.
*/
const executor = (seen: Array<string>) =>
RequestExecutor.layer.pipe(
@@ -55,6 +56,18 @@ const executor = (seen: Array<string>) =>
output: "https://replicate.test/a.webp",
urls: { get: "https://replicate.test/p_1", cancel: "https://replicate.test/p_1/cancel" },
})
if (web.url.startsWith("https://slow.test"))
return input.respond(
new ReadableStream({
start: (controller) =>
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify({ choices: [{ delta: { content: "Hello" } }] })}\n\n`,
),
),
}),
{ headers: { "content-type": "text/event-stream" } },
)
if (web.url.endsWith("/chat/completions"))
return input.respond(chatBody, { headers: { "content-type": "text/event-stream" } })
if (web.url.endsWith("/audio/speech"))
@@ -304,6 +317,77 @@ describe("AI promise client", () => {
await ai.dispose()
})
test("aborted calls reject and aborted streams throw with the signal's reason", async () => {
const ai = AI.make({ layer: executor([]) })
const slow = OpenAI.configure({ apiKey: "test", baseURL: "https://slow.test/v1" }).chat("gpt-4o-mini")
const aborted = new AbortController()
aborted.abort()
const reason = new Error("mine")
const rejected = await ai.run(Effect.never, { signal: aborted.signal }).catch((error: unknown) => error)
expect(rejected).toBe(aborted.signal.reason)
expect(rejected).toMatchObject({ name: "AbortError" })
const inFlight = new AbortController()
setTimeout(() => inFlight.abort(reason), 10)
expect(
await ai.llm
.generate({ model: slow, prompt: "Hello" }, { signal: inFlight.signal })
.catch((error: unknown) => error),
).toBe(reason)
const preAborted = await Array.fromAsync(
ai.speech.stream({ model: openai.speech("gpt-4o-mini-tts"), text: "Hello" }, { signal: aborted.signal }),
).catch((error: unknown) => error)
expect(preAborted).toBe(aborted.signal.reason)
expect(preAborted).toMatchObject({ name: "AbortError" })
const midStream = new AbortController()
const deltas: Array<string> = []
const midStreamFailure = await Array.fromAsync(
ai.llm.stream({ model: slow, prompt: "Hello" }, { signal: midStream.signal }),
(event) => {
if (!LLMEvent.is.textDelta(event)) return
deltas.push(event.text)
midStream.abort()
},
).catch((error: unknown) => error)
expect(deltas).toEqual(["Hello"])
expect(midStreamFailure).toBe(midStream.signal.reason)
expect(midStreamFailure).toMatchObject({ name: "AbortError" })
const model = Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5")
const generation = await ai.video.start({ model, prompt: "A kite" })
const polling = new AbortController()
const events: Array<string> = []
const eventsFailure = await Array.fromAsync(
generation.events({ poll: { interval: 60_000 }, signal: polling.signal }),
(event) => {
events.push(event.type)
polling.abort(reason)
},
).catch((error: unknown) => error)
expect(events).toEqual(["generation-progress"])
expect(eventsFailure).toBe(reason)
await ai.dispose()
})
test("breaking out of an abortable stream cleans up without throwing", async () => {
const ai = AI.make({ layer: executor([]) })
const slow = OpenAI.configure({ apiKey: "test", baseURL: "https://slow.test/v1" }).chat("gpt-4o-mini")
const controller = new AbortController()
const deltas: Array<string> = []
for await (const event of ai.llm.stream({ model: slow, prompt: "Hello" }, { signal: controller.signal })) {
if (!LLMEvent.is.textDelta(event)) continue
deltas.push(event.text)
break
}
controller.abort()
expect(deltas).toEqual(["Hello"])
await ai.dispose()
})
test("the default client is created lazily and can be disposed", async () => {
expect(typeof AI.ai.llm.generate).toBe("function")
expect(typeof AI.ai.image.generate).toBe("function")
+26
View File
@@ -355,6 +355,32 @@ describe("provider error rawBody classification", () => {
}
})
test("classifies Google invalid API keys as authentication failures", () => {
const rawBody = JSON.stringify({
error: {
code: 400,
message: "API key not valid. Please pass a valid API key.",
status: "INVALID_ARGUMENT",
details: [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
reason: "API_KEY_INVALID",
domain: "googleapis.com",
},
{
"@type": "type.googleapis.com/google.rpc.LocalizedMessage",
locale: "en-US",
message: "API key not valid. Please pass a valid API key.",
},
],
},
})
expect(
classifyProviderFailure({ message: "API key not valid. Please pass a valid API key.", status: 400, rawBody })
._tag,
).toBe("Authentication")
})
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
const reason = classifyProviderFailure({
message: "Request failed",
+4 -1
View File
@@ -300,7 +300,9 @@ describe("provider package entrypoints", () => {
})
expect(String(selected.provider)).toBe("example")
expect(selected.route.id).toBe("anthropic-messages")
expect(selected.route.id).toBe("anthropic-compatible-messages")
expect(selected.route.protocol).toBe("anthropic-messages")
expect(selected.route.providerMetadataKey).toBe("example")
expect(selected.route.endpoint).toMatchObject({
baseURL: "https://messages.example.test/v1",
})
@@ -319,6 +321,7 @@ describe("provider package entrypoints", () => {
thinking: { type: "adaptive" },
})
expect(selected.route.id).toBe("anthropic-messages")
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
})
@@ -1301,11 +1301,11 @@ describe("Bedrock Converse route", () => {
),
),
)
expect(response.events.filter((event) => event.type === "reasoning-delta" && event.text === "").at(-1)).toEqual({
type: "reasoning-delta",
expect(response.events.filter((event) => event.type === "reasoning-delta")).toEqual([])
expect(response.events.find((event) => event.type === "reasoning-start")).toEqual({
type: "reasoning-start",
id: "reasoning-0",
text: "",
providerMetadata: { bedrock: { redactedData } },
providerMetadata: undefined,
})
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
type: "reasoning-end",
@@ -1383,6 +1383,13 @@ describe("Bedrock Converse route", () => {
),
)
expect(response.events.filter((event) => event.type === "reasoning-delta")).toEqual([])
expect(response.events.find((event) => event.type === "reasoning-end")).toEqual({
type: "reasoning-end",
id: "reasoning-0",
providerMetadata: { bedrock: { redactedData: "AQID" } },
text: undefined,
})
expect(response.message.content).toEqual([
{ type: "reasoning", text: "", providerMetadata: { bedrock: { redactedData: "AQID" } } },
])
@@ -0,0 +1,50 @@
import { describe, expect } from "bun:test"
import { Effect, Stream } from "effect"
import { Transcription } from "../../src/index.js"
import { ElevenLabs } from "../../src/providers.js"
import { recordedTests } from "../recorded-test.js"
import { TRANSCRIPT, audio, audioRecording, dialog } from "./transcription-recording.js"
const model = ElevenLabs.configure({ apiKey: process.env.ELEVENLABS_API_KEY ?? "fixture" }).transcription("scribe_v2")
const recorded = recordedTests({
prefix: "elevenlabs-transcription",
provider: "elevenlabs",
protocol: "elevenlabs-transcription",
requires: ["ELEVENLABS_API_KEY"],
options: audioRecording,
})
describe("ElevenLabs Transcription recorded", () => {
recorded.effect("transcribes audio with word timestamps", () =>
Effect.gen(function* () {
const request = Transcription.request({ model, audio: yield* audio, timestamps: "word" })
const response = yield* Transcription.generate(request)
expect(response.text).toMatch(TRANSCRIPT)
expect(response.words?.map((word) => word.text)).toEqual(["Hello", "from", "OpenCode"])
expect(response.words?.every((word) => word.speaker === undefined && (word.confidence ?? 0) > 0)).toBe(true)
expect(response.segments).toBeUndefined()
expect(response.language).toBe("eng")
expect(response.durationSeconds).toBeGreaterThan(0)
expect(response.usage).toEqual({ type: "seconds", seconds: response.durationSeconds })
expect(response.providerMetadata?.elevenlabs?.transcriptionId).toEqual(expect.any(String))
const events = Array.from(yield* Stream.runCollect(Transcription.stream(request)))
expect(events.map((event) => event.type)).toEqual(["finish"])
}),
)
recorded.effect("groups diarized words into speaker turns", () =>
Effect.gen(function* () {
const response = yield* Transcription.generate({ model, audio: yield* dialog, diarize: true })
expect(response.segments?.map((segment) => segment.speaker)).toEqual(["speaker_0", "speaker_1"])
expect(response.segments?.[0].text).toMatch(/^Did the release ship\?$/)
expect(response.segments?.[1].text).toMatch(/^Yes, it shipped this morning\.?$/)
expect(response.segments?.map((segment) => segment.text).join(" ")).toBe(response.text)
expect(response.words?.some((word) => word.text.trim() === "")).toBe(false)
expect(new Set(response.words?.map((word) => word.speaker))).toEqual(new Set(["speaker_0", "speaker_1"]))
}),
)
})
@@ -92,5 +92,6 @@ const assertEvaluation = <Options extends EvaluationOptions>(
expect(response.answers.refund.probability).toBeGreaterThan(0.5)
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
expect(response.providerMetadata?.[metadataKey]?.confidence).toBeDefined()
expect(response.answers.department.confidence).toBeGreaterThan(0)
expect(response.answers.urgency.confidence).toBeGreaterThan(0)
})
@@ -0,0 +1,68 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMRequest, Message, ToolRuntime, toDefinitions } from "../../src/index.js"
import * as OpenAICompatible from "../../src/providers/openai-compatible.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { recordedTests } from "../recorded-test.js"
import { weatherRuntimeTool, weatherToolName } from "../recorded-scenarios.js"
const model = OpenAICompatible.configure({
provider: "google",
baseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture",
}).model("gemini-3.8-flash")
const recorded = recordedTests({
prefix: "openai-compatible-chat",
provider: "google",
protocol: "openai-chat",
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
tags: ["tool", "tool-loop", "continuation"],
metadata: { model: model.id },
})
describe("Gemini OpenAI-compatible Chat recorded", () => {
recorded.effect.with(
"replays thought signatures through a parallel tool loop",
{ cassette: "openai-compatible-chat/gemini-parallel-tool-signatures" },
() =>
Effect.gen(function* () {
const tools = { [weatherToolName]: weatherRuntimeTool }
const request = LLM.request({
model,
system: "Call get_weather for every requested city in parallel, then answer in one short sentence.",
prompt: "What is the weather in Paris and in Tokyo?",
tools: toDefinitions(tools),
cache: "none",
})
const first = yield* LLMClient.generate(request)
const calls = first.events.filter(LLMEvent.is.toolCall)
expect(calls.map((call) => call.input)).toEqual([{ city: "Paris" }, { city: "Tokyo" }])
const extraContent = calls[0]?.providerMetadata?.google?.extraContent
expect(extraContent).toEqual({ google: { thought_signature: expect.any(String) } })
const results = yield* Effect.forEach(calls, (call) => ToolRuntime.dispatch(tools, call))
const continuation = LLMRequest.update(request, {
messages: [
...request.messages,
first.message,
...calls.map((call, index) =>
Message.tool({ id: call.id, name: call.name, result: results[index]!.result }),
),
],
})
const prepared = yield* compileRequest(continuation)
const assistant = prepared.body.messages.find((message) => message.role === "assistant")
expect(assistant?.role === "assistant" ? assistant.tool_calls?.[0]?.extra_content : undefined).toEqual(
extraContent,
)
const second = yield* LLMClient.generate(continuation)
expect(second.events.filter(LLMEvent.is.toolCall)).toHaveLength(0)
expect(second.text).toMatch(/Paris/)
expect(second.text).toMatch(/Tokyo/)
}),
60_000,
)
})
+19 -3
View File
@@ -303,7 +303,8 @@ describe("Mistral Chat", () => {
fixedResponse(
sseEvents(
chunk({ content: [{ type: "thinking", thinking: [], marker: "empty" }] }),
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "Consider" }] }] }),
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "Con" }] }] }),
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "sider" }], closed: true }] }),
chunk({ content: [{ type: "text", text: "Answer" }] }),
chunk({}, "stop"),
),
@@ -313,6 +314,11 @@ describe("Mistral Chat", () => {
expect(response.reasoning).toBe("Consider")
expect(response.text).toBe("Answer")
expect(response.events.find(LLMEvent.is.reasoningStart)?.providerMetadata).toBeUndefined()
expect(response.events.filter(LLMEvent.is.reasoningDelta).map((event) => [event.text, event.providerMetadata])).toEqual([
["Con", undefined],
["sider", undefined],
])
expect(response.message.content).toEqual([
{
type: "reasoning",
@@ -321,8 +327,12 @@ describe("Mistral Chat", () => {
mistral: {
thinking: {
type: "thinking",
thinking: [{ type: "text", text: "Consider" }],
thinking: [
{ type: "text", text: "Con" },
{ type: "text", text: "sider" },
],
marker: "empty",
closed: true,
},
},
},
@@ -337,8 +347,12 @@ describe("Mistral Chat", () => {
content: [
{
type: "thinking",
thinking: [{ type: "text", text: "Consider" }],
thinking: [
{ type: "text", text: "Con" },
{ type: "text", text: "sider" },
],
marker: "empty",
closed: true,
},
{ type: "text", text: "Answer" },
],
@@ -357,6 +371,8 @@ describe("Mistral Chat", () => {
),
),
)
expect(response.events.find(LLMEvent.is.reasoningStart)?.providerMetadata).toBeUndefined()
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toEqual([])
expect(response.message.content).toEqual([
{
type: "reasoning",
@@ -472,6 +472,45 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("replays Gemini thought signatures as tool call extra content", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Weather in Paris and Tokyo?"),
Message.assistant([
ToolCallPart.make({
id: "call_1",
name: "lookup",
input: { city: "Paris" },
providerMetadata: { openai: { extraContent: { google: { thought_signature: "sig_1" } } } },
}),
ToolCallPart.make({ id: "call_2", name: "lookup", input: { city: "Tokyo" } }),
]),
Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }),
Message.tool({ id: "call_2", name: "lookup", result: "Rainy" }),
],
}),
)
const assistant = prepared.body.messages[1]
expect(assistant?.role === "assistant" ? assistant.tool_calls : undefined).toEqual([
{
id: "call_1",
type: "function",
function: { name: "lookup", arguments: encodeJson({ city: "Paris" }) },
extra_content: { google: { thought_signature: "sig_1" } },
},
{
id: "call_2",
type: "function",
function: { name: "lookup", arguments: encodeJson({ city: "Tokyo" }) },
},
])
}),
)
it.effect("limits OpenAI and Azure Chat tool call IDs to 40 characters", () =>
Effect.gen(function* () {
const id = `call_${"a".repeat(48)}`
@@ -1805,6 +1844,78 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves Gemini thought signatures on streamed parallel tool calls", () =>
Effect.gen(function* () {
// Gemini's OpenAI-compatible endpoint omits `index`, streams each call whole,
// and signs only the first call of a parallel batch.
const body = sseEvents(
deltaChunk({
role: "assistant",
tool_calls: [
{
extra_content: { google: { thought_signature: "sig_1" } },
id: "call_1",
type: "function",
function: { name: "lookup", arguments: '{"city":"Paris"}' },
},
],
}),
deltaChunk({
role: "assistant",
tool_calls: [{ id: "call_2", type: "function", function: { name: "lookup", arguments: '{"city":"Tokyo"}' } }],
}),
deltaChunk({}, "stop"),
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
{
type: "tool-call",
id: "call_1",
name: "lookup",
input: { city: "Paris" },
providerExecuted: undefined,
providerMetadata: { openai: { extraContent: { google: { thought_signature: "sig_1" } } } },
},
{
type: "tool-call",
id: "call_2",
name: "lookup",
input: { city: "Tokyo" },
providerExecuted: undefined,
providerMetadata: undefined,
},
])
}),
)
it.effect("keeps extra content that arrives before the tool identity", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [
{ index: 0, extra_content: { google: { thought_signature: "sig_1" } }, function: { arguments: "{" } },
],
}),
deltaChunk({ tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "}" } }] }),
deltaChunk({}, "tool_calls"),
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.filter(LLMEvent.is.toolCall).map((event) => event.providerMetadata)).toEqual([
{ openai: { extraContent: { google: { thought_signature: "sig_1" } } } },
])
}),
)
it.effect("does not finalize streamed tool calls when content is filtered", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -56,7 +56,9 @@ describe("xAI Responses route", () => {
expect(XAIResponses.protocol.body).not.toBe(OpenAIResponses.protocol.body)
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
expect(prepared.route).toBe("xai-responses")
expect(prepared.protocol).toBe("xai-responses")
expect(prepared.model.route.providerMetadataKey).toBe("xai")
expect(prepared.body.store).toBe(false)
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
}),
@@ -298,3 +300,14 @@ describe("xAI Responses route", () => {
}),
)
})
it.effect("names the xAI Chat route separately from its OpenAI Chat protocol", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model: XAI.configure({ apiKey: "test" }).chat("grok-4.6"), prompt: "Hello" }),
)
expect(prepared.route).toBe("xai-chat")
expect(prepared.protocol).toBe("openai-chat")
expect(prepared.model.route.providerMetadataKey).toBe("xai")
}),
)
+113 -1
View File
@@ -3,7 +3,7 @@ import { Effect, Fiber, Layer, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { HttpClientRequest } from "effect/unstable/http"
import { Media, Transcription, TranscriptionClient, type TranscriptionEvent } from "../src/index.js"
import { AssemblyAI, Deepgram, Google, OpenAI } from "../src/providers.js"
import { AssemblyAI, Deepgram, ElevenLabs, Google, OpenAI } from "../src/providers.js"
import { it } from "./lib/effect.js"
import { dynamicResponse, json, observe, type Call } from "./lib/http.js"
import { sseEvents } from "./lib/sse.js"
@@ -35,6 +35,9 @@ const formFields = (call: Call) =>
const assemblyai = AssemblyAI.configure({ apiKey: "aai-key", baseURL: "https://assemblyai.test" }).transcription(
"universal-3-5-pro",
)
const elevenlabs = ElevenLabs.configure({ apiKey: "test", baseURL: "https://elevenlabs.test" }).transcription(
"scribe_v2",
)
describe("Transcription", () => {
it.effect("rejects what a route cannot honor before sending anything", () =>
@@ -459,6 +462,115 @@ describe("Transcription", () => {
}),
)
it.effect("rejects ElevenLabs prompts, webhooks, per-channel transcripts, and untimed diarization", () =>
Effect.gen(function* () {
const errors = yield* Effect.all(
[
Transcription.generate({ model: elevenlabs, audio, prompt: "OpenCode" }),
Transcription.generate({ model: elevenlabs, audio, providerOptions: { webhook: true } }),
Transcription.generate({ model: elevenlabs, audio, http: { body: { use_multi_channel: true } } }),
Transcription.generate({
model: elevenlabs,
audio,
diarize: true,
providerOptions: { timestamps_granularity: "none" },
}),
Transcription.generate({
model: elevenlabs,
audio: Media.ref("file_1", { provider: "elevenlabs", mediaType: "audio/mpeg" }),
}),
].map((effect) => Effect.flip(effect)),
)
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
[
["UnsupportedOperation", "media.prompt"],
["UnsupportedOperation", "transcription.webhook"],
["UnsupportedOperation", "transcription.multichannel"],
["UnsupportedOperation", "media.timestamps"],
["InvalidRequest", false],
],
)
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
)
it.effect("sends ElevenLabs URL audio as source_url and groups diarized words into speaker turns", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const token = (text: string, type: string, start: number, end: number, speaker_id?: string) => ({
text,
type,
start,
end,
speaker_id,
logprob: 0,
})
const response = yield* Transcription.generate({
model: elevenlabs,
audio: Media.url("https://a.test/call.mp3"),
language: "en",
speakers: 2,
providerOptions: { keyterms: ["OpenCode", "Scribe"], tag_audio_events: true, diarize: false },
}).pipe(
Effect.provide(
layer((input) =>
observe(calls, input).pipe(
Effect.as(
json(input, {
language_code: "ENG",
text: "Ready? (laughs) Yes. Go",
words: [
token("Ready?", "word", 0, 0.5, "speaker_0"),
token(" ", "spacing", 0.5, 0.6, "speaker_0"),
token("(laughs)", "audio_event", 0.6, 1, "speaker_0"),
token(" ", "spacing", 1, 1.1, "speaker_0"),
token("Yes.", "word", 1.2, 1.5, "speaker_1"),
token(" ", "spacing", 1.5, 1.6, "speaker_1"),
token("Go", "word", 1.6, 1.9, "speaker_0"),
],
transcription_id: "tr_1",
audio_duration_secs: 2,
}),
),
),
),
),
)
// `observe` re-encodes the FormData with a new boundary, so read the boundary from the sent body.
const boundary = /^--(\S+)/.exec(calls[0].body)?.[1]
const form = yield* Effect.promise(() =>
new Response(calls[0].body, {
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
}).formData(),
)
expect(calls[0].url).toBe("https://elevenlabs.test/v1/speech-to-text")
expect(calls[0].headers.get("xi-api-key")).toBe("test")
expect(Array.from(form.entries())).toEqual([
["model_id", "scribe_v2"],
["source_url", "https://a.test/call.mp3"],
["language_code", "en"],
["diarize", "true"],
["num_speakers", "2"],
["keyterms", "OpenCode"],
["keyterms", "Scribe"],
["tag_audio_events", "true"],
])
expect(response.segments).toEqual([
{ text: "Ready?", startSeconds: 0, endSeconds: 0.5, speaker: "speaker_0" },
{ text: "Yes.", startSeconds: 1.2, endSeconds: 1.5, speaker: "speaker_1" },
{ text: "Go", startSeconds: 1.6, endSeconds: 1.9, speaker: "speaker_0" },
])
expect(response.words?.map((word) => [word.text, word.speaker, word.confidence])).toEqual([
["Ready?", "speaker_0", 1],
["Yes.", "speaker_1", 1],
["Go", "speaker_0", 1],
])
expect(response.language).toBe("eng")
expect(response.usage).toEqual({ type: "seconds", seconds: 2 })
expect(response.providerMetadata).toEqual({ elevenlabs: { transcriptionId: "tr_1" } })
}),
)
it.effect("rejects reading an AssemblyAI result before the transcript finishes", () =>
Effect.gen(function* () {
const generation = yield* Transcription.resume(assemblyai, { transcriptID: "tr_1" })
+235 -26
View File
@@ -1,9 +1,10 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { Effect, Fiber, Layer, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Media, Video, VideoClient, type GenerationEvent, type VideoEvent } from "../src/index.js"
import { Fal, Google, Runway, XAI } from "../src/providers.js"
import { it } from "./lib/effect.js"
import { dynamicResponse, json, observe, settle, type Call } from "./lib/http.js"
import { dynamicResponse, json, observe, settle, type Call, type HandlerInput } from "./lib/http.js"
const layer = (handler: Parameters<typeof dynamicResponse>[0]) =>
VideoClient.layer.pipe(Layer.provideMerge(dynamicResponse(handler)))
@@ -162,27 +163,39 @@ describe("Video / Google Veo", () => {
),
)
it.effect("surfaces an operation error as a failed generation with the provider body", () =>
Effect.gen(function* () {
const failure = {
name: operation,
done: true,
error: { code: 3, message: "Prompt violates policy", status: "INVALID_ARGUMENT" },
}
const error = yield* Video.generate({ model, prompt: "nope" }).pipe(
Effect.flip,
Effect.provide(
layer((input) =>
Effect.succeed(input.request.method === "POST" ? json(input, { name: operation }) : json(input, failure)),
),
),
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(error.message).toBe("Google Veo operation failed: Prompt violates policy")
expect(error.reason.body).toBe(JSON.stringify(failure))
expect(error.reason.http?.status).toBe(200)
}),
)
for (const terminal of [
{ error: { code: 3, message: "Prompt violates policy", status: "INVALID_ARGUMENT" }, tag: "InvalidRequest" },
{ error: { code: 9, message: "Unsupported resolution", status: "FAILED_PRECONDITION" }, tag: "InvalidRequest" },
{ error: { code: 11, message: "Duration out of range", status: "OUT_OF_RANGE" }, tag: "InvalidRequest" },
{ error: { code: 7, message: "Permission denied", status: "PERMISSION_DENIED" }, tag: "Authentication" },
{ error: { code: 16, message: "Invalid credentials", status: "UNAUTHENTICATED" }, tag: "Authentication" },
{ error: { code: 8, message: "Quota exceeded", status: "RESOURCE_EXHAUSTED" }, tag: "RateLimit" },
{ error: { code: 13, message: "Internal error", status: "INTERNAL" }, tag: "ProviderInternal" },
{ error: { code: 14, message: "Service unavailable", status: "UNAVAILABLE" }, tag: "ProviderInternal" },
{ error: { message: "Something broke" }, tag: "ProviderInternal" },
]) {
it.effect(
`surfaces ${terminal.error.status ?? "an uncoded"} operation error as ${terminal.tag} with the provider body`,
() =>
Effect.gen(function* () {
const failure = { name: operation, done: true, error: terminal.error }
const error = yield* Video.generate({ model, prompt: "nope" }).pipe(
Effect.flip,
Effect.provide(
layer((input) =>
Effect.succeed(
input.request.method === "POST" ? json(input, { name: operation }) : json(input, failure),
),
),
),
)
expect(error.reason._tag).toBe(terminal.tag)
expect(error.message).toBe(`Google Veo operation failed: ${terminal.error.message}`)
expect(error.reason.body).toBe(JSON.stringify(failure))
expect(error.reason.http?.status).toBe(200)
}),
)
}
it.effect("reports fully filtered output as a content policy failure", () =>
Effect.gen(function* () {
@@ -332,12 +345,37 @@ describe("Video / xAI", () => {
for (const terminal of [
{
body: { status: "failed", error: { code: "invalid_argument", message: "Prompt cannot be empty." } },
tag: "ProviderInternal",
tag: "InvalidRequest",
message: "xAI Video generation failed (invalid_argument): Prompt cannot be empty.",
},
{
body: { status: "failed", error: { code: "failed_precondition", message: "Extension is not supported." } },
tag: "InvalidRequest",
message: "xAI Video generation failed (failed_precondition): Extension is not supported.",
},
{
body: { status: "failed", error: { code: "permission_denied", message: "Team lacks access." } },
tag: "Authentication",
message: "xAI Video generation failed (permission_denied): Team lacks access.",
},
{
body: { status: "failed", error: { code: "service_unavailable", message: "Overloaded." } },
tag: "ProviderInternal",
message: "xAI Video generation failed (service_unavailable): Overloaded.",
},
{
body: { status: "failed", error: { code: "internal_error", message: "Generation failed." } },
tag: "ProviderInternal",
message: "xAI Video generation failed (internal_error): Generation failed.",
},
{
body: { status: "failed", error: { code: "constructor", message: "Future code." } },
tag: "ProviderInternal",
message: "xAI Video generation failed (constructor): Future code.",
},
{ body: { status: "expired" }, tag: "InvalidRequest", message: "xAI Video request req_1 expired" },
]) {
it.effect(`surfaces ${terminal.body.status} generations with the provider body`, () =>
it.effect(`surfaces ${terminal.body.error?.code ?? terminal.body.status} generations with the provider body`, () =>
Effect.gen(function* () {
const error = yield* Video.generate({ model, prompt: "x" }).pipe(Effect.flip)
expect(error.reason._tag).toBe(terminal.tag)
@@ -558,7 +596,10 @@ describe("Video / fal", () => {
]) {
it.effect(`fails await for ${failure.name} with the response_url body and HTTP context`, () =>
Effect.gen(function* () {
const error = yield* Video.generate({ model, prompt: "x" }).pipe(Effect.flip)
// A transient 500 on the result fetch is retried first; the body and HTTP context survive the final failure.
const fiber = yield* Effect.forkChild(Video.generate({ model, prompt: "x" }).pipe(Effect.flip))
yield* TestClock.adjust("5 minutes")
const error = yield* Fiber.join(fiber)
expect(error.reason._tag).toBe(failure.tag)
expect(error.reason.body).toBe(JSON.stringify(failure.result.body))
expect(error.reason.http).toMatchObject({ url: urls.response, status: failure.result.status })
@@ -765,6 +806,11 @@ describe("Video / Runway", () => {
tag: "ProviderInternal",
message: "Runway task failed (INTERNAL.BAD_OUTPUT.CODE01): Something broke",
},
{
body: { status: "FAILED", failure: "Unsupported dimensions", failureCode: "ASSET.INVALID" },
tag: "InvalidRequest",
message: "Runway task failed (ASSET.INVALID): Unsupported dimensions",
},
{ body: { status: "CANCELLED" }, tag: "InvalidRequest", message: "Runway task task_1 was cancelled" },
]) {
it.effect(`surfaces ${terminal.body.failureCode ?? terminal.body.status} with the task body`, () =>
@@ -908,6 +954,169 @@ describe("Video / Runway", () => {
)
})
// ---------------------------------------------------------------------------
// Transient read failures
// ---------------------------------------------------------------------------
describe("Video / transient read failures", () => {
const model = Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5")
const succeeded = { id: "task_1", status: "SUCCEEDED", output: ["https://runway.test/out.mp4"] }
const failure = (input: HandlerInput, status: number, headers?: Record<string, string>) =>
json(input, { error: `HTTP ${status}` }, { status, headers })
const methods = (calls: ReadonlyArray<Call>) => calls.map((call) => call.method)
it.effect("retries a 503 status poll and a 503 result read, then returns the result", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const response = yield* settle(
Video.generate({ model, prompt: "x" }, { poll: { interval: "1 second" } }),
5,
).pipe(
Effect.provide(
layer((input) =>
Effect.gen(function* () {
const { call, nth } = yield* observe(calls, input)
if (call.method === "POST") return json(input, { id: "task_1" })
// 1: status fails, 2: status succeeds, 3: result fails, 4: result succeeds.
if (nth === 1 || nth === 3) return failure(input, 503)
return json(input, succeeded)
}),
),
),
)
expect(response.video.source).toMatchObject({ type: "url", url: "https://runway.test/out.mp4" })
expect(methods(calls)).toEqual(["POST", "GET", "GET", "GET", "GET"])
}),
)
it.effect("waits for a 429 retry-after before polling again", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const fiber = yield* Effect.forkChild(
Video.generate({ model, prompt: "x" }, { poll: { interval: "1 second" } }).pipe(
Effect.provide(
layer((input) =>
Effect.gen(function* () {
const { call, nth } = yield* observe(calls, input)
if (call.method === "POST") return json(input, { id: "task_1" })
if (nth === 1) return failure(input, 429, { "retry-after": "10" })
return json(input, succeeded)
}),
),
),
),
)
yield* TestClock.adjust("9 seconds")
expect(methods(calls)).toEqual(["POST", "GET"])
yield* TestClock.adjust("1 second")
yield* Fiber.join(fiber)
expect(methods(calls)).toEqual(["POST", "GET", "GET", "GET"])
}),
)
it.effect("fails a 400 status poll without retrying", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const error = yield* Video.generate({ model, prompt: "x" }).pipe(
Effect.flip,
Effect.provide(
layer((input) =>
Effect.gen(function* () {
const { call } = yield* observe(calls, input)
return call.method === "POST" ? json(input, { id: "task_1" }) : failure(input, 400)
}),
),
),
)
expect(error.reason._tag).toBe("InvalidRequest")
expect(methods(calls)).toEqual(["POST", "GET"])
}),
)
it.effect("stops retrying at poll.timeout with a Timeout reason", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const error = yield* settle(
Video.generate({ model, prompt: "x" }, { poll: { interval: "1 second", timeout: "5 seconds" } }).pipe(
Effect.flip,
),
6,
).pipe(
Effect.provide(
layer((input) =>
Effect.gen(function* () {
const { call } = yield* observe(calls, input)
return call.method === "POST" ? json(input, { id: "task_1" }) : failure(input, 503)
}),
),
),
)
expect(error.reason._tag).toBe("Timeout")
expect(calls.filter((call) => call.method === "GET").length).toBeGreaterThan(1)
}),
)
it.effect("bounds a streamed result read's retries by poll.timeout", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const error = yield* settle(
Video.stream({ model, prompt: "x" }, { poll: { interval: "1 second", timeout: "5 seconds" } }).pipe(
Stream.runCollect,
Effect.flip,
),
6,
).pipe(
Effect.provide(
layer((input) =>
Effect.gen(function* () {
const { call, nth } = yield* observe(calls, input)
if (call.method === "POST") return json(input, { id: "task_1" })
return nth === 1 ? json(input, succeeded) : failure(input, 503)
}),
),
),
)
expect(error.reason._tag).toBe("Timeout")
expect(calls.filter((call) => call.method === "GET").length).toBeGreaterThan(2)
}),
)
it.effect("never retries a failed submit", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const error = yield* Video.generate({ model, prompt: "x" }).pipe(
Effect.flip,
Effect.provide(layer((input) => observe(calls, input).pipe(Effect.map(() => failure(input, 503))))),
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(methods(calls)).toEqual(["POST"])
}),
)
it.effect("never retries a failed cancel", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const error = yield* Effect.gen(function* () {
const generation = yield* Video.start({ model, prompt: "x" })
return yield* generation.cancel().pipe(Effect.flip)
}).pipe(
Effect.provide(
layer((input) =>
Effect.gen(function* () {
const { call } = yield* observe(calls, input)
if (call.method === "POST") return json(input, { id: "task_1" })
if (call.method === "DELETE") return failure(input, 503)
return json(input, { id: "task_1", status: "RUNNING" })
}),
),
),
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(methods(calls)).toEqual(["POST", "GET", "DELETE"])
}),
)
})
// ---------------------------------------------------------------------------
// Shared queued behavior
// ---------------------------------------------------------------------------
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.18",
"version": "2.0.19",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.18",
"version": "2.0.19",
"type": "module",
"license": "MIT",
"bin": {
+3 -3
View File
@@ -51,7 +51,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to continue"),
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
Flag.optional,
),
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
@@ -328,7 +328,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to continue"),
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
Flag.optional,
),
fork: Flag.boolean("fork").pipe(
@@ -368,7 +368,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to continue"),
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
Flag.optional,
),
fork: Flag.boolean("fork").pipe(
+15 -1
View File
@@ -11,6 +11,10 @@ import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode/util/npm"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { Env } from "../../env"
import { Service } from "@opencode/client/effect/service"
import { OpenCode } from "@opencode/client/promise"
import { findSession } from "../../session-target"
import { errorMessage } from "../../util/error"
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
@@ -46,6 +50,15 @@ export default Runtime.handler(Commands, (input) =>
Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")),
),
)
const session = Option.getOrUndefined(input.session)
// A missing --session ID becomes the ID of the session the first prompt creates.
const sessionExists =
session !== undefined &&
(yield* Effect.tryPromise({
try: () =>
findSession(OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }), session),
catch: (cause) => new Error(errorMessage(cause)),
})) !== undefined
const updater = yield* Updater.Service
let installing: string | undefined
const updateListeners = new Set<(version: string) => void>()
@@ -81,7 +94,8 @@ export default Runtime.handler(Commands, (input) =>
},
args: {
continue: input.continue,
sessionID: Option.getOrUndefined(input.session),
sessionID: sessionExists ? session : undefined,
newSessionID: sessionExists ? undefined : session,
prompt: Option.getOrUndefined(input.prompt),
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
},
+7 -2
View File
@@ -135,6 +135,7 @@ export async function runNonInteractivePrompt(input: Input) {
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
if (!input.auto) {
permissionRejected = true
if (input.compatibility !== "v1") process.exitCode = 1
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL +
@@ -163,6 +164,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (!formAlreadySettled(error)) throw error
}
formCancelled = true
if (input.compatibility !== "v1") process.exitCode = 1
}
const consume = async () => {
@@ -493,7 +495,8 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.execution.interrupted") {
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) return
if (event.data.reason === "user" && interrupted) process.exitCode = 130
if (event.data.reason !== "user" && !emittedError) {
// A declined tool call ends the step with an interruption; it was already reported above.
if (event.data.reason !== "user" && !emittedError && !permissionRejected && !formCancelled) {
emittedError = true
process.exitCode = 1
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
@@ -620,7 +623,9 @@ export async function runNonInteractivePrompt(input: Input) {
UI.error(item.state.error.message)
}
if (message.error && !emittedError) {
// A declined tool call ends its step with an interrupted-step error that is
// only a consequence of our own rejection; it was already reported above.
if (message.error && !emittedError && !permissionRejected && !formCancelled) {
emittedError = true
process.exitCode = 1
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
+13 -8
View File
@@ -63,6 +63,7 @@ export async function resolveSessionTarget(input: {
(await input.client.session
.create(
{
id: input.session,
agent: prepared.agent,
model: prepared.model,
location: { directory: location.directory },
@@ -101,14 +102,11 @@ async function selectSession(input: {
fork?: boolean
signal?: AbortSignal
}) {
const explicit = input.session
? await input.client.session.get({ sessionID: input.session }, ...requestOptions(input.signal)).catch((error) => {
if (error && typeof error === "object" && "_tag" in error && error._tag === "SessionNotFoundError")
return undefined
throw error
})
: undefined
if (input.session && !explicit) throw new Error("Session not found")
const explicit = input.session ? await findSession(input.client, input.session, input.signal) : undefined
if (input.session && !explicit) {
if (input.fork) throw new Error("Session not found")
return { session: undefined }
}
if (explicit)
return {
session: input.fork
@@ -133,6 +131,13 @@ async function selectSession(input: {
}
}
export function findSession(client: OpenCodeClient, sessionID: string, signal?: AbortSignal) {
return client.session.get({ sessionID }, ...requestOptions(signal)).catch((error) => {
if (error && typeof error === "object" && "_tag" in error && error._tag === "SessionNotFoundError") return undefined
throw error
})
}
async function latestSession(
client: OpenCodeClient,
location: LocationGetOutput,
@@ -309,6 +309,7 @@ async function capture(input: Parameters<typeof run>[0]) {
afterEach(() => {
mock.restore()
process.exitCode = 0
})
describe("runNonInteractivePrompt", () => {
@@ -433,6 +434,7 @@ describe("runNonInteractivePrompt", () => {
expect(sdk.form.list).toHaveBeenCalledWith({
location: { directory: "/work tree" },
})
expect(process.exitCode).toBe(1)
})
test("attach mode cancels only session-owned forms", async () => {
@@ -448,6 +450,7 @@ describe("runNonInteractivePrompt", () => {
{ sessionID: "global", formID: "frm_pending_global" },
expect.anything(),
)
expect(process.exitCode).toBe(1)
})
test("V1 JSON output flushes step_start before an unrelated step failure", async () => {
+22
View File
@@ -42,6 +42,28 @@ describe("session target resolver", () => {
})
})
test("creates a missing explicit Session with its ID", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.session, "get").mockRejectedValue({ _tag: "SessionNotFoundError" })
spyOn(client.location, "get").mockResolvedValue(location("/project"))
const create = spyOn(client.session, "create").mockResolvedValue(session("ses_chosen", "/project"))
const target = await resolveSessionTarget({ client, session: "ses_chosen", prepare })
expect(create).toHaveBeenCalledWith(expect.objectContaining({ id: "ses_chosen" }))
expect(target).toMatchObject({ session: { id: "ses_chosen" }, resume: false })
})
test("does not create a missing explicit Session to fork", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.session, "get").mockRejectedValue({ _tag: "SessionNotFoundError" })
const create = spyOn(client.session, "create")
await expect(resolveSessionTarget({ client, session: "ses_chosen", fork: true, prepare })).rejects.toThrow(
"Session not found",
)
expect(create).not.toHaveBeenCalled()
})
test("paginates to continue the exact directory", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/project"))
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.18",
"version": "2.0.19",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.18",
"version": "2.0.19",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.18",
"version": "2.0.19",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.18",
"version": "2.0.19",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.18",
"version": "2.0.19",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.18",
"version": "2.0.19",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.18",
"version": "2.0.19",
"type": "module",
"license": "MIT",
"scripts": {
+1 -7
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.18",
"version": "2.0.19",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
@@ -66,12 +66,6 @@
"node": "./src/shell/parser-wasm.node.ts",
"default": "./src/shell/parser-wasm.bun.ts"
},
"#process-lock-ffi": {
"workerd": "./src/util/process-lock-ffi.workerd.ts",
"bun": "./src/util/process-lock-ffi.bun.ts",
"node": "./src/util/process-lock-ffi.node.ts",
"default": "./src/util/process-lock-ffi.bun.ts"
},
"#v1-migration": {
"types": "./src/database/v1-migration.bun.ts",
"bun": "./src/database/v1-migration.bun.ts",
-1
View File
@@ -26,7 +26,6 @@ const result = await Bun.build({
"#fff",
"#photon-wasm",
"#shell-parser-wasm",
"#process-lock-ffi",
"#v1-migration",
],
splitting: true,
-6
View File
@@ -30,12 +30,6 @@ export interface ExternalDirectoryAuthorization {
readonly save: string
}
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
action: input.action,
resources: [input.resource],
save: [input.save],
})
export interface Target {
readonly absolute: AbsolutePath
/** Location-relative for internal paths, absolute for external paths. */
-6
View File
@@ -1,6 +0,0 @@
export * as File from "./file.js"
import { FileDiff } from "@opencode/schema/file-diff"
export const Diff = FileDiff.Info
export type Diff = typeof Diff.Type
+19 -9
View File
@@ -1,6 +1,7 @@
export * as Generate from "./generate.js"
import { LLM, LLMClient, AIError } from "@opencode/ai"
import { SessionID } from "@opencode/schema/session-id"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { llmClient } from "./effect/app-node-platform.js"
@@ -60,15 +61,24 @@ export const layer = Layer.effect(
? `Model unavailable: ${input.model.providerID}/${input.model.id}`
: "No model specified and no supported model is available",
})
const response = yield* llm.generate(LLM.request({ model: resolved.model, prompt: input.prompt })).pipe(
Effect.mapError(
(error: AIError) =>
new UnavailableError({
message: error.message,
service: resolved.ref.providerID,
}),
),
)
const response = yield* llm
.generate(
LLM.request({
model: resolved.model,
prompt: input.prompt,
// Gateways require session attribution even for a stateless call; no Session is stored.
http: { headers: { "x-opencode-session": SessionID.create() } },
}),
)
.pipe(
Effect.mapError(
(error: AIError) =>
new UnavailableError({
message: error.message,
service: resolved.ref.providerID,
}),
),
)
return response.text
})
+3 -3
View File
@@ -7,7 +7,7 @@ import { AbsolutePath, RelativePath } from "./schema.js"
import { FSUtil } from "@opencode/util/fs-util"
import { AppProcess } from "@opencode/util/process"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { File } from "./file.js"
import { FileDiff } from "@opencode/schema/file-diff"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { VcsPatch } from "./vcs/patch.js"
import { gitExecutable } from "./util/git-executable.js"
@@ -152,7 +152,7 @@ export interface Interface {
to: TreeID
context?: number
paths?: readonly RelativePath[]
}) => Effect.Effect<readonly File.Diff[], OperationError>
}) => Effect.Effect<readonly FileDiff.Info[], OperationError>
readonly restore: (input: {
repository: Repository
files: ReadonlyMap<RelativePath, TreeID>
@@ -571,7 +571,7 @@ const layer = Layer.effect(
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
} satisfies File.Diff
} satisfies FileDiff.Info
})
})
+1 -1
View File
@@ -152,7 +152,7 @@ export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Ser
const replacements: LayerNode.Replacements = [
...(options.discovery === false ? vanillaReplacements : []),
...(options.replacements ?? []),
Location.node.replace(Location.boundNode(ref, { discovery: options.discovery })),
Location.node.replace(Location.boundNode(ref)),
InstancePlugins.node.replace(InstancePlugins.bound(options.plugins ?? [])),
]
+11 -13
View File
@@ -2,13 +2,12 @@ export * as InstructionBuiltIns from "./builtins.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import type { Session } from "@opencode/schema/session"
import { Global } from "@opencode/util/global"
import { Location } from "../location.js"
import { Instructions } from "./index.js"
export interface Interface {
readonly load: (sessionID: Session.ID) => Effect.Effect<Instructions.List>
readonly load: () => Effect.Effect<Instructions.List>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/InstructionBuiltIns") {}
@@ -19,16 +18,24 @@ const layer = Layer.effect(
const global = yield* Global.Service
const location = yield* Location.Service
return Service.of({
load: (sessionID) =>
load: () =>
Effect.succeed(
Instructions.combine([
Instructions.make({
key: Instructions.Key.make("core/date"),
codec: Schema.toCodecJson(Schema.String),
read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
render: {
initial: (date) => `Today's date: ${date}`,
changed: (_previous, date) => `Today's date is now: ${date}`,
},
}),
Instructions.make({
key: Instructions.Key.make("core/environment"),
codec: Schema.toCodecJson(Schema.String),
read: Effect.sync(() =>
[
"<env>",
` Current conversation session ID: ${sessionID}`,
` Working directory: ${location.directory}`,
` Workspace root folder: ${location.project.directory}`,
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
@@ -44,15 +51,6 @@ const layer = Layer.effect(
["The environment you are running in is now:", environment].join("\n"),
},
}),
Instructions.make({
key: Instructions.Key.make("core/date"),
codec: Schema.toCodecJson(Schema.String),
read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
render: {
initial: (date) => `Today's date: ${date}`,
changed: (_previous, date) => `Today's date is now: ${date}`,
},
}),
]),
),
})
-3
View File
@@ -1,3 +0,0 @@
/** @deprecated Use FileAccess for path resolution and authorization. */
export { FileAccess as LocationMutation } from "./file-access.js"
export * from "./file-access.js"
-1
View File
@@ -8,7 +8,6 @@ import { LocationServiceMap } from "./location-service-map.js"
export { LocationServiceMap } from "./location-service-map.js"
export type LocationServices = Instance.Services
export type LocationError = Instance.Error
export function buildLocationServiceMap(
replacements: LayerNode.Replacements = [],
+4 -4
View File
@@ -16,12 +16,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
export const node = LayerNode.unbound(Service, tags.values.location)
const layer = (ref: Ref, options?: { readonly discovery?: boolean }) =>
const layer = (ref: Ref) =>
Layer.effect(
Service,
Effect.gen(function* () {
const project = yield* Project.Service
const resolved = yield* project.resolve(ref.directory, options)
const resolved = yield* project.resolve(ref.directory)
return Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
@@ -31,9 +31,9 @@ const layer = (ref: Ref, options?: { readonly discovery?: boolean }) =>
}),
)
export const boundNode = (ref: Ref, options?: { readonly discovery?: boolean }) =>
export const boundNode = (ref: Ref) =>
makeLocationNode({
service: Service,
layer: layer(ref, options),
layer: layer(ref),
deps: [Project.node],
})
-2
View File
@@ -45,8 +45,6 @@ export const ResourceTemplate = Mcp.ResourceTemplate
export type ResourceTemplate = Mcp.ResourceTemplate
export const ResourceCatalog = Mcp.ResourceCatalog
export type ResourceCatalog = Mcp.ResourceCatalog
export const ResourceContentPart = Mcp.ResourceContentPart
export type ResourceContentPart = Mcp.ResourceContentPart
export const ResourceContent = Mcp.ResourceContent
export type ResourceContent = Mcp.ResourceContent
File diff suppressed because one or more lines are too long
@@ -1,55 +1,62 @@
import { Effect } from "effect"
import { isArrayNonEmpty } from "effect/Array"
import { define } from "@opencode/plugin/effect/plugin"
import { Form } from "@opencode/schema/form"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
import { configuredSettings } from "./configured.js"
const providerID = Provider.ID.make("cloudflare-ai-gateway")
const accountIdField = Form.StringField.make({
type: "string",
key: "accountId",
title: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
required: true,
})
const gatewayIdField = Form.StringField.make({
type: "string",
key: "gatewayId",
title: "Enter your Cloudflare AI Gateway ID",
placeholder: "e.g. my-gateway",
required: true,
})
export const CloudflareAIGatewayPlugin = define({
id: "opencode.provider.cloudflare.ai.gateway",
effect: Effect.fn(function* (ctx) {
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
const gatewayId = process.env.CLOUDFLARE_GATEWAY_ID
const configured = yield* configuredSettings(providerID)
const form = iife(() => {
if (typeof configured?.baseURL === "string") return
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || stringOption(configured ?? {}, "accountId")
const gatewayId =
process.env.CLOUDFLARE_GATEWAY_ID ||
stringOption(configured ?? {}, "gatewayId") ||
stringOption(configured ?? {}, "gateway")
if (accountId && gatewayId) return
const accountIdForm = Form.StringField.make({
type: "string",
key: "accountId",
title: "Enter your Cloudflare Account ID",
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
required: true,
})
const gatewayIdForm = Form.StringField.make({
type: "string",
key: "gatewayId",
title: "Enter your Cloudflare AI Gateway ID",
placeholder: "e.g. my-gateway",
required: true,
})
if (accountId) return Form.Fields.make([gatewayIdForm])
if (gatewayId) return Form.Fields.make([accountIdForm])
return Form.Fields.make([accountIdForm, gatewayIdForm])
})
const fields =
typeof configured?.baseURL === "string"
? []
: [
...(accountId || typeof configured?.accountId === "string" ? [] : [accountIdField]),
...(gatewayId || typeof configured?.gatewayId === "string" ? [] : [gatewayIdField]),
]
yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID: providerID,
method: {
type: "key",
label: "Gateway API token",
form,
form: isArrayNonEmpty(fields) ? Form.Fields.make(fields) : undefined,
},
})
})
yield* ctx.provider.transform((evt) => {
const item = evt.get(providerID)
if (!item || (!accountId && !gatewayId)) return
evt.update(item.provider.id, (provider) => {
if (typeof provider.settings?.baseURL === "string") return
provider.settings = {
...(accountId ? { accountId } : {}),
...(gatewayId ? { gatewayId } : {}),
...provider.settings,
}
})
})
}),
})
function stringOption(options: Record<string, unknown>, key: string) {
return typeof options[key] === "string" ? options[key] : undefined
}
@@ -50,7 +50,7 @@ export const CloudflareWorkersAIPlugin = define({
})
function resolveAccountId(options: Record<string, unknown>) {
return process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
return stringOption(options, "accountId") ?? process.env.CLOUDFLARE_ACCOUNT_ID
}
function workersEndpoint(accountId: string) {
@@ -11,6 +11,7 @@ import { Model } from "../../model.js"
import { Agent } from "../../agent.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Provider } from "../../provider.js"
import { SessionAffinity } from "../../session/affinity.js"
import type { PluginInternal } from "../internal.js"
const clientID = "Ov23li8tweQw6odWQebz"
@@ -272,7 +273,7 @@ export const GithubCopilotPlugin = define({
.pipe(Effect.orElseSucceed(() => undefined))
const interaction = interactionType(evt.kind, session?.parentID !== undefined)
evt.headers["X-Interaction-Type"] = interaction
evt.headers["X-Interaction-Id"] = evt.sessionID
evt.headers["X-Interaction-Id"] = session ? SessionAffinity.get(session) : evt.sessionID
if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent"
}),
{ providerID: Provider.ID.githubCopilot },
+7 -2
View File
@@ -9,6 +9,7 @@ import { Bus } from "../../bus.js"
import { Integration } from "../../integration.js"
import { OauthCallbackPage } from "../../oauth/page.js"
import { Provider } from "../../provider.js"
import { SessionAffinity } from "../../session/affinity.js"
import type { PluginInternal } from "../internal.js"
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
@@ -299,12 +300,16 @@ export const OpenAIPlugin = define({
yield* ctx.session.hook(
"model.request",
(evt) =>
Effect.sync(() => {
Effect.gen(function* () {
if (!chatgpt) return
if (evt.baseURL && URL.canParse(evt.baseURL) && new URL(evt.baseURL).origin === "https://api.openai.com")
evt.baseURL = codexBaseURL
const session = yield* ctx.session
.get({ sessionID: evt.sessionID })
.pipe(Effect.orElseSucceed(() => undefined))
evt.headers.originator = "opencode"
evt.headers["session-id"] = evt.sessionID
// ChatGPT routes its prompt cache on this header, so children share the parent's.
evt.headers["session-id"] = session ? SessionAffinity.get(session) : evt.sessionID
}),
{ providerID: Provider.ID.openai },
)
+2 -5
View File
@@ -65,7 +65,7 @@ export interface Interface {
/** Records Project activity for recency ordering, at most once per minute per Project. */
readonly activate: (projectID: ID) => Effect.Effect<void>
/** Resolves and persists the owning Project. */
readonly resolve: (input: AbsolutePath, options?: { readonly discovery?: boolean }) => Effect.Effect<Resolved>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
@@ -334,10 +334,7 @@ const layer = Layer.effect(
}
})
const resolve = Effect.fn("Project.resolve")(function* (
input: AbsolutePath,
_options?: { readonly discovery?: boolean },
) {
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const directory = AbsolutePath.make(yield* fs.resolve(input))
const native = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
+20 -18
View File
@@ -6,7 +6,6 @@ import { Context, Effect, Layer, Schema, Types } from "effect"
import { Pty } from "@opencode/schema/pty"
import { Bus } from "./bus.js"
import { Location } from "./location.js"
import { PtyID } from "./pty/schema.js"
import { ShellSelect } from "./shell/select.js"
import { lazy } from "./util/lazy.js"
@@ -35,6 +34,9 @@ type Active = {
listeners: Disp[]
}
export const ID = Pty.ID
export type ID = Pty.ID
export const Info = Pty.Info
export type Info = Types.DeepMutable<typeof Info.Type>
@@ -69,21 +71,21 @@ export type Attachment = {
}
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Pty.NotFoundError", {
ptyID: PtyID,
ptyID: ID,
}) {}
export class ExitedError extends Schema.TaggedError<ExitedError>()("Pty.ExitedError", {
ptyID: PtyID,
ptyID: ID,
}) {}
export interface Interface {
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
readonly create: (input: CreateInput) => Effect.Effect<Info>
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
readonly write: (id: PtyID, data: string) => Effect.Effect<void, NotFoundError>
readonly attach: (id: PtyID, input: AttachInput) => Effect.Effect<Attachment, NotFoundError | ExitedError>
readonly update: (id: ID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly remove: (id: ID) => Effect.Effect<void, NotFoundError>
readonly write: (id: ID, data: string) => Effect.Effect<void, NotFoundError>
readonly attach: (id: ID, input: AttachInput) => Effect.Effect<Attachment, NotFoundError | ExitedError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
@@ -96,8 +98,8 @@ const layer = Layer.effect(
const shell = yield* ShellSelect.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<PtyID, Active>()
const exitOrder: PtyID[] = []
const sessions = new Map<ID, Active>()
const exitOrder: ID[] = []
function notifyEnd(session: Active, event: { exitCode?: number }) {
for (const subscriber of session.subscribers.values()) {
@@ -131,13 +133,13 @@ const layer = Layer.effect(
}),
)
const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
const requireSession = Effect.fn("Pty.requireSession")(function* (id: ID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ ptyID: id })
return session
})
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
const removeSession = Effect.fnUntraced(function* (id: ID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
@@ -148,7 +150,7 @@ const layer = Layer.effect(
yield* bus.publish(Pty.Event.Deleted, { id: session.info.id })
})
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
const remove = Effect.fn("Pty.remove")(function* (id: ID) {
yield* requireSession(id)
yield* removeSession(id)
})
@@ -157,12 +159,12 @@ const layer = Layer.effect(
return Array.from(sessions.values()).map((session) => session.info)
})
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
const get = Effect.fn("Pty.get")(function* (id: ID) {
return (yield* requireSession(id)).info
})
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = PtyID.ascending()
const id = ID.ascending()
const command = input.command || (yield* shell.resolve({ priority: "config" }))
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
@@ -242,7 +244,7 @@ const layer = Layer.effect(
return info
})
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
const update = Effect.fn("Pty.update")(function* (id: ID, input: UpdateInput) {
const session = yield* requireSession(id)
if (input.title) session.info.title = input.title
if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows)
@@ -250,12 +252,12 @@ const layer = Layer.effect(
return session.info
})
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
const write = Effect.fn("Pty.write")(function* (id: ID, data: string) {
const session = yield* requireSession(id)
if (session.info.status === "running") session.process.write(data)
})
const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
const attach = Effect.fn("Pty.attach")(function* (id: ID, input: AttachInput) {
const session = yield* requireSession(id)
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
-1
View File
@@ -1 +0,0 @@
export { ID as PtyID } from "@opencode/schema/pty"
+2 -2
View File
@@ -2,7 +2,7 @@ export * as PtyTicket from "./ticket.js"
import type { Workspace } from "@opencode/schema/workspace"
import { PtyTicket } from "@opencode/schema/pty-ticket"
import { PtyID } from "./schema.js"
import type { Pty } from "@opencode/schema/pty"
import { Cache, Context, Duration, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
@@ -12,7 +12,7 @@ const CAPACITY = 10_000
export const ConnectToken = PtyTicket.ConnectToken
export type Scope = {
readonly ptyID: PtyID
readonly ptyID: Pty.ID
readonly directory?: string
readonly workspaceID?: Workspace.ID
}
+2 -1
View File
@@ -145,8 +145,9 @@ function parts(input: string) {
.filter(Boolean)
}
// cachePath makes each `:`-separated host part a directory.
function safeHost(input: string) {
return Boolean(input) && !input.startsWith("-") && !/[\s/\\]/.test(input)
return Boolean(input) && !input.startsWith("-") && input.split(":").every(safeSegment)
}
function safeSegment(input: string) {
+8
View File
@@ -0,0 +1,8 @@
export * as SessionAffinity from "./affinity.js"
import type { SessionSchema } from "./schema.js"
// TODO: Should the `model.request` hook expose affinity so plugins stop deriving it from `ctx.session.get`?
/** The Session ID that groups model requests for provider cache routing: children share the parent's, forks the fork source's. */
export const get = (session: Pick<SessionSchema.Info, "id" | "parentID" | "fork">) =>
session.parentID ?? session.fork?.sessionID ?? session.id
+8 -5
View File
@@ -132,7 +132,7 @@ const layer = Layer.effect(
const loaded = yield* Effect.all(
{
tools: registry.snapshot(permissions),
builtins: builtins.load(sessionID),
builtins: builtins.load(),
discovery: discovery.load(),
skills: skillInstructions.load(permissions),
references: referenceInstructions.load(),
@@ -144,13 +144,16 @@ const layer = Layer.effect(
return {
session,
agent: { ...agent, info: agent.info },
// Ordered from most to least shared across sessions so the baseline stays a reusable
// prompt-cache prefix: user-level catalog and guidance, then project instructions, then
// the date and environment, which vary by day and directory.
instructions: Instructions.combine([
loaded.builtins,
CodeModeInstructions.make(loaded.tools.codeModeCatalog),
loaded.discovery,
loaded.skills,
loaded.references,
loaded.mcp,
loaded.references,
loaded.skills,
loaded.discovery,
loaded.builtins,
loaded.entries,
]),
tools: loaded.tools,
-9
View File
@@ -50,15 +50,6 @@ export class StepFailedError extends Schema.TaggedError<StepFailedError>()("Sess
}
}
export class UserInterruptedError extends Schema.TaggedError<UserInterruptedError>()(
"Session.UserInterruptedError",
{},
) {
override get message() {
return "Session interrupted by user"
}
}
export class PromptConflictError extends Schema.TaggedError<PromptConflictError>()("Session.PromptConflictError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
+1 -4
View File
@@ -12,7 +12,6 @@ import { SessionRunner } from "./runner/index.js"
import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
import { toSessionError } from "./to-session-error.js"
import { UserInterruptedError } from "./error.js"
import { SessionInbox } from "./inbox.js"
export interface Interface {
@@ -51,9 +50,7 @@ type InterruptReason = "user" | "shutdown" | "inactivity"
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: InterruptReason) {
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
if (Cause.hasInterrupts(exit.cause)) return { type: "interrupted" as const, reason: reason ?? "shutdown" }
const failure = Cause.squash(exit.cause)
if (failure instanceof UserInterruptedError) return { type: "interrupted" as const, reason: "user" as const }
return { type: "failed" as const, error: toSessionError(failure) }
return { type: "failed" as const, error: toSessionError(Cause.squash(exit.cause)) }
}
/** Process-local execution: drains run in this process using the selected instance. */
+8 -5
View File
@@ -31,6 +31,7 @@ import { Permission } from "../permission.js"
import { PluginHooks } from "../plugin/hooks.js"
import { QuestionTool } from "../tool/plugin/question.js"
import { Tool } from "../tool.js"
import { SessionAffinity } from "./affinity.js"
import { SessionModelTransport } from "./model-transport.js"
import { SessionProviderContext } from "./provider-context.js"
import { SessionRunnerModel } from "./runner/model.js"
@@ -46,6 +47,8 @@ const IMAGE_REMOVED =
const GENERATION_KEYS = new Set(Object.keys(GenerationOptions.fields))
// Used when the catalog has no output limit for the model.
const OUTPUT_TOKEN_FALLBACK = 32_000
// No reply needs more, however much the model allows.
const OUTPUT_TOKEN_MAX = 256_000
// A summary never needs more, and a request asking for more cannot be shrunk to fit a window the catalog overstates.
const SUMMARY_OUTPUT_MAX = 32_000
// Prompt text is estimated at about 4 characters per token, which can run low on dense text such as code.
@@ -87,7 +90,7 @@ const outputLimit = (
kind: "primary" | "compaction",
inputTokens?: Input["inputTokens"],
) => {
const model = limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK
const model = Math.min(limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK, OUTPUT_TOKEN_MAX)
const requested = kind === "compaction" ? Math.min(model, SUMMARY_OUTPUT_MAX) : model
if (inputTokens === undefined || limit.context <= 0) return requested
const room = limit.context - inputTokens.measured - Math.ceil(inputTokens.estimated * (1 + ESTIMATE_ERROR))
@@ -268,17 +271,17 @@ export const layer = Layer.effect(
const entries = Object.entries(shaped.options)
const generation = Object.fromEntries(entries.filter(([k]) => GENERATION_KEYS.has(k))) as GenerationOptionsFields
const providerOptions = Object.fromEntries(entries.filter(([k]) => !GENERATION_KEYS.has(k)))
const affinity = session.parentID ?? session.fork?.sessionID ?? session.id
const affinity = SessionAffinity.get(session)
const base = LLM.request({
model: model.model,
http: {
headers: {
"x-session-affinity": session.id,
"X-Session-Id": session.id,
"x-session-affinity": affinity,
"X-Session-Id": affinity,
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
"User-Agent": App.useragent(app),
"x-opencode-project": session.projectID,
"x-opencode-session": session.id,
"x-opencode-session": affinity,
"x-opencode-client": app.name,
},
},
+1 -2
View File
@@ -4,7 +4,7 @@ import type { AIError } from "@opencode/ai"
import { Context, Data, Effect } from "effect"
import { SessionSchema } from "../schema.js"
import type { Promotable } from "../inbox.js"
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
import type { AgentNotFoundError, MessageDecodeError, StepFailedError } from "../error.js"
import { SessionRunnerModel } from "./model.js"
import type { Instructions } from "../../instructions/index.js"
@@ -14,7 +14,6 @@ export type RunError =
| MessageDecodeError
| AgentNotFoundError
| StepFailedError
| UserInterruptedError
| Instructions.InitializationBlocked
export type Continuation = { readonly step: number }
-13
View File
@@ -29,19 +29,6 @@ export class ModelUnavailableError extends Schema.TaggedError<ModelUnavailableEr
return `Model unavailable: ${this.providerID}/${this.modelID}`
}
}
export const VariantUnavailableError = ModelResolver.VariantUnavailableError
export type VariantUnavailableError = ModelResolver.VariantUnavailableError
export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export const ModelConfigurationError = ModelResolver.ModelConfigurationError
export type ModelConfigurationError = ModelResolver.ModelConfigurationError
export const ModelInitializationError = ModelResolver.ModelInitializationError
export type ModelInitializationError = ModelResolver.ModelInitializationError
export const UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export type UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export const UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
export type UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error
export type Resolved = ModelResolver.Resolved
+3 -57
View File
@@ -1,6 +1,6 @@
export * as SessionRunnerRetry from "./retry.js"
import { AIError, isContextOverflowFailure } from "@opencode/ai"
import { AIError, isRetryable } from "@opencode/ai"
import { Agent } from "@opencode/schema/agent"
import { Model } from "@opencode/schema/model"
import { SessionError } from "@opencode/schema/session-error"
@@ -10,7 +10,8 @@ import type { PluginHooks } from "../../plugin/hooks.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { toSessionError } from "../to-session-error.js"
export { isRetryable }
interface Input {
readonly cause: AIError
@@ -27,43 +28,6 @@ export interface Decision {
readonly delay: number
}
export function isRetryable(error: AIError) {
const override = error.reason.http?.headers["x-should-retry"]
if (override === "true") return true
if (override === "false") return false
switch (error.reason._tag) {
case "RateLimit":
case "ProviderInternal":
return true
// A WebSocket acknowledgment marks delivery accepted before model output may exist.
// Read failures can still recover; the Step chooses retry versus continuation from durable output.
case "Transport":
return (
error.reason.delivery !== "rejected" &&
(error.reason.delivery !== "accepted" || error.reason.operation === "read")
)
case "InvalidProviderOutput":
return error.reason.classification === "incomplete-stream"
// Unrecognized failures retry: classification records affirmative
// deterministic evidence, and transient failures are exactly the ones
// that arrive in shapes no classifier anticipates.
case "UnknownProvider":
return true
case "Authentication":
case "QuotaExceeded":
case "ContentPolicy":
case "InvalidRequest":
case "UnsupportedOperation":
case "NoRoute":
case "Timeout":
return false
default: {
const exhaustive: never = error.reason
return exhaustive
}
}
}
/** Bound provider-requested delays so a hostile or buggy retry-after cannot stall a session for hours. */
const RETRY_AFTER_MAX = Duration.toMillis("15 minutes")
@@ -119,24 +83,6 @@ export const policy = (sessionID: SessionSchema.ID) =>
})
})
/**
* Retries one auxiliary request's transient failures under a shared `policy` allowance, letting the
* session retry hook adjust each decision. Context overflow is never transient: callers recover it.
*/
export const transient =
(decide: Effect.Success<ReturnType<typeof policy>>, input: Pick<Input, "agent" | "model" | "hook">) =>
<A, R>(effect: Effect.Effect<A, AIError, R>) =>
Effect.retry(effect, {
while: (cause) =>
Effect.gen(function* () {
if (isContextOverflowFailure(cause)) return false
const decision = yield* decide({ ...input, cause, error: toSessionError(cause), retry: isRetryable(cause) })
if (!decision.retry) return false
yield* Effect.sleep(decision.delay)
return true
}),
})
export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
Effect.gen(function* () {
const decide = yield* policy(sessionID)
@@ -3,7 +3,8 @@ import { Tool } from "@opencode/schema/tool"
import { SessionError } from "@opencode/schema/session-error"
import { Permission } from "../permission.js"
import { Integration } from "../integration.js"
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error.js"
import { AgentNotFoundError, StepFailedError } from "./error.js"
import { ModelResolver } from "../model-resolver.js"
import { SessionRunnerModel } from "./runner/model.js"
export function toSessionError(cause: unknown): SessionError.Error {
@@ -48,18 +49,17 @@ export function toSessionError(cause: unknown): SessionError.Error {
return unwrapped.message === "" ? { ...unwrapped, type: "tool.execution", message: cause.message } : unwrapped
}
if (cause instanceof StepFailedError) return cause.error
if (cause instanceof SessionRunnerModel.UnsupportedCompactionError)
if (cause instanceof ModelResolver.UnsupportedCompactionError)
return { type: "provider.unsupported-operation", message: cause.message }
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
if (
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
cause instanceof SessionRunnerModel.ModelUnavailableError ||
cause instanceof SessionRunnerModel.VariantUnavailableError ||
cause instanceof SessionRunnerModel.UnsupportedPackageError ||
cause instanceof SessionRunnerModel.ModelConfigurationError ||
cause instanceof SessionRunnerModel.ModelInitializationError ||
cause instanceof SessionRunnerModel.UnresolvedProviderVariablesError
cause instanceof ModelResolver.VariantUnavailableError ||
cause instanceof ModelResolver.UnsupportedPackageError ||
cause instanceof ModelResolver.ModelConfigurationError ||
cause instanceof ModelResolver.ModelInitializationError ||
cause instanceof ModelResolver.UnresolvedProviderVariablesError
)
return { type: "provider.no-route", message: cause.message }
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
+2 -2
View File
@@ -3,7 +3,7 @@ export * as Snapshot from "./snapshot.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import path from "path"
import { Context, Effect, Fiber, Layer, Schema, Scope } from "effect"
import { File } from "./file.js"
import { FileDiff } from "@opencode/schema/file-diff"
import { FSUtil } from "@opencode/util/fs-util"
import { Git } from "./git.js"
import { Global } from "@opencode/util/global"
@@ -58,7 +58,7 @@ export interface Interface extends State.Transformable<Editor> {
* Generate structured per-file diffs between two captured trees. `context`
* controls unchanged lines around each unified diff hunk.
*/
readonly diff: (input: DiffInput) => Effect.Effect<readonly File.Diff[], Error>
readonly diff: (input: DiffInput) => Effect.Effect<readonly FileDiff.Info[], Error>
/**
* Restore selected project-relative paths from their associated trees. A path

Some files were not shown because too many files have changed in this diff Show More