mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-27 19:17:36 +00:00
Compare commits
45
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29dc70cff4 | ||
|
|
15406155aa | ||
|
|
abfaa5210b | ||
|
|
fbf47807fb | ||
|
|
67146125dc | ||
|
|
8ba230e2fb | ||
|
|
12e1821cc7 | ||
|
|
63e66ca3e6 | ||
|
|
cc782a0cc8 | ||
|
|
01eb18144b | ||
|
|
01208048dc | ||
|
|
995f76cb63 | ||
|
|
c33de198e3 | ||
|
|
24224bcb57 | ||
|
|
7913c8db59 | ||
|
|
d9987ef9c8 | ||
|
|
f0ed4f67df | ||
|
|
2109d68d39 | ||
|
|
cefb2968e2 | ||
|
|
00d179015f | ||
|
|
1d4e1233e5 | ||
|
|
d14f20b46e | ||
|
|
37049a5a13 | ||
|
|
a64bc3616e | ||
|
|
39021dfd67 | ||
|
|
709ddc0d79 | ||
|
|
041885d838 | ||
|
|
29ce49db0f | ||
|
|
00738c5b2d | ||
|
|
6ec8ca920f | ||
|
|
6585bb7105 | ||
|
|
f954688fbb | ||
|
|
1463dabde9 | ||
|
|
29ea6ee05b | ||
|
|
b170904731 | ||
|
|
88e1fa9304 | ||
|
|
ff1bf315ed | ||
|
|
144ce00e00 | ||
|
|
ad504094f0 | ||
|
|
bad6834a3e | ||
|
|
0c4bbc3cd1 | ||
|
|
ae7dd82126 | ||
|
|
65d5123ead | ||
|
|
4eb46a8885 | ||
|
|
1986e92842 |
@@ -184,7 +184,7 @@ const table = sqliteTable("session", {
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep native compaction mechanisms out of `SessionCompaction`. Plugins register `native` strategies through the `SessionCompaction` editor that turn a prepared request into a replacement window (the built-in `NativeCompactionPlugin` handles `@opencode/ai` compaction operations); later registrations win. Core owns the provider-mode decision, route provenance, the retry policy, overflow recovery, interruption, usage accounting, and checkpoint persistence.
|
||||
- Keep provider-specific native compaction mechanisms in `@opencode/ai` behind `LLMClient.compact`. `SessionCompaction` chooses a summary or native compaction from the model's `compaction` setting and owns route provenance, request shrinking, the retry policy, interruption, usage accounting, and checkpoint persistence.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep event replay ownership separate from clustered Session execution ownership.
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -112,13 +112,14 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@clack/core": "1.0.0-alpha.1",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
@@ -135,7 +136,7 @@
|
||||
"effect": "catalog:",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"open": "10.1.2",
|
||||
"picocolors": "1.1.1",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
@@ -177,7 +178,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -193,7 +194,7 @@
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.112",
|
||||
"effect": "4.0.0-rc.117",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -203,7 +204,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -216,7 +217,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -252,7 +253,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -279,7 +280,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -296,7 +297,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -320,7 +321,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -340,7 +341,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -404,11 +405,12 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/which": "3.0.4",
|
||||
"drizzle-kit": "catalog:",
|
||||
"fast-check": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "5.0.0",
|
||||
@@ -457,7 +459,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -494,7 +496,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -510,9 +512,9 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.117",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -529,7 +531,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -542,7 +544,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -556,7 +558,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -571,7 +573,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -610,7 +612,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -640,7 +642,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -655,7 +657,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -679,7 +681,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -700,7 +702,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -722,7 +724,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -757,7 +759,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -777,7 +779,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -811,7 +813,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -830,7 +832,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -876,7 +878,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -890,7 +892,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -909,7 +911,6 @@
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"get-east-asian-width": "catalog:",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
@@ -925,7 +926,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -960,7 +961,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -982,6 +983,7 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"open": "11.0.4",
|
||||
"pacote": "21.5.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -997,7 +999,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1038,7 +1040,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -1098,15 +1100,16 @@
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.2",
|
||||
"effect": "catalog:",
|
||||
"open": "11.0.4",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@corvu/drawer": "0.2.4",
|
||||
"@effect/opentelemetry": "4.0.0-rc.112",
|
||||
"@effect/platform-node": "4.0.0-rc.112",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
|
||||
"@effect/opentelemetry": "4.0.0-rc.117",
|
||||
"@effect/platform-node": "4.0.0-rc.117",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.117",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.117",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.13",
|
||||
@@ -1146,7 +1149,8 @@
|
||||
"dompurify": "3.4.14",
|
||||
"drizzle-kit": "1.0.0-rc.5-ab785fc",
|
||||
"drizzle-orm": "1.0.0-rc.5-169397b",
|
||||
"effect": "4.0.0-rc.112",
|
||||
"effect": "4.0.0-rc.117",
|
||||
"fast-check": "4.9.0",
|
||||
"fuzzysort": "3.1.0",
|
||||
"get-east-asian-width": "1.6.0",
|
||||
"hono": "4.10.7",
|
||||
@@ -1678,13 +1682,13 @@
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.12.0", "", {}, "sha512-mlUE+rZ8CatQekLhnaiN91Iemdd+e2gFKooGlnRB3oPTL3VghLfX24dx7HrzMNeC1JrIB/0kpsfyty3f5HNfxQ=="],
|
||||
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.112", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.112" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-OTRv1DxTHUmnakgJ6XVM8wVgF1KgZH4UXnOemwSLUwUjXO+RCikzF8oR/rlVmOGq81KtQzj1URM4M4nchlQOuQ=="],
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-rc.117", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-rc.117" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-IWAn2rU/RZLStkXfcdF1nGxcKQ7kbHct7N98T/5yOmSYiXvD8ZIxsGZhuAcRPPjc0NbNU2Ay8DZLPesyhJhUgA=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.112", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.112", "mime": "^4.1.0", "undici": "^8.10.0" }, "peerDependencies": { "effect": "^4.0.0-rc.112", "redis": ">=5.0.0 <7.0.0" } }, "sha512-/BMAcdNGQQskLmI0Zoa95KfTZkr9HV9N4NSxaSrusG6GeW6Ulp9KvZ+Rlaiw8lnOt43CXjFLdfll5/k5rxL4hQ=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-rc.117", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-rc.117", "undici": "^8.10.2" }, "peerDependencies": { "effect": "^4.0.0-rc.117", "redis": ">=5.0.0 <7.0.0" } }, "sha512-WbPcvQDkqXFnCeG6LohJ9eVNapCDhZbCpezhYKxm0IsZEk7swakRh7RkC1t2CXzd3hGmszTv2k9e/pXyZuL+wQ=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.112", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-ttjz0xKamFN7vL8pNDYVwddJLjZvqKePc05djlz2VcdaKbLsnYbtMnL1rbOfHgEnIUSHGh7FkjaN4DM1Ov81sQ=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-rc.117", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.3" }, "peerDependencies": { "effect": "^4.0.0-rc.117" } }, "sha512-GyRkEfLBG49MwJe+/XDPiXYcQ44RA9p2z0AurlsWhR1cd1PqOUMEZU1pORL+/R5hR8aH6ZuqoJYD8lAc6h+wlA=="],
|
||||
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.112", "", { "peerDependencies": { "effect": "^4.0.0-rc.112" } }, "sha512-EqR8pWZo3VzedRvP6qo5HcfqNmFvSa+sQLWQl3oBMx2KY1FCc8NYjep79wYjNkW0qABnffrPUXCMTOncd1A+5g=="],
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-rc.117", "", { "peerDependencies": { "effect": "^4.0.0-rc.117" } }, "sha512-UjgkEmR+s5Rc870sDsDzu/8AA5KQqcBeq5qgH6AFe5OYfv3GCMfoQTkIvnl+Q3M5KaZosYgwwEy9uij/J2HOJw=="],
|
||||
|
||||
"@electron-internal/extract-zip": ["@electron-internal/extract-zip@1.0.5", "", {}, "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA=="],
|
||||
|
||||
@@ -2018,18 +2022,6 @@
|
||||
|
||||
"@motionone/utils": ["@motionone/utils@10.18.0", "", { "dependencies": { "@motionone/types": "^10.17.1", "hey-listen": "^1.0.8", "tslib": "^2.3.1" } }, "sha512-3XVF7sgyTSI2KWvTf6uLlBJ5iAgRgmvp3bpuOiQJvInd4nZ19ET8lX5unn30SlmRH7hXbBbH+Gxd0m0klJ3Xtw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
|
||||
|
||||
"@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="],
|
||||
@@ -3864,7 +3856,7 @@
|
||||
|
||||
"editorconfig": ["editorconfig@1.0.7", "", { "dependencies": { "@one-ini/wasm": "0.1.1", "commander": "^10.0.0", "minimatch": "^9.0.1", "semver": "^7.5.3" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw=="],
|
||||
|
||||
"effect": ["effect@4.0.0-rc.112", "", { "dependencies": { "fast-check": "^4.9.0", "msgpackr": "^2.0.5" } }, "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A=="],
|
||||
"effect": ["effect@4.0.0-rc.117", "", {}, "sha512-UUyi9QiOW4nySFIBM3KnYbNMd9EZliOdKfBcyLD0mPMPheO2fkuXS5y0opjWc9oqzlJxPQICEGm8gGw3kh9j/w=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
@@ -4322,7 +4314,7 @@
|
||||
|
||||
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
|
||||
|
||||
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
"is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="],
|
||||
|
||||
"is-document.all": ["is-document.all@1.0.0", "", { "dependencies": { "call-bound": "^1.0.4" } }, "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g=="],
|
||||
|
||||
@@ -4340,6 +4332,8 @@
|
||||
|
||||
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
|
||||
|
||||
"is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="],
|
||||
|
||||
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
|
||||
|
||||
"is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
|
||||
@@ -4386,7 +4380,7 @@
|
||||
|
||||
"is-whitespace": ["is-whitespace@0.3.0", "", {}, "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg=="],
|
||||
|
||||
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
@@ -4700,7 +4694,7 @@
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
|
||||
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
@@ -4748,10 +4742,6 @@
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"msgpackr": ["msgpackr@2.0.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="],
|
||||
|
||||
"msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
|
||||
@@ -4794,8 +4784,6 @@
|
||||
|
||||
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
|
||||
|
||||
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
|
||||
|
||||
"node-html-parser": ["node-html-parser@7.1.0", "", { "dependencies": { "css-select": "^5.1.0", "he": "1.2.0" } }, "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ=="],
|
||||
|
||||
"node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="],
|
||||
@@ -4850,7 +4838,7 @@
|
||||
|
||||
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
|
||||
|
||||
"open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="],
|
||||
"open": ["open@11.0.4", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg=="],
|
||||
|
||||
"openai": ["openai@6.49.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg=="],
|
||||
|
||||
@@ -4994,6 +4982,8 @@
|
||||
|
||||
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
|
||||
|
||||
"powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="],
|
||||
|
||||
"preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="],
|
||||
|
||||
"preact-render-to-string": ["preact-render-to-string@6.6.5", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA=="],
|
||||
@@ -5590,7 +5580,7 @@
|
||||
|
||||
"uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="],
|
||||
|
||||
"undici": ["undici@8.10.0", "", {}, "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ=="],
|
||||
"undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
@@ -5814,7 +5804,7 @@
|
||||
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
"wsl-utils": ["wsl-utils@1.0.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA=="],
|
||||
|
||||
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
|
||||
|
||||
@@ -5910,8 +5900,6 @@
|
||||
|
||||
"@astrojs/telemetry/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||
|
||||
"@astrojs/telemetry/is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="],
|
||||
|
||||
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="],
|
||||
|
||||
"@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="],
|
||||
@@ -6044,8 +6032,6 @@
|
||||
|
||||
"@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="],
|
||||
|
||||
"@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"@cloudflare/vite-plugin/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
@@ -6394,8 +6380,6 @@
|
||||
|
||||
"builder-util/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
|
||||
|
||||
"chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
@@ -6500,6 +6484,10 @@
|
||||
|
||||
"import-in-the-middle/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||
|
||||
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
|
||||
@@ -6510,8 +6498,6 @@
|
||||
|
||||
"lighthouse/devtools-protocol": ["devtools-protocol@0.0.1663043", "", {}, "sha512-33aOY3ZnBP1dgZsshgaL+/XlsQleiFZgyUaDtdZkEa1nbZhVY1MoDeWjk+wxg25fU924l1ZJfoGNmjjeA/5s1w=="],
|
||||
|
||||
"lighthouse/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
||||
|
||||
"lighthouse/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
|
||||
|
||||
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
|
||||
@@ -6614,8 +6600,6 @@
|
||||
|
||||
"sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
|
||||
|
||||
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"storybook-solidjs-vite/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
||||
|
||||
"storybook-solidjs-vite/vite": ["vite@7.1.11", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg=="],
|
||||
@@ -6648,6 +6632,8 @@
|
||||
|
||||
"tw-to-css/tailwindcss": ["tailwindcss@3.3.2", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.5.3", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.2.12", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.18.2", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.0.0", "postcss": "^8.4.23", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.1", "postcss-nested": "^6.0.1", "postcss-selector-parser": "^6.0.11", "postcss-value-parser": "^4.2.0", "resolve": "^1.22.2", "sucrase": "^3.32.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-9jPkMiIBXvPc2KywkraqsUfbfj+dHDb+JPWtSJa9MLFdrPyazI7q6WX2sUrm7R9eVR7qqv3Pas7EvQFzxKnI6w=="],
|
||||
|
||||
"unifont/undici": ["undici@8.10.0", "", {}, "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ=="],
|
||||
|
||||
"unplugin/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
|
||||
"unused-filename/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
@@ -6702,6 +6688,10 @@
|
||||
|
||||
"write-file-atomic/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"wsl-utils/powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
||||
|
||||
"yaml-language-server/prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
|
||||
|
||||
"yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="],
|
||||
@@ -7336,8 +7326,6 @@
|
||||
|
||||
"builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"chrome-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
@@ -7398,12 +7386,6 @@
|
||||
|
||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"lighthouse/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
|
||||
|
||||
"lighthouse/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"lighthouse/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
|
||||
"miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||
@@ -7674,6 +7656,10 @@
|
||||
|
||||
"@astrojs/starlight/@astrojs/mdx/@astrojs/markdown-remark/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
|
||||
|
||||
"@astrojs/starlight/astro/@astrojs/telemetry/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"@astrojs/starlight/astro/@astrojs/telemetry/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"@astrojs/starlight/astro/p-queue/p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
|
||||
|
||||
"@astrojs/starlight/astro/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
@@ -8076,6 +8062,10 @@
|
||||
|
||||
"@opencode/web/@astrojs/cloudflare/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
|
||||
|
||||
"@opencode/web/astro/@astrojs/telemetry/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"@opencode/web/astro/@astrojs/telemetry/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"@opencode/web/astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"@opencode/web/astro/p-queue/p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
|
||||
@@ -8216,6 +8206,10 @@
|
||||
|
||||
"archiver-utils/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"astro-expressive-code/astro/@astrojs/telemetry/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"astro-expressive-code/astro/@astrojs/telemetry/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"astro-expressive-code/astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"astro-expressive-code/astro/p-queue/p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
|
||||
@@ -8318,6 +8312,10 @@
|
||||
|
||||
"temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"toolbeam-docs-theme/astro/@astrojs/telemetry/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"toolbeam-docs-theme/astro/@astrojs/telemetry/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"toolbeam-docs-theme/astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"toolbeam-docs-theme/astro/p-queue/p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-aQQQhaUlAhpfqzH0vNi0IJ1cg7FQHIKYzxeq5d8PZoU=",
|
||||
"aarch64-linux": "sha256-r9aDFu3UYmudmmYPhzCrpFvQlaejXc8V1IzLtG3jZPc=",
|
||||
"aarch64-darwin": "sha256-B0m41LelD7d61vPHGIZZSO/cU7gbjHDJHt6oxNRRM8Q=",
|
||||
"x86_64-darwin": "sha256-9TWJsyI3Y6BMomtGSgqA1th9LpxpqP4F5Tl/GyexVYw="
|
||||
"x86_64-linux": "sha256-7DgxTpKv6ITKTom0mhlJNPQfdEjtEHK5P8uyCr26HZw=",
|
||||
"aarch64-linux": "sha256-PJxW1Ibfx6oS1neWPSHqzP1Pm1HT9my1TrrHmOb6/no=",
|
||||
"aarch64-darwin": "sha256-VIme5VHfM8JxNiDSOykkr5FytghDLI0FxkhiOXUSyQw=",
|
||||
"x86_64-darwin": "sha256-rQ/j0QkR1vxAq4jgUbr0nY4RDyiLTJqN8q1AfoiEqVQ="
|
||||
}
|
||||
}
|
||||
|
||||
+8
-6
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
@@ -42,10 +42,10 @@
|
||||
"packages/stats/*"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-rc.112",
|
||||
"@effect/platform-node": "4.0.0-rc.112",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.112",
|
||||
"@effect/opentelemetry": "4.0.0-rc.117",
|
||||
"@effect/platform-node": "4.0.0-rc.117",
|
||||
"@effect/platform-node-shared": "4.0.0-rc.117",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-rc.117",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.4.0",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
@@ -79,7 +79,8 @@
|
||||
"dompurify": "3.4.14",
|
||||
"drizzle-kit": "1.0.0-rc.5-ab785fc",
|
||||
"drizzle-orm": "1.0.0-rc.5-169397b",
|
||||
"effect": "4.0.0-rc.112",
|
||||
"effect": "4.0.0-rc.117",
|
||||
"fast-check": "4.9.0",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
@@ -162,6 +163,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.2",
|
||||
"effect": "catalog:",
|
||||
"open": "11.0.4",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Message.media(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. `LLM.generate`/`LLM.stream` and Promise `ai.llm.generate`/`ai.llm.stream` accept ergonomic input or an `LLMRequest`; both paths use the same canonical request. Core still builds, logs, replays, and updates that durable `LLMRequest` boundary. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path.
|
||||
|
||||
Modality namespaces mirror `LLM` exactly: `Image.request`, `Image.generate`, `Image.stream` (later `Video`, `Speech`, `Transcription`). Common request fields (`images`, `mask`, `n`, `size`, `aspectRatio`, `seed`, `format`) lower natively or fail with a typed `AIError`; provider-native controls always live under `providerOptions`, never under a modality-specific `options` key.
|
||||
Modality namespaces mirror `LLM` exactly: `Image.request`, `Image.generate`, `Image.stream`, and the same for `Video`, `Speech`, and `Transcription`. Common request fields (`images`, `mask`, `n`, `size`, `aspectRatio`, `seed`, `format`) lower natively or fail with a typed `AIError`; provider-native controls always live under `providerOptions`, never under a modality-specific `options` key.
|
||||
|
||||
Media payloads are always `Media.Asset` (`src/media.ts`). Construct them with `Media.bytes`, `Media.base64`, `Media.url`, `Media.ref`, `Media.fromDataUrl`, or `Media.file`; never introduce a parallel `data: string | Uint8Array` shape. `MediaPart.media`, `ImageRequest.images`/`mask`, `ImageResponse.images`, and the `media` `LLMEvent` all share it. Protocols branch on `asset.source.type` and `asset.kind` and use `ProviderShared.inlineMedia` / `requireInlineMedia` / `mediaUrl` / `MediaInput.refID` rather than re-deriving base64 or URL handling.
|
||||
Media payloads are always `Media.Asset` (`src/media.ts`). Construct them with `Media.bytes`, `Media.base64`, `Media.url`, `Media.ref`, `Media.fromDataUrl`, or `Media.file`; never introduce a parallel `data: string | Uint8Array` shape. `MediaPart.media`, `ImageRequest.images`/`mask`, `ImageResponse.images`, and the `media` `LLMEvent` all share it. Protocols branch on `asset.source.type` and `asset.kind` and use `ProviderShared.requireInlineMedia` / `inlineRequired` / `mediaUrl` / `mediaReference` and `MediaInput.inlineBytes` / `refID` rather than re-deriving base64 or URL handling.
|
||||
|
||||
`schema/messages.ts → media.ts → route/executor-service.ts` is an accepted runtime dependency from the schema layer on the executor service tag: `Media.Asset.bytes()` must be able to download `url` sources, and the tag lives in that leaf module precisely so the schema barrel never imports the executor implementation (which imports the schema barrel back). Do not move the tag into `route/executor.ts` or import `route/executor.ts` from `src/schema/*` or `src/media.ts`.
|
||||
|
||||
@@ -98,7 +98,7 @@ 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`, 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`. `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.
|
||||
|
||||
@@ -112,7 +112,7 @@ For providers where the URL is derived from typed inputs (Azure resource name, B
|
||||
|
||||
### Provider Facades
|
||||
|
||||
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id. Media models use per-modality selectors on the same facade (`openai.image(id)`, later `.video` / `.speech` / `.transcription`) that mirror `openai.responses(id)`; the one-word overlap with the request namespace is accepted over a second construction path:
|
||||
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id. Media models use per-modality selectors on the same facade (`openai.image(id)`, `.speech(id)`, `.transcription(id)`, `google.video(id)`) that mirror `openai.responses(id)`; the one-word overlap with the request namespace is accepted over a second construction path:
|
||||
|
||||
```ts
|
||||
const openai = OpenAI.configure({ apiKey, baseURL })
|
||||
|
||||
+19
-18
@@ -475,18 +475,18 @@ const program = Effect.gen(function* () {
|
||||
Common fields are portable in shape, not in support. Unsupported fields fail with a typed `AIError` before any network
|
||||
call rather than being dropped, so check this table before swapping only the `model`:
|
||||
|
||||
| Provider | `n` | `size` | `aspectRatio` | `seed` | `format` | `images` | `mask` |
|
||||
| --------------------- | --- | --------- | ------------- | ------ | -------- | ------------------------- | ------------------- |
|
||||
| OpenAI | ✓¹ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ |
|
||||
| Google (Gemini) | 1 | ✗ | ✓ | ✓ | ✗ | ✓ (no public URLs) | ✗ |
|
||||
| xAI | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ |
|
||||
| Z.ai | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
|
||||
| Meta | ✓ | ✓ (hint) | ✗ | ✗ | ✓ | ✓ | ✗ |
|
||||
| Black Forest Labs | 1 | per model | per model | ✓ | ✓ | per model (1–8) | `flux-pro-1.0-fill` |
|
||||
| fal | ✓ | per model | per model | ✓ | ✓ | 1 (several on `/edit`) | ✓ |
|
||||
| Replicate | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ (use `providerOptions`) | ✗ |
|
||||
| Stability `image` | 1 | ✗ | ✓ | ✓ | ✓ | 1 (not on `core`) | ✗ |
|
||||
| Stability `upscale()` | ✗ | ✗ | ✗ | ✓ | ✓ | exactly 1 (required) | ✗ |
|
||||
| Provider | `n` | `size` | `aspectRatio` | `seed` | `format` | `images` | `mask` |
|
||||
| --------------------- | --- | --------- | ------------- | ------ | -------- | -------------------------------- | ------------------- |
|
||||
| OpenAI | ✓¹ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ |
|
||||
| Google (Gemini) | 1 | ✗ | ✓ | ✓ | ✗ | ✓ (no public URLs) | ✗ |
|
||||
| xAI | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ |
|
||||
| Z.ai | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
|
||||
| Meta | ✓ | ✓ (hint) | ✗ | ✗ | ✓ | ✓ | ✗ |
|
||||
| Black Forest Labs | 1 | per model | per model | ✓ | ✓ | per model (1–8) | `flux-pro-1.0-fill` |
|
||||
| fal | ✓ | per model | per model | ✓ | ✓ | 1 (several on `/edit`, `/multi`) | ✓ |
|
||||
| Replicate | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ (use `providerOptions`) | ✗ |
|
||||
| Stability `image` | 1 | ✗ | ✓ | ✓ | ✓ | 1 (not on `core`) | ✗ |
|
||||
| Stability `upscale()` | ✗ | ✗ | ✗ | ✓ | ✓ | exactly 1 (required) | ✗ |
|
||||
|
||||
✓ lowers natively; ✗ fails whenever the field is set (including `n: 1`); `1` means `n > 1` fails. ¹ `Image.stream` on OpenAI generates one image. fal
|
||||
rejects `size` and `aspectRatio` together; which one a fal or BFL model takes depends on the model.
|
||||
@@ -621,8 +621,7 @@ persist the bytes promptly if they must remain available.
|
||||
### Partial images
|
||||
|
||||
OpenAI's GPT image models stream previews. `Image.stream` sends `stream: true` with `partialImages` (0–3, default 2)
|
||||
and emits `image-partial` events before each final `image`; `Image.generate` keeps the plain JSON request.
|
||||
`dall-e-*` models do not stream and fail typed:
|
||||
and emits `image-partial` events before each final `image`; `Image.generate` keeps the plain JSON request:
|
||||
|
||||
```ts
|
||||
import { Stream } from "effect"
|
||||
@@ -699,7 +698,7 @@ const program = Effect.gen(function* () {
|
||||
})
|
||||
```
|
||||
|
||||
The hosted result is represented as a provider-executed tool call and tool result, and the generated image is also emitted as a first-class `media` `LLMEvent` (`response.message` then carries a `media` part). Gemini image-capable models emit the same `media` event for inline image output. Retaining `response.message` preserves the generated image for continuation on both routes.
|
||||
The hosted result is represented as a provider-executed tool call and a tool result whose content carries the generated image as a file. Gemini image-capable models instead emit a first-class `media` `LLMEvent` for inline image output (`response.message` then carries a `media` part). Retaining `response.message` preserves the generated image for continuation on both routes.
|
||||
|
||||
## Video generation
|
||||
|
||||
@@ -840,9 +839,10 @@ Provider notes:
|
||||
- **OpenAI** streams over SSE (`stream_format: "sse"`), which is also the only place it reports token usage; `tts-1`
|
||||
and `tts-1-hd` do not support SSE and stream the raw audio body instead. `pcm` is 24 kHz 16-bit mono. `language`
|
||||
and `timestamps` are not supported.
|
||||
- **Gemini TTS** returns raw 16-bit PCM only (`audio/L16;codec=pcm;rate=24000`), so any `format` other than `pcm`
|
||||
fails typed; wrap the samples yourself. Style is directed in the text, so `instructions` and `speed` fail typed.
|
||||
Only `gemini-3.1-flash-tts-preview` and later support streaming. Two-speaker audio goes through
|
||||
- **Gemini TTS** returns the provider's default output: WAV for Gemini 3.8 TTS `generate`, raw 16-bit PCM
|
||||
(`audio/L16;codec=pcm;rate=24000`) otherwise. `pcm` is the only explicit `format` it accepts, and it fails typed on
|
||||
Gemini 3.8 `generate`; the route never wraps PCM as WAV. Style is directed in the text, so `instructions` and
|
||||
`speed` fail typed. Only `gemini-3.1-flash-tts-preview` and later support streaming. Two-speaker audio goes through
|
||||
`providerOptions.speechConfig.multiSpeakerVoiceConfig`.
|
||||
- **ElevenLabs** requires `voice` (the path voice id) and authenticates with `xi-api-key`. `format` maps to the
|
||||
`output_format` query parameter (`mp3_44100_128`, `pcm_24000`, `wav_24000`, `opus_48000_64`);
|
||||
@@ -944,6 +944,7 @@ const transcript = await generation.await({ poll: { interval: 3_000 } })
|
||||
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
|
||||
- **`Media`** — the shared asset type (`Media.Asset`, `Media.Source`) and constructors used by messages, tool results, and media requests.
|
||||
- **`Generation`** — provider-neutral handle for an in-flight media generation (`await`, `refresh`, `cancel`, `events`) used by queued media routes.
|
||||
- **`Video.request` / `generate` / `stream` / `start` / `resume`** — queued video generation through a provider-neutral request; `VideoClient` is its Effect service and layer.
|
||||
- **`Speech.request` / `Speech.generate` / `Speech.stream`** — text-to-speech through a provider-neutral request; `SpeechClient` is its Effect service and layer.
|
||||
- **`Transcription.request` / `generate` / `stream` / `start` / `resume`** — speech-to-text over inline, streaming, and queued routes; `TranscriptionClient` is its Effect service and layer.
|
||||
- **`AIClient.layer` / `AIClient.layerWith(executor)`** — every modality client plus the request executor in one layer.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Media generation in `@opencode/ai` — public API direction
|
||||
|
||||
Status: phases 1–4 implemented (through Image queued routes and partial images); phase 5 proposal.
|
||||
Status: phases 1–4 implemented (through Image queued routes and partial images; ElevenLabs Scribe transcription
|
||||
pending); phase 5 proposal.
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -40,7 +41,7 @@ The design below is derived from a survey of the raw provider APIs (OpenAI, Gemi
|
||||
|
||||
### Model selection
|
||||
|
||||
A model value is built as `OpenAI.configure({ apiKey }).responses("gpt-5")` or `.image("gpt-image-2")`: `configure` fixes credentials, endpoint, and defaults; the selector fixes which of the provider's APIs to hit and binds the typed `providerOptions` generic. Media follows the same shape with one selector per modality — `openai.image(id)` today, `.video(id)` / `.speech(id)` / `.transcription(id)` as those modalities land — mirroring `openai.responses(id)`. `Image.request` accepts `ImageModel` only, exactly as `LLM.request` accepts `LanguageModel`.
|
||||
A model value is built as `OpenAI.configure({ apiKey }).responses("gpt-5")` or `.image("gpt-image-2")`: `configure` fixes credentials, endpoint, and defaults; the selector fixes which of the provider's APIs to hit and binds the typed `providerOptions` generic. Media follows the same shape with one selector per modality — `.image(id)`, `.video(id)`, `.speech(id)`, `.transcription(id)` on the facades that offer each — mirroring `openai.responses(id)`. `Image.request` accepts `ImageModel` only, exactly as `LLM.request` accepts `LanguageModel`.
|
||||
|
||||
```ts
|
||||
import { OpenAI, Google } from "@opencode/ai/providers"
|
||||
@@ -135,8 +136,8 @@ Effect.gen(function* () {
|
||||
})
|
||||
```
|
||||
|
||||
`size` and `aspectRatio` are not interchangeable; each route rejects fields it cannot lower — see the README's Image
|
||||
portability matrix.
|
||||
`size` and `aspectRatio` are not interchangeable; each route rejects fields it cannot lower — see the portability table
|
||||
in the README's Image generation section.
|
||||
|
||||
Editing is not a separate function; `images`/`mask` on the request select the edit path in the route (OpenAI `/images/edits`, Gemini multimodal parts, xAI `/images/edits`). Routes that cannot honor `mask` fail with `Unsupported`.
|
||||
|
||||
@@ -166,8 +167,8 @@ Effect.gen(function* () {
|
||||
|
||||
// Simple: wait for it.
|
||||
const response = yield* Video.generate(request, { poll: { interval: "10 seconds", timeout: "10 minutes" } })
|
||||
response.video // Media.Asset: url with expiresAt (+ transient `headers` for Veo downloads)
|
||||
response.usage // credits on Runway; the other three report none
|
||||
response.video // Media.Asset: url (expiresAt on Veo and Runway; transient `headers` for Veo downloads)
|
||||
response.usage // credits on Runway; the other three report none (xAI's usage.cost_in_usd_ticks is not decoded)
|
||||
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
|
||||
yield* response.video.materialize() // pull bytes before the URL expires
|
||||
|
||||
@@ -175,7 +176,7 @@ Effect.gen(function* () {
|
||||
const generation = yield* Video.start(request) // Generation<VideoResponse>
|
||||
generation.id; generation.status; generation.progress; generation.position; generation.token
|
||||
yield* generation.await({ poll }) // VideoResponse
|
||||
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; no-op for Veo and xAI
|
||||
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; Veo and xAI succeed without a request
|
||||
|
||||
// Resume from another process. The token is validated against the route's codec and refreshed once. It carries no
|
||||
// route identity, so persist the provider and model ID alongside it: `resume` needs the model.
|
||||
@@ -188,10 +189,11 @@ Effect.gen(function* () {
|
||||
|
||||
Tokens are route-owned JSON: Veo `{ operation }`, xAI `{ requestID }`, Runway `{ taskID }`, fal
|
||||
`{ requestID, statusURL, responseURL, cancelURL }` (fal's follow-up URLs are authoritative and absolute). Common-field
|
||||
lowering per provider: Veo takes inline media only and rejects `audio: false` and `n > 1`; xAI rejects `seed` and
|
||||
`negativePrompt` and routes a `video` input to edits or (`providerOptions.mode: "extend"`) extensions; fal rejects
|
||||
`durationSeconds`, `references`, and `frames.last` because the field names and enums differ per model; Runway passes
|
||||
`aspectRatio` through as its pixel `ratio` and rejects `n`.
|
||||
lowering per provider: Veo takes inline media only, rejects `audio: false` and `n > 1`, and requires `frames.first`
|
||||
when `frames.last` is set; xAI rejects `n`, `seed`, and `negativePrompt` and routes a `video` input to edits or
|
||||
(`providerOptions.mode: "extend"`) extensions; fal rejects `n`, plus `durationSeconds`, `references`, and `frames.last`
|
||||
because the field names and enums differ per model; Runway passes `aspectRatio` through as its pixel `ratio` and
|
||||
rejects `n`.
|
||||
|
||||
Deferred: `Video.complete(model, token, webhook)` (finish from a webhook payload without polling) and provider poll
|
||||
hints (none of the four providers emit one). Later providers: Luma, Kling, MiniMax, Replicate.
|
||||
@@ -241,10 +243,12 @@ name→id resolution. Multi-speaker (Gemini `speechConfig.multiSpeakerVoiceConfi
|
||||
`opus_48000_64`, Cartesia `{ container, encoding, sample_rate }`, Deepgram `encoding`+`container`) and declares the
|
||||
asset's media type rather than sniffing, because headerless PCM can look like an MPEG frame sync. Headerless PCM
|
||||
always carries `info.encoding`, `info.sampleRate`, and `info.channels`; its media type is the provider's declaration
|
||||
(Gemini `audio/L16;codec=pcm;rate=24000`, Deepgram's `content-type`) or `audio/pcm`. Gemini returns PCM only, so any
|
||||
other `format` is rejected rather than wrapped as WAV by the route. Every `format` value a route cannot produce (unknown
|
||||
to it, a container on Cartesia SSE, WAV on an ElevenLabs stream, anything but PCM on Gemini) fails the same way as an
|
||||
unsupported field: `UnsupportedOperation` with `operation: "media.format"`.
|
||||
(Gemini `audio/L16;codec=pcm;rate=24000`, Deepgram's `content-type`) or `audio/pcm`. Gemini's asset follows the
|
||||
provider's declared type: WAV for Gemini 3.8 TTS `generate`, headerless PCM otherwise. The route never wraps PCM as WAV,
|
||||
so `pcm` is the only explicit `format` it accepts, and not on Gemini 3.8 `generate`. Every `format` value a route cannot
|
||||
produce (unknown to it, a container on Cartesia SSE, WAV on an ElevenLabs stream, anything but `pcm` on Gemini, `pcm` on
|
||||
Gemini 3.8 `generate`) fails the same way as an unsupported field: `UnsupportedOperation` with
|
||||
`operation: "media.format"`.
|
||||
|
||||
**Timestamps.** `timestamps: true` on the request asks for alignment. ElevenLabs selects the `with-timestamps`
|
||||
endpoints (character-level, NDJSON when streaming); Cartesia sets `add_timestamps` on `/tts/sse` (word-level; a
|
||||
@@ -277,7 +281,7 @@ const request = Transcription.request({
|
||||
language: "en", // provider-native passthrough
|
||||
timestamps: "segment", // none | segment | word
|
||||
diarize: true,
|
||||
speakers: 2, // expected count, hint only (AssemblyAI)
|
||||
speakers: 2, // exact speaker count (AssemblyAI only)
|
||||
providerOptions: { known_speaker_names: ["agent"] },
|
||||
})
|
||||
|
||||
@@ -291,10 +295,11 @@ yield* Transcription.resume(model, token)
|
||||
Transcription is the first modality whose providers span all three protocol kinds, and it needed no fourth kind.
|
||||
Every `MediaRoute` now carries its `kind`; `TranscriptionRoute` is the union of the inline, stream, and queued routes;
|
||||
`TranscriptionModel.fromRoute` is overloaded per protocol kind (arity picks the overload: `<Options>`,
|
||||
`<Options, Frame, State>`, `<Options, Token>`) and composes through `MediaRoute.inline` / `stream` / `queued`; and
|
||||
`TranscriptionClient` dispatches on `route.kind`. `generate` on a queued route is `start` then `await`; `stream` on an
|
||||
inline route is the response as a single `finish`, and on a queued route it is the status observations followed by
|
||||
`finish`. `start` / `resume` on a non-queued route fail with `UnsupportedOperation` (`transcription.start`). The
|
||||
`<Options, Frame, State>`, `<Options, Token>`) and composes through the shared `composeRoute` (`src/media-model.ts`),
|
||||
which picks `MediaRoute.inline` / `stream` / `queued`; and `TranscriptionClient`, like every modality client, is
|
||||
`MediaClient.make` (`src/media-client.ts`), which dispatches on `route.kind`. `generate` on a queued route is `start`
|
||||
then `await`; `stream` on an inline route is the response as a single `finish`, and on a queued route it is the status
|
||||
observations followed by `finish`. `start` / `resume` on a non-queued route fail with `UnsupportedOperation` (`transcription.start`). The
|
||||
`finish` event carries the whole transcript (text, segments, words, language, duration, usage), so the stream route's
|
||||
`collect` is just "take `finish`".
|
||||
|
||||
@@ -309,11 +314,12 @@ Settled rules:
|
||||
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 a hint; only AssemblyAI (`speakers_expected`) accepts it.
|
||||
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.
|
||||
- **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, `english` from whisper-1, `en_us` from AssemblyAI. Deepgram and AssemblyAI assume English
|
||||
unless asked to detect, so a missing `language` enables their detection.
|
||||
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.
|
||||
- **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.
|
||||
@@ -323,7 +329,7 @@ Settled rules:
|
||||
|
||||
| Provider | Kind | Audio input | `timestamps` | `diarize` | Unsupported | Usage |
|
||||
|---|---|---|---|---|---|---|
|
||||
| OpenAI | stream (`stream: true` in `stream` mode) | multipart `file` (inline only) | `whisper-1` (`verbose_json`); diarize model: `segment` | `gpt-4o-transcribe-diarize` (`diarized_json`) | `speakers`; `prompt` on the diarize model; streaming on `whisper-1` | `tokens` or `seconds` |
|
||||
| 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`) |
|
||||
| AssemblyAI | queued (upload → submit → poll) | `/v2/upload` then `audio_url`, or a URL | words always; `segment` → `speaker_labels` | `speaker_labels` | — | `seconds` (`audio_duration`) |
|
||||
@@ -353,7 +359,7 @@ GenerationAwaitOptions = { poll?: Poll }
|
||||
Poll = { interval?: Duration; timeout?: Duration }
|
||||
```
|
||||
|
||||
`Generation` is not video-specific. Image routes on BFL, fal, and Replicate are queued; `Image.start` exists for them. A route declares itself `inline` or `queued`; `generate` on a queued route is `start` then `await`.
|
||||
`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`.
|
||||
|
||||
### Usage
|
||||
|
||||
@@ -400,14 +406,15 @@ Streams become `AsyncIterable` via `Stream.toAsyncIterable`. `AIError` is thrown
|
||||
|
||||
### Providers
|
||||
|
||||
Existing facades gain per-modality selectors; the modality routes each facade provides:
|
||||
Existing facades gain per-modality selectors; the modality routes each facade provides (*italics* are not
|
||||
implemented):
|
||||
|
||||
| Facade | llm | image | video | speech | transcription | other |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `OpenAI` | responses (default), chat | Images API (stream) | Sora (deprecated 2026-09-24) | ✓ | ✓ | |
|
||||
| `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 | soundEffect, music |
|
||||
| `ElevenLabs` | | | | ✓ | *Scribe (pending)* | *soundEffect, music (phase 5)* |
|
||||
| `Cartesia` | | | | ✓ | | |
|
||||
| `Deepgram` | | | | Aura | ✓ | |
|
||||
| `Fal` | | ✓ (queued) | ✓ | | | |
|
||||
@@ -416,7 +423,7 @@ Existing facades gain per-modality selectors; the modality routes each facade pr
|
||||
| `Replicate` | | ✓ (queued) | | | | |
|
||||
| `Stability` | | `image` (inline), `upscale()` (queued) | | | | |
|
||||
| `Runway` | | | ✓ | | | |
|
||||
| `Luma`, `Kling`, `MiniMax` | | per provider | | | | |
|
||||
| `Luma`, `Kling`, `MiniMax` | | *deferred* | *deferred* | | | |
|
||||
|
||||
New facades follow the existing one-file-per-provider rule. The facade selector is the public path for media models; modality-specific package entrypoints (for example `@opencode/ai/providers/openai/images`) are deferred until Core has a modality-aware model resolver.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { OpenAI } from "@opencode/ai/providers"
|
||||
* hover imports and local values to see how the public API is typed.
|
||||
*/
|
||||
|
||||
const apiKey = Config.redacted("OPENAI_API_KEY")
|
||||
const apiKey = Config.Redacted("OPENAI_API_KEY")
|
||||
|
||||
// 1. Configure a provider. The configured facade records provider identity,
|
||||
// deployment options, authentication, and defaults. Per-modality selectors pick
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -383,7 +383,7 @@ const readEnvFile = Effect.fn("RecordingEnv.readFile")(function* () {
|
||||
})
|
||||
|
||||
const readConfigString = (provider: ConfigProvider.ConfigProvider, name: string) =>
|
||||
Config.string(name)
|
||||
Config.String(name)
|
||||
.parse(provider)
|
||||
.pipe(
|
||||
Effect.match({
|
||||
|
||||
@@ -53,6 +53,8 @@ export type Event = Observation | { readonly type: "generation-finished"; readon
|
||||
|
||||
const TERMINAL: ReadonlySet<Status> = new Set(["completed", "failed", "cancelled", "expired"])
|
||||
|
||||
export const isTerminal = (status: Status) => TERMINAL.has(status)
|
||||
|
||||
export class Generation<Response> {
|
||||
readonly id: string
|
||||
readonly status: Status
|
||||
@@ -81,7 +83,7 @@ export class Generation<Response> {
|
||||
}
|
||||
|
||||
get terminal() {
|
||||
return TERMINAL.has(this.status)
|
||||
return isTerminal(this.status)
|
||||
}
|
||||
|
||||
refresh(): Effect.Effect<Generation<Response>, AIError> {
|
||||
@@ -109,9 +111,10 @@ export class Generation<Response> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Status observations as a stream, ending after the first terminal observation. Each poll is bounded by the time
|
||||
* remaining until `poll.timeout`, so a hung status request fails the stream instead of stalling it. (`Stream.interruptWhen`
|
||||
* would express this directly but deadlocks under `TestClock` when the source completes while the timer sleeps.)
|
||||
* Status observations as a stream, ending after the first terminal observation. Each poll and each sleep between polls
|
||||
* is bounded by the time remaining until `poll.timeout`, so a hung status request or a long interval fails the stream at
|
||||
* the deadline instead of stalling it. (`Stream.interruptWhen` would express this directly but deadlocks under
|
||||
* `TestClock` when the source completes while the timer sleeps.)
|
||||
*/
|
||||
events(options?: AwaitOptions): Stream.Stream<Event, AIError> {
|
||||
if (this.terminal) return Stream.make(this.event())
|
||||
@@ -120,17 +123,26 @@ 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) =>
|
||||
this.refresh().pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.millis(Math.max(0, deadline - now)),
|
||||
orElse: () => this.timeoutError(timeout),
|
||||
}),
|
||||
),
|
||||
now >= deadline
|
||||
? this.timeoutError(timeout)
|
||||
: this.refresh().pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.millis(deadline - now),
|
||||
orElse: () => this.timeoutError(timeout),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return Stream.fromEffectSchedule(refresh, this.schedule(options?.poll)).pipe(
|
||||
const schedule = this.schedule(options?.poll).pipe(
|
||||
Schedule.modifyDelay((meta) =>
|
||||
Effect.succeed(Duration.min(meta.duration, Duration.millis(Math.max(0, deadline - meta.now)))),
|
||||
),
|
||||
)
|
||||
return Stream.fromEffectSchedule(refresh, schedule).pipe(
|
||||
Stream.takeUntil((generation) => generation.terminal),
|
||||
Stream.map((generation) => generation.event()),
|
||||
)
|
||||
|
||||
@@ -110,8 +110,11 @@ const fromRequest = Effect.fn("AssemblyAITranscription.fromRequest")(function* (
|
||||
language_code: request.language,
|
||||
language_detection: request.language === undefined ? true : undefined,
|
||||
prompt: request.prompt,
|
||||
// Turn-level `utterances`, the only segments AssemblyAI returns, require speaker labels.
|
||||
speaker_labels: request.diarize === true || request.timestamps === "segment" ? true : undefined,
|
||||
// Turn-level `utterances`, the only segments AssemblyAI returns, and `speakers_expected` require speaker labels.
|
||||
speaker_labels:
|
||||
request.diarize === true || request.timestamps === "segment" || request.speakers !== undefined
|
||||
? true
|
||||
: undefined,
|
||||
speakers_expected: request.speakers,
|
||||
},
|
||||
request.providerOptions,
|
||||
@@ -155,8 +158,7 @@ const decodeResult = Effect.fn("AssemblyAITranscription.decodeResult")(function*
|
||||
const error = transcript.error ?? undefined
|
||||
if (status === "failed")
|
||||
return yield* output.ended("failed", `${route.name} transcription failed${error === undefined ? "" : `: ${error}`}`)
|
||||
if (status !== "completed")
|
||||
return yield* output.invalid(`${route.name} transcript ${context.token.transcriptID} has not finished`)
|
||||
if (status !== "completed") return yield* output.pending(context.token.transcriptID)
|
||||
const duration = transcript.audio_duration ?? undefined
|
||||
return new TranscriptionResponse({
|
||||
text: transcript.text ?? "",
|
||||
|
||||
@@ -31,13 +31,21 @@ export type Request = ImageRequestFor<BlackForestLabsImageOptions>
|
||||
// 2. Token and response schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Regional clusters answer on different hosts, so the returned `polling_url` is followed verbatim. */
|
||||
export const Token = Schema.Struct({ id: Schema.String, pollingURL: Schema.String })
|
||||
/**
|
||||
* Regional clusters answer on different hosts, so the returned `polling_url` is followed verbatim. BFL reports the
|
||||
* credit cost on submit, so it rides on the token; it is optional so tokens persisted before it existed still decode.
|
||||
*/
|
||||
export const Token = Schema.Struct({
|
||||
id: Schema.String,
|
||||
pollingURL: Schema.String,
|
||||
cost: Schema.optionalKey(Schema.Number),
|
||||
})
|
||||
export type Token = Schema.Schema.Type<typeof Token>
|
||||
|
||||
const StartResponse = Schema.Struct({
|
||||
id: Schema.String,
|
||||
polling_url: Schema.String,
|
||||
cost: optionalNull(Schema.Number),
|
||||
})
|
||||
|
||||
const Result = Schema.Struct({
|
||||
@@ -145,7 +153,11 @@ const fromRequest = Effect.fn("BlackForestLabsImages.fromRequest")(function* (re
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeStart = route.decodeStarted(StartResponse, (value) => ({
|
||||
token: { id: value.id, pollingURL: value.polling_url },
|
||||
token: {
|
||||
id: value.id,
|
||||
pollingURL: value.polling_url,
|
||||
...(value.cost === undefined || value.cost === null ? {} : { cost: value.cost }),
|
||||
},
|
||||
snapshot: { id: value.id, status: "queued" },
|
||||
}))
|
||||
|
||||
@@ -169,14 +181,16 @@ const decodeResult = Effect.fn("BlackForestLabsImages.decodeResult")(function* (
|
||||
if (isModerated(document.status)) return yield* output.contentPolicy(`${route.name} moderated the generation`)
|
||||
if (status === "failed" || status === "expired")
|
||||
return yield* output.ended(status, `${route.name} generation ${context.token.id} ended with ${document.status}`)
|
||||
if (status !== "completed" || document.result === undefined || document.result === null)
|
||||
if (status !== "completed") return yield* output.pending(context.token.id)
|
||||
if (document.result === undefined || document.result === null)
|
||||
return yield* output.invalid(`${route.name} generation ${context.token.id} has no result`)
|
||||
const { sample, seed, prompt, ...rest } = document.result
|
||||
// A settled `cost` on the result supersedes the submit-time cost carried on the token.
|
||||
const cost = document.cost ?? context.token.cost
|
||||
return new ImageResponse({
|
||||
// `sample` is a signed URL that expires 10 minutes after the result is ready, so it is downloaded now.
|
||||
images: [yield* context.materialize(Media.url(sample))],
|
||||
usage:
|
||||
document.cost === undefined || document.cost === null ? undefined : { type: "credits", credits: document.cost },
|
||||
usage: cost === undefined ? undefined : { type: "credits", credits: cost },
|
||||
providerMetadata: {
|
||||
bfl: { id: context.token.id, seed: seed ?? undefined, prompt: prompt ?? undefined, ...rest },
|
||||
},
|
||||
|
||||
@@ -67,6 +67,9 @@ const queryParameters = (request: Request) => {
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("DeepgramSpeech.fromRequest")(function* (request: Request) {
|
||||
// Not in `unsupported`: that list would also reject `timestamps: false`, which asks for nothing.
|
||||
if (request.timestamps === true)
|
||||
return yield* route.unsupported("media.timestamps", `${route.name} does not return timestamps`)
|
||||
if (
|
||||
request.format !== undefined &&
|
||||
FORMATS[request.format] === undefined &&
|
||||
@@ -86,24 +89,32 @@ const fromRequest = Effect.fn("DeepgramSpeech.fromRequest")(function* (request:
|
||||
// 6. Stream parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HEADERLESS_ENCODINGS: Readonly<Record<string, SpeechStream.PcmEncoding>> = {
|
||||
linear16: "pcm_s16le",
|
||||
mulaw: "pcm_mulaw",
|
||||
alaw: "pcm_alaw",
|
||||
/** Deepgram wraps raw encodings in WAV unless `container` is `none`, and defaults their sample rate per encoding. */
|
||||
const HEADERLESS_ENCODINGS: Readonly<
|
||||
Record<string, { readonly encoding: SpeechStream.PcmEncoding; readonly sampleRate: number }>
|
||||
> = {
|
||||
linear16: { encoding: "pcm_s16le", sampleRate: 24000 },
|
||||
mulaw: { encoding: "pcm_mulaw", sampleRate: 8000 },
|
||||
alaw: { encoding: "pcm_alaw", sampleRate: 8000 },
|
||||
}
|
||||
|
||||
const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) => {
|
||||
const headers = context.http.headers
|
||||
const mediaType = headers["content-type"]
|
||||
const format = audioFormat(context.request)
|
||||
const encoding = HEADERLESS_ENCODINGS[format.encoding ?? ""]
|
||||
const headerless = HEADERLESS_ENCODINGS[format.encoding ?? ""]
|
||||
const container = format.container ?? (headerless === undefined ? undefined : "wav")
|
||||
const requestID = headers["dg-request-id"]
|
||||
const modelName = headers["dg-model-name"]
|
||||
return SpeechStream.finish(route, state, {
|
||||
...(format.container === "none" && encoding !== undefined
|
||||
? SpeechStream.pcm(encoding, SpeechStream.sampleRate(mediaType), mediaType)
|
||||
...(container === "none" && headerless !== undefined
|
||||
? SpeechStream.pcm(
|
||||
headerless.encoding,
|
||||
SpeechStream.sampleRate(mediaType) ?? context.request.providerOptions?.sampleRate ?? headerless.sampleRate,
|
||||
mediaType,
|
||||
)
|
||||
: // Deepgram's default encoding is MP3; WAV is a container around any encoding.
|
||||
{ mediaType, info: { format: format.container === "wav" ? "wav" : (format.encoding ?? "mp3") } }),
|
||||
{ mediaType, info: { format: container === "wav" ? "wav" : (format.encoding ?? "mp3") } }),
|
||||
usage: SpeechStream.headerUsage("characters", headers["dg-char-count"]),
|
||||
providerMetadata:
|
||||
requestID === undefined && modelName === undefined
|
||||
@@ -117,7 +128,7 @@ const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) =
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.stream<Request, SpeechEvent, Uint8Array, State>(route, {
|
||||
unsupported: ["voice", "language", "instructions", "timestamps"],
|
||||
unsupported: ["voice", "language", "instructions"],
|
||||
body: { from: fromRequest },
|
||||
frames: (bytes) => bytes,
|
||||
initial: () => ({ chunks: [] }),
|
||||
|
||||
@@ -49,7 +49,7 @@ const QueueResult = Schema.StructWithRest(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sizing = (model: string) => {
|
||||
if (/^fal-ai\/(nano-banana|flux-pro\/v1\.1-ultra)/.test(model)) return "aspect_ratio"
|
||||
if (/^fal-ai\/(nano-banana|flux-pro\/(v1\.1-ultra|kontext))/.test(model)) return "aspect_ratio"
|
||||
if (model.startsWith("fal-ai/flux")) return "image_size"
|
||||
return undefined
|
||||
}
|
||||
@@ -63,20 +63,24 @@ const validate = (request: Request) => {
|
||||
return Effect.fail(route.unsupported("media.size", `${id} sizes by aspectRatio`))
|
||||
if (request.aspectRatio !== undefined && field === "image_size")
|
||||
return Effect.fail(route.unsupported("media.aspectRatio", `${id} sizes by size (image_size)`))
|
||||
if ((request.images?.length ?? 0) > 1 && !isEdit(id))
|
||||
if ((request.images?.length ?? 0) > 1 && !takesImageList(id))
|
||||
return Effect.fail(
|
||||
route.unsupported("media.images", `${id} takes one image_url; use an /edit endpoint for several images`),
|
||||
route.unsupported(
|
||||
"media.images",
|
||||
`${id} takes one image_url; use an /edit or /multi endpoint for several images`,
|
||||
),
|
||||
)
|
||||
return Effect.void
|
||||
}
|
||||
|
||||
// `/edit` endpoints take an `image_urls` list; image-to-image, fill, and Ultra take one `image_url` (beside `mask_url`).
|
||||
const isEdit = (model: string) => model.endsWith("/edit")
|
||||
// `/edit` and `/multi` (Kontext) endpoints take an `image_urls` list; image-to-image, fill, and Ultra take one
|
||||
// `image_url` (beside `mask_url`).
|
||||
const takesImageList = (model: string) => model.endsWith("/edit") || model.endsWith("/multi")
|
||||
|
||||
const fromRequest = Effect.fn("FalImages.fromRequest")(function* (request: Request) {
|
||||
yield* validate(request)
|
||||
const images = yield* Effect.forEach(request.images ?? [], (image) => FalQueue.mediaUrl(image, route.name))
|
||||
const edit = isEdit(request.model.id)
|
||||
const list = takesImageList(request.model.id)
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
@@ -86,8 +90,8 @@ const fromRequest = Effect.fn("FalImages.fromRequest")(function* (request: Reque
|
||||
image_size: request.size === undefined ? undefined : MediaInput.dimensions(request.size),
|
||||
aspect_ratio: request.aspectRatio,
|
||||
output_format: request.format,
|
||||
image_urls: edit && images.length > 0 ? images : undefined,
|
||||
image_url: edit ? undefined : images[0],
|
||||
image_urls: list && images.length > 0 ? images : undefined,
|
||||
image_url: list ? undefined : images[0],
|
||||
mask_url: request.mask === undefined ? undefined : yield* FalQueue.mediaUrl(request.mask, route.name),
|
||||
},
|
||||
request.providerOptions,
|
||||
@@ -112,12 +116,14 @@ const decodeResult = Effect.fn("FalImages.decodeResult")(function* (
|
||||
// With the safety checker on, flagged images come back blacked out rather than omitted.
|
||||
const flagged = (has_nsfw_concepts ?? []).flatMap((value, index) => (value ? [index] : []))
|
||||
return new ImageResponse({
|
||||
images: images.map((image) =>
|
||||
Media.url(image.url, {
|
||||
mediaType: image.content_type ?? undefined,
|
||||
info: { width: image.width ?? undefined, height: image.height ?? undefined },
|
||||
}),
|
||||
),
|
||||
images: images.map((image) => {
|
||||
const info = { width: image.width ?? undefined, height: image.height ?? undefined }
|
||||
// `sync_mode: true` returns data URIs instead of hosted URLs.
|
||||
return (
|
||||
Media.parseDataUrl(image.url, { info }) ??
|
||||
Media.url(image.url, { mediaType: image.content_type ?? undefined, info })
|
||||
)
|
||||
}),
|
||||
notices:
|
||||
flagged.length === 0
|
||||
? undefined
|
||||
|
||||
@@ -526,19 +526,7 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
||||
if (finishReason === undefined) return hasToolCalls ? "tool-calls" : "unknown"
|
||||
if (finishReason === "STOP") return hasToolCalls ? "tool-calls" : "stop"
|
||||
if (finishReason === "MAX_TOKENS") return "length"
|
||||
if (
|
||||
finishReason === "IMAGE_SAFETY" ||
|
||||
finishReason === "RECITATION" ||
|
||||
finishReason === "SAFETY" ||
|
||||
finishReason === "BLOCKLIST" ||
|
||||
finishReason === "PROHIBITED_CONTENT" ||
|
||||
finishReason === "SPII" ||
|
||||
finishReason === "MODEL_ARMOR" ||
|
||||
finishReason === "IMAGE_PROHIBITED_CONTENT" ||
|
||||
finishReason === "IMAGE_RECITATION" ||
|
||||
finishReason === "LANGUAGE"
|
||||
)
|
||||
return "content-filter"
|
||||
if (GeminiGenerateContent.contentFiltered(finishReason)) return "content-filter"
|
||||
if (
|
||||
finishReason === "MALFORMED_FUNCTION_CALL" ||
|
||||
finishReason === "UNEXPECTED_TOOL_CALL" ||
|
||||
|
||||
@@ -101,7 +101,7 @@ const generationConfig = (request: Request) => {
|
||||
const fromRequest = Effect.fn("GoogleImages.fromRequest")(function* (request: Request) {
|
||||
if (request.n !== undefined && request.n > 1)
|
||||
return yield* route.unsupported(
|
||||
"image.n",
|
||||
"media.n",
|
||||
`${route.name} generates one image per request; call it once per image instead of n=${request.n}`,
|
||||
)
|
||||
const parts = yield* Effect.forEach(request.images ?? [], (image) =>
|
||||
|
||||
@@ -56,6 +56,9 @@ interface State extends SpeechStream.Audio, GeminiGenerateContent.Metadata {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fromRequest = Effect.fn("GoogleSpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
// Not in `unsupported`: that list would also reject `timestamps: false`, which asks for nothing.
|
||||
if (request.timestamps === true)
|
||||
return yield* route.unsupported("media.timestamps", `${route.name} does not return timestamps`)
|
||||
if (request.format === "pcm" && request.mode === "generate" && /^gemini-3\.8-.*-tts(?:-|$)/.test(request.model.id))
|
||||
return yield* route.unsupported(
|
||||
"media.format",
|
||||
@@ -99,10 +102,14 @@ const step = Effect.fn("GoogleSpeech.step")(function* (state: State, frame: stri
|
||||
part.inlineData === undefined ? [] : [part.inlineData],
|
||||
)
|
||||
const next: State = { ...GeminiGenerateContent.track(state, chunk), mimeType: state.mimeType ?? audio[0]?.mimeType }
|
||||
return [next, audio.flatMap((part) => SpeechStream.delta(next, part.data)[1])] as const
|
||||
const events = audio.flatMap((part) => SpeechStream.delta(next, part.data)[1])
|
||||
const withheld = next.chunks.length === 0 ? GeminiGenerateContent.withheld(route.name, chunk, frame) : undefined
|
||||
if (withheld !== undefined) return yield* withheld
|
||||
return [next, events] as const
|
||||
})
|
||||
|
||||
const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) => {
|
||||
if (state.finishReason === undefined) return Effect.fail(route.incomplete())
|
||||
const sampleRate = SpeechStream.sampleRate(state.mimeType) ?? DEFAULT_SAMPLE_RATE
|
||||
const output =
|
||||
state.mimeType?.split(";")[0]?.toLowerCase() === "audio/wav"
|
||||
@@ -115,8 +122,9 @@ const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) =
|
||||
return SpeechStream.finish(route, state, {
|
||||
...output,
|
||||
usage: GeminiGenerateContent.usage(state.usage),
|
||||
notices: GeminiGenerateContent.notices(route.name, state),
|
||||
providerMetadata: GeminiGenerateContent.providerMetadata(state),
|
||||
detail: state.finishReason === undefined ? undefined : `finish reason: ${state.finishReason}`,
|
||||
detail: `finish reason: ${state.finishReason}`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -125,7 +133,7 @@ const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) =
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string, State>(route, {
|
||||
unsupported: ["instructions", "speed", "timestamps"],
|
||||
unsupported: ["instructions", "speed"],
|
||||
body: { from: fromRequest },
|
||||
frames: (bytes, context) => GeminiGenerateContent.frames(bytes, context.request.mode),
|
||||
initial: () => ({ chunks: [] }),
|
||||
|
||||
@@ -154,6 +154,9 @@ const step = Effect.fn("GoogleTranscription.step")(function* (state: State, fram
|
||||
.filter((item) => item.length > 0)
|
||||
.join(" ")
|
||||
const delta = text.length === 0 || state.text.length === 0 ? text : ` ${text}`
|
||||
const withheld =
|
||||
state.text.length + delta.length === 0 ? GeminiGenerateContent.withheld(route.name, chunk, frame) : undefined
|
||||
if (withheld !== undefined) return yield* withheld
|
||||
const events: ReadonlyArray<TranscriptionEvent> = [
|
||||
...(delta.length === 0 ? [] : [TranscriptionTextDeltaEvent.make({ delta })]),
|
||||
...segments.map((segment) => TranscriptionSegmentEvent.make({ segment })),
|
||||
@@ -169,6 +172,7 @@ const finish = (state: State) => {
|
||||
segments: state.segments.length === 0 ? undefined : state.segments,
|
||||
words: state.words.length === 0 ? undefined : state.words,
|
||||
usage: GeminiGenerateContent.usage(state.usage),
|
||||
notices: GeminiGenerateContent.notices(route.name, state),
|
||||
providerMetadata: GeminiGenerateContent.providerMetadata(state),
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -149,8 +149,7 @@ const decodeResult = Effect.fn("GoogleVideo.decodeResult")(function* (
|
||||
const output = yield* decodeOperation(response)
|
||||
const operation = output.value
|
||||
const status = statusOf(operation)
|
||||
if (status === "running")
|
||||
return yield* output.invalid(`${route.name} operation ${context.token.operation} has not finished`)
|
||||
if (status === "running") return yield* output.pending(context.token.operation)
|
||||
if (status === "failed")
|
||||
return yield* output.ended(
|
||||
"failed",
|
||||
|
||||
@@ -48,15 +48,17 @@ const Usage = Schema.Struct({
|
||||
output_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
})
|
||||
|
||||
const OpenAIImageResponse = Schema.Struct({
|
||||
data: Schema.Array(
|
||||
Schema.Struct({
|
||||
b64_json: Schema.optional(Schema.String),
|
||||
url: Schema.optional(Schema.String),
|
||||
revised_prompt: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
/** What the provider actually rendered; it can differ from the request when `auto` or a default applied. */
|
||||
const Settings = {
|
||||
output_format: Schema.optional(Schema.String),
|
||||
size: Schema.optional(Schema.String),
|
||||
quality: Schema.optional(Schema.String),
|
||||
background: Schema.optional(Schema.String),
|
||||
}
|
||||
|
||||
const OpenAIImageResponse = Schema.Struct({
|
||||
data: Schema.Array(Schema.Struct({ b64_json: Schema.String })),
|
||||
...Settings,
|
||||
usage: Schema.optional(Usage),
|
||||
})
|
||||
|
||||
@@ -69,11 +71,13 @@ const StreamEvent = Schema.Union([
|
||||
type: Schema.Literals(["image_generation.partial_image", "image_edit.partial_image"]),
|
||||
b64_json: Schema.String,
|
||||
partial_image_index: Schema.Number,
|
||||
...Settings,
|
||||
output_format: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literals(["image_generation.completed", "image_edit.completed"]),
|
||||
b64_json: Schema.String,
|
||||
...Settings,
|
||||
output_format: Schema.String,
|
||||
usage: Schema.optional(Usage),
|
||||
}),
|
||||
@@ -92,6 +96,9 @@ type Frame = string | { readonly document: string; readonly requested: string |
|
||||
interface State {
|
||||
readonly completed: number
|
||||
readonly format?: string
|
||||
readonly size?: string
|
||||
readonly quality?: string
|
||||
readonly background?: string
|
||||
readonly usage?: MediaUsage
|
||||
}
|
||||
|
||||
@@ -110,10 +117,6 @@ const nativeOptions = (options: OpenAIImageOptions | undefined) => {
|
||||
|
||||
const streamOptions = (request: MediaProtocol.Addressed<Request>) => {
|
||||
if (request.mode !== "stream") return Effect.succeed(undefined)
|
||||
if (request.model.id.startsWith("dall-e"))
|
||||
return Effect.fail(
|
||||
route.unsupported("media.stream", `${request.model.id} does not stream; use Image.generate or a GPT image model`),
|
||||
)
|
||||
if (request.n !== undefined && request.n > 1)
|
||||
return Effect.fail(
|
||||
route.unsupported("media.n", `${route.name} streams one image; use Image.generate for n=${request.n}`),
|
||||
@@ -194,21 +197,34 @@ const usage = (value: Schema.Schema.Type<typeof Usage> | undefined): MediaUsage
|
||||
details: { openai: value },
|
||||
}
|
||||
|
||||
const eventImage = (frame: string, label: string, data: string, format: string) =>
|
||||
/** `size` echoes the rendered `WIDTHxHEIGHT`; `auto` or any other value leaves the dimensions unknown. */
|
||||
const info = (format: string, size: string | undefined): Media.Info => {
|
||||
const match = size?.match(/^(\d+)x(\d+)$/)
|
||||
return match ? { format, width: Number(match[1]), height: Number(match[2]) } : { format }
|
||||
}
|
||||
|
||||
const eventImage = (frame: string, label: string, data: string, format: string, size: string | undefined) =>
|
||||
MediaInput.decodedAsset((message, cause) => route.frameError(message, frame, cause), label, data, `image/${format}`, {
|
||||
info: { format },
|
||||
info: info(format, size),
|
||||
})
|
||||
|
||||
const onEvent = Effect.fn("OpenAIImages.onEvent")(function* (state: State, frame: string) {
|
||||
const event = yield* decodeEvent(frame)
|
||||
const format = event.output_format
|
||||
if ("partial_image_index" in event) {
|
||||
const image = yield* eventImage(frame, `${route.name} partial image`, event.b64_json, format)
|
||||
const image = yield* eventImage(frame, `${route.name} partial image`, event.b64_json, format, event.size)
|
||||
return [state, [ImagePartialEvent.make({ index: event.partial_image_index, image })]] as const
|
||||
}
|
||||
const image = yield* eventImage(frame, `${route.name} result ${state.completed}`, event.b64_json, format)
|
||||
const image = yield* eventImage(frame, `${route.name} result ${state.completed}`, event.b64_json, format, event.size)
|
||||
return [
|
||||
{ ...state, completed: state.completed + 1, format, usage: usage(event.usage) },
|
||||
{
|
||||
completed: state.completed + 1,
|
||||
format,
|
||||
size: event.size,
|
||||
quality: event.quality,
|
||||
background: event.background,
|
||||
usage: usage(event.usage),
|
||||
},
|
||||
[ImageOutputEvent.make({ index: state.completed, image })],
|
||||
] as const
|
||||
})
|
||||
@@ -219,16 +235,20 @@ const onDocument = Effect.fn("OpenAIImages.onDocument")(function* (frame: Exclud
|
||||
Effect.mapError((cause) => invalid(`${route.name} returned an invalid response`, cause)),
|
||||
)
|
||||
const format = decoded.output_format ?? frame.requested ?? "png"
|
||||
const mediaType = `image/${format}`
|
||||
const images = yield* Effect.forEach(decoded.data, (item, index) =>
|
||||
MediaInput.imageOutput(invalid, `${route.name} result ${index}`, item, mediaType, {
|
||||
info: { format },
|
||||
providerMetadata:
|
||||
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||
MediaInput.decodedAsset(invalid, `${route.name} result ${index}`, item.b64_json, `image/${format}`, {
|
||||
info: info(format, decoded.size),
|
||||
}),
|
||||
)
|
||||
if (images.length === 0) return yield* invalid(`${route.name} returned no images`)
|
||||
const state: State = { completed: images.length, format, usage: usage(decoded.usage) }
|
||||
const state: State = {
|
||||
completed: images.length,
|
||||
format,
|
||||
size: decoded.size,
|
||||
quality: decoded.quality,
|
||||
background: decoded.background,
|
||||
usage: usage(decoded.usage),
|
||||
}
|
||||
return [state, images.map((image, index) => ImageOutputEvent.make({ index, image }))] as const
|
||||
})
|
||||
|
||||
@@ -237,7 +257,17 @@ const step = (state: State, frame: Frame) => (typeof frame === "string" ? onEven
|
||||
const finish = (state: State) => {
|
||||
if (state.completed === 0) return Effect.fail(route.incomplete())
|
||||
return Effect.succeed([
|
||||
ImageFinishEvent.make({ usage: state.usage, providerMetadata: { openai: { outputFormat: state.format } } }),
|
||||
ImageFinishEvent.make({
|
||||
usage: state.usage,
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
outputFormat: state.format,
|
||||
size: state.size,
|
||||
quality: state.quality,
|
||||
background: state.background,
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -143,12 +143,14 @@ const adapter = {
|
||||
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
|
||||
// Only GPT-6 Astra accepts `configuration_update`, and never alongside automatic `context_management` compaction.
|
||||
// GPT-6 Astra, Sol, and Luna accept `configuration_update` only in standard mode (not `reasoning.mode: "pro"` or
|
||||
// `-pro` slugs), and never alongside automatic `context_management` compaction.
|
||||
const supportsEffortUpdates = (request: LLMRequest) => {
|
||||
if (request.providerOptions?.contextManagement !== undefined) return false
|
||||
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(request.http?.body?.reasoning)) return false
|
||||
const override = request.model.compatibility?.supportsEffortUpdates
|
||||
if (override !== undefined) return override
|
||||
return /(?:^|\/)gpt-6-astra$/i.test(request.model.id)
|
||||
return /(?:^|\/)gpt-6-(?:astra|sol|luna)$/i.test(request.model.id)
|
||||
}
|
||||
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
|
||||
@@ -60,7 +60,17 @@ interface State extends SpeechStream.Audio {
|
||||
// `sse` is not supported for `tts-1` or `tts-1-hd`; those models stream the raw audio body instead.
|
||||
const supportsSse = (model: string) => !/^tts-1(-hd)?(-|$)/.test(model)
|
||||
|
||||
const FORMATS = new Set(["mp3", "opus", "aac", "flac", "wav", "pcm"])
|
||||
|
||||
const fromRequest = Effect.fn("OpenAISpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
// Not in `unsupported`: that list would also reject `timestamps: false`, which asks for nothing.
|
||||
if (request.timestamps === true)
|
||||
return yield* route.unsupported("media.timestamps", `${route.name} does not return timestamps`)
|
||||
if (request.format !== undefined && !FORMATS.has(request.format))
|
||||
return yield* route.unsupported(
|
||||
"media.format",
|
||||
`${route.name} supports the mp3, opus, aac, flac, wav, and pcm formats, not "${request.format}"`,
|
||||
)
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
@@ -109,7 +119,9 @@ const onEvent = Effect.fn("OpenAISpeech.onEvent")(function* (state: State, frame
|
||||
|
||||
const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) => {
|
||||
if (isSse(context.body) && !state.done) return Effect.fail(route.incomplete())
|
||||
const format = context.request.format ?? "mp3"
|
||||
// The sent body reflects `providerOptions` and `http.body` overrides of `format`.
|
||||
const sent = context.body.type === "json" ? context.body.value.response_format : undefined
|
||||
const format = typeof sent === "string" ? sent : "mp3"
|
||||
return SpeechStream.finish(route, state, {
|
||||
...(format === "pcm" ? SpeechStream.pcm("pcm_s16le", PCM_SAMPLE_RATE) : SpeechStream.container(format)),
|
||||
usage: state.usage,
|
||||
@@ -121,7 +133,7 @@ const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) =
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string | Uint8Array, State>(route, {
|
||||
unsupported: ["language", "timestamps"],
|
||||
unsupported: ["language"],
|
||||
body: { from: fromRequest },
|
||||
frames: (bytes, context) => (isSse(context.body) ? Framing.sse.frame(bytes) : bytes),
|
||||
initial: () => ({ chunks: [], done: false }),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { mergeJsonRecords, type MediaUsage } from "../schema/index.js"
|
||||
import { AIError, mergeJsonRecords, type MediaUsage } from "../schema/index.js"
|
||||
import {
|
||||
TranscriptionFinishEvent,
|
||||
TranscriptionModel,
|
||||
@@ -59,6 +60,9 @@ const Usage = Schema.Union([
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
total_tokens: Schema.optional(Schema.Number),
|
||||
input_token_details: Schema.optional(
|
||||
Schema.Struct({ audio_tokens: Schema.optional(Schema.Number), text_tokens: Schema.optional(Schema.Number) }),
|
||||
),
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("duration"), seconds: Schema.Number }),
|
||||
])
|
||||
@@ -75,14 +79,23 @@ const transcriptFields = {
|
||||
usage: Schema.optional(Usage),
|
||||
}
|
||||
|
||||
/** OpenAI may add stream event types; frames outside `EVENT_TYPES` are ignored. */
|
||||
const EventType = Schema.Struct({ type: Schema.String })
|
||||
const Event = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("transcript.text.delta"), delta: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("transcript.text.segment"), ...Segment.fields }),
|
||||
Schema.Struct({ type: Schema.Literal("transcript.text.done"), ...transcriptFields }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("error"),
|
||||
message: Schema.optional(Schema.String),
|
||||
error: Schema.optional(Schema.Struct({ message: Schema.optional(Schema.String) })),
|
||||
}),
|
||||
])
|
||||
const EVENT_TYPES = new Set(["transcript.text.delta", "transcript.text.segment", "transcript.text.done", "error"])
|
||||
const Transcript = Schema.Struct(transcriptFields)
|
||||
type Transcript = Schema.Schema.Type<typeof Transcript>
|
||||
|
||||
const decodeEventType = route.decodeFrame(EventType)
|
||||
const decodeEvent = route.decodeFrame(Event)
|
||||
const decodeTranscript = route.decodeFrame(Transcript)
|
||||
|
||||
@@ -118,10 +131,12 @@ const capabilities = (model: string): Capabilities => {
|
||||
return TRANSCRIBE
|
||||
}
|
||||
|
||||
/** whisper-1 ignores `stream`, so its `stream` mode sends a plain request and emits only `finish`. */
|
||||
const streamsEvents = (request: MediaProtocol.Addressed<Request>) =>
|
||||
request.mode === "stream" && capabilities(request.model.id).stream
|
||||
|
||||
const validate = (request: MediaProtocol.Addressed<Request>, model: Capabilities) => {
|
||||
const id = request.model.id
|
||||
if (request.mode === "stream" && !model.stream)
|
||||
return Effect.fail(route.unsupported("media.stream", `${id} does not stream; use Transcription.generate`))
|
||||
if (request.diarize === true && !model.diarize)
|
||||
return Effect.fail(route.unsupported("media.diarize", `${id} does not diarize; use gpt-4o-transcribe-diarize`))
|
||||
if (request.prompt !== undefined && model.diarize)
|
||||
@@ -173,7 +188,7 @@ const fromRequest = Effect.fn("OpenAITranscription.fromRequest")(function* (requ
|
||||
timestamp_granularities: responseFormat === "verbose_json" ? [request.timestamps] : undefined,
|
||||
// Diarizing audio longer than 30 seconds requires a chunking strategy.
|
||||
chunking_strategy: model.diarize ? "auto" : undefined,
|
||||
stream: request.mode === "stream" ? true : undefined,
|
||||
stream: streamsEvents(request) ? true : undefined,
|
||||
},
|
||||
{
|
||||
overlay: mergeJsonRecords(request.providerOptions, request.http?.body),
|
||||
@@ -196,7 +211,15 @@ const segment = (value: Schema.Schema.Type<typeof Segment>): TranscriptionSegmen
|
||||
})
|
||||
|
||||
const onEvent = Effect.fn("OpenAITranscription.onEvent")(function* (state: State, frame: string) {
|
||||
if (!EVENT_TYPES.has((yield* decodeEventType(frame)).type)) return [state, []] as const
|
||||
const event = yield* decodeEvent(frame)
|
||||
if (event.type === "error")
|
||||
return yield* new AIError({
|
||||
reason: classifyProviderFailure({
|
||||
message: `${route.name} stream failed: ${event.message ?? event.error?.message ?? "unknown error"}`,
|
||||
rawBody: frame,
|
||||
}),
|
||||
})
|
||||
if (event.type === "transcript.text.done") return [{ ...state, transcript: event }, []] as const
|
||||
if (event.type === "transcript.text.delta")
|
||||
return [state, event.delta.length === 0 ? [] : [TranscriptionTextDeltaEvent.make({ delta: event.delta })]] as const
|
||||
@@ -246,7 +269,7 @@ export const protocol = MediaProtocol.stream<Request, TranscriptionEvent, Frame,
|
||||
unsupported: ["speakers"],
|
||||
body: { from: fromRequest },
|
||||
frames: (bytes, context) =>
|
||||
context.request.mode === "stream"
|
||||
streamsEvents(context.request)
|
||||
? Framing.sse.frame(bytes)
|
||||
: Framing.document.frame(bytes).pipe(Stream.map((document) => ({ document }))),
|
||||
initial: () => ({ segments: [] }),
|
||||
|
||||
@@ -132,8 +132,7 @@ const decodeResult = Effect.fn("ReplicateImages.decodeResult")(function* (
|
||||
status,
|
||||
`${route.name} prediction ${context.token.id} ${prediction.status}${typeof prediction.error === "string" ? `: ${prediction.error}` : ""}`,
|
||||
)
|
||||
if (status !== "completed")
|
||||
return yield* output.invalid(`${route.name} prediction ${context.token.id} has not finished`)
|
||||
if (status !== "completed") return yield* output.pending(context.token.id)
|
||||
if (prediction.data_removed === true)
|
||||
return yield* output.ended("expired", `${route.name} removed the output of prediction ${context.token.id}`)
|
||||
if (!isOutput(prediction.output))
|
||||
|
||||
@@ -141,8 +141,7 @@ const decodeResult = Effect.fn("RunwayVideo.decodeResult")(function* (
|
||||
}
|
||||
if (status === "cancelled")
|
||||
return yield* output.ended("cancelled", `${route.name} task ${context.token.taskID} was cancelled`)
|
||||
if (status !== "completed")
|
||||
return yield* output.invalid(`${route.name} task ${context.token.taskID} has not finished`)
|
||||
if (status !== "completed") return yield* output.pending(context.token.taskID)
|
||||
const urls = task.output ?? []
|
||||
if (urls.length === 0) return yield* output.invalid(`${route.name} task succeeded without any output`)
|
||||
return new VideoResponse({
|
||||
@@ -171,7 +170,7 @@ export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>(rout
|
||||
start: { body: { from: fromRequest }, decode: decodeStart },
|
||||
status: { path: taskPath, decode: decodeStatus },
|
||||
result: { path: taskPath, decode: decodeResult },
|
||||
cancel: { method: "DELETE", path: taskPath },
|
||||
cancel: { method: "DELETE", path: taskPath, activeOnly: true },
|
||||
})
|
||||
|
||||
const startPath = (request: Request) => {
|
||||
|
||||
@@ -175,7 +175,7 @@ const decodeUpscaleResult = Effect.fn("StabilityImages.decodeUpscaleResult")(fun
|
||||
) {
|
||||
if (response.status === 202) {
|
||||
const output = yield* upscaleRoute.text(response)
|
||||
return yield* output.invalid(`${upscaleRoute.name} upscale ${context.token.id} has not finished`)
|
||||
return yield* output.pending(context.token.id)
|
||||
}
|
||||
return yield* decodeUpscaleImage(response)
|
||||
})
|
||||
|
||||
@@ -72,6 +72,44 @@ export const blocked = (name: string, chunk: Chunk, frame: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
const CONTENT_FILTER_REASONS = new Set([
|
||||
"IMAGE_SAFETY",
|
||||
"RECITATION",
|
||||
"SAFETY",
|
||||
"BLOCKLIST",
|
||||
"PROHIBITED_CONTENT",
|
||||
"SPII",
|
||||
"MODEL_ARMOR",
|
||||
"IMAGE_PROHIBITED_CONTENT",
|
||||
"IMAGE_RECITATION",
|
||||
"LANGUAGE",
|
||||
])
|
||||
|
||||
/** Finish reasons for which Gemini stops output on safety or policy grounds. */
|
||||
export const contentFiltered = (finishReason: string | undefined) =>
|
||||
finishReason !== undefined && CONTENT_FILTER_REASONS.has(finishReason)
|
||||
|
||||
/** Callers check that the response produced no output: a policy stop after output is a partial result instead. */
|
||||
export const withheld = (name: string, chunk: Chunk, frame: string) => {
|
||||
const finishReason = chunk.candidates?.[0]?.finishReason
|
||||
if (!contentFiltered(finishReason)) return undefined
|
||||
return new AIError({
|
||||
reason: new ContentPolicyError({ message: `${name} withheld its output (${finishReason})`, body: frame }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Any finish reason other than `STOP` means the output may be cut short, so it is surfaced rather than dropped. */
|
||||
export const notices = (name: string, state: Metadata): ReadonlyArray<Media.Notice> | undefined =>
|
||||
state.finishReason === undefined || state.finishReason === "STOP"
|
||||
? undefined
|
||||
: [
|
||||
{
|
||||
type: contentFiltered(state.finishReason) ? "filtered" : "other",
|
||||
message: `${name} finished with ${state.finishReason}`,
|
||||
providerMetadata: { google: { finishReason: state.finishReason } },
|
||||
},
|
||||
]
|
||||
|
||||
export const usage = (usage: UsageMetadata | undefined): MediaUsage | undefined =>
|
||||
usage === undefined
|
||||
? undefined
|
||||
|
||||
@@ -85,6 +85,7 @@ export const finish = (
|
||||
readonly mediaType: string | undefined
|
||||
readonly info?: Media.Info
|
||||
readonly usage?: MediaUsage
|
||||
readonly notices?: ReadonlyArray<Media.Notice>
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
readonly detail?: string
|
||||
},
|
||||
@@ -97,6 +98,7 @@ export const finish = (
|
||||
SpeechFinishEvent.make({
|
||||
audio: Media.bytes(concatBytes(state.chunks), output.mediaType, { info: output.info }),
|
||||
usage: output.usage,
|
||||
notices: output.notices,
|
||||
providerMetadata: output.providerMetadata,
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -101,7 +101,8 @@ const decodeResponse = Effect.fn("XAIImages.decodeResponse")(function* (
|
||||
)
|
||||
if (images.length === 0) return yield* output.invalid(`${route.name} returned no images`)
|
||||
const usage = ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
|
||||
// xAI reports image counts rather than tokens, seconds, or credits; the raw record stays in provider metadata.
|
||||
// xAI reports a USD cost (`cost_in_usd_ticks`) rather than tokens, seconds, or credits; the raw record stays in
|
||||
// provider metadata.
|
||||
return new ImageResponse({
|
||||
images,
|
||||
providerMetadata: usage === undefined ? undefined : { xai: { usage } },
|
||||
|
||||
@@ -136,8 +136,7 @@ const decodeResult = Effect.fn("XAIVideo.decodeResult")(function* (
|
||||
const output = yield* decodeVideoStatus(response)
|
||||
const decoded = output.value
|
||||
const status = yield* MediaProtocol.status(STATUS, decoded.status, output)
|
||||
if (status === "running")
|
||||
return yield* output.invalid(`${route.name} request ${context.token.requestID} has not finished`)
|
||||
if (status === "running") return yield* output.pending(context.token.requestID)
|
||||
if (status === "failed") {
|
||||
const code = decoded.error?.code ?? undefined
|
||||
const message = decoded.error?.message ?? undefined
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js"
|
||||
import { Media } from "../media.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { mergeJsonRecords, type OpenString } from "../schema/index.js"
|
||||
@@ -9,6 +8,7 @@ import { mergeJsonRecords, type OpenString } from "../schema/index.js"
|
||||
const route = MediaProtocol.identity({ id: "zai-images", name: "Z.ai Images", provider: "zai" })
|
||||
export const DEFAULT_BASE_URL = "https://api.z.ai/api/paas/v4"
|
||||
export const PATH = "/images/generations"
|
||||
const OUTPUT_RETENTION = Duration.days(30)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
@@ -76,7 +76,7 @@ const decodeResponse = Effect.fn("ZAIImages.decodeResponse")(function* (
|
||||
const filters = decoded.content_filter ?? []
|
||||
return new ImageResponse({
|
||||
// Z.ai returns only URLs and no content type; the media type resolves when the asset is materialized.
|
||||
images: decoded.data.map((item) => Media.url(item.url)),
|
||||
images: yield* Effect.forEach(decoded.data, (item) => MediaProtocol.expiringUrl(item.url, OUTPUT_RETENTION)),
|
||||
// Z.ai reports applied content filters alongside a successful result; surface them instead of dropping them.
|
||||
notices:
|
||||
filters.length === 0
|
||||
|
||||
@@ -95,7 +95,7 @@ export const optional = (secret: Secret | undefined, source = "optional value")
|
||||
export const config = (name: string) =>
|
||||
credential(
|
||||
Effect.gen(function* () {
|
||||
const secret = yield* Config.option(Config.redacted(name))
|
||||
const secret = yield* Config.option(Config.Redacted(name))
|
||||
if (Option.isSome(secret) && Redacted.value(secret.value) !== "") return secret.value
|
||||
return yield* Effect.fail(new MissingCredentialError(name, `${name} is not set`))
|
||||
}),
|
||||
|
||||
@@ -137,6 +137,11 @@ export interface Queued<Request, Response, Token> {
|
||||
readonly cancel?: {
|
||||
readonly method: AuthInput["method"]
|
||||
readonly path: (token: Token) => string
|
||||
/**
|
||||
* Fetch a fresh status first and skip the call for terminal generations, for providers whose cancel endpoint
|
||||
* destroys finished work (Runway's `DELETE /v1/tasks/{id}` deletes completed tasks and their outputs).
|
||||
*/
|
||||
readonly activeOnly?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +200,8 @@ 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); `contentPolicy` is a moderated result.
|
||||
* 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.
|
||||
*/
|
||||
const text = Effect.fn("MediaProtocol.text")(function* (response: HttpClientResponse.HttpClientResponse) {
|
||||
const http = context(response)
|
||||
@@ -224,6 +230,14 @@ export const identity = (input: { readonly id: string; readonly name: string; re
|
||||
? new ProviderInternalError({ message, body, http })
|
||||
: new InvalidRequestError({ message, body, http }),
|
||||
}),
|
||||
pending: (id: string) =>
|
||||
new AIError({
|
||||
reason: new InvalidRequestError({
|
||||
message: `${input.name} generation ${id} has not finished; await it before reading the result`,
|
||||
body,
|
||||
http,
|
||||
}),
|
||||
}),
|
||||
contentPolicy: (message: string) => new AIError({ reason: new ContentPolicyError({ message, body, http }) }),
|
||||
}
|
||||
})
|
||||
@@ -285,9 +299,8 @@ export const status = <Table extends Record<string, Status>>(
|
||||
raw: string,
|
||||
output: Output,
|
||||
): Effect.Effect<Status, AIError> => {
|
||||
const normalized: Status | undefined = table[raw]
|
||||
if (normalized === undefined) return Effect.fail(output.invalid(`Unknown generation status "${raw}"`))
|
||||
return Effect.succeed(normalized)
|
||||
if (!Object.hasOwn(table, raw)) return Effect.fail(output.invalid(`Unknown generation status "${raw}"`))
|
||||
return Effect.succeed(table[raw])
|
||||
}
|
||||
|
||||
/** A `url` asset whose provider-declared retention window starts now. */
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Endpoint } from "./endpoint.js"
|
||||
import { RequestExecutorService, type Interface } from "./executor-service.js"
|
||||
import { RequestExecutor } from "./executor.js"
|
||||
import { MediaProtocol } from "./media-protocol.js"
|
||||
import { Generation } from "../generation.js"
|
||||
import { Generation, isTerminal } from "../generation.js"
|
||||
import type { Media } from "../media.js"
|
||||
import {
|
||||
AIError,
|
||||
@@ -164,14 +164,19 @@ export const queued = <Request extends MediaRequest, Response, Token>(
|
||||
transport
|
||||
.call("GET", operation.path(token), http, execute)
|
||||
.pipe(Effect.flatMap((sent) => operation.decode(sent.response, { token, auth: sent.auth, materialize })))
|
||||
const status = poll(protocol.status)
|
||||
const cancel = protocol.cancel
|
||||
const send =
|
||||
cancel === undefined
|
||||
? undefined
|
||||
: transport.call(cancel.method, cancel.path(token), http, execute).pipe(Effect.asVoid)
|
||||
return {
|
||||
status: poll(protocol.status),
|
||||
status,
|
||||
result: poll(protocol.result),
|
||||
cancel:
|
||||
cancel === undefined
|
||||
? undefined
|
||||
: transport.call(cancel.method, cancel.path(token), http, execute).pipe(Effect.asVoid),
|
||||
send !== undefined && cancel?.activeOnly
|
||||
? status.pipe(Effect.flatMap((snapshot) => (isTerminal(snapshot.status) ? Effect.void : send)))
|
||||
: send,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,7 +385,11 @@ const encode = (body: MediaProtocol.Body | undefined, headers: Headers.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
/** Common fields are never silently dropped: a present field the protocol declared unsupported fails typed. */
|
||||
/**
|
||||
* Common fields are never silently dropped: a present field the protocol declared unsupported fails typed. `false`
|
||||
* counts as present because some booleans mean something when false (video `audio`); protocols reject opt-in
|
||||
* booleans such as speech `timestamps` with `=== true` in `body.from` instead of listing them.
|
||||
*/
|
||||
const rejectUnsupported = <Request extends object>(
|
||||
route: string,
|
||||
provider: ProviderID,
|
||||
|
||||
+28
-2
@@ -6,6 +6,7 @@ import type {
|
||||
ToolOutput as ToolOutputType,
|
||||
} from "./schema/index.js"
|
||||
import { ToolDefinition, ToolFailure, ToolOutput } from "./schema/index.js"
|
||||
import { isRecord } from "./utils/record.js"
|
||||
|
||||
/**
|
||||
* Schema constraint for tool parameters / success values: no decoding or
|
||||
@@ -200,7 +201,7 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
|
||||
_definition: new ToolDefinition({
|
||||
name: "",
|
||||
description: config.description,
|
||||
inputSchema: toJsonSchema(config.parameters),
|
||||
inputSchema: toInputJsonSchema(config.parameters),
|
||||
outputSchema: toJsonSchema(config.success),
|
||||
}),
|
||||
}
|
||||
@@ -230,8 +231,33 @@ export const toDefinitions = (tools: Tools): ReadonlyArray<ToolDefinitionClass>
|
||||
}),
|
||||
)
|
||||
|
||||
const toInputJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
|
||||
// Effect represents Struct({}) as any non-null value; tool parameters must
|
||||
// instead describe an object. Inline named empty inputs so providers such as
|
||||
// Gemini can recognize a no-argument tool, while retaining schema annotations.
|
||||
if (
|
||||
schema.ast._tag !== "Objects" ||
|
||||
schema.ast.propertySignatures.length !== 0 ||
|
||||
schema.ast.indexSignatures.length !== 0 ||
|
||||
schema.ast.encoding !== undefined ||
|
||||
schema.ast.checks !== undefined
|
||||
)
|
||||
return toJsonSchema(schema)
|
||||
|
||||
const json = Schema.toJsonSchemaDocument(schema, {
|
||||
onExcessProperty: "error",
|
||||
referencePolicy: () => undefined,
|
||||
}).schema
|
||||
if (isRecord(json.not) && json.not.type === "null" && Object.keys(json.not).length === 1) {
|
||||
const normalized: JsonSchema.JsonSchema = { type: "object", properties: {}, additionalProperties: false, ...json }
|
||||
delete normalized.not
|
||||
return normalized
|
||||
}
|
||||
return toJsonSchema(schema)
|
||||
}
|
||||
|
||||
const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
|
||||
const document = Schema.toJsonSchemaDocument(schema)
|
||||
const document = Schema.toJsonSchemaDocument(schema, { onExcessProperty: "error" })
|
||||
if (Object.keys(document.definitions).length === 0) return document.schema
|
||||
return { ...document.schema, $defs: document.definitions }
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ type LanguageModel = {
|
||||
declare const auth: Auth.Definition
|
||||
declare const optionalAuthModel: LanguageModelFactory<BaseOptions, "optional", LanguageModel>
|
||||
declare const requiredAuthModel: LanguageModelFactory<BaseOptions, "required", LanguageModel>
|
||||
const configApiKey = Config.redacted("OPENAI_API_KEY")
|
||||
const configApiKey = Config.Redacted("OPENAI_API_KEY")
|
||||
|
||||
OpenAIChat.route.model({ id: "gpt-4.1-mini" })
|
||||
|
||||
|
||||
@@ -351,10 +351,34 @@ describe("OpenAI Responses effort updates", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strips markers when the body overlay selects pro reasoning mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "fixture", http: { body: { reasoning: { mode: "pro" } } } }).responses(
|
||||
"gpt-6-sol",
|
||||
),
|
||||
messages: conversation,
|
||||
providerOptions: { reasoningEffort: "low" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(updates(prepared.body)).toEqual([])
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "low" })
|
||||
}),
|
||||
)
|
||||
|
||||
for (const [id, supported] of [
|
||||
["gpt-6-astra", true],
|
||||
["openai/gpt-6-astra", true],
|
||||
["gpt-6-sol", true],
|
||||
["openai/gpt-6-sol", true],
|
||||
["gpt-6-luna", true],
|
||||
["openai/gpt-6-luna", true],
|
||||
["gpt-6-astra-2026-09-01", false],
|
||||
["gpt-6-sol-pro", false],
|
||||
["gpt-6-luna-pro", false],
|
||||
["gpt-6-sol-fast", false],
|
||||
["gpt-5.6-sol", false],
|
||||
] as const) {
|
||||
it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () =>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Provider as ProviderSubpath } from "@opencode/ai/provider"
|
||||
import {
|
||||
AssemblyAI,
|
||||
Baseten,
|
||||
BlackForestLabs,
|
||||
Cartesia,
|
||||
CloudflareAIGateway,
|
||||
CloudflareWorkersAI,
|
||||
@@ -32,14 +33,18 @@ import {
|
||||
Fal,
|
||||
Fireworks,
|
||||
Google,
|
||||
Meta,
|
||||
OpenCodeZen,
|
||||
OpenAI,
|
||||
OpenAICompatible,
|
||||
OpenRouter,
|
||||
Replicate,
|
||||
Runway,
|
||||
Stability,
|
||||
TypeSafeAI,
|
||||
VercelAIGateway,
|
||||
XAI,
|
||||
ZAI,
|
||||
} from "@opencode/ai/providers"
|
||||
import {
|
||||
OpenAIChat,
|
||||
@@ -151,8 +156,34 @@ describe("public exports", () => {
|
||||
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(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")
|
||||
expect(Google.provider.image).toBe(Google.image)
|
||||
expect(XAI.configure({ apiKey: "fixture" }).image("grok-imagine-image").route.id).toBe("xai-images")
|
||||
expect(XAI.provider.image).toBe(XAI.image)
|
||||
expect(Fal.configure({ apiKey: "fixture" }).image("fal-ai/flux/dev").route.id).toBe("fal-images")
|
||||
expect(Fal.provider.image).toBe(Fal.image)
|
||||
expect(BlackForestLabs.configure({ apiKey: "fixture" }).image("flux-2-pro").route.id).toBe("bfl-images")
|
||||
expect(BlackForestLabs.provider.image).toBe(BlackForestLabs.image)
|
||||
expect(Replicate.configure({ apiKey: "fixture" }).image("black-forest-labs/flux-schnell").route.id).toBe(
|
||||
"replicate-images",
|
||||
)
|
||||
expect(Replicate.provider.image).toBe(Replicate.image)
|
||||
expect(Stability.configure({ apiKey: "fixture" }).image("sd3.5-large").route.id).toBe("stability-images")
|
||||
expect(Stability.provider.image).toBe(Stability.image)
|
||||
expect(Stability.configure({ apiKey: "fixture" }).upscale().route.id).toBe("stability-upscale")
|
||||
expect(Stability.provider.upscale).toBe(Stability.upscale)
|
||||
expect(Meta.configure({ apiKey: "fixture" }).image("muse-image").route.id).toBe("meta-images")
|
||||
expect(Meta.provider.image).toBe(Meta.image)
|
||||
expect(ZAI.configure({ apiKey: "fixture" }).image("glm-image").route.id).toBe("zai-images")
|
||||
expect(ZAI.provider.image).toBe(ZAI.image)
|
||||
expect(XAI.configure({ apiKey: "fixture" }).video("grok-imagine-video-1.5").route.id).toBe("xai-video")
|
||||
expect(XAI.provider.video).toBe(XAI.video)
|
||||
expect(Google.configure({ apiKey: "fixture" }).video("veo-3.1-generate-preview").route.id).toBe("google-video")
|
||||
expect(Google.provider.video).toBe(Google.video)
|
||||
expect(Fal.configure({ apiKey: "fixture" }).video("fal-ai/veo3.1").route.id).toBe("fal-video")
|
||||
expect(Fal.provider.video).toBe(Fal.video)
|
||||
expect(Runway.configure({ apiKey: "fixture" }).video("gen4.5").route.id).toBe("runway-video")
|
||||
expect(Runway.provider.video).toBe(Runway.video)
|
||||
expect(OpenAI.configure({ apiKey: "fixture" }).speech("gpt-4o-mini-tts").route.id).toBe("openai-speech")
|
||||
|
||||
@@ -97,6 +97,26 @@ describe("Generation", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails an event stream at the deadline when the poll interval is longer than the timeout", () =>
|
||||
Effect.gen(function* () {
|
||||
const scripted = yield* scriptedRoute(["running"], "never")
|
||||
const generation = new Generation(scripted.route, "t", { id: "gen_1", status: "queued" })
|
||||
|
||||
const fiber = yield* Effect.forkChild(
|
||||
generation
|
||||
.events({ poll: { interval: "30 seconds", timeout: "10 seconds" } })
|
||||
.pipe(Stream.runCollect, Effect.flip),
|
||||
)
|
||||
yield* TestClock.adjust("9 seconds")
|
||||
expect(fiber.pollUnsafe()).toBeUndefined()
|
||||
yield* TestClock.adjust("1 second")
|
||||
const error = yield* Fiber.join(fiber)
|
||||
|
||||
expect(error.reason._tag).toBe("Timeout")
|
||||
expect(yield* Ref.get(scripted.polls)).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("surfaces the route failure body for failed generations", () =>
|
||||
Effect.gen(function* () {
|
||||
const scripted = yield* scriptedRoute(["running", "failed"], "unused")
|
||||
|
||||
@@ -78,8 +78,11 @@ describe("Image", () => {
|
||||
mediaType: "image/webp",
|
||||
})
|
||||
expect(yield* response.image.bytes()).toEqual(Uint8Array.from([1, 2, 3]))
|
||||
expect(response.image.providerMetadata).toEqual({ openai: { revisedPrompt: "A precise robot" } })
|
||||
expect(response.image.info).toEqual({ format: "webp", width: 2048, height: 2048 })
|
||||
expect(response.usage).toMatchObject({ type: "tokens", total: 12 })
|
||||
expect(response.providerMetadata).toEqual({
|
||||
openai: { outputFormat: "webp", size: "2048x2048", quality: "high", background: "opaque" },
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
@@ -107,8 +110,11 @@ describe("Image", () => {
|
||||
})
|
||||
return input.respond(
|
||||
JSON.stringify({
|
||||
data: [{ b64_json: "AQID", revised_prompt: "A precise robot" }, { b64_json: "BAUG" }],
|
||||
data: [{ b64_json: "AQID" }, { b64_json: "BAUG" }],
|
||||
output_format: "webp",
|
||||
size: "2048x2048",
|
||||
quality: "high",
|
||||
background: "opaque",
|
||||
usage: { input_tokens: 4, output_tokens: 8, total_tokens: 12 },
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
@@ -144,6 +150,7 @@ describe("Image", () => {
|
||||
),
|
||||
)
|
||||
expect(response.image.source).toEqual({ type: "bytes", data: Uint8Array.from([1, 2, 3]), mediaType: "image/png" })
|
||||
expect(response.image.info).toEqual({ format: "png" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -725,6 +732,7 @@ describe("Image", () => {
|
||||
const errors = yield* Effect.all(
|
||||
[
|
||||
Image.start({ model: Google.configure({ apiKey: "test" }).image("gemini-3.1-flash-image"), prompt }),
|
||||
Image.generate({ model: Google.configure({ apiKey: "test" }).image("gemini-3.1-flash-image"), prompt, n: 2 }),
|
||||
Image.start({
|
||||
model: BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro"),
|
||||
prompt,
|
||||
@@ -735,7 +743,6 @@ describe("Image", () => {
|
||||
prompt,
|
||||
size: "512x512",
|
||||
}),
|
||||
Stream.runCollect(Image.stream({ model: openai.image("dall-e-3"), prompt })),
|
||||
Stream.runCollect(Image.stream({ model: openai.image("gpt-image-2"), prompt, n: 2 })),
|
||||
Image.start({ model: replicate, prompt, seed: 7 }),
|
||||
Image.start({
|
||||
@@ -749,9 +756,9 @@ describe("Image", () => {
|
||||
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
|
||||
[
|
||||
["UnsupportedOperation", "image.start"],
|
||||
["UnsupportedOperation", "media.n"],
|
||||
["UnsupportedOperation", "media.aspectRatio"],
|
||||
["UnsupportedOperation", "media.size"],
|
||||
["UnsupportedOperation", "media.stream"],
|
||||
["UnsupportedOperation", "media.n"],
|
||||
["UnsupportedOperation", "media.seed"],
|
||||
["InvalidRequest", false],
|
||||
@@ -761,6 +768,104 @@ describe("Image", () => {
|
||||
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
|
||||
)
|
||||
|
||||
const falToken = {
|
||||
requestID: "r1",
|
||||
statusURL: "https://queue.fal.test/fal-ai/flux/requests/r1/status",
|
||||
responseURL: "https://queue.fal.test/fal-ai/flux/requests/r1",
|
||||
cancelURL: "https://queue.fal.test/fal-ai/flux/requests/r1/cancel",
|
||||
}
|
||||
const falSubmitted = {
|
||||
request_id: falToken.requestID,
|
||||
status_url: falToken.statusURL,
|
||||
response_url: falToken.responseURL,
|
||||
cancel_url: falToken.cancelURL,
|
||||
}
|
||||
const bodies: Array<unknown> = []
|
||||
it.effect("sizes fal Kontext by aspect ratio and sends several images to /multi", () =>
|
||||
Effect.gen(function* () {
|
||||
const fal = Fal.configure({ apiKey: "test", baseURL: "https://queue.fal.test" })
|
||||
const images = [Media.url("https://example.test/a.png"), Media.url("https://example.test/b.png")]
|
||||
const rejected = yield* Image.start({
|
||||
model: fal.image("fal-ai/flux-pro/kontext"),
|
||||
prompt: "A lighthouse",
|
||||
size: "512x512",
|
||||
}).pipe(Effect.flip)
|
||||
yield* Image.start({
|
||||
model: fal.image("fal-ai/flux-pro/kontext"),
|
||||
prompt: "A lighthouse",
|
||||
images: images.slice(0, 1),
|
||||
aspectRatio: "16:9",
|
||||
})
|
||||
yield* Image.start({ model: fal.image("fal-ai/flux-pro/kontext/max/multi"), prompt: "A lighthouse", images })
|
||||
|
||||
expect(rejected.reason).toMatchObject({ _tag: "UnsupportedOperation", operation: "media.size" })
|
||||
expect(bodies).toEqual([
|
||||
{ prompt: "A lighthouse", aspect_ratio: "16:9", image_url: "https://example.test/a.png" },
|
||||
{ prompt: "A lighthouse", image_urls: ["https://example.test/a.png", "https://example.test/b.png"] },
|
||||
])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) => {
|
||||
bodies.push(JSON.parse(input.text))
|
||||
return Effect.succeed(json(input, falSubmitted))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("decodes fal sync_mode data URIs as inline images", () =>
|
||||
Effect.gen(function* () {
|
||||
const generation = yield* Image.resume(Fal.configure({ apiKey: "test" }).image("fal-ai/flux/schnell"), falToken)
|
||||
const response = yield* generation.await()
|
||||
|
||||
expect(response.images.map((image) => image.source)).toEqual([
|
||||
{ type: "base64", data: "AQID", mediaType: "image/png" },
|
||||
{ type: "url", url: "https://v3.fal.media/out.jpg", mediaType: "image/jpeg" },
|
||||
])
|
||||
expect(response.image.info).toEqual({ width: 512, height: 512 })
|
||||
expect(yield* response.image.bytes()).toEqual(Uint8Array.from([1, 2, 3]))
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(
|
||||
input.request.url === falToken.statusURL
|
||||
? json(input, { status: "COMPLETED" })
|
||||
: json(input, {
|
||||
images: [
|
||||
{ url: "data:image/png;base64,AQID", width: 512, height: 512, content_type: "image/png" },
|
||||
{ url: "https://v3.fal.media/out.jpg", width: 512, height: 512, content_type: "image/jpeg" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const falDetail = { detail: [{ loc: ["body", "prompt"], msg: "Invalid input", type: "value_error" }] }
|
||||
it.effect(
|
||||
"fails a fal await whose COMPLETED status carries an error with the response_url body and HTTP context",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const generation = yield* Image.resume(Fal.configure({ apiKey: "test" }).image("fal-ai/flux/schnell"), falToken)
|
||||
expect(generation.status).toBe("failed")
|
||||
const error = yield* generation.await().pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.reason.body).toBe(JSON.stringify(falDetail))
|
||||
expect(error.reason.http).toMatchObject({ url: falToken.responseURL, status: 422 })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(
|
||||
input.request.url === falToken.statusURL
|
||||
? json(input, { status: "COMPLETED", error: "Invalid input", error_type: "ValidationError" })
|
||||
: json(input, falDetail, { status: 422 }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const moderated = { id: "req_1", status: "Content Moderated" }
|
||||
const prediction = {
|
||||
id: "p_1",
|
||||
@@ -768,6 +873,56 @@ describe("Image", () => {
|
||||
output: { text: "not an image" },
|
||||
urls: { get: "https://replicate.test/p_1", cancel: "https://replicate.test/p_1/cancel" },
|
||||
}
|
||||
for (const pending of [
|
||||
{
|
||||
model: BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro"),
|
||||
token: { id: "req_1", pollingURL: "https://bfl.test/v1/get_result?id=req_1" },
|
||||
status: 200,
|
||||
body: { id: "req_1", status: "Pending" },
|
||||
message: "Black Forest Labs generation req_1",
|
||||
},
|
||||
{
|
||||
model: Replicate.configure({ apiKey: "test" }).image("owner/model"),
|
||||
token: { id: "p_1", getURL: "https://replicate.test/p_1", cancelURL: "https://replicate.test/p_1/cancel" },
|
||||
status: 200,
|
||||
body: {
|
||||
id: "p_1",
|
||||
status: "processing",
|
||||
urls: { get: "https://replicate.test/p_1", cancel: "https://replicate.test/p_1/cancel" },
|
||||
},
|
||||
message: "Replicate generation p_1",
|
||||
},
|
||||
{
|
||||
model: Stability.configure({ apiKey: "test", baseURL: "https://stability.test" }).upscale(),
|
||||
token: { id: "up_1" },
|
||||
status: 202,
|
||||
body: { id: "up_1", status: "in-progress" },
|
||||
message: "Stability AI generation up_1",
|
||||
},
|
||||
]) {
|
||||
it.effect(`rejects reading a ${pending.model.provider} result before the generation finishes`, () =>
|
||||
Effect.gen(function* () {
|
||||
const generation = yield* Image.resume(pending.model, pending.token)
|
||||
const error = yield* generation.result().pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toBe(`${pending.message} has not finished; await it before reading the result`)
|
||||
expect(error.reason.body).toBe(JSON.stringify(pending.body))
|
||||
expect(error.reason.http?.status).toBe(pending.status)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(
|
||||
input.respond(JSON.stringify(pending.body), {
|
||||
status: pending.status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("classifies terminal outcomes the recordings never saw", () =>
|
||||
Effect.gen(function* () {
|
||||
const bfl = yield* Image.resume(BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro"), {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, Ref, Schema } from "effect"
|
||||
import { FileSystem } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { Media, Message } from "../src/index.js"
|
||||
import { AIError, Media, Message } from "../src/index.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
import { dynamicResponse, scriptedResponses } from "./lib/http.js"
|
||||
|
||||
@@ -161,6 +161,56 @@ describe("Media", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps transient url download headers out of toJSON and AssetSchema encoding", () =>
|
||||
Effect.sync(() => {
|
||||
const asset = Media.url("https://cdn.example.test/video.mp4", {
|
||||
mediaType: "video/mp4",
|
||||
expiresAt: 42,
|
||||
headers: { "x-goog-api-key": "secret" },
|
||||
})
|
||||
expect(asset.headers).toEqual({ "x-goog-api-key": "secret" })
|
||||
const source = { type: "url", url: "https://cdn.example.test/video.mp4", mediaType: "video/mp4", expiresAt: 42 }
|
||||
|
||||
expect(asset.toJSON()).not.toHaveProperty("headers")
|
||||
expect(JSON.stringify(asset)).not.toContain("secret")
|
||||
expect(asset.toJSON().source).toEqual(source)
|
||||
|
||||
const encoded = Schema.encodeSync(Media.AssetSchema)(asset)
|
||||
expect(encoded).not.toHaveProperty("headers")
|
||||
expect(encoded.source).toEqual(source)
|
||||
|
||||
const codec = Schema.fromJsonString(Media.AssetSchema)
|
||||
const json = Schema.encodeSync(codec)(asset)
|
||||
expect(json).not.toContain("secret")
|
||||
const restored = Schema.decodeSync(codec)(json)
|
||||
expect(restored).toBeInstanceOf(Media.Asset)
|
||||
expect(restored.source).toEqual(source)
|
||||
expect(restored.expiresAt).toBe(42)
|
||||
expect(restored.headers).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails url downloads with non-2xx status as a typed AIError keeping http and body", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = JSON.stringify({ error: { message: "file expired" } })
|
||||
const error = yield* Media.url("https://cdn.example.test/expired.png")
|
||||
.bytes()
|
||||
.pipe(
|
||||
Effect.flip,
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.succeed(input.respond(body, { status: 404, headers: { "content-type": "application/json" } })),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.message).toContain("file expired")
|
||||
expect(error.reason.http?.status).toBe(404)
|
||||
expect(error.reason.http?.url).toBe("https://cdn.example.test/expired.png")
|
||||
expect(error.reason.body).toBe(body)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads files with sniffed media types and writes materialized assets", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
|
||||
@@ -33,6 +33,8 @@ describe("Black Forest Labs Images recorded", () => {
|
||||
|
||||
expect(response.image.source.type).toBe("bytes")
|
||||
expect(dimensions(yield* response.image.bytes())).toEqual({ width: 512, height: 512 })
|
||||
// BFL reports cost on submit only; the Ready result omits it.
|
||||
expect(response.usage).toEqual({ type: "credits", credits: 1.4000000000000001 })
|
||||
}),
|
||||
{ timeout: 15 * 60 * 1000 },
|
||||
)
|
||||
|
||||
@@ -29,7 +29,11 @@ describe("OpenAI Images recorded", () => {
|
||||
|
||||
expect(response.images).toHaveLength(1)
|
||||
expect(response.image.mediaType).toBe("image/jpeg")
|
||||
expect(response.image.info).toEqual({ format: "jpeg", width: 1024, height: 1024 })
|
||||
expect((yield* response.image.bytes()).length).toBeGreaterThan(0)
|
||||
expect(response.providerMetadata).toEqual({
|
||||
openai: { outputFormat: "jpeg", size: "1024x1024", quality: "low", background: "opaque" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -76,8 +80,13 @@ describe("OpenAI Images recorded", () => {
|
||||
expect(events.map((event) => event.type)).toEqual(["image-partial", "image", "finish"])
|
||||
const image = events.find(ImageEvent.is.image)
|
||||
expect(image?.image.mediaType).toBe("image/jpeg")
|
||||
expect(image?.image.info).toEqual({ format: "jpeg", width: 1024, height: 1024 })
|
||||
expect(dimensions(yield* image!.image.bytes())).toEqual({ width: 1024, height: 1024 })
|
||||
expect(events.find(ImageEvent.is.finish)?.usage).toMatchObject({ type: "tokens" })
|
||||
const finish = events.find(ImageEvent.is.finish)
|
||||
expect(finish?.usage).toMatchObject({ type: "tokens" })
|
||||
expect(finish?.providerMetadata).toEqual({
|
||||
openai: { outputFormat: "jpeg", size: "1024x1024", quality: "low", background: "opaque" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -44,7 +44,12 @@ describe("OpenAI Transcription recorded", () => {
|
||||
expect(deltas.length).toBeGreaterThan(1)
|
||||
expect(deltas.join("")).toBe(finish.text)
|
||||
expect(finish.text).toMatch(TRANSCRIPT)
|
||||
expect(finish.usage).toMatchObject({ type: "tokens", input: expect.any(Number), output: expect.any(Number) })
|
||||
expect(finish.usage).toMatchObject({
|
||||
type: "tokens",
|
||||
input: expect.any(Number),
|
||||
output: expect.any(Number),
|
||||
details: { openai: { input_token_details: { audio_tokens: expect.any(Number) } } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -32,7 +32,12 @@ describe("Z.ai Images", () => {
|
||||
|
||||
expect(response.images).toHaveLength(1)
|
||||
expect(response.image.mediaType).toBe("application/octet-stream")
|
||||
expect(response.image.source).toEqual({ type: "url", url: "https://cdn.z.ai/generated.png" })
|
||||
// Z.ai documents that output URLs expire 30 days after generation; the test clock starts at 0.
|
||||
expect(response.image.source).toEqual({
|
||||
type: "url",
|
||||
url: "https://cdn.z.ai/generated.png",
|
||||
expiresAt: 30 * 24 * 60 * 60 * 1000,
|
||||
})
|
||||
expect(response.notices).toEqual([
|
||||
{
|
||||
type: "moderated",
|
||||
|
||||
@@ -46,7 +46,10 @@ describe("Speech", () => {
|
||||
respond(
|
||||
JSON.stringify({
|
||||
candidates: [
|
||||
{ content: { parts: [{ inlineData: { mimeType: "audio/wav", data: Encoding.encodeBase64(bytes) } }] } },
|
||||
{
|
||||
content: { parts: [{ inlineData: { mimeType: "audio/wav", data: Encoding.encodeBase64(bytes) } }] },
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
}),
|
||||
"application/json",
|
||||
@@ -60,6 +63,42 @@ describe("Speech", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("describes OpenAI audio in the format the request body actually asked for", () =>
|
||||
Effect.gen(function* () {
|
||||
const [pcm, wav] = yield* Effect.all([
|
||||
Speech.generate({ model: openai, text: "Hi", format: "mp3", providerOptions: { response_format: "pcm" } }),
|
||||
Speech.generate({ model: openai, text: "Hi", http: { body: { response_format: "wav" } } }),
|
||||
]).pipe(Effect.provide(respond("\u0001\u0002", "application/octet-stream")))
|
||||
expect(pcm.audio.mediaType).toBe("audio/pcm")
|
||||
expect(pcm.audio.info).toEqual({ format: "pcm", encoding: "pcm_s16le", sampleRate: 24000, channels: 1 })
|
||||
expect(wav.audio.mediaType).toBe("audio/wav")
|
||||
expect(wav.audio.info?.format).toBe("wav")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("describes Deepgram raw encodings in their default WAV container", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Speech.generate({ model: deepgram, text: "Hi", providerOptions: { encoding: "mulaw" } })
|
||||
expect(response.audio.mediaType).toBe("audio/wav")
|
||||
expect(response.audio.info?.format).toBe("wav")
|
||||
}).pipe(Effect.provide(respond("RIFF....WAVEfmt ", "audio/wav"))),
|
||||
)
|
||||
|
||||
it.effect("always gives headerless Deepgram PCM a sample rate", () =>
|
||||
Effect.gen(function* () {
|
||||
const [requested, defaulted] = yield* Effect.all([
|
||||
Speech.generate({
|
||||
model: deepgram,
|
||||
text: "Hi",
|
||||
providerOptions: { encoding: "mulaw", container: "none", sampleRate: 16000 },
|
||||
}),
|
||||
Speech.generate({ model: deepgram, text: "Hi", providerOptions: { encoding: "alaw", container: "none" } }),
|
||||
]).pipe(Effect.provide(respond("\u0001\u0002", "audio/basic")))
|
||||
expect(requested.audio.info).toEqual({ format: "pcm", encoding: "pcm_mulaw", sampleRate: 16000, channels: 1 })
|
||||
expect(defaulted.audio.info).toEqual({ format: "pcm", encoding: "pcm_alaw", sampleRate: 8000, channels: 1 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects raw PCM for Gemini 3.8 unary requests before sending", () =>
|
||||
Effect.gen(function* () {
|
||||
const errors = yield* Effect.all(
|
||||
@@ -76,6 +115,7 @@ describe("Speech", () => {
|
||||
const errors = yield* Effect.all(
|
||||
[
|
||||
Speech.generate({ model: openai, text: "Hi", timestamps: true }),
|
||||
Speech.generate({ model: openai, text: "Hi", format: "ogg" }),
|
||||
Speech.generate({ model: google, text: "Hi", format: "mp3" }),
|
||||
Speech.generate({ model: google, text: "Hi", instructions: "Warm." }),
|
||||
collect(Speech.stream({ model: elevenlabs, text: "Hi", voice, format: "wav" })),
|
||||
@@ -87,16 +127,57 @@ describe("Speech", () => {
|
||||
[
|
||||
["UnsupportedOperation", "media.timestamps"],
|
||||
["UnsupportedOperation", "media.format"],
|
||||
["UnsupportedOperation", "media.format"],
|
||||
["UnsupportedOperation", "media.instructions"],
|
||||
["UnsupportedOperation", "media.format"],
|
||||
["UnsupportedOperation", "media.format"],
|
||||
["UnsupportedOperation", "media.voice"],
|
||||
],
|
||||
)
|
||||
expect(errors[1].reason).toMatchObject({ provider: "google", route: "google-speech" })
|
||||
expect(errors[1].reason).toMatchObject({ provider: "openai", route: "openai-speech" })
|
||||
expect(errors[2].reason).toMatchObject({ provider: "google", route: "google-speech" })
|
||||
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
|
||||
)
|
||||
|
||||
it.effect("treats timestamps: false as not asking for timestamps on routes that cannot return them", () =>
|
||||
Effect.gen(function* () {
|
||||
const bytes = Uint8Array.from([1, 2, 3])
|
||||
const gemini = JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ inlineData: { mimeType: "audio/L16;codec=pcm;rate=24000", data: "AQID" } }] },
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
})
|
||||
const responses = yield* Effect.all([
|
||||
Speech.generate({ model: openai, text: "Hi", timestamps: false }).pipe(
|
||||
Effect.provide(respond(new Blob([bytes]).stream(), "audio/mpeg")),
|
||||
),
|
||||
Speech.generate({ model: google, text: "Hi", timestamps: false }).pipe(
|
||||
Effect.provide(respond(gemini, "application/json")),
|
||||
),
|
||||
Speech.generate({ model: deepgram, text: "Hi", timestamps: false }).pipe(
|
||||
Effect.provide(respond(new Blob([bytes]).stream(), "audio/mpeg")),
|
||||
),
|
||||
])
|
||||
for (const response of responses) expect(yield* response.audio.bytes()).toEqual(bytes)
|
||||
|
||||
const errors = yield* Effect.all(
|
||||
[openai, google, deepgram].map((model) =>
|
||||
Speech.generate({ model, text: "Hi", timestamps: true }).pipe(Effect.flip),
|
||||
),
|
||||
).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network"))))
|
||||
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
|
||||
[
|
||||
["UnsupportedOperation", "media.timestamps"],
|
||||
["UnsupportedOperation", "media.timestamps"],
|
||||
["UnsupportedOperation", "media.timestamps"],
|
||||
],
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies stream failures and keeps the provider payload and HTTP context", () =>
|
||||
Effect.gen(function* () {
|
||||
const badFrame = JSON.stringify({ type: "speech.audio.delta", audio: "not base64!" })
|
||||
@@ -127,6 +208,38 @@ describe("Speech", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("surfaces Gemini speech that ended without STOP instead of returning it as complete", () =>
|
||||
Effect.gen(function* () {
|
||||
const document = (finishReason?: string) =>
|
||||
JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ inlineData: { mimeType: "audio/L16;codec=pcm;rate=24000", data: "AQI=" } }] },
|
||||
finishReason,
|
||||
},
|
||||
],
|
||||
})
|
||||
const withheld = JSON.stringify({ candidates: [{ finishReason: "SAFETY" }] })
|
||||
const generate = (body: string) =>
|
||||
Speech.generate({ model: google, text: "Hi" }).pipe(Effect.provide(respond(body, "application/json")))
|
||||
|
||||
const truncated = yield* generate(document()).pipe(Effect.flip)
|
||||
const partial = yield* generate(document("MAX_TOKENS"))
|
||||
const policy = yield* generate(withheld).pipe(Effect.flip)
|
||||
|
||||
expect(truncated.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
|
||||
expect(yield* partial.audio.bytes()).toEqual(Uint8Array.from([1, 2]))
|
||||
expect(partial.notices).toEqual([
|
||||
{
|
||||
type: "other",
|
||||
message: "Google Speech finished with MAX_TOKENS",
|
||||
providerMetadata: { google: { finishReason: "MAX_TOKENS" } },
|
||||
},
|
||||
])
|
||||
expect(policy.reason).toMatchObject({ _tag: "ContentPolicy", body: withheld })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses ElevenLabs timestamped records split across network chunks", () =>
|
||||
Effect.gen(function* () {
|
||||
const record = (bytes: ReadonlyArray<number>, character: string, start: number) =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM } from "../src/index.js"
|
||||
import { OpenAIChat } from "../src/protocols.js"
|
||||
import { AnthropicMessages, Gemini, OpenAIChat, OpenAIResponses } from "../src/protocols.js"
|
||||
import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema.js"
|
||||
import { Tool, toDefinitions } from "../src/tool.js"
|
||||
import { Auth } from "../src/route.js"
|
||||
@@ -9,6 +9,126 @@ import { compileRequest } from "../src/route/client.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
|
||||
describe("tool schema projections", () => {
|
||||
test("only normalizes typed empty input structs, preserving raw schemas and output schemas", () => {
|
||||
const schema = { not: { type: "null" } }
|
||||
const definitions = Tool.toDefinitions({
|
||||
typed: Tool.make({
|
||||
description: "Typed",
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.Struct({}),
|
||||
execute: () => Effect.succeed({}),
|
||||
}),
|
||||
raw: Tool.make({ description: "Raw", jsonSchema: schema, execute: () => Effect.succeed({}) }),
|
||||
})
|
||||
expect(definitions[0]?.inputSchema).toEqual({ type: "object", properties: {}, additionalProperties: false })
|
||||
expect(definitions[0]?.outputSchema).toEqual(schema)
|
||||
expect(definitions[1]?.inputSchema).toEqual(schema)
|
||||
})
|
||||
|
||||
test("retains empty input descriptions and preserves named outputs and explicit raw schemas", () => {
|
||||
const parameters = Schema.Struct({}).annotate({ identifier: "Ping", description: "No arguments" })
|
||||
const raw = { $ref: "#/$defs/Raw", $defs: { Raw: { not: { type: "null" }, description: "Raw schema" } } }
|
||||
const definitions = Tool.toDefinitions({
|
||||
typed: Tool.make({ description: "Ping", parameters, success: parameters, execute: () => Effect.succeed({}) }),
|
||||
raw: Tool.make({ description: "Raw", jsonSchema: raw, execute: () => Effect.succeed({}) }),
|
||||
})
|
||||
expect(definitions[0]?.inputSchema).toEqual({
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
description: "No arguments",
|
||||
})
|
||||
expect(definitions[0]?.outputSchema).toEqual({
|
||||
$ref: "#/$defs/Ping",
|
||||
$defs: { Ping: { not: { type: "null" }, description: "No arguments" } },
|
||||
})
|
||||
expect(definitions[1]?.inputSchema).toEqual(raw)
|
||||
})
|
||||
|
||||
const empty = { type: "object", properties: {}, additionalProperties: false }
|
||||
const nonempty = {
|
||||
type: "object",
|
||||
properties: { query: { type: "string" } },
|
||||
required: ["query"],
|
||||
additionalProperties: false,
|
||||
}
|
||||
const raw = { type: "object", properties: { value: { type: "number" } }, additionalProperties: true }
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
route: OpenAIChat.route,
|
||||
tools: [empty, nonempty, raw].map((parameters, index) => ({
|
||||
type: "function",
|
||||
function: { name: ["ping", "lookup", "raw"][index], parameters },
|
||||
})),
|
||||
},
|
||||
{
|
||||
route: OpenAIResponses.route,
|
||||
tools: [empty, nonempty, raw].map((parameters, index) => ({
|
||||
type: "function",
|
||||
name: ["ping", "lookup", "raw"][index],
|
||||
parameters,
|
||||
})),
|
||||
},
|
||||
{
|
||||
route: AnthropicMessages.route,
|
||||
tools: [empty, nonempty, raw].map((input_schema, index) => ({
|
||||
name: ["ping", "lookup", "raw"][index],
|
||||
input_schema,
|
||||
})),
|
||||
},
|
||||
{
|
||||
route: Gemini.route,
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [empty, nonempty, raw].map((parametersJsonSchema, index) => ({
|
||||
name: ["ping", "lookup", "raw"][index],
|
||||
parametersJsonSchema,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
]) {
|
||||
for (const input of [
|
||||
{ name: "plain", schema: Schema.Struct({}) },
|
||||
{ name: "described", schema: Schema.Struct({}).annotate({ description: "No arguments" }) },
|
||||
{ name: "named", schema: Schema.Struct({}).annotate({ identifier: "Ping" }) },
|
||||
{
|
||||
name: "named and described",
|
||||
schema: Schema.Struct({}).annotate({ identifier: "Ping", description: "No arguments" }),
|
||||
},
|
||||
]) {
|
||||
it.effect(
|
||||
`${scenario.route.id} prepares ${input.name} empty Effect Struct tools alongside nonempty and raw schemas`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: scenario.route.with({ auth: Auth.bearer("test") }).model({ id: "test-model" }),
|
||||
prompt: "Use a tool.",
|
||||
tools: Tool.toDefinitions({
|
||||
ping: Tool.make({
|
||||
description: "Ping",
|
||||
parameters: input.schema,
|
||||
success: Schema.String,
|
||||
execute: () => Effect.succeed("pong"),
|
||||
}),
|
||||
lookup: Tool.make({
|
||||
description: "Lookup",
|
||||
parameters: Schema.Struct({ query: Schema.String }),
|
||||
success: Schema.String,
|
||||
execute: (input) => Effect.succeed(input.query),
|
||||
}),
|
||||
raw: Tool.make({ description: "Raw", jsonSchema: raw, execute: () => Effect.succeed("raw") }),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.tools).toMatchObject(scenario.tools)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
test("moonshot strips $ref siblings and converts tuple arrays to a schema object", () => {
|
||||
expect(
|
||||
ToolSchemaProjection.moonshot({
|
||||
@@ -91,7 +211,12 @@ describe("tool schema projections", () => {
|
||||
const exclusive = { oneOf: [{ type: "object" }, { type: "object", required: ["a"] }] }
|
||||
const object = { type: "object", properties: {} }
|
||||
|
||||
expect(yield* parameters(parameterless)).toEqual({ type: "object", description: "No input." })
|
||||
expect(yield* parameters(parameterless)).toEqual({
|
||||
type: "object",
|
||||
properties: {},
|
||||
additionalProperties: false,
|
||||
description: "No input.",
|
||||
})
|
||||
expect(yield* parameters({})).toEqual({ type: "object" })
|
||||
expect(yield* parameters({ description: "Query", properties: { q: { type: "string" } } })).toEqual({
|
||||
type: "object",
|
||||
|
||||
@@ -2,10 +2,11 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer, Stream } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { Media, Transcription, TranscriptionClient } from "../src/index.js"
|
||||
import { Media, Transcription, TranscriptionClient, type TranscriptionEvent } from "../src/index.js"
|
||||
import { AssemblyAI, Deepgram, Google, OpenAI } from "../src/providers.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
import { dynamicResponse } from "./lib/http.js"
|
||||
import { dynamicResponse, json, observe, type Call } from "./lib/http.js"
|
||||
import { sseEvents } from "./lib/sse.js"
|
||||
|
||||
const layer = (handler: Parameters<typeof dynamicResponse>[0]) =>
|
||||
TranscriptionClient.layer.pipe(Layer.provideMerge(dynamicResponse(handler)))
|
||||
@@ -16,6 +17,21 @@ const deepgram = Deepgram.configure({ apiKey: "test", baseURL: "https://deepgram
|
||||
const google = Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).transcription(
|
||||
"gemini-3.5-transcribe",
|
||||
)
|
||||
/**
|
||||
* Multipart fields of a recorded request, with repeated names collected in order. The boundary comes from the body:
|
||||
* each conversion of a FormData request to a web request picks a fresh one, so the recorded headers may not match.
|
||||
*/
|
||||
const formFields = (call: Call) =>
|
||||
Effect.promise(() =>
|
||||
new Response(call.body, {
|
||||
headers: { "content-type": `multipart/form-data; boundary=${call.body.slice(2, call.body.indexOf("\r\n"))}` },
|
||||
}).formData(),
|
||||
).pipe(
|
||||
Effect.map((form) =>
|
||||
Object.fromEntries([...new Set(form.keys())].map((key) => [key, form.getAll(key).map((value) => String(value))])),
|
||||
),
|
||||
)
|
||||
|
||||
const assemblyai = AssemblyAI.configure({ apiKey: "aai-key", baseURL: "https://assemblyai.test" }).transcription(
|
||||
"universal-3-5-pro",
|
||||
)
|
||||
@@ -25,7 +41,6 @@ describe("Transcription", () => {
|
||||
Effect.gen(function* () {
|
||||
const errors = yield* Effect.all(
|
||||
[
|
||||
Stream.runCollect(Transcription.stream({ model: openai.transcription("whisper-1"), audio })),
|
||||
Transcription.generate({ model: openai.transcription("gpt-4o-mini-transcribe"), audio, diarize: true }),
|
||||
Transcription.generate({ model: openai.transcription("gpt-4o-mini-transcribe"), audio, timestamps: "word" }),
|
||||
Transcription.generate({ model: openai.transcription("gpt-4o-transcribe-diarize"), audio, prompt: "Names" }),
|
||||
@@ -53,7 +68,6 @@ describe("Transcription", () => {
|
||||
)
|
||||
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
|
||||
[
|
||||
["UnsupportedOperation", "media.stream"],
|
||||
["UnsupportedOperation", "media.diarize"],
|
||||
["UnsupportedOperation", "media.timestamps"],
|
||||
["UnsupportedOperation", "media.prompt"],
|
||||
@@ -70,6 +84,259 @@ describe("Transcription", () => {
|
||||
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
|
||||
)
|
||||
|
||||
it.effect("ignores unknown OpenAI stream events and fails on an error event with the frame", () =>
|
||||
Effect.gen(function* () {
|
||||
const sse = (...frames: ReadonlyArray<string>) => frames.map((frame) => `data: ${frame}\n\n`).join("")
|
||||
const failure = `{"type":"error","error":{"type":"server_error","code":"server_error","message":"The server had an error"}}`
|
||||
const bodies = [
|
||||
sse(
|
||||
`{"type":"transcript.text.delta","delta":"Hi"}`,
|
||||
`{"type":"transcript.text.future","payload":1}`,
|
||||
`{"type":"transcript.text.done","text":"Hi"}`,
|
||||
"[DONE]",
|
||||
),
|
||||
sse(`{"type":"transcript.text.delta","delta":"Hi"}`, failure),
|
||||
]
|
||||
const model = openai.transcription("gpt-4o-mini-transcribe")
|
||||
const program = Effect.gen(function* () {
|
||||
const events = Array.from(yield* Stream.runCollect(Transcription.stream({ model, audio })))
|
||||
const error = yield* Stream.runCollect(Transcription.stream({ model, audio })).pipe(Effect.flip)
|
||||
return { events, error }
|
||||
})
|
||||
const { events, error } = yield* program.pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.sync(() =>
|
||||
input.respond(bodies.shift() ?? "", { headers: { "content-type": "text/event-stream" } }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"])
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", body: failure })
|
||||
expect(error.message).toContain("The server had an error")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams whisper-1 as a single finish from a plain request", () =>
|
||||
Effect.gen(function* () {
|
||||
const bodies: Array<string> = []
|
||||
const events = Array.from(
|
||||
yield* Stream.runCollect(Transcription.stream({ model: openai.transcription("whisper-1"), audio })).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.sync(() => {
|
||||
bodies.push(input.text)
|
||||
return input.respond(
|
||||
JSON.stringify({ text: "Hello there.", usage: { type: "duration", seconds: 2 } }),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(bodies[0]).not.toContain('name="stream"')
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({ type: "finish", text: "Hello there.", usage: { type: "seconds", seconds: 2 } }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams diarized segments and finishes with the accumulated segments", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const body = sseEvents(
|
||||
{ type: "transcript.text.segment", id: "seg_0", text: " Hello", start: 0.25, end: 0.7, speaker: "A" },
|
||||
{ type: "transcript.text.segment", id: "seg_1", text: " there.", start: 0.7, end: 1.25, speaker: "B" },
|
||||
{ type: "transcript.text.done", text: "Hello there.", usage: { type: "duration", seconds: 2 } },
|
||||
)
|
||||
const events = Array.from(
|
||||
yield* Stream.runCollect(
|
||||
Transcription.stream({ model: openai.transcription("gpt-4o-transcribe-diarize"), audio, diarize: true }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
observe(calls, input).pipe(
|
||||
Effect.as(input.respond(body, { headers: { "content-type": "text/event-stream" } })),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const form = yield* formFields(calls[0])
|
||||
expect(form).toMatchObject({
|
||||
model: ["gpt-4o-transcribe-diarize"],
|
||||
response_format: ["diarized_json"],
|
||||
chunking_strategy: ["auto"],
|
||||
stream: ["true"],
|
||||
})
|
||||
const segments = [
|
||||
{ text: "Hello", startSeconds: 0.25, endSeconds: 0.7, speaker: "A" },
|
||||
{ text: "there.", startSeconds: 0.7, endSeconds: 1.25, speaker: "B" },
|
||||
]
|
||||
expect(events).toEqual([
|
||||
{ type: "segment", segment: segments[0] },
|
||||
{ type: "segment", segment: segments[1] },
|
||||
expect.objectContaining({
|
||||
type: "finish",
|
||||
text: "Hello there.",
|
||||
segments,
|
||||
usage: { type: "seconds", seconds: 2 },
|
||||
}),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requests whisper-1 segment timestamps as verbose_json", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const response = yield* Transcription.generate({
|
||||
model: openai.transcription("whisper-1"),
|
||||
audio,
|
||||
timestamps: "segment",
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
observe(calls, input).pipe(
|
||||
Effect.as(
|
||||
json(input, {
|
||||
text: "Hello there.",
|
||||
language: "English",
|
||||
duration: 1.25,
|
||||
segments: [
|
||||
{ id: 0, text: " Hello", start: 0.25, end: 0.7 },
|
||||
{ id: 1, text: " there.", start: 0.7, end: 1.25 },
|
||||
],
|
||||
usage: { type: "duration", seconds: 2 },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const form = yield* formFields(calls[0])
|
||||
expect(form).toMatchObject({
|
||||
model: ["whisper-1"],
|
||||
response_format: ["verbose_json"],
|
||||
"timestamp_granularities[]": ["segment"],
|
||||
})
|
||||
expect(form.stream).toBeUndefined()
|
||||
expect(response).toMatchObject({
|
||||
text: "Hello there.",
|
||||
segments: [
|
||||
{ text: "Hello", startSeconds: 0.25, endSeconds: 0.7 },
|
||||
{ text: "there.", startSeconds: 0.7, endSeconds: 1.25 },
|
||||
],
|
||||
language: "english",
|
||||
durationSeconds: 1.25,
|
||||
usage: { type: "seconds", seconds: 2 },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails an OpenAI stream that ends without transcript.text.done as incomplete", () =>
|
||||
Effect.gen(function* () {
|
||||
const events: Array<TranscriptionEvent> = []
|
||||
const error = yield* Transcription.stream({ model: openai.transcription("gpt-4o-mini-transcribe"), audio }).pipe(
|
||||
Stream.runForEach((event) => Effect.sync(() => events.push(event))),
|
||||
Effect.flip,
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(
|
||||
input.respond(sseEvents({ type: "transcript.text.delta", delta: "Hel" }), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(events).toEqual([{ type: "text-delta", delta: "Hel" }])
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sends a Deepgram URL source as a JSON body and repeats array query parameters", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const response = yield* Transcription.generate({
|
||||
model: deepgram,
|
||||
audio: Media.url("https://a.test/call.mp3", { mediaType: "audio/mpeg" }),
|
||||
language: "en",
|
||||
providerOptions: { keyterm: ["OpenCode", "Effect"] },
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
observe(calls, input).pipe(
|
||||
Effect.as(
|
||||
json(input, {
|
||||
metadata: { request_id: "dg_1", duration: 2 },
|
||||
results: { channels: [{ alternatives: [{ transcript: "Hello there." }] }] },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const url = new URL(calls[0].url)
|
||||
expect(url.origin + url.pathname).toBe("https://deepgram.test/v1/listen")
|
||||
expect([...url.searchParams]).toEqual([
|
||||
["model", "nova-3"],
|
||||
["smart_format", "true"],
|
||||
["language", "en"],
|
||||
["keyterm", "OpenCode"],
|
||||
["keyterm", "Effect"],
|
||||
])
|
||||
expect(calls[0].headers.get("content-type")).toBe("application/json")
|
||||
expect(JSON.parse(calls[0].body)).toEqual({ url: "https://a.test/call.mp3" })
|
||||
expect(response).toMatchObject({
|
||||
text: "Hello there.",
|
||||
usage: { type: "seconds", seconds: 2 },
|
||||
providerMetadata: { deepgram: { requestId: "dg_1" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("transcribes an AssemblyAI URL source without uploading it first", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const response = yield* Transcription.generate({
|
||||
model: assemblyai,
|
||||
audio: Media.url("https://a.test/call.mp3", { mediaType: "audio/mpeg" }),
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call } = yield* observe(calls, input)
|
||||
if (call.method === "POST") return json(input, { id: "tr_1", status: "queued" })
|
||||
return json(input, { id: "tr_1", status: "completed", text: "Hello there.", audio_duration: 2 })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
|
||||
"POST https://assemblyai.test/v2/transcript",
|
||||
"GET https://assemblyai.test/v2/transcript/tr_1",
|
||||
"GET https://assemblyai.test/v2/transcript/tr_1",
|
||||
])
|
||||
expect(JSON.parse(calls[0].body)).toEqual({
|
||||
audio_url: "https://a.test/call.mp3",
|
||||
speech_models: ["universal-3-5-pro"],
|
||||
language_detection: true,
|
||||
})
|
||||
expect(response).toMatchObject({ text: "Hello there.", usage: { type: "seconds", seconds: 2 } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"uploads inline audio to AssemblyAI, resumes polling from a persisted token, and surfaces failed transcripts",
|
||||
() =>
|
||||
@@ -171,4 +438,76 @@ describe("Transcription", () => {
|
||||
expect(failure.reason).toMatchObject({ _tag: "ProviderInternal", body: failed })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("enables AssemblyAI speaker labels when only an expected speaker count is given", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
yield* Transcription.start({ model: assemblyai, audio: Media.url("https://a.test/call.mp3"), speakers: 2 }).pipe(
|
||||
Effect.provide(
|
||||
layer((input) => observe(calls, input).pipe(Effect.as(json(input, { id: "tr_1", status: "queued" })))),
|
||||
),
|
||||
)
|
||||
expect(calls.map((call) => JSON.parse(call.body))).toEqual([
|
||||
{
|
||||
audio_url: "https://a.test/call.mp3",
|
||||
speech_models: ["universal-3-5-pro"],
|
||||
language_detection: true,
|
||||
speaker_labels: true,
|
||||
speakers_expected: 2,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects reading an AssemblyAI result before the transcript finishes", () =>
|
||||
Effect.gen(function* () {
|
||||
const generation = yield* Transcription.resume(assemblyai, { transcriptID: "tr_1" })
|
||||
const error = yield* generation.result().pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toBe("AssemblyAI generation tr_1 has not finished; await it before reading the result")
|
||||
expect(error.reason.body).toBe(JSON.stringify({ id: "tr_1", status: "processing" }))
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(
|
||||
input.respond(JSON.stringify({ id: "tr_1", status: "processing" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("surfaces Gemini transcripts that ended without STOP instead of returning them as complete", () =>
|
||||
Effect.gen(function* () {
|
||||
const document = (finishReason?: string) =>
|
||||
JSON.stringify({
|
||||
candidates: [{ content: { parts: [{ audioTranscription: { text: "Hello" } }] }, finishReason }],
|
||||
})
|
||||
const withheld = JSON.stringify({ candidates: [{ finishReason: "SAFETY" }] })
|
||||
const generate = (body: string) =>
|
||||
Transcription.generate({ model: google, audio }).pipe(
|
||||
Effect.provide(
|
||||
layer((input) => Effect.succeed(input.respond(body, { headers: { "content-type": "application/json" } }))),
|
||||
),
|
||||
)
|
||||
|
||||
const truncated = yield* generate(document()).pipe(Effect.flip)
|
||||
const partial = yield* generate(document("MAX_TOKENS"))
|
||||
const policy = yield* generate(withheld).pipe(Effect.flip)
|
||||
|
||||
expect(truncated.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
|
||||
expect(partial.text).toBe("Hello")
|
||||
expect(partial.notices).toEqual([
|
||||
{
|
||||
type: "other",
|
||||
message: "Google Transcription finished with MAX_TOKENS",
|
||||
providerMetadata: { google: { finishReason: "MAX_TOKENS" } },
|
||||
},
|
||||
])
|
||||
expect(policy.reason).toMatchObject({ _tag: "ContentPolicy", body: withheld })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Media, Video, VideoClient, type GenerationEvent } from "../src/index.js"
|
||||
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"
|
||||
@@ -542,6 +542,42 @@ describe("Video / fal", () => {
|
||||
),
|
||||
)
|
||||
|
||||
for (const failure of [
|
||||
{
|
||||
name: "a COMPLETED status carrying an error",
|
||||
status: { status: "COMPLETED", error: "Invalid input", error_type: "ValidationError" },
|
||||
result: { status: 422, body: { detail: [{ loc: ["body", "prompt"], msg: "Invalid input" }] } },
|
||||
tag: "InvalidRequest",
|
||||
},
|
||||
{
|
||||
name: "a failing response_url",
|
||||
status: { status: "COMPLETED" },
|
||||
result: { status: 500, body: { detail: "Internal error" } },
|
||||
tag: "ProviderInternal",
|
||||
},
|
||||
]) {
|
||||
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)
|
||||
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 })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(
|
||||
input.request.method === "POST"
|
||||
? json(input, submitted)
|
||||
: input.request.url === urls.response
|
||||
? json(input, failure.result.body, { status: failure.result.status })
|
||||
: json(input, failure.status),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("rejects model-specific common fields and points at providerOptions", () =>
|
||||
Effect.gen(function* () {
|
||||
const errors = yield* Effect.forEach(
|
||||
@@ -576,7 +612,7 @@ describe("Video / Runway", () => {
|
||||
const model = runway.video("gen4.5")
|
||||
const taskUrl = "https://runway.test/v1/tasks/task_1"
|
||||
|
||||
it.effect("submits image_to_video with the API version header, polls the task, and reports credits", () =>
|
||||
it.effect("submits image_to_video, polls the task, reports credits, and keeps the finished task on cancel", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const program = Effect.gen(function* () {
|
||||
@@ -624,7 +660,7 @@ describe("Video / Runway", () => {
|
||||
return json(input, { id: "task_1", estimatedCost: { credits: 25 } })
|
||||
}
|
||||
expect(call.url).toBe(taskUrl)
|
||||
if (call.method === "DELETE") return input.respond(null, { status: 204 })
|
||||
if (call.method === "DELETE") return yield* Effect.die("cancel deleted a finished Runway task")
|
||||
if (nth === 1) return json(input, { id: "task_1", status: "PENDING", estimatedCost: { credits: 25 } })
|
||||
if (nth === 2) return json(input, { id: "task_1", status: "THROTTLED", estimatedCost: { credits: 25 } })
|
||||
if (nth === 3) return json(input, { id: "task_1", status: "RUNNING", progress: 0.5 })
|
||||
@@ -653,6 +689,32 @@ describe("Video / Runway", () => {
|
||||
`GET ${taskUrl}`,
|
||||
`GET ${taskUrl}`,
|
||||
`GET ${taskUrl}`,
|
||||
`GET ${taskUrl}`,
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("cancels a task that is still running", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
yield* Effect.gen(function* () {
|
||||
const generation = yield* Video.start({ model, prompt: "x" })
|
||||
yield* generation.cancel()
|
||||
}).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 input.respond(null, { status: 204 })
|
||||
return json(input, { id: "task_1", status: "RUNNING", progress: 0.2 })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
|
||||
"POST https://runway.test/v1/text_to_video",
|
||||
`GET ${taskUrl}`,
|
||||
`DELETE ${taskUrl}`,
|
||||
])
|
||||
}),
|
||||
@@ -792,6 +854,39 @@ describe("Video / Runway", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams the observations of a failed task and then fails with the task body", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const events: Array<VideoEvent> = []
|
||||
const failed = { status: "FAILED", failure: "Something broke", failureCode: "INTERNAL.BAD_OUTPUT.CODE01" }
|
||||
const program = Video.stream({ model, prompt: "x" }, { poll: { interval: "1 second" } }).pipe(
|
||||
Stream.runForEach((event) => Effect.sync(() => events.push(event))),
|
||||
Effect.flip,
|
||||
)
|
||||
const error = yield* settle(program, 3).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 json(input, { status: "PENDING" })
|
||||
if (nth === 2) return json(input, { status: "RUNNING", progress: 0.5 })
|
||||
return json(input, failed)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(events).toEqual([
|
||||
{ type: "generation-queued", id: "task_1", position: undefined },
|
||||
{ type: "generation-progress", id: "task_1", progress: 0.5 },
|
||||
])
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(error.message).toBe("Runway task failed (INTERNAL.BAD_OUTPUT.CODE01): Something broke")
|
||||
expect(error.reason.body).toBe(JSON.stringify(failed))
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails a stream with a Timeout reason once polling passes the poll deadline", () =>
|
||||
Effect.gen(function* () {
|
||||
const program = Video.stream(
|
||||
@@ -812,3 +907,172 @@ describe("Video / Runway", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared queued behavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Video / queued result", () => {
|
||||
for (const pending of [
|
||||
{
|
||||
model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).video("veo-3.1"),
|
||||
token: { operation: "models/veo-3.1/operations/op_1" },
|
||||
body: { name: "models/veo-3.1/operations/op_1", done: false },
|
||||
name: "Google Veo",
|
||||
},
|
||||
{
|
||||
model: XAI.configure({ apiKey: "test", baseURL: "https://xai.test/v1" }).video("grok-imagine-video-1.5"),
|
||||
token: { requestID: "req_1" },
|
||||
body: { status: "pending", progress: 40 },
|
||||
name: "xAI Video",
|
||||
},
|
||||
{
|
||||
model: Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5"),
|
||||
token: { taskID: "task_1" },
|
||||
body: { status: "RUNNING", progress: 0.5 },
|
||||
name: "Runway",
|
||||
},
|
||||
]) {
|
||||
it.effect(`rejects reading a ${pending.model.provider} result before the generation finishes`, () =>
|
||||
Effect.gen(function* () {
|
||||
const generation = yield* Video.resume(pending.model, pending.token)
|
||||
const error = yield* generation.result().pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toBe(
|
||||
`${pending.name} generation ${generation.id} has not finished; await it before reading the result`,
|
||||
)
|
||||
expect(error.reason.body).toBe(JSON.stringify(pending.body))
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}).pipe(Effect.provide(layer((input) => Effect.succeed(json(input, pending.body))))),
|
||||
)
|
||||
}
|
||||
|
||||
const veoOperation = "models/veo-3.1/operations/op_1"
|
||||
const falURLs = {
|
||||
status: "https://queue.fal.test/fal-ai/veo3.1/requests/r1/status",
|
||||
response: "https://queue.fal.test/fal-ai/veo3.1/requests/r1",
|
||||
cancel: "https://queue.fal.test/fal-ai/veo3.1/requests/r1/cancel",
|
||||
}
|
||||
for (const queued of [
|
||||
{
|
||||
model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).video("veo-3.1"),
|
||||
submitted: { name: veoOperation },
|
||||
token: { operation: veoOperation },
|
||||
submitURL: "https://google.test/v1beta/models/veo-3.1:predictLongRunning",
|
||||
statusURL: `https://google.test/v1beta/${veoOperation}`,
|
||||
resultURL: `https://google.test/v1beta/${veoOperation}`,
|
||||
running: { name: veoOperation, done: false },
|
||||
done: {
|
||||
name: veoOperation,
|
||||
done: true,
|
||||
response: { generateVideoResponse: { generatedSamples: [{ video: { uri: "https://google.test/out.mp4" } }] } },
|
||||
},
|
||||
result: undefined,
|
||||
url: "https://google.test/out.mp4",
|
||||
},
|
||||
{
|
||||
model: XAI.configure({ apiKey: "test", baseURL: "https://xai.test/v1" }).video("grok-imagine-video-1.5"),
|
||||
submitted: { request_id: "req_1" },
|
||||
token: { requestID: "req_1" },
|
||||
submitURL: "https://xai.test/v1/videos/generations",
|
||||
statusURL: "https://xai.test/v1/videos/req_1",
|
||||
resultURL: "https://xai.test/v1/videos/req_1",
|
||||
running: { status: "pending", progress: 40 },
|
||||
done: { status: "done", video: { url: "https://vidgen.x.ai/out.mp4", respect_moderation: true } },
|
||||
result: undefined,
|
||||
url: "https://vidgen.x.ai/out.mp4",
|
||||
},
|
||||
{
|
||||
model: Fal.configure({ apiKey: "test", baseURL: "https://queue.fal.test" }).video("fal-ai/veo3.1"),
|
||||
submitted: {
|
||||
request_id: "r1",
|
||||
status_url: falURLs.status,
|
||||
response_url: falURLs.response,
|
||||
cancel_url: falURLs.cancel,
|
||||
},
|
||||
token: { requestID: "r1", statusURL: falURLs.status, responseURL: falURLs.response, cancelURL: falURLs.cancel },
|
||||
submitURL: "https://queue.fal.test/fal-ai/veo3.1",
|
||||
statusURL: falURLs.status,
|
||||
resultURL: falURLs.response,
|
||||
running: { status: "IN_PROGRESS" },
|
||||
done: { status: "COMPLETED" },
|
||||
result: { video: { url: "https://v3.fal.media/out.mp4" } },
|
||||
url: "https://v3.fal.media/out.mp4",
|
||||
},
|
||||
]) {
|
||||
it.effect(`resumes a ${queued.model.provider} generation from a JSON round-tripped token`, () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const response = yield* Effect.gen(function* () {
|
||||
const started = yield* Video.start({ model: queued.model, prompt: "x" })
|
||||
const resumed = yield* Video.resume(queued.model, JSON.parse(JSON.stringify(started.token)))
|
||||
expect(resumed.status).toBe("running")
|
||||
expect(resumed.token).toEqual(queued.token)
|
||||
return yield* resumed.await()
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call, nth } = yield* observe(calls, input)
|
||||
if (call.method === "POST") return json(input, queued.submitted)
|
||||
if (call.url === queued.resultURL && queued.result !== undefined) return json(input, queued.result)
|
||||
return json(input, nth === 1 ? queued.running : queued.done)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.video.source).toEqual(expect.objectContaining({ type: "url", url: queued.url }))
|
||||
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
|
||||
`POST ${queued.submitURL}`,
|
||||
`GET ${queued.statusURL}`,
|
||||
`GET ${queued.statusURL}`,
|
||||
`GET ${queued.resultURL}`,
|
||||
])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const queued of [
|
||||
{
|
||||
model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).video("veo-3.1"),
|
||||
submitted: { name: veoOperation },
|
||||
},
|
||||
{
|
||||
model: XAI.configure({ apiKey: "test", baseURL: "https://xai.test/v1" }).video("grok-imagine-video-1.5"),
|
||||
submitted: { request_id: "req_1" },
|
||||
},
|
||||
]) {
|
||||
it.effect(`cancels a ${queued.model.provider} generation without sending a request`, () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
yield* Effect.gen(function* () {
|
||||
const generation = yield* Video.start({ model: queued.model, prompt: "x" })
|
||||
yield* generation.cancel()
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call } = yield* observe(calls, input)
|
||||
if (call.method !== "POST") return yield* Effect.die(`cancel sent ${call.method} ${call.url}`)
|
||||
return json(input, queued.submitted)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(calls.map((call) => call.method)).toEqual(["POST"])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("rejects a status that only matches an inherited property", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Video.resume(
|
||||
XAI.configure({ apiKey: "test", baseURL: "https://xai.test/v1" }).video("grok-imagine-video-1.5"),
|
||||
{ requestID: "req_1" },
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toBe('Unknown generation status "constructor"')
|
||||
expect(error.reason.body).toBe(JSON.stringify({ status: "constructor" }))
|
||||
}).pipe(Effect.provide(layer((input) => Effect.succeed(json(input, { status: "constructor" }))))),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -45,7 +45,7 @@ const Current = Persistence.struct({ session: SessionsSchema })
|
||||
|
||||
export const ModelSelectionSchema = Persistence.migrate(
|
||||
Current,
|
||||
Schema.Struct({
|
||||
Persistence.legacy({
|
||||
session: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
pick: Persistence.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}).pipe(
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("persistence schemas", () => {
|
||||
|
||||
test("legacy migration observes missing fields before initial defaults are applied", () => {
|
||||
const current = Persistence.struct({ mode: Schema.Literals(["compact", "full"]), enabled: Schema.Boolean })
|
||||
const stored = Schema.Struct({
|
||||
const stored = Persistence.legacy({
|
||||
mode: Schema.optional(Schema.Unknown),
|
||||
expanded: Schema.optional(Schema.Boolean),
|
||||
}).pipe(
|
||||
@@ -53,6 +53,38 @@ describe("persistence schemas", () => {
|
||||
expect(Schema.encodeSync(schema)(decode({ expanded: true }))).toEqual({ mode: "full", enabled: true })
|
||||
})
|
||||
|
||||
test("legacy read shapes carry unknown nested fields through to the current schema", () => {
|
||||
const current = Persistence.struct({
|
||||
general: Persistence.struct({
|
||||
mode: Schema.Literals(["compact", "full"]),
|
||||
theme: Schema.String,
|
||||
nested: Persistence.struct({ keep: Schema.Boolean }),
|
||||
}),
|
||||
other: Persistence.struct({ flag: Schema.Boolean }),
|
||||
})
|
||||
const stored = Persistence.legacy({
|
||||
general: Persistence.optional(Persistence.legacy({ expanded: Persistence.optional(Schema.Boolean) })),
|
||||
}).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
const general = value.general
|
||||
if (!general || general.expanded === undefined) return value
|
||||
return { ...value, general: { ...general, mode: general.expanded ? "full" : "compact" } }
|
||||
}),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
)
|
||||
const schema = Persistence.withInitial(Persistence.migrate(current, stored), {
|
||||
general: { mode: "compact", theme: "light", nested: { keep: false } },
|
||||
other: { flag: false },
|
||||
})
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
expect(decode({ general: { expanded: true, theme: "dark", nested: { keep: true } }, other: { flag: true } })).toEqual({
|
||||
general: { mode: "full", theme: "dark", nested: { keep: true } },
|
||||
other: { flag: true },
|
||||
})
|
||||
})
|
||||
|
||||
test("initial merging preserves field codecs and replaces arrays rather than merging indexes", () => {
|
||||
const current = Persistence.struct({
|
||||
amount: Schema.NumberFromString.check(Schema.isFinite()),
|
||||
|
||||
@@ -23,13 +23,11 @@ export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
initial: NoInfer<S["Type"]>,
|
||||
) {
|
||||
const schema = isMigrated(definition) ? definition.current : definition
|
||||
const read = isMigrated(definition)
|
||||
? SchemaParser.decodeUnknownResult(definition.read, { onExcessProperty: "preserve" })
|
||||
: Result.succeed<unknown>
|
||||
const read = isMigrated(definition) ? SchemaParser.decodeUnknownResult(definition.read) : Result.succeed<unknown>
|
||||
const encode = Schema.encodeUnknownSync(schema)
|
||||
return Schema.Unknown.pipe(
|
||||
Schema.decode<Schema.Unknown>({
|
||||
decode: SchemaGetter.transformOrFail((value) =>
|
||||
decode: SchemaGetter.transformEffect((value) =>
|
||||
Effect.fromResult(Result.map(read(value), (stored) => merge(initial, recover(schema.ast, stored, initial)))),
|
||||
),
|
||||
encode: SchemaGetter.transform((value) => encode(value)),
|
||||
@@ -38,6 +36,12 @@ export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
)
|
||||
}
|
||||
|
||||
// A legacy read shape declares only the fields it migrates. Decoding strips unknown keys, so every
|
||||
// struct level in a `migrate` read schema must stay open for the current fields it does not name.
|
||||
export function legacy<const Fields extends Schema.Struct.Fields>(fields: Fields) {
|
||||
return Schema.StructWithRest(Schema.Struct(fields), [Schema.Record(Schema.String, Schema.Unknown)])
|
||||
}
|
||||
|
||||
// Object-level codecs own their recovery. Plain structs can recover fields independently.
|
||||
function recover(ast: SchemaAST.AST, value: unknown, initial: unknown): unknown {
|
||||
if (value === undefined) return initial
|
||||
|
||||
@@ -53,7 +53,7 @@ const State = Persistence.struct({
|
||||
export function serverState(canonicalLocalServer: () => string | undefined = () => undefined) {
|
||||
return Persistence.migrate(
|
||||
State,
|
||||
Schema.Struct({ projects: Projects, lastProject: LastProject }).pipe(
|
||||
Persistence.legacy({ projects: Projects, lastProject: LastProject }).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((value) => {
|
||||
const canonical = canonicalLocalServer()
|
||||
|
||||
@@ -185,9 +185,9 @@ function legacyTimelineActivity(value: boolean | "hidden" | "compact" | "full" |
|
||||
|
||||
export const settingsPersistence = Persistence.migrate(
|
||||
settingsSchema,
|
||||
Schema.Struct({
|
||||
Persistence.legacy({
|
||||
general: Persistence.optional(
|
||||
Schema.Struct({
|
||||
Persistence.legacy({
|
||||
// Keep invalid explicit values distinct from absent values so legacy preferences cannot replace them.
|
||||
timelineDetail: Schema.optional(
|
||||
Schema.NullOr(
|
||||
|
||||
@@ -176,16 +176,16 @@ export const layoutSchema = Persistence.struct({
|
||||
|
||||
export const layoutPersistence = Persistence.migrate(
|
||||
layoutSchema,
|
||||
Schema.Struct({
|
||||
Persistence.legacy({
|
||||
sidebar: Persistence.optional(
|
||||
Schema.Struct({
|
||||
Persistence.legacy({
|
||||
workspaces: Persistence.optional(Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Schema.Boolean)])),
|
||||
workspacesDefault: Persistence.optional(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
review: Persistence.optional(Schema.Struct({ panelOpened: Persistence.optional(Schema.Boolean) })),
|
||||
review: Persistence.optional(Persistence.legacy({ panelOpened: Persistence.optional(Schema.Boolean) })),
|
||||
fileTree: Persistence.optional(
|
||||
Schema.Struct({
|
||||
Persistence.legacy({
|
||||
opened: Persistence.optional(Schema.Boolean),
|
||||
width: Persistence.optional(Schema.Finite),
|
||||
tab: Persistence.optional(Schema.Literals(["changes", "all"])),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -25,6 +25,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@clack/core": "1.0.0-alpha.1",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -41,7 +42,7 @@
|
||||
"effect": "catalog:",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"open": "10.1.2",
|
||||
"picocolors": "1.1.1",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Schema } from "effect"
|
||||
import { Spec } from "../framework/spec"
|
||||
import { Updater } from "../services/updater"
|
||||
|
||||
export const PrintLogs = GlobalFlag.setting("print-logs")({
|
||||
flag: Flag.boolean("print-logs").pipe(
|
||||
export const PrintLogs = GlobalFlag.Setting("print-logs")({
|
||||
flag: Flag.Boolean("print-logs").pipe(
|
||||
Flag.withDescription("Print logs to stderr (server logs require --standalone)"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
@@ -13,23 +13,23 @@ export const PrintLogs = GlobalFlag.setting("print-logs")({
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
const ServerParams = {
|
||||
standalone: Flag.boolean("standalone").pipe(
|
||||
standalone: Flag.Boolean("standalone").pipe(
|
||||
Flag.withDescription("Run with a private server instead of the background service"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
server: Flag.string("server").pipe(
|
||||
server: Flag.String("server").pipe(
|
||||
Flag.withDescription("Connect to a server URL instead of the background service"),
|
||||
Flag.optional,
|
||||
),
|
||||
}
|
||||
|
||||
const PermissionParams = {
|
||||
auto: Flag.boolean("auto").pipe(
|
||||
auto: Flag.Boolean("auto").pipe(
|
||||
Flag.withDescription("Auto-approve permissions that are not explicitly denied"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe(
|
||||
yolo: Flag.Boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
dangerouslySkipPermissions: Flag.Boolean("dangerously-skip-permissions").pipe(
|
||||
Flag.withDefault(false),
|
||||
Flag.withHidden,
|
||||
),
|
||||
@@ -40,32 +40,32 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
params: {
|
||||
...ServerParams,
|
||||
...PermissionParams,
|
||||
directory: Argument.string("directory").pipe(
|
||||
directory: Argument.String("directory").pipe(
|
||||
Argument.withDescription("Directory to start OpenCode in"),
|
||||
Argument.optional,
|
||||
),
|
||||
continue: Flag.boolean("continue").pipe(
|
||||
continue: Flag.Boolean("continue").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Continue the last session"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
session: Flag.string("session").pipe(
|
||||
session: Flag.String("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
prompt: Flag.String("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
},
|
||||
commands: [
|
||||
Spec.make("upgrade", {
|
||||
description: "Upgrade OpenCode to the latest or a specific version",
|
||||
aliases: ["update"],
|
||||
params: {
|
||||
target: Argument.string("target").pipe(
|
||||
target: Argument.String("target").pipe(
|
||||
Argument.withDescription("Version to upgrade to (with or without a leading v)"),
|
||||
Argument.optional,
|
||||
),
|
||||
method: Flag.choice("method", Updater.methods).pipe(
|
||||
method: Flag.Literals("method", Updater.methods).pipe(
|
||||
Flag.withAlias("m"),
|
||||
Flag.withDescription("Installation method to use"),
|
||||
Flag.optional,
|
||||
@@ -75,21 +75,21 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("uninstall", {
|
||||
description: "Uninstall OpenCode and remove all related files",
|
||||
params: {
|
||||
keepConfig: Flag.boolean("keep-config").pipe(
|
||||
keepConfig: Flag.Boolean("keep-config").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Keep configuration files"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
keepData: Flag.boolean("keep-data").pipe(
|
||||
keepData: Flag.Boolean("keep-data").pipe(
|
||||
Flag.withAlias("d"),
|
||||
Flag.withDescription("Keep session data and snapshots"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
dryRun: Flag.boolean("dry-run").pipe(
|
||||
dryRun: Flag.Boolean("dry-run").pipe(
|
||||
Flag.withDescription("Show what would be removed without removing"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
force: Flag.boolean("force").pipe(
|
||||
force: Flag.Boolean("force").pipe(
|
||||
Flag.withAlias("f"),
|
||||
Flag.withDescription("Skip confirmation prompts"),
|
||||
Flag.withDefault(false),
|
||||
@@ -101,17 +101,17 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "Make a request to the running server",
|
||||
params: {
|
||||
...ServerParams,
|
||||
request: Argument.string("operation | method path").pipe(
|
||||
request: Argument.String("operation | method path").pipe(
|
||||
Argument.withDescription("OpenAPI operation ID, or an HTTP method followed by a path"),
|
||||
Argument.variadic({ min: 1, max: 2 }),
|
||||
),
|
||||
data: Flag.string("data").pipe(Flag.withAlias("d"), Flag.withDescription("Request body"), Flag.optional),
|
||||
header: Flag.string("header").pipe(
|
||||
data: Flag.String("data").pipe(Flag.withAlias("d"), Flag.withDescription("Request body"), Flag.optional),
|
||||
header: Flag.String("header").pipe(
|
||||
Flag.withAlias("H"),
|
||||
Flag.withDescription("Request header in name:value form"),
|
||||
Flag.atMost(100),
|
||||
),
|
||||
param: Flag.keyValuePair("param").pipe(Flag.withDescription("OpenAPI path or query parameter"), Flag.optional),
|
||||
param: Flag.KeyValuePair("param").pipe(Flag.withDescription("OpenAPI path or query parameter"), Flag.optional),
|
||||
},
|
||||
}),
|
||||
Spec.make("debug", {
|
||||
@@ -122,7 +122,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("paths", {
|
||||
description: "Show global paths (data, config, cache, state)",
|
||||
params: {
|
||||
name: Argument.choice("name", [
|
||||
name: Argument.Literals("name", [
|
||||
"db",
|
||||
"home",
|
||||
"data",
|
||||
@@ -134,7 +134,9 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
"log",
|
||||
"repos",
|
||||
]).pipe(
|
||||
Argument.withDescription("Print only one path: db, home, data, config, cache, state, tmp, bin, log, repos"),
|
||||
Argument.withDescription(
|
||||
"Print only one path: db, home, data, config, cache, state, tmp, bin, log, repos",
|
||||
),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
@@ -142,28 +144,28 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
],
|
||||
}),
|
||||
Spec.make("auth", {
|
||||
description: "manage AI providers and credentials",
|
||||
description: "manage integrations and credentials",
|
||||
commands: [
|
||||
Spec.make("list", {
|
||||
description: "list providers and credentials",
|
||||
description: "list integrations and credentials",
|
||||
params: {
|
||||
...ServerParams,
|
||||
format: Flag.choice("format", ["default", "json"]).pipe(
|
||||
format: Flag.Literals("format", ["default", "json"]).pipe(
|
||||
Flag.withDescription("Output format"),
|
||||
Flag.withDefault("default"),
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("login", {
|
||||
description: "log in to a provider",
|
||||
description: "connect an integration",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
target: Argument.String("target").pipe(
|
||||
Argument.withDescription("Integration ID, name, or well-known provider URL"),
|
||||
Argument.optional,
|
||||
),
|
||||
method: Flag.string("method").pipe(Flag.withDescription("Authentication method ID"), Flag.optional),
|
||||
answer: Flag.string("answer").pipe(
|
||||
method: Flag.String("method").pipe(Flag.withDescription("Authentication method ID"), Flag.optional),
|
||||
answer: Flag.String("answer").pipe(
|
||||
Flag.withDescription("Provider form answer (key=value; repeat for multiple fields)"),
|
||||
Flag.atMost(100),
|
||||
),
|
||||
@@ -173,11 +175,11 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "log out of a saved account",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
target: Argument.String("target").pipe(
|
||||
Argument.withDescription("Integration ID or name"),
|
||||
Argument.optional,
|
||||
),
|
||||
credential: Argument.string("credential").pipe(
|
||||
credential: Argument.String("credential").pipe(
|
||||
Argument.withDescription("Credential ID or label (opens an account picker when omitted)"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -187,11 +189,11 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "switch the active account for an integration",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
target: Argument.String("target").pipe(
|
||||
Argument.withDescription("Integration ID or name"),
|
||||
Argument.optional,
|
||||
),
|
||||
credential: Argument.string("credential").pipe(
|
||||
credential: Argument.String("credential").pipe(
|
||||
Argument.withDescription("Credential ID or label (opens an account picker when omitted)"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -206,21 +208,21 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("add", {
|
||||
description: "Add an MCP server to your configuration",
|
||||
params: {
|
||||
name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")),
|
||||
command: Argument.string("command").pipe(
|
||||
name: Argument.String("name").pipe(Argument.withDescription("Name of the MCP server")),
|
||||
command: Argument.String("command").pipe(
|
||||
Argument.withDescription("Command and arguments for a local server, passed after --"),
|
||||
Argument.variadic({ min: 0 }),
|
||||
),
|
||||
url: Flag.string("url").pipe(Flag.withDescription("URL for a remote MCP server"), Flag.optional),
|
||||
header: Flag.keyValuePair("header").pipe(
|
||||
url: Flag.String("url").pipe(Flag.withDescription("URL for a remote MCP server"), Flag.optional),
|
||||
header: Flag.KeyValuePair("header").pipe(
|
||||
Flag.withDescription("HTTP header for a remote server, as name=value"),
|
||||
Flag.optional,
|
||||
),
|
||||
env: Flag.keyValuePair("env").pipe(
|
||||
env: Flag.KeyValuePair("env").pipe(
|
||||
Flag.withDescription("Environment variable for a local server, as name=value"),
|
||||
Flag.optional,
|
||||
),
|
||||
global: Flag.boolean("global").pipe(
|
||||
global: Flag.Boolean("global").pipe(
|
||||
Flag.withDescription("Write to the global config instead of the project config"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
@@ -228,11 +230,13 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
}),
|
||||
Spec.make("auth", {
|
||||
description: "Authenticate with an OAuth-capable remote MCP server",
|
||||
params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) },
|
||||
params: {
|
||||
name: Argument.String("name").pipe(Argument.withDescription("Name of the MCP server"), Argument.optional),
|
||||
},
|
||||
}),
|
||||
Spec.make("logout", {
|
||||
description: "Remove stored OAuth credentials for an MCP server",
|
||||
params: { name: Argument.string("name").pipe(Argument.withDescription("Name of the MCP server")) },
|
||||
params: { name: Argument.String("name").pipe(Argument.withDescription("Name of the MCP server")) },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
@@ -242,7 +246,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("list", {
|
||||
description: "List plugins",
|
||||
params: {
|
||||
builtin: Flag.boolean("builtin").pipe(
|
||||
builtin: Flag.Boolean("builtin").pipe(
|
||||
Flag.withDescription("Include built-in server plugins"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
@@ -251,13 +255,13 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("add", {
|
||||
description: "Install a plugin and add it to the global configuration",
|
||||
params: {
|
||||
package: Argument.string("package").pipe(Argument.withDescription("npm registry or Git package specifier")),
|
||||
package: Argument.String("package").pipe(Argument.withDescription("npm registry or Git package specifier")),
|
||||
},
|
||||
}),
|
||||
Spec.make("check", {
|
||||
description: "Check package plugins for updates",
|
||||
params: {
|
||||
target: Argument.string("target").pipe(
|
||||
target: Argument.String("target").pipe(
|
||||
Argument.withDescription("Configured package target"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -266,7 +270,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("update", {
|
||||
description: "Update package plugins",
|
||||
params: {
|
||||
target: Argument.string("target").pipe(
|
||||
target: Argument.String("target").pipe(
|
||||
Argument.withDescription("Configured package target; omit to update all outdated plugins"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -275,7 +279,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("remove", {
|
||||
description: "Remove a plugin from global configuration",
|
||||
params: {
|
||||
package: Argument.string("package").pipe(Argument.withDescription("configured package specifier")),
|
||||
package: Argument.String("package").pipe(Argument.withDescription("configured package specifier")),
|
||||
},
|
||||
}),
|
||||
],
|
||||
@@ -288,108 +292,108 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "Show shareable usage statistics",
|
||||
params: {
|
||||
...ServerParams,
|
||||
days: Flag.integer("days").pipe(
|
||||
days: Flag.Int("days").pipe(
|
||||
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
|
||||
Flag.withDescription("Show the last N days; 0 means today"),
|
||||
Flag.optional,
|
||||
),
|
||||
year: Flag.integer("year").pipe(
|
||||
year: Flag.Int("year").pipe(
|
||||
Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1970, maximum: 9_999 }))),
|
||||
Flag.withDescription("Show a calendar year"),
|
||||
Flag.optional,
|
||||
),
|
||||
all: Flag.boolean("all").pipe(Flag.withDescription("Show lifetime statistics"), Flag.withDefault(false)),
|
||||
project: Flag.string("project").pipe(
|
||||
all: Flag.Boolean("all").pipe(Flag.withDescription("Show lifetime statistics"), Flag.withDefault(false)),
|
||||
project: Flag.String("project").pipe(
|
||||
Flag.withDescription('Filter by project ID, or use "." for the current project'),
|
||||
Flag.optional,
|
||||
),
|
||||
models: Flag.boolean("models").pipe(Flag.withDescription("Show model usage"), Flag.withDefault(false)),
|
||||
tools: Flag.boolean("tools").pipe(Flag.withDescription("Show tool reliability"), Flag.withDefault(false)),
|
||||
cost: Flag.boolean("cost").pipe(Flag.withDescription("Show cost and token details"), Flag.withDefault(false)),
|
||||
full: Flag.boolean("full").pipe(Flag.withDescription("Show every detailed section"), Flag.withDefault(false)),
|
||||
limit: Flag.integer("limit").pipe(
|
||||
models: Flag.Boolean("models").pipe(Flag.withDescription("Show model usage"), Flag.withDefault(false)),
|
||||
tools: Flag.Boolean("tools").pipe(Flag.withDescription("Show tool reliability"), Flag.withDefault(false)),
|
||||
cost: Flag.Boolean("cost").pipe(Flag.withDescription("Show cost and token details"), Flag.withDefault(false)),
|
||||
full: Flag.Boolean("full").pipe(Flag.withDescription("Show every detailed section"), Flag.withDefault(false)),
|
||||
limit: Flag.Int("limit").pipe(
|
||||
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
|
||||
Flag.withDescription("Number of rows in detailed sections"),
|
||||
Flag.withDefault(5),
|
||||
),
|
||||
json: Flag.boolean("json").pipe(Flag.withDescription("Output statistics as JSON"), Flag.withDefault(false)),
|
||||
json: Flag.Boolean("json").pipe(Flag.withDescription("Output statistics as JSON"), Flag.withDefault(false)),
|
||||
},
|
||||
}),
|
||||
Spec.make("mini", {
|
||||
description: "Start the minimal interactive interface",
|
||||
params: {
|
||||
...ServerParams,
|
||||
continue: Flag.boolean("continue").pipe(
|
||||
continue: Flag.Boolean("continue").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Continue the last session"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
session: Flag.string("session").pipe(
|
||||
session: Flag.String("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
fork: Flag.boolean("fork").pipe(
|
||||
fork: Flag.Boolean("fork").pipe(
|
||||
Flag.withDescription("Fork the session when continuing"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
replay: Flag.boolean("replay").pipe(
|
||||
replay: Flag.Boolean("replay").pipe(
|
||||
Flag.withDescription("Restore session history on resume and resize (disable with --no-replay)"),
|
||||
Flag.optional,
|
||||
),
|
||||
replayLimit: Flag.integer("replay-limit").pipe(
|
||||
replayLimit: Flag.Int("replay-limit").pipe(
|
||||
Flag.withDescription("Limit replay to the newest N messages (default: 200)"),
|
||||
Flag.optional,
|
||||
),
|
||||
model: Flag.string("model").pipe(
|
||||
model: Flag.String("model").pipe(
|
||||
Flag.withAlias("m"),
|
||||
Flag.withDescription("Model to use in the format provider/model"),
|
||||
Flag.optional,
|
||||
),
|
||||
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
|
||||
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
demo: Flag.boolean("demo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
agent: Flag.String("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
|
||||
prompt: Flag.String("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
demo: Flag.Boolean("demo").pipe(Flag.withDefault(false), Flag.withHidden),
|
||||
},
|
||||
}),
|
||||
Spec.make("run", {
|
||||
description: "Run OpenCode with a message",
|
||||
params: {
|
||||
...ServerParams,
|
||||
message: Argument.string("message").pipe(
|
||||
message: Argument.String("message").pipe(
|
||||
Argument.withDescription("Message to send"),
|
||||
Argument.variadic({ min: 0 }),
|
||||
),
|
||||
continue: Flag.boolean("continue").pipe(
|
||||
continue: Flag.Boolean("continue").pipe(
|
||||
Flag.withAlias("c"),
|
||||
Flag.withDescription("Continue the last session"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
session: Flag.string("session").pipe(
|
||||
session: Flag.String("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
fork: Flag.boolean("fork").pipe(
|
||||
fork: Flag.Boolean("fork").pipe(
|
||||
Flag.withDescription("Fork the session before continuing"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
model: Flag.string("model").pipe(
|
||||
model: Flag.String("model").pipe(
|
||||
Flag.withAlias("m"),
|
||||
Flag.withDescription("Model to use in the format provider/model#variant"),
|
||||
Flag.optional,
|
||||
),
|
||||
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
|
||||
format: Flag.choice("format", ["default", "json"]).pipe(
|
||||
agent: Flag.String("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional),
|
||||
format: Flag.Literals("format", ["default", "json"]).pipe(
|
||||
Flag.withDescription("Output format"),
|
||||
Flag.withDefault("default"),
|
||||
),
|
||||
file: Flag.string("file").pipe(
|
||||
file: Flag.String("file").pipe(
|
||||
Flag.withAlias("f"),
|
||||
Flag.withDescription("File to attach to the message"),
|
||||
Flag.atMost(100),
|
||||
),
|
||||
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional),
|
||||
thinking: Flag.boolean("thinking").pipe(Flag.withDescription("Show thinking blocks"), Flag.withDefault(false)),
|
||||
title: Flag.String("title").pipe(Flag.withDescription("Session title"), Flag.optional),
|
||||
thinking: Flag.Boolean("thinking").pipe(Flag.withDescription("Show thinking blocks"), Flag.withDefault(false)),
|
||||
...PermissionParams,
|
||||
},
|
||||
}),
|
||||
@@ -400,13 +404,13 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "List top-level sessions in the current project, newest first",
|
||||
params: {
|
||||
...ServerParams,
|
||||
maxCount: Flag.integer("max-count").pipe(
|
||||
maxCount: Flag.Int("max-count").pipe(
|
||||
Flag.withAlias("n"),
|
||||
Flag.withSchema(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
|
||||
Flag.withDescription("Limit to N most recent sessions (default: 100)"),
|
||||
Flag.optional,
|
||||
),
|
||||
format: Flag.choice("format", ["table", "json"]).pipe(
|
||||
format: Flag.Literals("format", ["table", "json"]).pipe(
|
||||
Flag.withDescription("Output format"),
|
||||
Flag.withDefault("table"),
|
||||
),
|
||||
@@ -416,18 +420,18 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "Delete a session and its child sessions",
|
||||
params: {
|
||||
...ServerParams,
|
||||
sessionID: Argument.string("sessionID").pipe(Argument.withDescription("Session ID to delete")),
|
||||
sessionID: Argument.String("sessionID").pipe(Argument.withDescription("Session ID to delete")),
|
||||
},
|
||||
}),
|
||||
Spec.make("export", {
|
||||
description: "Export session data as JSON",
|
||||
params: {
|
||||
...ServerParams,
|
||||
session: Argument.string("session").pipe(
|
||||
session: Argument.String("session").pipe(
|
||||
Argument.withDescription("Session ID to export"),
|
||||
Argument.optional,
|
||||
),
|
||||
sanitize: Flag.boolean("sanitize").pipe(
|
||||
sanitize: Flag.Boolean("sanitize").pipe(
|
||||
Flag.withDescription("Redact sensitive transcript and file data"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
@@ -437,8 +441,8 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
description: "Import session data from a JSON file or URL",
|
||||
params: {
|
||||
...ServerParams,
|
||||
file: Argument.string("file").pipe(Argument.withDescription("JSON file or URL to import")),
|
||||
directory: Flag.string("directory").pipe(
|
||||
file: Argument.String("file").pipe(Argument.withDescription("JSON file or URL to import")),
|
||||
directory: Flag.String("directory").pipe(
|
||||
Flag.withDescription("Directory in which to import the session"),
|
||||
Flag.optional,
|
||||
),
|
||||
@@ -456,8 +460,8 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("get", {
|
||||
description: "Get service configuration",
|
||||
params: {
|
||||
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env"), Argument.optional),
|
||||
name: Argument.string("name").pipe(
|
||||
key: Argument.String("key").pipe(Argument.withDescription("Service setting or env"), Argument.optional),
|
||||
name: Argument.String("name").pipe(
|
||||
Argument.withDescription("Environment variable name"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -466,11 +470,11 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("set", {
|
||||
description: "Set service configuration",
|
||||
params: {
|
||||
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
|
||||
value: Argument.string("value").pipe(
|
||||
key: Argument.String("key").pipe(Argument.withDescription("Service setting or env")),
|
||||
value: Argument.String("value").pipe(
|
||||
Argument.withDescription("Setting value or environment variable name"),
|
||||
),
|
||||
nestedValue: Argument.string("env-value").pipe(
|
||||
nestedValue: Argument.String("env-value").pipe(
|
||||
Argument.withDescription("Environment variable value"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -479,8 +483,8 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("unset", {
|
||||
description: "Unset service configuration",
|
||||
params: {
|
||||
key: Argument.string("key").pipe(Argument.withDescription("Service setting or env")),
|
||||
name: Argument.string("name").pipe(
|
||||
key: Argument.String("key").pipe(Argument.withDescription("Service setting or env")),
|
||||
name: Argument.String("name").pipe(
|
||||
Argument.withDescription("Environment variable name"),
|
||||
Argument.optional,
|
||||
),
|
||||
@@ -497,7 +501,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("pair", {
|
||||
description: "Print one-time links to connect a browser or app",
|
||||
params: {
|
||||
url: Flag.string("url").pipe(
|
||||
url: Flag.String("url").pipe(
|
||||
Flag.withDescription("Use an external HTTP(S) server URL in pairing links"),
|
||||
Flag.mapTryCatch(
|
||||
(value) => {
|
||||
@@ -515,15 +519,15 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
Spec.make("serve", {
|
||||
description: "Start the v2 API and web server",
|
||||
params: {
|
||||
hostname: Flag.string("hostname").pipe(Flag.optional),
|
||||
port: Flag.integer("port").pipe(Flag.optional),
|
||||
cors: Flag.string("cors").pipe(
|
||||
hostname: Flag.String("hostname").pipe(Flag.optional),
|
||||
port: Flag.Int("port").pipe(Flag.optional),
|
||||
cors: Flag.String("cors").pipe(
|
||||
Flag.withSchema(Schema.NonEmptyString),
|
||||
Flag.withDescription("Additional allowed CORS origin (repeat for multiple origins)"),
|
||||
Flag.atLeast(0),
|
||||
),
|
||||
service: Flag.boolean("service").pipe(Flag.withDefault(false)),
|
||||
stdio: Flag.boolean("stdio").pipe(Flag.withDefault(false)),
|
||||
service: Flag.Boolean("service").pipe(Flag.withDefault(false)),
|
||||
stdio: Flag.Boolean("stdio").pipe(Flag.withDefault(false)),
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { autocomplete, intro, log, outro, select, spinner, text } from "@clack/prompts"
|
||||
import { intro, log, outro, select, spinner, text } from "@clack/prompts"
|
||||
import { Effect, Option } from "effect"
|
||||
import type { FormAnswer, IntegrationInfo, OpenCodeClient } from "@opencode/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { selectIntegration, type IntegrationChoice } from "../../../ui/integration-picker"
|
||||
import { handlePromptErrors, openUrl, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
import { answerForm, secret } from "./form"
|
||||
import {
|
||||
@@ -21,10 +22,8 @@ const integrationPriority = new Map([
|
||||
["opencode", 1],
|
||||
["openai", 2],
|
||||
["github-copilot", 3],
|
||||
["google", 4],
|
||||
["anthropic", 5],
|
||||
["openrouter", 6],
|
||||
["vercel", 7],
|
||||
["anthropic", 4],
|
||||
["google", 5],
|
||||
])
|
||||
|
||||
export default Runtime.handler(
|
||||
@@ -74,30 +73,35 @@ const findIntegration = Effect.fn("cli.auth.login.integration")(function* (clien
|
||||
}
|
||||
const integrations = yield* loadIntegrations(client)
|
||||
if (target) return yield* resolveIntegration(integrations, target)
|
||||
const available = integrations
|
||||
const choices = loginChoices(integrations)
|
||||
if (choices.length === 0) return yield* Effect.fail(new Error("No authentication integrations are available"))
|
||||
const id = yield* prompt<string>(() => selectIntegration(choices))
|
||||
return yield* resolveIntegration(integrations, id)
|
||||
})
|
||||
|
||||
export function loginChoices(integrations: IntegrationInfo[]): IntegrationChoice[] {
|
||||
return integrations
|
||||
.filter((integration) => connectMethods(integration).length > 0)
|
||||
.toSorted(
|
||||
(a, b) =>
|
||||
Number(b.metadata?.source === "mcp") - Number(a.metadata?.source === "mcp") ||
|
||||
(integrationPriority.get(a.id) ?? integrationPriority.size) -
|
||||
(integrationPriority.get(b.id) ?? integrationPriority.size) ||
|
||||
a.name.localeCompare(b.name) ||
|
||||
a.id.localeCompare(b.id),
|
||||
)
|
||||
if (available.length === 0) return yield* Effect.fail(new Error("No authentication integrations are available"))
|
||||
const id = yield* prompt<string>(() =>
|
||||
autocomplete({
|
||||
message: "Select integration",
|
||||
maxItems: 8,
|
||||
options: available.map((integration) => {
|
||||
const option = { value: integration.id, label: integration.name, hint: integration.id }
|
||||
if (integration.connections.length > 0) return { ...option, hint: "connected" }
|
||||
if (integration.id === "opencode") return { ...option, hint: "recommended" }
|
||||
return option
|
||||
}),
|
||||
}),
|
||||
)
|
||||
return yield* resolveIntegration(available, id)
|
||||
})
|
||||
.map((integration) => ({
|
||||
value: integration.id,
|
||||
label: integration.name,
|
||||
category:
|
||||
integration.metadata?.source === "mcp"
|
||||
? "MCP"
|
||||
: integrationPriority.has(integration.id)
|
||||
? "Popular"
|
||||
: "Services",
|
||||
connected: integration.connections.length > 0,
|
||||
}))
|
||||
}
|
||||
|
||||
const chooseMethod = Effect.fn("cli.auth.login.method")(function* (methods: ConnectMethod[], target?: string) {
|
||||
if (target) return yield* resolveMethod(methods, target)
|
||||
@@ -143,17 +147,18 @@ const keyLogin = Effect.fn("cli.auth.login.key")(function* (
|
||||
)
|
||||
})
|
||||
|
||||
const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* (
|
||||
export const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* (
|
||||
client: OpenCodeClient,
|
||||
integration: IntegrationInfo,
|
||||
method: Extract<ConnectMethod, { type: "oauth" }>,
|
||||
answer?: FormAnswer,
|
||||
label?: string,
|
||||
) {
|
||||
const progress = spinner()
|
||||
progress.start("Starting authorization...")
|
||||
const started = yield* request((signal) =>
|
||||
client.integration.oauth.connect(
|
||||
{ integrationID: integration.id, methodID: method.id, answer, location },
|
||||
{ integrationID: integration.id, methodID: method.id, answer, label, location },
|
||||
{ signal },
|
||||
),
|
||||
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
|
||||
@@ -190,16 +195,14 @@ const oauthLogin = Effect.fn("cli.auth.login.oauth")(function* (
|
||||
return
|
||||
}
|
||||
|
||||
const waiting = spinner()
|
||||
waiting.start("Waiting for authorization...")
|
||||
const status = yield* waitForOAuth(client, integration.id, attempt.attemptID).pipe(
|
||||
Effect.tapCause(() => Effect.sync(() => waiting.stop("Authentication failed", 1))),
|
||||
)
|
||||
// Clack's spinner captures Ctrl+C and exits the process directly, which would skip the finalizer that
|
||||
// cancels the attempt. Waits that can last minutes use plain log lines so Ctrl+C interrupts normally.
|
||||
log.step("Waiting for authorization...")
|
||||
const status = yield* waitForOAuth(client, integration.id, attempt.attemptID)
|
||||
if (status.status === "complete") {
|
||||
waiting.stop(`Connected to ${integration.name}`)
|
||||
log.success(`Connected to ${integration.name}`)
|
||||
return
|
||||
}
|
||||
waiting.stop("Authentication failed", 1)
|
||||
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
|
||||
yield* Effect.fail(new Error("Authorization expired"))
|
||||
})
|
||||
@@ -226,14 +229,21 @@ const commandLogin = Effect.fn("cli.auth.login.command")(function* (
|
||||
),
|
||||
).pipe(Effect.ignore),
|
||||
)
|
||||
const status = yield* waitForCommand(client, integration.id, started.data.attemptID, (message) =>
|
||||
progress.message(message.trim() || "Waiting for authentication command..."),
|
||||
).pipe(Effect.tapCause(() => Effect.sync(() => progress.stop("Authentication failed", 1))))
|
||||
progress.stop("Authentication command started")
|
||||
// The status message accumulates the command's stderr; print each completed line once.
|
||||
let printed = 0
|
||||
log.step("Waiting for authentication command...")
|
||||
const status = yield* waitForCommand(client, integration.id, started.data.attemptID, (message) => {
|
||||
const end = message.lastIndexOf("\n") + 1
|
||||
if (end <= printed) return
|
||||
const output = message.slice(printed, end).trim()
|
||||
printed = end
|
||||
if (output) log.message(output)
|
||||
})
|
||||
if (status.status === "complete") {
|
||||
progress.stop(`Connected to ${integration.name}`)
|
||||
log.success(`Connected to ${integration.name}`)
|
||||
return
|
||||
}
|
||||
progress.stop("Authentication failed", 1)
|
||||
if (status.status === "failed") yield* Effect.fail(new Error(status.message))
|
||||
yield* Effect.fail(new Error("Authentication expired"))
|
||||
})
|
||||
|
||||
@@ -1,65 +1,109 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
OpenCode,
|
||||
type IntegrationAttemptStatus,
|
||||
type IntegrationOAuthMethod,
|
||||
type OpenCodeClient,
|
||||
} from "@opencode/client"
|
||||
import { confirm, intro, log, outro } from "@clack/prompts"
|
||||
import { Effect, Option } from "effect"
|
||||
import { OpenCode, type IntegrationInfo, type IntegrationOAuthMethod, type McpServer } from "@opencode/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { resolveIntegration } from "./resolve"
|
||||
import { selectIntegration, type IntegrationChoice } from "../../../ui/integration-picker"
|
||||
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
import { answerForm } from "../auth/form"
|
||||
import { oauthLogin } from "../auth/login"
|
||||
import { loadIntegrations, request } from "../auth/shared"
|
||||
|
||||
const location = { directory: process.cwd() }
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.auth,
|
||||
Effect.fn("cli.mcp.auth")(function* (input) {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
if (!integration)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
|
||||
const method = integration.methods.find(
|
||||
(candidate): candidate is IntegrationOAuthMethod => candidate.type === "oauth",
|
||||
)
|
||||
if (!method)
|
||||
return yield* Effect.fail(new Error(`MCP server "${input.name}" is not an OAuth-capable remote server`))
|
||||
|
||||
const started = yield* Effect.promise(() =>
|
||||
client.integration.oauth.connect({ integrationID: integration.id, methodID: method.id, location }),
|
||||
)
|
||||
const attempt = started.data
|
||||
if (attempt.mode === "code")
|
||||
return yield* Effect.fail(new Error("This server requires manual code entry, which the CLI does not support"))
|
||||
|
||||
process.stdout.write(attempt.instructions + EOL + attempt.url + EOL)
|
||||
|
||||
const result = yield* poll(client, integration.id, attempt.attemptID)
|
||||
if (result.status === "complete") {
|
||||
process.stdout.write(`Authenticated with ${input.name}` + EOL)
|
||||
return
|
||||
}
|
||||
const reason = result.status === "failed" ? `: ${result.message}` : ""
|
||||
return yield* Effect.fail(new Error(`Authentication ${result.status}${reason}`))
|
||||
}),
|
||||
Effect.fn("cli.mcp.auth")((input) => authenticate(Option.getOrUndefined(input.name)).pipe(handlePromptErrors)),
|
||||
)
|
||||
|
||||
const poll = (
|
||||
client: OpenCodeClient,
|
||||
integrationID: string,
|
||||
attemptID: string,
|
||||
): Effect.Effect<Exclude<IntegrationAttemptStatus, { status: "pending" }>> =>
|
||||
Effect.gen(function* () {
|
||||
const status = yield* Effect.promise(() =>
|
||||
client.integration.oauth.status({ integrationID, attemptID, location }),
|
||||
).pipe(Effect.map((result) => result.data))
|
||||
if (status.status === "pending") {
|
||||
yield* Effect.sleep("1 second")
|
||||
return yield* poll(client, integrationID, attemptID)
|
||||
const authenticate = Effect.fn("cli.mcp.auth.run")(function* (name?: string) {
|
||||
if (!name) yield* requireInteractive("Pass an MCP server name when running without an interactive terminal")
|
||||
intro("Authenticate an MCP server")
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const integrations = yield* loadIntegrations(client)
|
||||
const servers = yield* request((signal) => client.mcp.list({ location }, { signal }))
|
||||
const choices = mcpAuthChoices(servers.data, integrations)
|
||||
if (!name && choices.length === 0) {
|
||||
log.warn("No OAuth-capable MCP servers configured")
|
||||
log.info(
|
||||
`Remote MCP servers support OAuth by default. Add one with \`opencode mcp add\` or in opencode.json:\n${exampleConfig}`,
|
||||
)
|
||||
outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
const server = name ? servers.data.find((item) => item.name === name) : undefined
|
||||
if (name && !server) return yield* Effect.fail(new Error(`MCP server not found: ${name}`))
|
||||
const integrationID = server
|
||||
? server.integrationID
|
||||
: yield* prompt<string>(() => selectIntegration(choices, "MCP server"))
|
||||
const integration = integrations.find((item) => item.id === integrationID)
|
||||
const method = integration?.methods.find(
|
||||
(candidate): candidate is IntegrationOAuthMethod => candidate.type === "oauth",
|
||||
)
|
||||
if (!integration || !method)
|
||||
return yield* Effect.fail(new Error(`MCP server "${name}" is not an OAuth-capable remote server`))
|
||||
|
||||
if (integration.connections.length > 0) {
|
||||
const status = servers.data.find((item) => item.integrationID === integration.id)?.status.status
|
||||
if (status === "needs_auth") log.warn(`${integration.name} has expired credentials. Re-authenticating...`)
|
||||
if (status !== "needs_auth" && process.stdin.isTTY && process.stdout.isTTY) {
|
||||
const again = yield* prompt<boolean>(() =>
|
||||
confirm({ message: `${integration.name} already has valid credentials. Re-authenticate?` }),
|
||||
)
|
||||
if (!again) {
|
||||
outro("Cancelled")
|
||||
return
|
||||
}
|
||||
}
|
||||
return status
|
||||
})
|
||||
}
|
||||
|
||||
// Re-authenticating replaces the previous sign-in rather than adding an account. The new credential
|
||||
// keeps the active one's label, and the old ones are only removed once it is stored, so a failed
|
||||
// attempt keeps them.
|
||||
const previous = integration.connections.filter((connection) => connection.type === "credential")
|
||||
yield* oauthLogin(client, integration, method, yield* answerForm(method.form), previous[0]?.label)
|
||||
yield* Effect.forEach(
|
||||
previous,
|
||||
(connection) => request((signal) => client.credential.remove({ credentialID: connection.id }, { signal })),
|
||||
{ discard: true },
|
||||
)
|
||||
outro("Done")
|
||||
})
|
||||
|
||||
const exampleConfig = `
|
||||
"mcp": {
|
||||
"my-server": {
|
||||
"type": "remote",
|
||||
"url": "https://example.com/mcp"
|
||||
}
|
||||
}`
|
||||
|
||||
// Choices carry the server-owned integration ID so provider integrations with colliding names never match.
|
||||
export function mcpAuthChoices(servers: McpServer[], integrations: IntegrationInfo[]): IntegrationChoice[] {
|
||||
const byID = new Map(integrations.map((integration) => [integration.id, integration]))
|
||||
return servers
|
||||
.flatMap((server) => {
|
||||
const integration = server.integrationID ? byID.get(server.integrationID) : undefined
|
||||
if (!integration?.methods.some((method) => method.type === "oauth")) return []
|
||||
return [
|
||||
{
|
||||
value: integration.id,
|
||||
label: server.name,
|
||||
category: "MCP" as const,
|
||||
connected: integration.connections.length > 0,
|
||||
hint: statusHint(server.status),
|
||||
},
|
||||
]
|
||||
})
|
||||
.toSorted((a, b) => a.label.localeCompare(b.label) || a.value.localeCompare(b.value))
|
||||
}
|
||||
|
||||
function statusHint(status: McpServer["status"]) {
|
||||
if (status.status === "needs_auth") return "needs authentication"
|
||||
if (status.status === "failed" || status.status === "disabled") return status.status
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import { Config } from "effect"
|
||||
// The opencode server password: sent by clients connecting to an explicit
|
||||
// --server, and adopted by a manually run or standalone server. The legacy
|
||||
// name is still honored.
|
||||
export const password = Config.redacted("OPENCODE_PASSWORD").pipe(
|
||||
Config.orElse(() => Config.redacted("OPENCODE_SERVER_PASSWORD")),
|
||||
export const password = Config.Redacted("OPENCODE_PASSWORD").pipe(
|
||||
Config.orElse(() => Config.Redacted("OPENCODE_SERVER_PASSWORD")),
|
||||
Config.withDefault(undefined),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Service, type Info } from "@opencode/client/effect/service"
|
||||
import path from "node:path"
|
||||
import { Effect, FileSystem, Schedule, Schema } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { NetAddress } from "effect/unstable/net"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
|
||||
const infoJson = Schema.fromJsonString(Service.Info)
|
||||
@@ -11,7 +12,7 @@ const encodeInfo = Schema.encodeEffect(infoJson)
|
||||
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
|
||||
|
||||
export const register = Effect.fnUntraced(function* (options: {
|
||||
readonly address: HttpServer.Address
|
||||
readonly address: NetAddress.SocketAddress
|
||||
readonly password: string
|
||||
readonly id: string
|
||||
readonly file: string
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NodeSocket } from "@effect/platform-node"
|
||||
import { Effect, Schema, Stdio, Stream } from "effect"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
|
||||
const Response = Schema.fromJsonString(Schema.Struct({ value: Schema.NullOr(Schema.String) }))
|
||||
const Port = Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThan(0), Schema.isLessThanOrEqualTo(65535))
|
||||
@@ -10,16 +11,15 @@ export const askpass = Effect.gen(function* () {
|
||||
const port = yield* Schema.decodeUnknownEffect(Port)(process.env.OPENCODE_SSH_ASKPASS_PORT)
|
||||
const stdio = yield* Stdio.Stdio
|
||||
const socket = yield* NodeSocket.makeNet({ host: "127.0.0.1", port })
|
||||
const write = yield* socket.writer
|
||||
const writer = yield* socket.writer
|
||||
const response = { text: "" }
|
||||
yield* Effect.all(
|
||||
[
|
||||
socket.runString((text) =>
|
||||
Effect.sync(() => {
|
||||
response.text += text
|
||||
}),
|
||||
),
|
||||
write(
|
||||
Effect.gen(function* () {
|
||||
const pull = yield* Socket.readerString(socket)
|
||||
while (true) response.text += (yield* pull).join("")
|
||||
}).pipe(Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void)),
|
||||
writer.write(
|
||||
JSON.stringify({
|
||||
token: process.env.OPENCODE_SSH_ASKPASS_TOKEN,
|
||||
text: process.argv.slice(2).join(" "),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { AutocompletePrompt } from "@clack/core"
|
||||
import { S_BAR, S_BAR_END, S_RADIO_ACTIVE, S_RADIO_INACTIVE, symbol } from "@clack/prompts"
|
||||
import color from "picocolors"
|
||||
|
||||
export type IntegrationChoice = {
|
||||
value: string
|
||||
label: string
|
||||
category: "MCP" | "Popular" | "Services"
|
||||
connected: boolean
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export async function selectIntegration(choices: IntegrationChoice[], kind = "integration") {
|
||||
const result = await new AutocompletePrompt<IntegrationChoice>({
|
||||
options: choices,
|
||||
filter: (search, choice) =>
|
||||
[choice.label, choice.value, choice.category].some((value) => value.toLowerCase().includes(search.toLowerCase())),
|
||||
validate: (value) => (value ? undefined : `Select an ${kind}`),
|
||||
render() {
|
||||
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} Select ${kind}`
|
||||
if (this.state === "submit") {
|
||||
const choice = choices.find((item) => item.value === this.value)
|
||||
return `${title}\n${color.gray(S_BAR)} ${color.dim(choice?.label ?? "")}`
|
||||
}
|
||||
if (this.state === "cancel")
|
||||
return `${title}\n${color.gray(S_BAR)} ${color.strikethrough(color.dim(this.userInput))}`
|
||||
|
||||
// Leave room for the category headings as well as Clack's title and footer.
|
||||
const maxItems = Math.min(8, Math.max(2, (process.stdout.rows ?? 24) - 14 - Number(this.state === "error")))
|
||||
const compact = (process.stdout.rows ?? 24) < 18
|
||||
const start = Math.min(
|
||||
Math.max(0, this.cursor - Math.min(2, maxItems - 1)),
|
||||
Math.max(0, this.filteredOptions.length - maxItems),
|
||||
)
|
||||
const visible = this.filteredOptions.slice(start, start + maxItems)
|
||||
const rows = visible.flatMap((choice, index) => [
|
||||
...(index === 0 || visible[index - 1].category !== choice.category
|
||||
? [...(compact ? [] : [`${color.cyan(S_BAR)} `]), `${color.cyan(S_BAR)} ${color.bold(choice.category)}`]
|
||||
: []),
|
||||
`${color.cyan(S_BAR)} ${start + index === this.cursor ? color.green(S_RADIO_ACTIVE) : color.dim(S_RADIO_INACTIVE)} ${
|
||||
start + index === this.cursor ? choice.label : color.dim(choice.label)
|
||||
}${choice.connected ? ` ${color.green("✓")}` : ""}${choice.hint ? ` ${color.dim(`(${choice.hint})`)}` : ""}`,
|
||||
])
|
||||
return [
|
||||
title,
|
||||
`${color.cyan(S_BAR)} ${color.dim("Search:")} ${this.isNavigating ? color.dim(this.userInput) : this.userInputWithCursor}`,
|
||||
...(visible.length === 0 && this.userInput
|
||||
? [`${color.cyan(S_BAR)} ${color.yellow(`No ${kind}s found`)}`]
|
||||
: []),
|
||||
...(this.state === "error" && visible.length > 0
|
||||
? [`${color.yellow(S_BAR)} ${color.yellow(this.error)}`]
|
||||
: []),
|
||||
...(start > 0 ? [`${color.cyan(S_BAR)} ${color.dim("…")}`] : []),
|
||||
...rows,
|
||||
...(start + maxItems < this.filteredOptions.length ? [`${color.cyan(S_BAR)} ${color.dim("…")}`] : []),
|
||||
`${color.cyan(S_BAR)} ${color.dim(
|
||||
(process.stdout.columns ?? 80) < 50
|
||||
? "↑/↓ navigate • Enter select"
|
||||
: "↑/↓ to select • Enter: confirm • Type: to search",
|
||||
)}`,
|
||||
color.cyan(S_BAR_END),
|
||||
].join("\n")
|
||||
},
|
||||
}).prompt()
|
||||
if (typeof result === "string" || typeof result === "symbol") return result
|
||||
throw new Error(`No ${kind} selected`)
|
||||
}
|
||||
@@ -16,12 +16,13 @@ export function requireInteractive(message: string) {
|
||||
}
|
||||
|
||||
export const openUrl = Effect.fn("cli.prompt.open-url")(function* (url: string) {
|
||||
const { default: open } = yield* Effect.promise(() => import("open"))
|
||||
yield* Effect.promise(() => open(url)).pipe(Effect.ignore)
|
||||
const browser = yield* Effect.promise(() => import("@opencode/util/open"))
|
||||
yield* Effect.promise(() => browser.openUrl(url)).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
export function handlePromptErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
return effect.pipe(
|
||||
Effect.onInterrupt(() => Effect.sync(() => cancel("Cancelled"))),
|
||||
Effect.catchIf(
|
||||
(error) => error === cancelled,
|
||||
() =>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { IntegrationInfo } from "@opencode/client"
|
||||
import { loginChoices } from "../src/commands/handlers/auth/login"
|
||||
|
||||
const integration = (value: Partial<IntegrationInfo> & Pick<IntegrationInfo, "id" | "name">): IntegrationInfo => ({
|
||||
methods: [{ type: "key" }],
|
||||
connections: [],
|
||||
...value,
|
||||
})
|
||||
|
||||
test("groups the CLI choices like /connect while keeping stable login IDs", () => {
|
||||
expect(
|
||||
loginChoices([
|
||||
integration({ id: "mistral", name: "Mistral" }),
|
||||
integration({ id: "openai", name: "OpenAI" }),
|
||||
integration({ id: "linear", name: "Linear", metadata: { source: "mcp" } }),
|
||||
integration({ id: "github", name: "GitHub", metadata: { source: "mcp" } }),
|
||||
integration({ id: "opencode", name: "OpenCode Console" }),
|
||||
integration({ id: "opencode-go", name: "OpenCode Go", connections: [{ type: "env", name: "GO_KEY" }] }),
|
||||
integration({ id: "unused", name: "Unused", methods: [{ type: "env", names: ["UNUSED_KEY"] }] }),
|
||||
]),
|
||||
).toEqual([
|
||||
{ value: "github", label: "GitHub", category: "MCP", connected: false },
|
||||
{ value: "linear", label: "Linear", category: "MCP", connected: false },
|
||||
{ value: "opencode-go", label: "OpenCode Go", category: "Popular", connected: true },
|
||||
{ value: "opencode", label: "OpenCode Console", category: "Popular", connected: false },
|
||||
{ value: "openai", label: "OpenAI", category: "Popular", connected: false },
|
||||
{ value: "mistral", label: "Mistral", category: "Services", connected: false },
|
||||
])
|
||||
})
|
||||
@@ -20,12 +20,12 @@ describe("auth command", () => {
|
||||
expect(auth.stdout).toContain("list")
|
||||
expect(auth.stdout).toContain("login")
|
||||
expect(auth.stdout).toContain("logout")
|
||||
expect(auth.stdout).toContain("manage AI providers and credentials")
|
||||
expect(auth.stdout).toContain("list providers and credentials")
|
||||
expect(auth.stdout).toContain("log in to a provider")
|
||||
expect(auth.stdout).toContain("manage integrations and credentials")
|
||||
expect(auth.stdout).toContain("list integrations and credentials")
|
||||
expect(auth.stdout).toContain("connect an integration")
|
||||
expect(auth.stdout).toContain("log out of a saved account")
|
||||
expect(auth.stdout).toContain("switch the active account for an integration")
|
||||
expect(auth.stdout).not.toContain("connect")
|
||||
expect(auth.stdout).not.toMatch(/^ connect\s/m)
|
||||
expect(list.exitCode).toBe(0)
|
||||
expect(list.stdout).toContain("opencode auth list [flags]")
|
||||
expect(list.stdout).toContain("--format")
|
||||
@@ -216,7 +216,8 @@ describe("auth command", () => {
|
||||
expect(requests).toContainEqual({ method: "DELETE", path: `${endpoint}/con_oauth` })
|
||||
})
|
||||
|
||||
test("settles the OAuth spinner when status polling fails", async () => {
|
||||
test("reports OAuth status polling failures and cancels the attempt", async () => {
|
||||
let cancelled = false
|
||||
using server = authServer((request, url) => {
|
||||
if (url.pathname === "/api/integration") {
|
||||
return Response.json(
|
||||
@@ -245,6 +246,7 @@ describe("auth command", () => {
|
||||
return new Response("Unavailable", { status: 500 })
|
||||
}
|
||||
if (url.pathname === "/api/integration/openai/connect/oauth/con_oauth" && request.method === "DELETE") {
|
||||
cancelled = true
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
@@ -252,8 +254,10 @@ describe("auth command", () => {
|
||||
|
||||
const result = await cli(["auth", "login", "openai", "--server", server.url.toString()])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stdout).toContain("Authentication failed")
|
||||
expect(result.stdout).toContain("Waiting for authorization...")
|
||||
expect(result.stdout).toContain("UnexpectedStatus: 500")
|
||||
expect(result.stdout).toContain("Failed")
|
||||
expect(cancelled).toBe(true)
|
||||
expect(result.stdout).not.toContain("\n at ")
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import type { IntegrationInfo, McpServer } from "@opencode/client"
|
||||
import { mcpAuthChoices } from "../src/commands/handlers/mcp/auth"
|
||||
|
||||
const server = (
|
||||
name: string,
|
||||
integrationID?: string,
|
||||
status: McpServer["status"] = { status: "pending" },
|
||||
): McpServer => ({ name, integrationID, status })
|
||||
|
||||
const integration = (id: string, methods: IntegrationInfo["methods"], connected = false): IntegrationInfo => ({
|
||||
id,
|
||||
name: id,
|
||||
methods,
|
||||
connections: connected ? [{ type: "credential", method: "oauth", id: "cred_1", label: "Work" }] : [],
|
||||
})
|
||||
|
||||
test("offers only OAuth-capable MCP servers by their server identity", () => {
|
||||
expect(
|
||||
mcpAuthChoices(
|
||||
[
|
||||
server("Linear", "mcp_linear", { status: "needs_auth", error: "expired" }),
|
||||
server("Local"),
|
||||
server("API key only", "mcp_key"),
|
||||
server("GitHub", "mcp_github"),
|
||||
server("Sentry", "mcp_sentry", { status: "failed", error: "boom" }),
|
||||
server("Unresolved", "mcp_missing"),
|
||||
],
|
||||
[
|
||||
integration("mcp_linear", [{ type: "oauth", id: "login", label: "Linear" }], true),
|
||||
integration("mcp_github", [{ type: "oauth", id: "login", label: "GitHub" }]),
|
||||
integration("mcp_sentry", [{ type: "oauth", id: "login", label: "Sentry" }]),
|
||||
integration("mcp_key", [{ type: "key" }]),
|
||||
integration("Linear", [{ type: "oauth", id: "login", label: "A provider with a colliding name" }]),
|
||||
],
|
||||
),
|
||||
).toEqual([
|
||||
{ value: "mcp_github", label: "GitHub", category: "MCP", connected: false, hint: undefined },
|
||||
{ value: "mcp_linear", label: "Linear", category: "MCP", connected: true, hint: "needs authentication" },
|
||||
{ value: "mcp_sentry", label: "Sentry", category: "MCP", connected: false, hint: "failed" },
|
||||
])
|
||||
})
|
||||
|
||||
test("mcp auth accepts an optional server name and rejects no-name noninteractive calls before connecting", async () => {
|
||||
const cli = (args: string[]) =>
|
||||
Bun.spawn([process.execPath, "run", "src/index.ts", "mcp", "auth", ...args], {
|
||||
cwd: path.join(import.meta.dir, ".."),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const help = cli(["--help"])
|
||||
expect(await new Response(help.stdout).text()).toContain("opencode mcp auth [flags] [<name>]")
|
||||
expect(await help.exited).toBe(0)
|
||||
|
||||
const missing = cli([])
|
||||
expect(await new Response(missing.stdout).text()).toContain(
|
||||
"Pass an MCP server name when running without an interactive terminal",
|
||||
)
|
||||
expect(await new Response(missing.stderr).text()).toBe("")
|
||||
expect(await missing.exited).toBe(1)
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { Global } from "@opencode/util/global"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { NetAddress } from "effect/unstable/net"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
@@ -501,7 +502,7 @@ test("service registration replaces a stale owner with the bound address", async
|
||||
try {
|
||||
const cleanup = await Effect.runPromise(
|
||||
ServiceRegistration.register({
|
||||
address: { _tag: "TcpAddress", hostname: "127.0.0.1", port: 4321 },
|
||||
address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 4321),
|
||||
password: "secret",
|
||||
id: "owner",
|
||||
file: registration,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -37,7 +37,7 @@
|
||||
"@opencode/protocol": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-rc.112",
|
||||
"effect": "4.0.0-rc.117",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -2130,7 +2130,7 @@ export type ConfigEntry =
|
||||
settings?: ConfigModelSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
capabilities?: { tools?: boolean; input?: Array<string>; output?: Array<string> }
|
||||
capabilities?: ModelCapabilities
|
||||
variants?: Array<{
|
||||
id: string
|
||||
settings?: ConfigModelSettings
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { JsonPointer, Schema } from "effect"
|
||||
import { JsonPointer, Schema, SchemaAST } from "effect"
|
||||
import type { Tool, JsonSchema, SchemaType } from "./tool.js"
|
||||
|
||||
const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
|
||||
@@ -12,8 +12,14 @@ const renderKey = (name: string): string => (identifierSegment.test(name) ? name
|
||||
const effectNumberSentinel = (schema: JsonSchema) =>
|
||||
schema.type === "string" &&
|
||||
Array.isArray(schema.enum) &&
|
||||
schema.enum.length === 1 &&
|
||||
(schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity")
|
||||
(schema.enum.length === 1 ||
|
||||
(schema.enum.length === 3 && ["NaN", "Infinity", "-Infinity"].every((value) => schema.enum?.includes(value)))) &&
|
||||
schema.enum.every((value) => value === "NaN" || value === "Infinity" || value === "-Infinity")
|
||||
|
||||
const definitionName = (ref: string): string | undefined => {
|
||||
const tokens = JsonPointer.parseUriFragment(ref)
|
||||
return tokens?.length === 2 && (tokens[0] === "$defs" || tokens[0] === "definitions") ? tokens[1] : undefined
|
||||
}
|
||||
|
||||
const intersection = (members: ReadonlyArray<string>): string => {
|
||||
const concrete = members.filter((member) => member !== "unknown")
|
||||
@@ -38,8 +44,7 @@ const hasUnresolvedRef = (
|
||||
if (visited.has(schema)) return false
|
||||
const nextVisited = new Set([...visited, schema])
|
||||
if (schema.$ref !== undefined) {
|
||||
const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
|
||||
const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
|
||||
const name = definitionName(schema.$ref)
|
||||
if (name === undefined || definitions[name] === undefined || seen.has(name)) return true
|
||||
if (hasUnresolvedRef(definitions[name], definitions, new Set([...seen, name]), nextVisited)) return true
|
||||
}
|
||||
@@ -130,8 +135,7 @@ const renderSchema = (
|
||||
? ctx
|
||||
: { ...ctx, definitions: { ...ctx.definitions, ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) } }
|
||||
if (schema.$ref) {
|
||||
const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
|
||||
const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
|
||||
const name = definitionName(schema.$ref)
|
||||
if (!name || !nested.definitions[name] || seen.has(name)) return "unknown"
|
||||
return intersection([
|
||||
renderSchema(nested.definitions[name], nested, depth, new Set([...seen, name])),
|
||||
@@ -203,7 +207,7 @@ const renderSchema = (
|
||||
export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => {
|
||||
try {
|
||||
const visible = decoded ? Schema.toType(schema) : schema
|
||||
const document = Schema.toJsonSchemaDocument(visible) as {
|
||||
const document = Schema.toJsonSchemaDocument(visible, { onExcessProperty: "error" }) as {
|
||||
readonly schema: JsonSchema
|
||||
readonly definitions?: Readonly<Record<string, JsonSchema>>
|
||||
}
|
||||
@@ -230,7 +234,7 @@ export type InputProperty = {
|
||||
export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
|
||||
try {
|
||||
const document = isEffectSchema(tool.input)
|
||||
? (Schema.toJsonSchemaDocument(tool.input) as {
|
||||
? (Schema.toJsonSchemaDocument(tool.input, { onExcessProperty: "error" }) as {
|
||||
readonly schema: JsonSchema
|
||||
readonly definitions?: Readonly<Record<string, JsonSchema>>
|
||||
})
|
||||
@@ -241,8 +245,7 @@ export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
|
||||
const definitions = document.definitions ?? {}
|
||||
let schema = document.schema
|
||||
if (schema.$ref !== undefined) {
|
||||
const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
|
||||
const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
|
||||
const name = definitionName(schema.$ref)
|
||||
const resolved = name === undefined ? undefined : definitions[name]
|
||||
if (resolved === undefined) return []
|
||||
schema = resolved
|
||||
@@ -261,10 +264,13 @@ export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
|
||||
export const inputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
|
||||
isEffectSchema(tool.input) ? toTypeScript(tool.input, false, pretty) : jsonSchemaToTypeScript(tool.input, pretty)
|
||||
|
||||
// Empty object schemas render as `{}` in compact form; anything with properties,
|
||||
// an index signature, or union members renders differently, so equality is a
|
||||
// conservative emptiness test for both Effect and JSON Schema inputs.
|
||||
export const isEmptyInput = <R>(tool: Tool<R>): boolean => inputTypeScript(tool) === "{}"
|
||||
// Effect 4.0 models an empty Struct as a non-nullish object, so inspect the encoded AST callers supply.
|
||||
// Raw JSON Schema inputs retain the compact `{}` rendering check.
|
||||
export const isEmptyInput = <R>(tool: Tool<R>): boolean => {
|
||||
if (!isEffectSchema(tool.input)) return inputTypeScript(tool) === "{}"
|
||||
const encoded = SchemaAST.toEncoded(tool.input.ast)
|
||||
return SchemaAST.isObjects(encoded) && encoded.propertySignatures.length === 0 && encoded.indexSignatures.length === 0
|
||||
}
|
||||
|
||||
export const outputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
|
||||
tool.output === undefined
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Schema, SchemaGetter } from "effect"
|
||||
import { CodeMode, Tool } from "../src/index.js"
|
||||
import { inputTypeScript, jsonSchemaToTypeScript, outputTypeScript } from "../src/tool-schema.js"
|
||||
import {
|
||||
decodeInput,
|
||||
inputProperties,
|
||||
inputTypeScript,
|
||||
jsonSchemaToTypeScript,
|
||||
outputTypeScript,
|
||||
} from "../src/tool-schema.js"
|
||||
|
||||
// A raw JSON Schema tool in the shape an MCP adapter produces: render-only input schema
|
||||
// whose property descriptions and constraints must surface as JSDoc in pretty signatures.
|
||||
@@ -525,6 +531,34 @@ describe("pretty signature rendering", () => {
|
||||
})
|
||||
|
||||
describe("JSON Schema definition scope", () => {
|
||||
test.each(["Lookup Input", "café", "A/B~C% D#E"])(
|
||||
"resolves Effect definition %s in signatures and input metadata",
|
||||
(identifier) => {
|
||||
const tool = Tool.make({
|
||||
description: "Named tool",
|
||||
input: Schema.Struct({ city: Schema.String }).annotate({ identifier }),
|
||||
output: Schema.Struct({ city: Schema.String }).annotate({ identifier }),
|
||||
execute: (input) => Effect.succeed(input),
|
||||
})
|
||||
expect(inputTypeScript(tool)).toBe("{ city: string }")
|
||||
expect(outputTypeScript(tool)).toBe("{ city: string }")
|
||||
expect(inputProperties(tool)).toEqual([{ name: "city", description: undefined, required: true }])
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["#/$defs/%", "#/$defs/A~2B", "#/$defs/Missing", "#/$defs/Loop", "https://example.test/schema"])(
|
||||
"keeps malformed, unresolved, and recursive reference %s unknown",
|
||||
($ref) => {
|
||||
const tool = Tool.make({
|
||||
description: "Unresolved tool",
|
||||
input: { $ref, $defs: { Loop: { $ref: "#/$defs/Loop" } } },
|
||||
execute: () => Effect.succeed(null),
|
||||
})
|
||||
expect(inputTypeScript(tool)).toBe("unknown")
|
||||
expect(inputProperties(tool)).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["definitions", "$defs"])("resolves root %s and lets $defs take precedence", (key) => {
|
||||
const schema = { $ref: `#/${key}/Value`, [key]: { Value: { type: "string" } } }
|
||||
expect(jsonSchemaToTypeScript(schema)).toBe("string")
|
||||
@@ -612,14 +646,51 @@ describe("non-identifier property names render as quoted keys", () => {
|
||||
input: Schema.Struct({ "foo-bar": Schema.String, plain: Schema.optionalKey(Schema.Number) }),
|
||||
execute: () => Effect.succeed(null),
|
||||
})
|
||||
expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number | "Infinity" | "-Infinity" | "NaN" }')
|
||||
expect(inputTypeScript(tool, true)).toBe(
|
||||
["{", ' "foo-bar": string,', ' plain?: number | "Infinity" | "-Infinity" | "NaN",', "}"].join("\n"),
|
||||
)
|
||||
expect(inputTypeScript(tool)).toBe('{ "foo-bar": string; plain?: number }')
|
||||
expect(inputTypeScript(tool, true)).toBe(["{", ' "foo-bar": string,', " plain?: number,", "}"].join("\n"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("union schemas render every alternative", () => {
|
||||
test("Effect numbers advertise the numeric values accepted by the input decoder", () => {
|
||||
const tool = Tool.make({
|
||||
description: "Numeric tool",
|
||||
input: Schema.Struct({ amount: Schema.Number }),
|
||||
output: Schema.Number,
|
||||
execute: (input) => Effect.succeed(input.amount),
|
||||
})
|
||||
expect(inputTypeScript(tool)).toBe("{ amount: number }")
|
||||
expect(inputTypeScript(tool, true)).toBe("{\n amount: number,\n}")
|
||||
expect(outputTypeScript(tool)).toBe("number")
|
||||
expect(outputTypeScript(tool, true)).toBe("number")
|
||||
expect(decodeInput(tool, { amount: 42 })).toEqual({ amount: 42 })
|
||||
for (const amount of ["NaN", "Infinity", "-Infinity"]) {
|
||||
expect(() => decodeInput(tool, { amount })).toThrow("Expected number")
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps unrelated and partial grouped string enums alongside numbers", () => {
|
||||
expect(
|
||||
jsonSchemaToTypeScript({
|
||||
anyOf: [{ type: "number" }, { type: "string", enum: ["NaN", "Infinity", "unknown"] }],
|
||||
}),
|
||||
).toBe('number | "NaN" | "Infinity" | "unknown"')
|
||||
expect(
|
||||
jsonSchemaToTypeScript({
|
||||
anyOf: [{ type: "number" }, { type: "string", enum: ["NaN", "Infinity"] }],
|
||||
}),
|
||||
).toBe('number | "NaN" | "Infinity"')
|
||||
const tool = Tool.make({
|
||||
description: "Number or status",
|
||||
input: Schema.Union([Schema.Number, Schema.Literal("unknown")]),
|
||||
output: Schema.Union([Schema.Number, Schema.Literal("unknown")]),
|
||||
execute: (input) => Effect.succeed(input),
|
||||
})
|
||||
expect(inputTypeScript(tool)).toContain('"unknown"')
|
||||
expect(outputTypeScript(tool)).toContain('"unknown"')
|
||||
expect(decodeInput(tool, "unknown")).toBe("unknown")
|
||||
})
|
||||
|
||||
test("anyOf with a number branch keeps sibling alternatives", () => {
|
||||
const schema = {
|
||||
anyOf: [{ type: "string" }, { type: "number" }],
|
||||
@@ -807,6 +878,73 @@ describe("JSDoc signatures in catalogs and search results", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("empty input signatures agree with decoding", () => {
|
||||
test.each([
|
||||
{ name: "a plain empty struct", input: Schema.Struct({}) },
|
||||
{
|
||||
name: "an annotated empty struct",
|
||||
input: Schema.Struct({}).annotate({ identifier: "Empty", description: "No input" }),
|
||||
},
|
||||
{ name: "a checked empty struct", input: Schema.Struct({}).check(Schema.makeFilter(() => true)) },
|
||||
{
|
||||
name: "an empty encoded struct transformed to a non-empty struct",
|
||||
input: Schema.Struct({}).pipe(
|
||||
Schema.decodeTo(Schema.Struct({ token: Schema.String }), {
|
||||
decode: SchemaGetter.transform(() => ({ token: "x" })),
|
||||
encode: SchemaGetter.transform(() => ({})),
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ name: "a raw JSON Schema empty object", input: { type: "object", properties: {} } },
|
||||
])("$name advertises () and runs with zero arguments", async ({ input }) => {
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
ping: Tool.make({ description: "Ping", input, output: Schema.String, execute: () => Effect.succeed("pong") }),
|
||||
},
|
||||
})
|
||||
const signature = "tools.ping(): Promise<string>"
|
||||
expect(runtime.catalog[0]?.signature).toBe(signature)
|
||||
const result = await Effect.runPromise(runtime.execute('return search({ query: "tools.ping" })'))
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error("search failed")
|
||||
expect(result.value).toMatchObject({ items: [{ signature }] })
|
||||
expect(await Effect.runPromise(runtime.execute("return await tools.ping()"))).toMatchObject({
|
||||
ok: true,
|
||||
value: "pong",
|
||||
})
|
||||
})
|
||||
|
||||
test("a required encoded input transformed to an empty struct keeps its required fields", async () => {
|
||||
const consume = Tool.make({
|
||||
description: "Consume token",
|
||||
input: Schema.Struct({ token: Schema.String }).pipe(
|
||||
Schema.decodeTo(Schema.Struct({}), {
|
||||
decode: SchemaGetter.transform(() => ({})),
|
||||
encode: SchemaGetter.transform(() => ({ token: "x" })),
|
||||
}),
|
||||
),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("consumed"),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { consume } })
|
||||
const signature = "tools.consume({\n token: string,\n}): Promise<string>"
|
||||
expect(() => decodeInput(consume, {})).toThrow()
|
||||
expect(runtime.catalog[0]?.signature).toBe(signature)
|
||||
const result = await Effect.runPromise(runtime.execute('return search({ query: "tools.consume" })'))
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) throw new Error("search failed")
|
||||
expect(result.value).toMatchObject({ items: [{ signature }] })
|
||||
expect(await Effect.runPromise(runtime.execute("return await tools.consume()"))).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "InvalidToolInput" },
|
||||
})
|
||||
expect(await Effect.runPromise(runtime.execute('return await tools.consume({ token: "t" })'))).toMatchObject({
|
||||
ok: true,
|
||||
value: "consumed",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("non-identifier tool paths", () => {
|
||||
const resolveLibrary = Tool.make({
|
||||
description: "Resolve a Context7 library ID",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -98,7 +98,8 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/which": "3.0.4",
|
||||
"drizzle-kit": "catalog:"
|
||||
"drizzle-kit": "catalog:",
|
||||
"fast-check": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
|
||||
@@ -99,7 +99,7 @@ export const layer = (options?: Options) =>
|
||||
const wellknown = yield* WellKnown.Service
|
||||
const reloadLock = Semaphore.makeUnsafe(1)
|
||||
const updateLock = Semaphore.makeUnsafe(1)
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore" } as const
|
||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
|
||||
const errors: ParseError[] = []
|
||||
@@ -330,7 +330,8 @@ export const layer = (options?: Options) =>
|
||||
function* (patch: Patch) {
|
||||
const directory = initial.global ?? AbsolutePath.make(globalService.config)
|
||||
const candidates = ConfigDiscovery.names.map((name) => path.join(directory, name))
|
||||
const filepath = (yield* Effect.filter(candidates, fs.isFile)).at(-1) ?? path.join(directory, "opencode.jsonc")
|
||||
const filepath =
|
||||
(yield* Effect.filter(candidates, fs.isFile)).at(-1) ?? path.join(directory, "opencode.jsonc")
|
||||
const text = (yield* fs.readFileStringSafe(filepath)) ?? "{}\n"
|
||||
const updated = yield* Effect.try({
|
||||
try: () =>
|
||||
|
||||
@@ -42,7 +42,7 @@ export type Result =
|
||||
}
|
||||
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
|
||||
|
||||
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||
const options = { errors: "all", onExcessProperty: "ignore" } as const
|
||||
const unsupportedTopLevel = ["logLevel", "server", "subagent_depth", "layout"] as const
|
||||
const unsupportedExperimental = [
|
||||
"disable_paste_summary",
|
||||
|
||||
@@ -196,11 +196,11 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
|
||||
const agent = legacy
|
||||
? Option.getOrUndefined(
|
||||
Option.map(
|
||||
decodeLegacyAgent({ name, ...markdown.data, prompt: body }, { errors: "all", propertyOrder: "original" }),
|
||||
decodeLegacyAgent({ name, ...markdown.data, prompt: body }, { errors: "all" }),
|
||||
ConfigMigrateV1.migrateAgent,
|
||||
),
|
||||
)
|
||||
: Option.getOrUndefined(decodeAgent({ ...data, system: body }, { errors: "all", propertyOrder: "original" }))
|
||||
: Option.getOrUndefined(decodeAgent({ ...data, system: body }, { errors: "all" }))
|
||||
if (!agent) return
|
||||
const info = Option.getOrUndefined(
|
||||
decodeConfig({
|
||||
|
||||
@@ -18,7 +18,7 @@ export const Plugin = define({
|
||||
editor.configure({
|
||||
...(entry.info.compaction.auto === undefined ? {} : { auto: entry.info.compaction.auto }),
|
||||
...(entry.info.compaction.buffer === undefined ? {} : { buffer: entry.info.compaction.buffer }),
|
||||
...(entry.info.compaction.keep?.tokens === undefined ? {} : { tokens: entry.info.compaction.keep.tokens }),
|
||||
...(entry.info.compaction.keep?.tokens === undefined ? {} : { keep: entry.info.compaction.keep.tokens }),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -117,8 +117,13 @@ export const Plugin = define({
|
||||
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
|
||||
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
|
||||
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
|
||||
if (config.capabilities !== undefined)
|
||||
model.capabilities = Model.mergeCapabilities(config.capabilities, model.capabilities)
|
||||
if (config.capabilities !== undefined) {
|
||||
model.capabilities = {
|
||||
tools: config.capabilities.tools,
|
||||
input: [...config.capabilities.input],
|
||||
output: [...config.capabilities.output],
|
||||
}
|
||||
}
|
||||
if (config.variants !== undefined) {
|
||||
model.variants ??= []
|
||||
for (const variant of config.variants) {
|
||||
|
||||
@@ -48,10 +48,9 @@ const proxy = (value: string, environment: Environment = process.env) => {
|
||||
)
|
||||
}
|
||||
|
||||
const constructorOptions = (input: string | Array<string> | undefined): WebSocketOptions => {
|
||||
const constructorOptions = (input: Socket.WebSocketConstructorOptions | undefined): WebSocketOptions => {
|
||||
if (typeof input === "string" || Array.isArray(input)) return { protocols: input }
|
||||
// AI routes pass handshake options through Effect's browser-shaped constructor.
|
||||
return (input ?? {}) as WebSocketOptions
|
||||
return { headers: input?.headers === undefined ? undefined : Headers.fromInput(input.headers) }
|
||||
}
|
||||
|
||||
const proxyAgent = (url: string, selectedProxy: string | undefined) => {
|
||||
|
||||
@@ -197,6 +197,8 @@ export const provider = (options: Options): OAuthClientProvider => {
|
||||
saveTokens: (tokens) => options.store.saveTokens(tokens),
|
||||
redirectToAuthorization: (url) => {
|
||||
if (!redirect) throw refuse("user authorization")
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:")
|
||||
throw new Error(`MCP server "${options.config.url}" returned a ${url.protocol} authorization URL; only http and https are supported`)
|
||||
return redirect.open(url)
|
||||
},
|
||||
...(options.invalidate ? { invalidateCredentials: options.invalidate } : {}),
|
||||
|
||||
@@ -28,16 +28,6 @@ export type Compatibility = Model.Compatibility
|
||||
export const Capabilities = Model.Capabilities
|
||||
export type Capabilities = Model.Capabilities
|
||||
|
||||
/** Merges partial config capabilities onto a base model's capabilities, defaulting unset fields. */
|
||||
export const mergeCapabilities = (config: Partial<Capabilities>, base: Capabilities | undefined) => {
|
||||
const fallback = base ?? Capabilities.default()
|
||||
return {
|
||||
tools: config.tools ?? fallback.tools,
|
||||
input: [...(config.input ?? fallback.input)],
|
||||
output: [...(config.output ?? fallback.output)],
|
||||
}
|
||||
}
|
||||
|
||||
export const Cost = Model.Cost
|
||||
|
||||
export const Ref = Model.Ref
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,29 +0,0 @@
|
||||
export * as NativeCompactionPlugin from "./compaction.js"
|
||||
|
||||
import { LLMClient, Message } from "@opencode/ai"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { SessionCompaction } from "../session/compaction.js"
|
||||
import type { PluginInternal } from "./internal.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.compaction.native",
|
||||
effect: Effect.fn("NativeCompactionPlugin")(function* () {
|
||||
const llm = yield* LLMClient.Service
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
yield* compaction.transform((editor) => {
|
||||
editor.native((input) => {
|
||||
const request = input.request
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" }))
|
||||
return Effect.gen(function* () {
|
||||
const retained = yield* input.retained
|
||||
const result = yield* llm.compact(request, { ...input.options, mechanism: "trigger" })
|
||||
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
|
||||
})
|
||||
if (LLMClient.canCompact(request))
|
||||
return llm.compact(request, { mechanism: "endpoint", http: input.options.http })
|
||||
return undefined
|
||||
})
|
||||
})
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
@@ -88,7 +88,6 @@ import { WriteTool } from "../tool/plugin/write.js"
|
||||
import { AgentPlugin } from "./agent.js"
|
||||
import BrowserPlugin from "@opencode/plugin-browser"
|
||||
import { CommandPlugin } from "./command.js"
|
||||
import { NativeCompactionPlugin } from "./compaction.js"
|
||||
import { IdentityPlugin } from "./identity.js"
|
||||
import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
@@ -225,7 +224,6 @@ const pre = [
|
||||
SkillPlugin.Plugin,
|
||||
VcsHgPlugin.Plugin,
|
||||
ModelsDevPlugin,
|
||||
NativeCompactionPlugin.Plugin,
|
||||
...ProviderPlugins,
|
||||
...WebSearchPlugins,
|
||||
PatchTool.Plugin,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
|
||||
import type { SessionRequestKind } from "@opencode/plugin/effect/session"
|
||||
import type { SessionRequestKind, SessionTitle } from "@opencode/plugin/effect/session"
|
||||
import { Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { IntegrationConnection } from "../../integration/connection.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
@@ -8,6 +8,7 @@ import { CopilotModels } from "../../github-copilot/models.js"
|
||||
import { App } from "../../app.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Provider } from "../../provider.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
@@ -162,6 +163,7 @@ export const GithubCopilotPlugin = define({
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
const loaded: {
|
||||
baseURL?: string
|
||||
token?: string
|
||||
models?: CopilotModels.Snapshot
|
||||
connection?: Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
|
||||
} = {}
|
||||
@@ -173,6 +175,7 @@ export const GithubCopilotPlugin = define({
|
||||
: undefined
|
||||
if (credential?.type !== "oauth") {
|
||||
loaded.baseURL = undefined
|
||||
loaded.token = undefined
|
||||
loaded.models = undefined
|
||||
loaded.connection = undefined
|
||||
return
|
||||
@@ -200,6 +203,7 @@ export const GithubCopilotPlugin = define({
|
||||
)
|
||||
return
|
||||
loaded.baseURL = url
|
||||
loaded.token = credential.refresh
|
||||
loaded.models = remote
|
||||
loaded.connection = connection
|
||||
})
|
||||
@@ -273,6 +277,29 @@ export const GithubCopilotPlugin = define({
|
||||
}),
|
||||
{ providerID: Provider.ID.githubCopilot },
|
||||
)
|
||||
// GitHub's integration guide designates session naming as a utility scenario served by
|
||||
// free, rate-limited utility models. Any failure leaves the result unset so the normal
|
||||
// billable title path still runs.
|
||||
yield* ctx.session.hook(
|
||||
"title",
|
||||
(evt) =>
|
||||
Effect.gen(function* () {
|
||||
if (evt.model.providerID !== Provider.ID.githubCopilot) return
|
||||
if (!loaded.baseURL || !loaded.token || !loaded.models) return
|
||||
const agent = yield* ctx.agent.get({ agentID: Agent.ID.make("title") }).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (agent?.data.model) return
|
||||
const model = utilityTitleModels.find((id) => loaded.models?.has(id))
|
||||
if (!model) return
|
||||
evt.result = yield* utilityTitle(
|
||||
{ baseURL: loaded.baseURL, token: loaded.token, model, app: ctx.app },
|
||||
evt,
|
||||
).pipe(
|
||||
Effect.tapError((cause) => Effect.logDebug("Copilot utility title failed", { model, cause })),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
}),
|
||||
{ providerID: Provider.ID.githubCopilot },
|
||||
)
|
||||
yield* ctx.session.hook(
|
||||
"http.request",
|
||||
(evt) =>
|
||||
@@ -369,6 +396,70 @@ function request(url: string, init: RequestInit) {
|
||||
|
||||
type Fetch = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>
|
||||
|
||||
// Matches the Copilot client: gpt-4o-mini is the "small utility" model; when an account
|
||||
// lacks it the request falls through to the regular title path instead of another guess.
|
||||
export const utilityTitleModels = ["gpt-4o-mini"]
|
||||
|
||||
const UtilityCompletion = Schema.Struct({
|
||||
choices: Schema.Array(Schema.Struct({ message: Schema.Struct({ content: Schema.NullOr(Schema.String) }) })),
|
||||
})
|
||||
const decodeUtilityCompletion = Schema.decodeUnknownEffect(Schema.fromJsonString(UtilityCompletion))
|
||||
|
||||
// This issues its own /chat/completions call instead of steering core's title request.
|
||||
// The `session.title` hook exposes `model` read-only, so pointing core at another model
|
||||
// would mean widening that contract and re-resolving inside SessionModelRequest; rewriting
|
||||
// the body in `http.request` is worse still, since the endpoint (/responses for GPT-5,
|
||||
// /v1/messages for Claude) is fixed before that hook runs and gpt-4o-mini only serves
|
||||
// /chat/completions. Utility titles are Copilot-specific, stateless, non-streaming, and
|
||||
// free, so a small request here costs less than a core seam. Trade-offs: no session
|
||||
// usage record for the title step (the call bills nothing) and no `http.*` hook visibility.
|
||||
export function utilityTitle(
|
||||
input: { baseURL: string; token: string; model: string; app: App.Info; fetch?: Fetch },
|
||||
request: Pick<SessionTitle, "sessionID" | "system" | "messages" | "options">,
|
||||
) {
|
||||
const send = input.fetch ?? fetch
|
||||
const text = (parts: ReadonlyArray<{ type: string; text?: string | null }>) =>
|
||||
parts.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : [])).join("\n")
|
||||
const messages = [
|
||||
...(request.system.length ? [{ role: "system", content: text(request.system) }] : []),
|
||||
...request.messages.map((message) => ({ role: message.role, content: text(message.content) })),
|
||||
]
|
||||
return Effect.tryPromise({
|
||||
try: async (signal) => {
|
||||
const response = await send(`${input.baseURL}/chat/completions`, {
|
||||
method: "POST",
|
||||
signal,
|
||||
headers: {
|
||||
Authorization: `Bearer ${input.token}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": App.useragent(input.app),
|
||||
"X-GitHub-Api-Version": apiVersion,
|
||||
"Openai-Intent": "conversation-edits",
|
||||
"X-Interaction-Type": "agent-session-name-generation",
|
||||
"X-Interaction-Id": request.sessionID,
|
||||
"x-initiator": "agent",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: input.model,
|
||||
messages,
|
||||
stream: false,
|
||||
...(typeof request.options.maxTokens === "number" ? { max_tokens: request.options.maxTokens } : {}),
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Utility title request failed: ${response.status}`)
|
||||
return response.text()
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
}).pipe(
|
||||
Effect.timeout("20 seconds"),
|
||||
Effect.flatMap(decodeUtilityCompletion),
|
||||
Effect.flatMap((completion) => {
|
||||
const title = completion.choices[0]?.message.content?.trim()
|
||||
return title ? Effect.succeed(title) : Effect.fail(new Error("Utility title response was empty"))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function copilotFetch(token: string | undefined, upstream: Fetch | undefined, app: App.Info): Fetch {
|
||||
const send = upstream ?? fetch
|
||||
return async (input, init) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import type { SessionRequest } from "@opencode/plugin/effect/session"
|
||||
import { Deferred, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import type { Server } from "node:http"
|
||||
import { App } from "../../app.js"
|
||||
@@ -307,6 +308,13 @@ export const OpenAIPlugin = define({
|
||||
}),
|
||||
{ providerID: Provider.ID.openai },
|
||||
)
|
||||
// The ChatGPT backend rejects a requested output limit, and OpenAI counts one against rate limits.
|
||||
const omitOutputLimit = (evt: SessionRequest) =>
|
||||
Effect.sync(() => {
|
||||
delete evt.options.maxTokens
|
||||
})
|
||||
for (const name of ["context", "compaction"] as const)
|
||||
yield* ctx.session.hook(name, omitOutputLimit, { providerID: Provider.ID.openai })
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.provider.reload())))
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { IntegrationConnection } from "../../integration/connection.js"
|
||||
import { ManagedPolicy } from "../../managed-policy.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { WebSearch } from "../../websearch.js"
|
||||
import { ConfigPolicy } from "@opencode/schema/config/policy"
|
||||
@@ -224,7 +223,11 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
|
||||
model.package = config.package ?? (item.package !== undefined ? undefined : model.package)
|
||||
if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL
|
||||
if (config.capabilities !== undefined)
|
||||
model.capabilities = Model.mergeCapabilities(config.capabilities, model.capabilities)
|
||||
model.capabilities = {
|
||||
...config.capabilities,
|
||||
input: [...config.capabilities.input],
|
||||
output: [...config.capabilities.output],
|
||||
}
|
||||
model.settings = Provider.mergeOverlay(
|
||||
withoutCredentials(model.settings),
|
||||
withoutCredentials(config.settings),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,6 +44,14 @@ const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
|
||||
const IMAGE_REMOVED =
|
||||
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
|
||||
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
|
||||
// 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.
|
||||
const ESTIMATE_ERROR = 0.15
|
||||
// Never ask for less; only reachable with automatic compaction off, since it keeps the window from filling this far.
|
||||
const OUTPUT_TOKEN_MIN = 1_024
|
||||
|
||||
/** Tool errors, plus the user declining a permission or dismissing a question. */
|
||||
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
|
||||
@@ -69,6 +77,21 @@ export interface Input {
|
||||
readonly toolChoice?: LLM.RequestInput["toolChoice"]
|
||||
/** Only the durable runner may use a stateful WebSocket. */
|
||||
readonly webSocket?: "session"
|
||||
/** Prompt size, measured by the provider or estimated. The default output limit leaves room for it. */
|
||||
readonly inputTokens?: { readonly measured: number; readonly estimated: number }
|
||||
}
|
||||
|
||||
/** The default output limit: the catalog limit, fitted to the room the prompt leaves in the context window. */
|
||||
const outputLimit = (
|
||||
limit: Model.Info["limit"],
|
||||
kind: "primary" | "compaction",
|
||||
inputTokens?: Input["inputTokens"],
|
||||
) => {
|
||||
const model = limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK
|
||||
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))
|
||||
return Math.min(requested, Math.max(OUTPUT_TOKEN_MIN, room))
|
||||
}
|
||||
|
||||
export const baseTranscript = (input: {
|
||||
@@ -218,8 +241,19 @@ export const layer = Layer.effect(
|
||||
const given = new Map(
|
||||
tools.definitions.map((t) => [{ description: t.description, input: { ...t.inputSchema } }, t] as const),
|
||||
)
|
||||
// Hooks see the default output limit and may change or remove it. Titles and generate keep the provider default,
|
||||
// because their reasoning is hard to budget.
|
||||
const shaped = yield* shape(
|
||||
{ sessionID: session.id, model: model.ref, system: input.system, messages: input.messages, options: {} },
|
||||
{
|
||||
sessionID: session.id,
|
||||
model: model.ref,
|
||||
system: input.system,
|
||||
messages: input.messages,
|
||||
options:
|
||||
kind === "primary" || kind === "compaction"
|
||||
? { maxTokens: outputLimit(model.limit, kind, input.inputTokens) }
|
||||
: {},
|
||||
},
|
||||
Object.fromEntries(Array.from(given, ([d, t]) => [t.name, d])),
|
||||
)
|
||||
// Match by identity first, then by key. Entries matching neither were invented by a
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as SessionProviderContext from "./provider-context.js"
|
||||
|
||||
import { Message } from "@opencode/ai"
|
||||
import { SessionProviderContext } from "@opencode/schema/session-provider-context"
|
||||
import { Schema } from "effect"
|
||||
import { Predicate, Schema } from "effect"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Hash } from "@opencode/util/hash"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
@@ -54,5 +54,31 @@ export const encode = (provenance: Provenance, replacement: ReadonlyArray<Messag
|
||||
messages: Schema.decodeSync(Schema.fromJsonString(Schema.Json))(JSON.stringify(replacement)),
|
||||
})
|
||||
|
||||
export const decode = (context: Info) => Schema.decodeUnknownSync(messages)(context.messages)
|
||||
export const validate = (context: Info) => Schema.decodeUnknownEffect(messages)(context.messages)
|
||||
export const decode = (context: Info) => Schema.decodeUnknownSync(messages)(upgradeLegacyMedia(context.messages))
|
||||
export const validate = (context: Info) => Schema.decodeUnknownEffect(messages)(upgradeLegacyMedia(context.messages))
|
||||
|
||||
/**
|
||||
* Before 2.0.15, version-1 checkpoints stored mediaType/data directly on media parts. Core only built
|
||||
* those parts from prompt attachments, whose data the Prompt schema guarantees is base64.
|
||||
*/
|
||||
function upgradeLegacyMedia(input: Info["messages"]) {
|
||||
if (!Array.isArray(input)) return input
|
||||
return input.map((message: unknown) => {
|
||||
if (!Predicate.isObject(message) || !Array.isArray(message.content)) return message
|
||||
return {
|
||||
...message,
|
||||
content: message.content.map((part: unknown) => {
|
||||
if (
|
||||
!Predicate.isObject(part) ||
|
||||
part.type !== "media" ||
|
||||
part.media !== undefined ||
|
||||
typeof part.mediaType !== "string" ||
|
||||
typeof part.data !== "string"
|
||||
)
|
||||
return part
|
||||
const { mediaType, data, ...rest } = part
|
||||
return { ...rest, media: { source: { type: "base64", data, mediaType } } }
|
||||
}),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { SessionSchema } from "../schema.js"
|
||||
import { SessionStore } from "../store.js"
|
||||
import { SessionMessageTable } from "../sql.js"
|
||||
import { SessionTitle } from "../title.js"
|
||||
import { toSessionError } from "../to-session-error.js"
|
||||
import { DrainResult, Service, type Interface } from "./index.js"
|
||||
import { Snapshot } from "../../snapshot.js"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
@@ -109,39 +110,39 @@ const layer = Layer.effect(
|
||||
if (pending?.type === "move")
|
||||
return DrainResult.Moved({ continuation: continuing ? { step } : undefined })
|
||||
if (pending?.type === "compaction") {
|
||||
const session = yield* store.get(sessionID)
|
||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||
const compacted = yield* restore(
|
||||
Effect.gen(function* () {
|
||||
return yield* compaction.compactManual({
|
||||
session,
|
||||
resolveContext: (session) =>
|
||||
Effect.gen(function* () {
|
||||
const selected = yield* context.select(session.id)
|
||||
const model = yield* context.resolveModel(selected.session)
|
||||
// Preview updates without admitting them after the already-delivered compaction marker.
|
||||
const history = yield* SessionHistory.preview(
|
||||
db,
|
||||
session.id,
|
||||
selected.instructions,
|
||||
SessionProviderContext.provenance(model) ?? "local",
|
||||
)
|
||||
return {
|
||||
session: selected.session,
|
||||
agent: selected.agent,
|
||||
tools: selected.tools,
|
||||
model,
|
||||
initial: history.initial,
|
||||
messages: history.messages,
|
||||
instructionUpdate: history.instructionUpdate,
|
||||
}
|
||||
}),
|
||||
prepare: context.request.compaction,
|
||||
messages: yield* store.context(sessionID),
|
||||
const selected = yield* context.select(sessionID)
|
||||
const model = yield* context.resolveModel(selected.session)
|
||||
// Preview updates without admitting them after the already-delivered compaction marker.
|
||||
const history = yield* SessionHistory.preview(
|
||||
db,
|
||||
sessionID,
|
||||
selected.instructions,
|
||||
SessionProviderContext.provenance(model) ?? "local",
|
||||
)
|
||||
return yield* compaction.compact({
|
||||
reason: "manual",
|
||||
inputID: pending.id,
|
||||
started: true,
|
||||
context: {
|
||||
session: selected.session,
|
||||
agent: selected.agent,
|
||||
tools: selected.tools,
|
||||
model,
|
||||
initial: history.initial,
|
||||
messages: history.messages,
|
||||
},
|
||||
})
|
||||
}),
|
||||
}).pipe(
|
||||
Effect.catch((error) =>
|
||||
bus.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
inputID: pending.id,
|
||||
error: toSessionError(error),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).pipe(Effect.exit)
|
||||
if (Exit.isFailure(compacted)) {
|
||||
yield* bus.publish(SessionEvent.Compaction.Failed, {
|
||||
@@ -213,14 +214,9 @@ const layer = Layer.effect(
|
||||
// Reuse boundary preparation once; retries refresh context without delivering more input.
|
||||
const loaded = initial ?? (yield* prepareContext(sessionID).pipe(Effect.flatMap(context.load)))
|
||||
initial = undefined
|
||||
const compactionInput = {
|
||||
context: loaded,
|
||||
prepare: context.request.compaction,
|
||||
}
|
||||
if (compaction.required({ messages: loaded.messages, resolved: loaded.model, context: loaded })) {
|
||||
const result = yield* compaction.compact(compactionInput)
|
||||
if (result.status !== "completed") return yield* new StepFailedError({ error: result.error })
|
||||
if (result.recoveredOverflow) recoverOverflow = false
|
||||
const compacted = yield* compaction.compact({ reason: "auto", context: loaded })
|
||||
if (compacted.status === "failed") return yield* new StepFailedError({ error: compacted.error })
|
||||
if (compacted.status === "completed") {
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
continue
|
||||
}
|
||||
@@ -244,6 +240,7 @@ const layer = Layer.effect(
|
||||
// Keep tool definitions on the final Step to preserve the provider's cached prefix.
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
webSocket: "session",
|
||||
inputTokens: SessionCompaction.estimatePrompt(loaded),
|
||||
})
|
||||
const outcome = yield* steps.attempt({
|
||||
isLocationClosed: lifecycle.isClosed,
|
||||
@@ -263,9 +260,9 @@ const layer = Layer.effect(
|
||||
}),
|
||||
recoverContinuation,
|
||||
recoverOverflow: Effect.suspend(() =>
|
||||
recoverOverflow && compaction.enabled()
|
||||
recoverOverflow
|
||||
? compaction
|
||||
.compact({ ...compactionInput, overflow: true })
|
||||
.compact({ reason: "overflow", context: loaded })
|
||||
.pipe(Effect.map((result) => result.status === "completed"))
|
||||
: Effect.succeed(false),
|
||||
),
|
||||
|
||||
@@ -144,12 +144,19 @@ export const make = Effect.gen(function* () {
|
||||
if (Exit.isFailure(joined)) yield* interruptTools
|
||||
const tools = classifyToolExits(joined, toolRuns)
|
||||
|
||||
const overflow = overflowFailure ?? streamFailure
|
||||
if (
|
||||
!publisher.record().outputStarted &&
|
||||
isContextOverflowFailure(overflowFailure ?? streamFailure) &&
|
||||
isContextOverflowFailure(overflow) &&
|
||||
(yield* restore(input.recoverOverflow))
|
||||
)
|
||||
) {
|
||||
yield* Effect.logWarning("provider rejected the request as too long; compacting", {
|
||||
sessionID: input.sessionID,
|
||||
model: input.model.ref,
|
||||
message: overflow?.message,
|
||||
})
|
||||
return Outcome.Compacted()
|
||||
}
|
||||
|
||||
if (overflowFailure) yield* publisher.publish(overflowFailure)
|
||||
const recorded = publisher.record()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user