mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-23 09:07:37 +00:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c8a63e852 | ||
|
|
fabf56781c | ||
|
|
a25d304201 | ||
|
|
cc8886c8bb | ||
|
|
8ce629be22 | ||
|
|
68b28bdb98 | ||
|
|
150dc69e4b | ||
|
|
d5d4461e67 | ||
|
|
d56ce74373 | ||
|
|
17abc5906b | ||
|
|
8656838a5b | ||
|
|
53179daefa | ||
|
|
bab26d63ea | ||
|
|
f0381e5da3 | ||
|
|
740072694d | ||
|
|
2e4abeb25d | ||
|
|
43f1dad8e1 | ||
|
|
cf4b4c2312 | ||
|
|
3bf8a5a8cf | ||
|
|
ddeb19790a | ||
|
|
fe0d1682ca | ||
|
|
1746672c42 | ||
|
|
3a2203eaac | ||
|
|
38c320ea4c | ||
|
|
5c53cfc342 | ||
|
|
8683406690 | ||
|
|
126294a322 | ||
|
|
d2bbefbac8 |
@@ -53,8 +53,6 @@ runs:
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Install setuptools for distutils compatibility
|
||||
run: python3 -m pip install setuptools || pip install setuptools || true
|
||||
@@ -66,9 +64,9 @@ runs:
|
||||
# e.g. ./patches/ for standard-openapi
|
||||
# https://github.com/oven-sh/bun/issues/28147
|
||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||
bun install --linker hoisted ${{ inputs.install-flags }}
|
||||
bun install --frozen-lockfile --linker hoisted ${{ inputs.install-flags }}
|
||||
else
|
||||
bun install ${{ inputs.install-flags }}
|
||||
bun install --frozen-lockfile ${{ inputs.install-flags }}
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -176,7 +176,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -202,7 +202,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -215,7 +215,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -251,7 +251,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -278,7 +278,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -295,7 +295,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -319,7 +319,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -339,7 +339,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -367,7 +367,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "6.12.1",
|
||||
"gitlab-ai-provider": "6.16.0",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
"htmlparser2": "8.0.2",
|
||||
@@ -407,7 +407,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "5.0.0",
|
||||
@@ -456,7 +456,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -493,7 +493,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -509,7 +509,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -528,7 +528,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -541,7 +541,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -555,7 +555,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -570,7 +570,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -609,7 +609,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -639,7 +639,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -654,7 +654,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -678,7 +678,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -699,7 +699,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -721,7 +721,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -756,7 +756,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -776,7 +776,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -810,7 +810,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -829,7 +829,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -875,7 +875,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -889,7 +889,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -924,7 +924,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -959,7 +959,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -992,7 +992,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1033,7 +1033,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -1075,6 +1075,7 @@
|
||||
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
|
||||
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
|
||||
"@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch",
|
||||
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
@@ -4127,7 +4128,7 @@
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="],
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@6.16.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-HMC3sKgWYaYSsgm86Cnq2e6laHlYkhiFQ6rFD5qsVghv9//6h4Ofr7j5R2KjQx9Hl8EBercPsmzjoEjEN7dX5Q=="],
|
||||
|
||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-WBGj4wU5PQs+0zbDbScFhJs0GL8qWnswIsJb/Kee5Qc=",
|
||||
"aarch64-linux": "sha256-Z8hkyNMdqCpuv24q50QhO2RECINvENjBhLDY5FKcnFc=",
|
||||
"aarch64-darwin": "sha256-HZHb9HcXMOc7bKR7eADQSyfWxX37GAcoRi/EMSpRiP4=",
|
||||
"x86_64-darwin": "sha256-CcWCOAcW4t9ADs48fMhumSbDcQWdk91LpkG6fe/gWlk="
|
||||
"x86_64-linux": "sha256-DVimx64BQEkFaYZkW56E5eEfkGxuW5j/9C0wCImBFl4=",
|
||||
"aarch64-linux": "sha256-MhMl/ZkvcPXE31OvysutI6nKIJ6XtUa3c71+B4hnaQc=",
|
||||
"aarch64-darwin": "sha256-+wMPYLBnw2zTZj0if8EPwWC9O3tVqSgnhYNfQq8UZ8Q=",
|
||||
"x86_64-darwin": "sha256-l6LxVSXDbqVoXMVcGZAzRER0bxP6wDU4dfRDolx0JGM="
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
@@ -173,7 +173,7 @@
|
||||
"solid-js@1.9.15": "patches/solid-js@1.9.15.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
"pacote@21.5.0": "patches/pacote@21.5.0.patch",
|
||||
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
|
||||
|
||||
@@ -96,7 +96,9 @@ When a provider supports multiple physical transports, selection remains executi
|
||||
|
||||
### Media Routes
|
||||
|
||||
Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.make(...)` (`src/route/media.ts`) composes a `MediaProtocol` kind with `Endpoint` and `Auth` and owns 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)`; use `MediaProtocol.decodeJson` / `text` / `bytes` so decode failures retain the raw body and HTTP context. `Generation` (`src/generation.ts`) is the provider-neutral handle for a queued generation over a `GenerationRoute` (`status`, `result`, `cancel`, `pollHint`); the first video route implements it. Image protocol files follow the same section order as LLM protocols and declare unsupported common fields once through `MediaInput.rejectUnsupported`.
|
||||
Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.make(...)` (`src/route/media.ts`) composes a `MediaProtocol` kind with `Endpoint` and `Auth` and owns 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)`; use `MediaProtocol.decodeJson` / `text` / `bytes` so decode failures retain the raw body and HTTP context. `Generation` (`src/generation.ts`) is the provider-neutral handle for a queued generation over a `GenerationRoute` (`status`, `result`, `cancel`, `pollHint`). Image protocol files follow the same section order as LLM protocols and declare unsupported common fields once through `MediaInput.rejectUnsupported`.
|
||||
|
||||
`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 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`. `Generation.AwaitOptions` (`{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
|
||||
|
||||
### URL Construction
|
||||
|
||||
|
||||
@@ -602,6 +602,86 @@ 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.
|
||||
|
||||
## Video generation
|
||||
|
||||
Video mirrors `Image` with one difference: every provider is asynchronous, so the route is a submit-then-poll
|
||||
`Generation`. Models come from `.video(...)` selectors on the `Google` (Veo), `XAI`, `Fal`, and `Runway` facades.
|
||||
Common fields (`frames`, `references`, `video`, `durationSeconds`, `aspectRatio`, `resolution`, `audio`, `n`, `seed`,
|
||||
`negativePrompt`) lower natively or fail with a typed `AIError` before any network call; provider-native controls live
|
||||
under `providerOptions`, inferred from the selected model.
|
||||
|
||||
```ts
|
||||
import { Video, VideoClient } from "@opencode/ai"
|
||||
import { Google } from "@opencode/ai/providers"
|
||||
|
||||
const google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY })
|
||||
|
||||
// Simple: submit and wait.
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* Video.generate(
|
||||
{
|
||||
model: google.video("veo-3.1-generate-preview"),
|
||||
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
|
||||
aspectRatio: "16:9",
|
||||
resolution: "1080p",
|
||||
durationSeconds: 8,
|
||||
providerOptions: { personGeneration: "allow_adult" },
|
||||
},
|
||||
{ poll: { interval: "10 seconds", timeout: "10 minutes" } },
|
||||
)
|
||||
// Veo serves files for two days behind the API key. The asset knows the deadline (`expiresAt`) and carries the
|
||||
// download credentials only on the live instance (`asset.headers`), never in `source` or JSON: materialize
|
||||
// before persisting, or the persisted URL cannot be fetched again.
|
||||
return yield* response.video.materialize()
|
||||
})
|
||||
|
||||
// Explicit control: keep the handle, persist the token, resume elsewhere.
|
||||
const controlled = Effect.gen(function* () {
|
||||
const generation = yield* Video.start({ model: google.video("veo-3.1-generate-preview"), prompt })
|
||||
generation.id // provider operation / task / request id
|
||||
generation.status // "queued" | "running" | "completed" | "failed" | "cancelled" | "expired"
|
||||
generation.token // route-owned JSON: `{ operation }`, `{ requestID }`, `{ taskID }`, or fal's follow-up URLs
|
||||
const saved = JSON.stringify(generation.token)
|
||||
|
||||
const resumed = yield* Video.resume(google.video("veo-3.1-generate-preview"), JSON.parse(saved))
|
||||
return yield* resumed.await({ poll: { interval: "10 seconds" } })
|
||||
})
|
||||
|
||||
// Progress as a stream: generation-queued | generation-progress | video | finish.
|
||||
const events = Video.stream({ model: Runway.configure({ apiKey }).video("gen4.5"), prompt }, { poll })
|
||||
```
|
||||
|
||||
`VideoClient.layer` needs `RequestExecutor.Service`, and status polls, result fetches, cancels, and asset downloads
|
||||
all run through the same executor with the route's auth. `Generation.await` and `Generation.events` fail with a
|
||||
`Timeout` reason when `poll.timeout` (default 10 minutes) elapses. Failed,
|
||||
cancelled, and expired generations fail typed with the provider's terminal document on `reason.body`; moderation
|
||||
outcomes (Veo `raiMediaFilteredReasons`, xAI `respect_moderation`, Runway `SAFETY.*` codes) surface as `notices` when
|
||||
a video is still returned and as a `ContentPolicy` reason when nothing is.
|
||||
|
||||
Provider notes:
|
||||
|
||||
- **Google Veo** takes inline bytes only (materialize `url` assets first); `frames.last` requires `frames.first`;
|
||||
audio is always on, so `audio: false` fails typed; one video per request. Output URLs need the API key to
|
||||
download, which the returned asset holds transiently (see above).
|
||||
- **xAI** sends a `video` input to `/videos/edits`, or `/videos/extensions` with `providerOptions.mode: "extend"`.
|
||||
`seed` and `negativePrompt` are not supported.
|
||||
- **fal** endpoints are model-specific: `durationSeconds`, `references`, and `frames.last` fail typed and belong in
|
||||
`providerOptions` under the model's own names (`duration: "8s"`, `end_image_url`, …). Auth is
|
||||
`Authorization: Key <FAL_KEY>`.
|
||||
- **Runway** expects pixel ratios in `aspectRatio` for most models (`"1280:720"`), pins `X-Runway-Version`, reports
|
||||
`usage: { type: "credits" }`, and its output URLs expire after 24–48 hours.
|
||||
|
||||
The promise client exposes the same surface: `ai.video.start(...)` resolves to a handle with `await`, `refresh`,
|
||||
`cancel`, and `token`; `ai.video.generate`, `ai.video.resume(model, token)`, and `ai.video.stream` mirror the Effect
|
||||
API.
|
||||
|
||||
```ts
|
||||
import { ai } from "@opencode/ai/promise"
|
||||
|
||||
const generation = await ai.video.start({ model, prompt })
|
||||
const video = await generation.await({ poll: { interval: 10_000 }, signal })
|
||||
```
|
||||
|
||||
## Public API
|
||||
|
||||
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Media generation in `@opencode/ai` — public API direction
|
||||
|
||||
Status: proposal. Branch `media-support`.
|
||||
Status: phases 1–2 implemented; phases 3–5 proposal.
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -66,7 +66,7 @@ import { Media } from "@opencode/ai"
|
||||
Media.Source =
|
||||
| { type: "bytes"; data: Uint8Array; mediaType: string }
|
||||
| { type: "base64"; data: string; mediaType: string }
|
||||
| { type: "url"; url: string; mediaType?: string; expiresAt?: number; headers?: Record<string, string> }
|
||||
| { type: "url"; url: string; mediaType?: string; expiresAt?: number }
|
||||
| { type: "ref"; provider: ProviderID; id: string; mediaType?: string } // file_id, gs://, runway://, prior generation
|
||||
|
||||
class Media.Asset {
|
||||
@@ -76,6 +76,7 @@ class Media.Asset {
|
||||
readonly info?: { width?; height?; durationSeconds?; sampleRate?; channels?; encoding?; format? }
|
||||
readonly expiresAt?: number
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
readonly headers?: Record<string, string> // transient download credentials (Veo); never in source/JSON
|
||||
|
||||
bytes(): Effect<Uint8Array, AIError, RequestExecutor.Service> // downloads/decodes lazily, cached
|
||||
base64(): Effect<string, AIError, RequestExecutor.Service>
|
||||
@@ -129,40 +130,54 @@ Editing is not a separate function; `images`/`mask` on the request select the ed
|
||||
|
||||
#### Video
|
||||
|
||||
Shipped in phase 2 (`src/video.ts`, `src/video-client.ts`, protocols `google-video`, `xai-video`, `fal-video`, `runway-video`).
|
||||
|
||||
```ts
|
||||
const request = Video.request({
|
||||
model: google.video("veo-3.1-generate-preview"),
|
||||
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
|
||||
frames: { first: Media.file("./start.png"), last: Media.file("./end.png") },
|
||||
references: [Media.url("https://…/style.png")],
|
||||
video: Media.ref("openai", "video_123"), // edit / extend / remix source
|
||||
references: [Media.file("./style.png")],
|
||||
video: Media.bytes(previous, "video/mp4"), // edit / extend source
|
||||
durationSeconds: 8,
|
||||
aspectRatio: "16:9",
|
||||
resolution: "1080p",
|
||||
audio: true,
|
||||
n: 1,
|
||||
providerOptions: { personGeneration: "dont_allow", negativePrompt: "text, watermark" },
|
||||
seed: 7,
|
||||
negativePrompt: "text, watermark", // common, not provider-native
|
||||
providerOptions: { personGeneration: "allow_adult" },
|
||||
})
|
||||
|
||||
// Simple: wait for it.
|
||||
const response = yield* Video.generate(request, { poll: { interval: "10 seconds", timeout: "10 minutes" } })
|
||||
response.video // Media.Asset (url with expiresAt, or bytes when the route downloads)
|
||||
response.video // Media.Asset: url with expiresAt (+ transient `headers` for Veo downloads)
|
||||
response.usage // credits on Runway; the other three report none
|
||||
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
|
||||
yield* response.video.materialize() // pull bytes before the URL expires
|
||||
|
||||
// Explicit generation control.
|
||||
const generation = yield* Video.start(request) // Generation<VideoResponse>
|
||||
generation.id; generation.status; generation.progress; generation.token // token is serializable JSON
|
||||
generation.id; generation.status; generation.progress; generation.position; generation.token
|
||||
yield* generation.await({ poll }) // VideoResponse
|
||||
yield* generation.cancel()
|
||||
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; no-op for Veo and xAI
|
||||
|
||||
// Resume from another process.
|
||||
const resumed = yield* Video.resume(model, token) // Generation<VideoResponse>
|
||||
// Resume from another process. The token is validated against the route's codec and refreshed once.
|
||||
const resumed = yield* Video.resume(model, JSON.parse(saved))
|
||||
|
||||
// Progress as a stream.
|
||||
yield* Video.stream(request) // Stream<VideoEvent>: generation-queued { position } | generation-progress { progress, logs } | video { index, video } | finish
|
||||
yield* Video.stream(request, { poll }) // Stream<VideoEvent>: generation-queued { id, position } | generation-progress { id, progress } | video { index, video } | finish { usage, notices }
|
||||
```
|
||||
|
||||
Webhooks: `Video.complete(model, token, webhook)` finishes a generation from a webhook payload without polling. Token shape is route-owned and opaque (Veo operation name, fal `response_url`, Runway task id).
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
#### Speech (TTS)
|
||||
|
||||
@@ -209,18 +224,20 @@ Realtime STT over WebSocket is the same future `session` shape as input-streamin
|
||||
```ts
|
||||
class Generation<Response> {
|
||||
readonly id: string
|
||||
readonly model: MediaModel
|
||||
readonly route: GenerationRoute<Response> // token-free: { status, result, cancel?: Effect; pollHint? } closed over the decoded token
|
||||
readonly token: unknown // route-owned serializable JSON
|
||||
readonly status: "queued" | "running" | "completed" | "failed" | "cancelled" | "expired"
|
||||
readonly progress?: number // 0..1, normalized
|
||||
readonly position?: number
|
||||
readonly expiresAt?: number
|
||||
refresh(): Effect<Generation<Response>, AIError>
|
||||
await(options?: { poll?: Poll }): Effect<Response, AIError>
|
||||
result(): Effect<Response, AIError>
|
||||
await(options?: AwaitOptions): Effect<Response, AIError>
|
||||
cancel(): Effect<void, AIError>
|
||||
events(options?): Stream<GenerationEvent, AIError>
|
||||
events(options?: AwaitOptions): Stream<GenerationEvent, AIError> // fails with Timeout past poll.timeout, checked per observation
|
||||
}
|
||||
|
||||
AwaitOptions = { poll?: Poll }
|
||||
Poll = { interval?: Duration; timeout?: Duration; schedule?: Schedule } // route may override from provider hints (`openai-poll-after-ms`)
|
||||
```
|
||||
|
||||
@@ -288,10 +305,10 @@ New facades follow the existing one-file-per-provider rule. Package entrypoints
|
||||
Media does not fit the LLM four-axis route (SSE frames → event state machine) except for streaming TTS/STT. Reuse `Endpoint`, `Auth`, `Framing`, `RequestExecutor`, and add media protocol kinds:
|
||||
|
||||
- `MediaProtocol.inline` — `body.from(request)` (JSON, multipart, or query), `response.decode(response)` (JSON, or binary body → `Media.Asset`).
|
||||
- `MediaProtocol.queued` — `start`, `status`, `result`, `cancel`, optional `download`, `pollHint`, `token` schema.
|
||||
- `MediaProtocol.queued` — `start` (body + decode to `{ token, snapshot }`), `status`, `result`, optional `cancel`, `pollHint`, and a `token` codec. `result` is always a separate GET (against the status document for Veo/xAI/Runway, fal's `response_url` otherwise) so `await` after `start` and after `resume` share one path. `PollContext.auth` hands the auth headers the route sent to the protocol for output URLs that need them (Veo downloads); they become transient `Media.Asset.headers`, never part of `source`. There is no separate `download` step: `Media.Asset.bytes()` downloads through the executor with those headers. `MediaRoute.inline(...)` / `MediaRoute.queued(...)` compose each kind with endpoint and auth; the queued route decodes the token once and hands `Generation` a token-free `{ status, result, cancel? }`.
|
||||
- `MediaProtocol.stream` — framing + `step` state machine emitting modality events, same discipline as LLM protocols.
|
||||
|
||||
`Route.make` for media composes one protocol kind with endpoint/auth. The existing `ImageRoute { generate(request, execute) }` is the ad-hoc version of `inline` and gets folded in.
|
||||
`MediaRoute.inline` / `MediaRoute.queued` compose one protocol kind with endpoint/auth; `ImageModel`/`VideoModel` share the `MediaModel` base (`src/media-model.ts`).
|
||||
|
||||
### LLM integration
|
||||
|
||||
@@ -320,7 +337,7 @@ Foundation + Image ship together as the reference implementation, serially. Vide
|
||||
## Phasing
|
||||
|
||||
1. **Foundation** — per-modality selectors, `Media`, `Generation`, `Poll`, `Usage` union, `MediaProtocol` kinds, `@opencode/ai/promise` with `llm` + `image`. Port the five existing image protocols onto it. Unify `MediaPart` and add the `media` LLM event (fixes Gemini image output being dropped).
|
||||
2. **Video** — Veo, xAI, fal, Runway first. Then Luma, Kling, MiniMax, Replicate.
|
||||
2. **Video** — ✅ Veo, xAI, fal, Runway shipped (`MediaProtocol.queued`, `Video.start/generate/resume/stream`, promise `ai.video`). Deferred: `Video.complete` (webhooks), Luma, Kling, MiniMax, Replicate.
|
||||
3. **Speech + Transcription** — OpenAI, ElevenLabs, Gemini TTS, Deepgram, Cartesia, AssemblyAI. Streaming TTS from the start.
|
||||
4. **Image queued routes and partials** — BFL, fal, Replicate, Stability; OpenAI `partial_images` streaming.
|
||||
5. **Later** — ElevenLabs music/SFX, Lyria, `Speech.session` / `Transcription.session`, realtime.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -104,6 +104,36 @@ const PROVIDERS: ReadonlyArray<Provider> = [
|
||||
vars: [{ name: "XAI_API_KEY" }],
|
||||
validate: (env) => validateBearer("https://api.x.ai/v1/models", Redacted.make(env.XAI_API_KEY)),
|
||||
},
|
||||
{
|
||||
id: "fal",
|
||||
label: "fal",
|
||||
tier: "canary",
|
||||
note: "fal queue video recorded tests",
|
||||
vars: [{ name: "FAL_KEY" }],
|
||||
// fal has no free authenticated list endpoint; a 404 for an unknown request id proves the key was accepted.
|
||||
validate: (env) =>
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const response = yield* http.execute(
|
||||
HttpClientRequest.get(
|
||||
"https://queue.fal.run/fal-ai/veo3.1/requests/00000000-0000-0000-0000-000000000000/status",
|
||||
).pipe(HttpClientRequest.setHeaders({ authorization: `Key ${Redacted.value(Redacted.make(env.FAL_KEY))}` })),
|
||||
)
|
||||
if (response.status === 404) return undefined
|
||||
return yield* responseError(response)
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "runway",
|
||||
label: "Runway",
|
||||
tier: "canary",
|
||||
note: "Runway task video recorded tests",
|
||||
vars: [{ name: "RUNWAYML_API_SECRET" }],
|
||||
validate: (env) =>
|
||||
validateBearer("https://api.dev.runwayml.com/v1/organization", Redacted.make(env.RUNWAYML_API_SECRET), {
|
||||
"X-Runway-Version": "2024-11-06",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "cloudflare-ai-gateway",
|
||||
label: "Cloudflare AI Gateway",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Duration, Effect, Schedule, Schema, Stream } from "effect"
|
||||
import { Clock, Duration, Effect, Schedule, Schema, Stream } from "effect"
|
||||
import { AIError, TimeoutError } from "./schema/errors.js"
|
||||
|
||||
export const Status = Schema.Literals(["queued", "running", "completed", "failed", "cancelled", "expired"])
|
||||
@@ -15,13 +15,13 @@ export interface Snapshot {
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-owned generation operations. `token` is the route's serializable handle (operation name, task id, response URL)
|
||||
* so a generation can be resumed from another process; its shape is opaque to `Generation`.
|
||||
* Route-owned generation operations for one generation. The media route decodes its serializable token once (from the
|
||||
* submission response or a `resume` input) and closes over it, so `Generation` never sees the token's shape.
|
||||
*/
|
||||
export interface Route<Response> {
|
||||
readonly status: (token: unknown) => Effect.Effect<Snapshot, AIError>
|
||||
readonly result: (token: unknown) => Effect.Effect<Response, AIError>
|
||||
readonly cancel?: (token: unknown) => Effect.Effect<void, AIError>
|
||||
readonly status: Effect.Effect<Snapshot, AIError>
|
||||
readonly result: Effect.Effect<Response, AIError>
|
||||
readonly cancel?: Effect.Effect<void, AIError>
|
||||
/** Provider polling hint (e.g. `openai-poll-after-ms`) that overrides the default interval for the next poll. */
|
||||
readonly pollHint?: (snapshot: Snapshot) => Duration.Duration | undefined
|
||||
}
|
||||
@@ -33,6 +33,10 @@ export interface Poll {
|
||||
readonly schedule?: Schedule.Schedule<unknown, Snapshot>
|
||||
}
|
||||
|
||||
export interface AwaitOptions {
|
||||
readonly poll?: Poll
|
||||
}
|
||||
|
||||
export const DEFAULT_POLL_INTERVAL = Duration.seconds(5)
|
||||
export const DEFAULT_POLL_TIMEOUT = Duration.minutes(10)
|
||||
|
||||
@@ -52,6 +56,7 @@ export class Generation<Response> {
|
||||
|
||||
constructor(
|
||||
readonly route: Route<Response>,
|
||||
/** Route-owned serializable JSON; pass it to the modality's `resume` from another process. */
|
||||
readonly token: unknown,
|
||||
snapshot: Snapshot,
|
||||
) {
|
||||
@@ -77,51 +82,79 @@ export class Generation<Response> {
|
||||
}
|
||||
|
||||
refresh(): Effect.Effect<Generation<Response>, AIError> {
|
||||
return this.route.status(this.token).pipe(Effect.map((snapshot) => new Generation(this.route, this.token, snapshot)))
|
||||
return this.route.status.pipe(Effect.map((snapshot) => new Generation(this.route, this.token, snapshot)))
|
||||
}
|
||||
|
||||
/** Fetch the result without polling; non-completed terminal generations fail with the provider's terminal body. */
|
||||
result(): Effect.Effect<Response, AIError> {
|
||||
return this.route.result
|
||||
}
|
||||
|
||||
/** Poll until the generation reaches a terminal status, then fetch the result. Fails with a `Timeout` reason on deadline. */
|
||||
await(options?: { readonly poll?: Poll }): Effect.Effect<Response, AIError> {
|
||||
await(options?: AwaitOptions): Effect.Effect<Response, AIError> {
|
||||
const timeout = Duration.fromInputUnsafe(options?.poll?.timeout ?? DEFAULT_POLL_TIMEOUT)
|
||||
const settled = this.terminal ? Effect.succeed(this) : this.poll(options?.poll)
|
||||
return settled.pipe(
|
||||
// Non-completed terminal states also go through `result` so the route can surface its provider failure body.
|
||||
Effect.flatMap((generation) => generation.route.result(generation.token)),
|
||||
Effect.timeoutOrElse({
|
||||
duration: timeout,
|
||||
orElse: () =>
|
||||
new AIError({
|
||||
reason: new TimeoutError({
|
||||
message: `Generation ${this.id} did not finish within ${Duration.format(timeout)}`,
|
||||
timeoutMs: Duration.toMillis(timeout),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
Effect.flatMap((generation) => generation.result()),
|
||||
Effect.timeoutOrElse({ duration: timeout, orElse: () => this.timeoutError(timeout) }),
|
||||
)
|
||||
}
|
||||
|
||||
cancel(): Effect.Effect<void, AIError> {
|
||||
return this.route.cancel?.(this.token) ?? Effect.void
|
||||
return this.route.cancel ?? Effect.void
|
||||
}
|
||||
|
||||
/** Status observations as a stream, ending after the first terminal observation. */
|
||||
events(options?: { readonly poll?: Poll }): Stream.Stream<Event, AIError> {
|
||||
const observations = this.terminal
|
||||
? Stream.make(this)
|
||||
: Stream.fromEffectSchedule(this.refresh(), this.schedule(options?.poll)).pipe(
|
||||
Stream.takeUntil((generation) => generation.terminal),
|
||||
)
|
||||
return observations.pipe(
|
||||
Stream.map((generation): Event => {
|
||||
if (generation.terminal) return { type: "generation-finished", id: generation.id, status: generation.status }
|
||||
if (generation.status === "queued") return { type: "generation-queued", id: generation.id, position: generation.position }
|
||||
return { type: "generation-progress", id: generation.id, progress: generation.progress }
|
||||
}),
|
||||
/**
|
||||
* 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.)
|
||||
*/
|
||||
events(options?: AwaitOptions): Stream.Stream<Event, AIError> {
|
||||
if (this.terminal) return Stream.make(this.event())
|
||||
const timeout = Duration.fromInputUnsafe(options?.poll?.timeout ?? DEFAULT_POLL_TIMEOUT)
|
||||
return Stream.unwrap(
|
||||
Clock.currentTimeMillis.pipe(
|
||||
Effect.map((start) => {
|
||||
const deadline = start + Duration.toMillis(timeout)
|
||||
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),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return Stream.fromEffectSchedule(refresh, this.schedule(options?.poll)).pipe(
|
||||
Stream.takeUntil((generation) => generation.terminal),
|
||||
Stream.map((generation) => generation.event()),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private event(): Event {
|
||||
if (this.terminal) return { type: "generation-finished", id: this.id, status: this.status }
|
||||
if (this.status === "queued") return { type: "generation-queued", id: this.id, position: this.position }
|
||||
return { type: "generation-progress", id: this.id, progress: this.progress }
|
||||
}
|
||||
|
||||
private timeoutError(timeout: Duration.Duration) {
|
||||
return new AIError({
|
||||
reason: new TimeoutError({
|
||||
message: `Generation ${this.id} did not finish within ${Duration.format(timeout)}`,
|
||||
timeoutMs: Duration.toMillis(timeout),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
private poll(poll: Poll | undefined) {
|
||||
return this.refresh().pipe(Effect.repeat({ schedule: this.schedule(poll), until: (generation) => generation.terminal }))
|
||||
return this.refresh().pipe(
|
||||
Effect.repeat({ schedule: this.schedule(poll), until: (generation) => generation.terminal }),
|
||||
)
|
||||
}
|
||||
|
||||
private schedule(poll: Poll | undefined): Schedule.Schedule<unknown, Generation<Response>> {
|
||||
|
||||
@@ -48,7 +48,7 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
|
||||
generate,
|
||||
// Inline routes have no partial frames yet; the stream is the completed response expanded into events.
|
||||
stream: (request) =>
|
||||
Stream.unwrap(generate(request).pipe(Effect.map((response) => Stream.fromIterable(responseEvents(response))))),
|
||||
Stream.fromIterableEffect(Effect.map(generate(request), responseEvents)),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
+15
-76
@@ -1,17 +1,9 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Media } from "./media.js"
|
||||
import { Endpoint } from "./route/endpoint.js"
|
||||
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
|
||||
import { MediaRoute } from "./route/media.js"
|
||||
import type { MediaProtocol } from "./route/media-protocol.js"
|
||||
import {
|
||||
AIError,
|
||||
HttpOptions,
|
||||
InvalidRequestError,
|
||||
MediaUsage,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
ProviderMetadata,
|
||||
} from "./schema/index.js"
|
||||
import { AIError, HttpOptions, MediaUsage, ProviderMetadata } from "./schema/index.js"
|
||||
import { ImageClient, Service } from "./image-client.js"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -25,27 +17,11 @@ export type ImageRoute<Options extends ImageOptions = ImageOptions> = MediaRoute
|
||||
ImageResponse
|
||||
>
|
||||
|
||||
export class ImageModel<Options extends ImageOptions = ImageOptions> {
|
||||
declare protected readonly _Options: (options: Options) => Options
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: ImageRoute<Options>
|
||||
readonly http?: HttpOptions
|
||||
export class ImageModel<Options extends ImageOptions = ImageOptions> extends MediaModel<ImageRoute<Options>, Options> {
|
||||
declare protected readonly _ImageModel: void
|
||||
|
||||
constructor(input: ImageModel.Input<Options>) {
|
||||
this.id = input.id
|
||||
this.provider = input.provider
|
||||
this.route = input.route
|
||||
this.http = input.http
|
||||
}
|
||||
|
||||
static make<Options extends ImageOptions = ImageOptions>(input: ImageModel.MakeInput<Options>) {
|
||||
return new ImageModel<Options>({
|
||||
id: ModelID.make(input.id),
|
||||
provider: ProviderID.make(input.provider),
|
||||
route: input.route,
|
||||
http: input.http,
|
||||
})
|
||||
static make<Options extends ImageOptions = ImageOptions>(input: MediaModel.Input<ImageRoute<Options>>) {
|
||||
return new ImageModel<Options>(input)
|
||||
}
|
||||
|
||||
/** Compose an inline image protocol with its canonical path into a model for one deployment. */
|
||||
@@ -53,44 +29,20 @@ export class ImageModel<Options extends ImageOptions = ImageOptions> {
|
||||
route: ImageModel.RouteInput<Options>,
|
||||
input: MediaRoute.ModelInput,
|
||||
) {
|
||||
return ImageModel.make<Options>({
|
||||
return new ImageModel<Options>({
|
||||
id: input.id,
|
||||
provider: route.provider,
|
||||
http: input.http,
|
||||
route: MediaRoute.make({
|
||||
id: route.id,
|
||||
provider: route.provider,
|
||||
protocol: route.protocol,
|
||||
endpoint: Endpoint.path(route.path, { baseURL: input.baseURL ?? route.baseURL }),
|
||||
auth: input.auth,
|
||||
headers: input.headers,
|
||||
}),
|
||||
route: composeRoute(MediaRoute.inline, route, input),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ImageModel {
|
||||
export interface Input<Options extends ImageOptions = ImageOptions> {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: ImageRoute<Options>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
export interface MakeInput<Options extends ImageOptions = ImageOptions>
|
||||
extends Omit<Input<Options>, "id" | "provider"> {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string | ProviderID
|
||||
}
|
||||
|
||||
export interface RouteInput<Options extends ImageOptions = ImageOptions> {
|
||||
readonly id: string
|
||||
readonly provider: string | ProviderID
|
||||
readonly protocol: MediaProtocol.Inline<ImageRequestFor<Options>, ImageResponse>
|
||||
readonly path: Endpoint.EndpointPart<MediaProtocol.Body, ImageRequestFor<Options>>
|
||||
/** Canonical base URL; `ModelInput.baseURL` overrides it per deployment. */
|
||||
readonly baseURL?: string
|
||||
}
|
||||
export type RouteInput<Options extends ImageOptions = ImageOptions> = MediaModel.RouteInput<
|
||||
ImageRequestFor<Options>,
|
||||
MediaProtocol.Inline<ImageRequestFor<Options>, ImageResponse>
|
||||
>
|
||||
}
|
||||
|
||||
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
|
||||
@@ -107,11 +59,8 @@ export const ImageSize = Schema.declare<ImageSize>(
|
||||
{ title: "ImageSize" },
|
||||
)
|
||||
|
||||
export type ImageAspectRatio = `${number}:${number}`
|
||||
export const ImageAspectRatio = Schema.declare<ImageAspectRatio>(
|
||||
(value): value is ImageAspectRatio => typeof value === "string" && /^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(value),
|
||||
{ title: "ImageAspectRatio" },
|
||||
)
|
||||
export type ImageAspectRatio = Media.AspectRatio
|
||||
export const ImageAspectRatio = Media.AspectRatio
|
||||
|
||||
export type ImageFormat = "png" | "jpeg" | "webp" | (string & {})
|
||||
|
||||
@@ -216,17 +165,7 @@ export function request(input: ImageRequest | ImageRequestInput) {
|
||||
})
|
||||
}
|
||||
|
||||
const requestEffect = (input: ImageRequest | ImageRequestInput) =>
|
||||
Effect.try({
|
||||
try: () => request(input),
|
||||
catch: (error) =>
|
||||
new AIError({
|
||||
reason: new InvalidRequestError({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
cause: error,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
const requestEffect = (input: ImageRequest | ImageRequestInput) => tryRequest(() => request(input))
|
||||
|
||||
export function generate<const Model extends ImageModel>(
|
||||
input: ImageRequestInput<Model>,
|
||||
|
||||
@@ -29,9 +29,35 @@ export type {
|
||||
ImageRoute,
|
||||
} from "./image.js"
|
||||
export { Image } from "./image.js"
|
||||
export { VideoClient } from "./video-client.js"
|
||||
export {
|
||||
VideoAspectRatio,
|
||||
VideoEvent,
|
||||
VideoFrames,
|
||||
VideoModel,
|
||||
VideoModelSchema,
|
||||
VideoRequest,
|
||||
VideoResponse,
|
||||
} from "./video.js"
|
||||
export type {
|
||||
VideoModelOptions,
|
||||
VideoOptions,
|
||||
VideoRequestFor,
|
||||
VideoRequestInput,
|
||||
VideoResolution,
|
||||
VideoRoute,
|
||||
} from "./video.js"
|
||||
export { Video } from "./video.js"
|
||||
export { Media } from "./media.js"
|
||||
export { Generation } from "./generation.js"
|
||||
export type { Event as GenerationEvent, Poll, Route as GenerationRoute, Snapshot as GenerationSnapshot, Status as GenerationStatus } from "./generation.js"
|
||||
export type {
|
||||
AwaitOptions as GenerationAwaitOptions,
|
||||
Event as GenerationEvent,
|
||||
Poll,
|
||||
Route as GenerationRoute,
|
||||
Snapshot as GenerationSnapshot,
|
||||
Status as GenerationStatus,
|
||||
} from "./generation.js"
|
||||
export { Tool, ToolFailure, toDefinitions } from "./tool.js"
|
||||
export { ToolRuntime } from "./tool-runtime.js"
|
||||
export type { DispatchResult as ToolDispatchResult, ToolSettlement } from "./tool-runtime.js"
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Effect } from "effect"
|
||||
import { Endpoint } from "./route/endpoint.js"
|
||||
import type { MediaRoute } from "./route/media.js"
|
||||
import type { MediaProtocol } from "./route/media-protocol.js"
|
||||
import { AIError, HttpOptions, InvalidRequestError, ModelID, ProviderID } from "./schema/index.js"
|
||||
|
||||
/**
|
||||
* What every media model carries: ids, the configured route, and deployment `http` overlays. Modality classes
|
||||
* (`ImageModel`, `VideoModel`) extend it with their route type and a nominal marker so one cannot stand in for the
|
||||
* other in requests.
|
||||
*/
|
||||
export class MediaModel<Route, Options> {
|
||||
declare protected readonly _Options: (options: Options) => Options
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: Route
|
||||
readonly http?: HttpOptions
|
||||
|
||||
constructor(input: MediaModel.Input<Route>) {
|
||||
this.id = ModelID.make(input.id)
|
||||
this.provider = ProviderID.make(input.provider)
|
||||
this.route = input.route
|
||||
this.http = input.http
|
||||
}
|
||||
}
|
||||
|
||||
export namespace MediaModel {
|
||||
export interface Input<Route> {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string | ProviderID
|
||||
readonly route: Route
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
/** A protocol plus its canonical start path; `ModelInput.baseURL` overrides `baseURL` per deployment. */
|
||||
export interface RouteInput<Request extends MediaRoute.MediaRequest, Protocol> {
|
||||
readonly id: string
|
||||
readonly provider: string | ProviderID
|
||||
readonly protocol: Protocol
|
||||
readonly path: Endpoint.EndpointPart<MediaProtocol.Body, Request>
|
||||
readonly baseURL?: string
|
||||
/** Headers the protocol requires on every call, such as a pinned API version; deployment headers win. */
|
||||
readonly headers?: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
/** Compose a protocol route input with one deployment through `MediaRoute.inline` or `MediaRoute.queued`. */
|
||||
export const composeRoute = <Request extends MediaRoute.MediaRequest, Protocol, Route>(
|
||||
compose: (input: MediaRoute.Composition<Request> & { readonly protocol: Protocol }) => Route,
|
||||
route: MediaModel.RouteInput<Request, Protocol>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): Route =>
|
||||
compose({
|
||||
id: route.id,
|
||||
provider: route.provider,
|
||||
protocol: route.protocol,
|
||||
endpoint: Endpoint.path(route.path, { baseURL: input.baseURL ?? route.baseURL }),
|
||||
auth: input.auth,
|
||||
headers:
|
||||
route.headers === undefined && input.headers === undefined ? undefined : { ...route.headers, ...input.headers },
|
||||
})
|
||||
|
||||
/** Lift a synchronous Schema-class constructor into a typed `InvalidRequest` failure. */
|
||||
export const tryRequest = <A>(make: () => A): Effect.Effect<A, AIError> =>
|
||||
Effect.try({
|
||||
try: make,
|
||||
catch: (error) =>
|
||||
new AIError({
|
||||
reason: new InvalidRequestError({
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
cause: error,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
@@ -34,8 +34,6 @@ const UrlSource = Schema.Struct({
|
||||
mediaType: Schema.optional(Schema.String),
|
||||
/** Epoch milliseconds after which the provider no longer serves the URL. */
|
||||
expiresAt: Schema.optional(Schema.Number),
|
||||
/** Headers required to fetch the URL, such as provider auth for Veo downloads. */
|
||||
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
})
|
||||
|
||||
/** A provider-side handle: OpenAI `file_id`, Gemini file URI, `gs://`, `runway://`, or a prior generation id. */
|
||||
@@ -55,6 +53,12 @@ export type Source = Schema.Schema.Type<typeof Source>
|
||||
// Kind, Info, Notice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AspectRatio = `${number}:${number}`
|
||||
export const AspectRatio = Schema.declare<AspectRatio>(
|
||||
(value): value is AspectRatio => typeof value === "string" && /^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/.test(value),
|
||||
{ title: "Media.AspectRatio" },
|
||||
)
|
||||
|
||||
export const Kind = Schema.Literals(["image", "video", "audio", "document", "other"])
|
||||
export type Kind = Schema.Schema.Type<typeof Kind>
|
||||
|
||||
@@ -110,6 +114,8 @@ export class Asset {
|
||||
/** Epoch milliseconds after which a `url` source stops resolving. */
|
||||
readonly expiresAt?: number
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
/** Transient download credentials for `url` sources; see `Asset.Input.headers`. */
|
||||
readonly headers?: Record<string, string>
|
||||
|
||||
// Derived payload forms are cached on the instance because every protocol lowering re-reads the same payload. The
|
||||
// cache is check-then-set (concurrent first reads of a `url` source may both download) and is never observable
|
||||
@@ -127,6 +133,7 @@ export class Asset {
|
||||
this.info = input.info
|
||||
this.expiresAt = input.source.type === "url" ? input.source.expiresAt : undefined
|
||||
this.providerMetadata = input.providerMetadata
|
||||
this.headers = input.source.type === "url" ? input.headers : undefined
|
||||
}
|
||||
|
||||
/** Inline payload without effects, for protocols that embed base64 or data URLs directly. */
|
||||
@@ -151,7 +158,7 @@ export class Asset {
|
||||
? Effect.fromResult(Encoding.decodeBase64(source.data)).pipe(
|
||||
Effect.mapError((cause) => invalid(`Media asset contains invalid base64 data`, cause)),
|
||||
)
|
||||
: download(source)
|
||||
: download(source, this.headers)
|
||||
return decoded.pipe(Effect.tap((data) => Effect.sync(() => (this.#bytes = data))))
|
||||
})
|
||||
}
|
||||
@@ -198,6 +205,12 @@ export namespace Asset {
|
||||
readonly source: Source
|
||||
readonly info?: Info
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
/**
|
||||
* Headers required to download a `url` source, such as the provider API key Veo demands for its file URIs.
|
||||
* They are runtime-only: never part of `source`, `toJSON()`, or `AssetSchema`, so a persisted asset cannot leak
|
||||
* credentials and cannot be downloaded again after a round-trip. Call `materialize()` before persisting.
|
||||
*/
|
||||
readonly headers?: Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,10 +239,13 @@ export const AssetSchema = AssetEncoded.pipe(
|
||||
}),
|
||||
)
|
||||
|
||||
const download = Effect.fn("Media.download")(function* (source: Extract<Source, { readonly type: "url" }>) {
|
||||
const download = Effect.fn("Media.download")(function* (
|
||||
source: Extract<Source, { readonly type: "url" }>,
|
||||
headers: Record<string, string> | undefined,
|
||||
) {
|
||||
const executor = yield* Service
|
||||
const response = yield* executor.execute(
|
||||
HttpClientRequest.get(source.url).pipe(HttpClientRequest.setHeaders(source.headers ?? {})),
|
||||
HttpClientRequest.get(source.url).pipe(HttpClientRequest.setHeaders(headers ?? {})),
|
||||
)
|
||||
const buffer = yield* response.arrayBuffer.pipe(
|
||||
Effect.mapError(
|
||||
@@ -264,8 +280,8 @@ export const url = (
|
||||
value: string,
|
||||
options?: AssetOptions & Omit<Extract<Source, { readonly type: "url" }>, "type" | "url">,
|
||||
) => {
|
||||
const { mediaType, expiresAt, headers, ...rest } = options ?? {}
|
||||
return from({ type: "url", url: value, mediaType, expiresAt, headers }, rest)
|
||||
const { mediaType, expiresAt, ...rest } = options ?? {}
|
||||
return from({ type: "url", url: value, mediaType, expiresAt }, rest)
|
||||
}
|
||||
|
||||
export const ref = (provider: string | ProviderID, id: string, mediaType?: string, options?: AssetOptions) =>
|
||||
|
||||
+45
-11
@@ -1,4 +1,5 @@
|
||||
import { Effect, Layer, ManagedRuntime, Stream } from "effect"
|
||||
import type { AwaitOptions, Generation, Snapshot } from "./generation.js"
|
||||
import { Image, ImageModel, ImageRequest, type ImageRequestInput } from "./image.js"
|
||||
import { ImageClient } from "./image-client.js"
|
||||
import { LLM } from "./index.js"
|
||||
@@ -6,10 +7,12 @@ import { LLMClient } from "./route/client.js"
|
||||
import { RequestExecutor } from "./route/executor.js"
|
||||
import { LanguageModel, LLMRequest } from "./schema/index.js"
|
||||
import type { RequestInput } from "./llm.js"
|
||||
import { Video, VideoModel, VideoRequest, type VideoOptions, type VideoRequestInput } from "./video.js"
|
||||
import { VideoClient } from "./video-client.js"
|
||||
|
||||
/**
|
||||
* Promise-first entrypoint for scripts and non-Effect callers. One `ManagedRuntime` hosts the LLM and image clients
|
||||
* over a request executor; every method runs the corresponding Effect API and rethrows `AIError` unchanged.
|
||||
* Promise-first entrypoint for scripts and non-Effect callers. One `ManagedRuntime` hosts the LLM, image, and video
|
||||
* clients over a request executor; every method runs the corresponding Effect API and rethrows `AIError` unchanged.
|
||||
*/
|
||||
export interface Options {
|
||||
/** Executor layer; defaults to `RequestExecutor.fetchLayer`. Inject a recorder or middleware here. */
|
||||
@@ -23,8 +26,18 @@ export interface RunOptions {
|
||||
export type Services =
|
||||
| Layer.Success<typeof LLMClient.layer>
|
||||
| Layer.Success<typeof ImageClient.layer>
|
||||
| Layer.Success<typeof VideoClient.layer>
|
||||
| RequestExecutor.Service
|
||||
|
||||
/** Promise view of a `Generation`: its snapshot plus `await`, `refresh`, and `cancel` returning promises. */
|
||||
export type GenerationHandle<Response> = Snapshot & {
|
||||
/** Serializable JSON; pass it back to `resume` from another process. */
|
||||
readonly token: unknown
|
||||
readonly await: (options?: AwaitOptions & RunOptions) => Promise<Response>
|
||||
readonly refresh: (options?: RunOptions) => Promise<GenerationHandle<Response>>
|
||||
readonly cancel: (options?: RunOptions) => Promise<void>
|
||||
}
|
||||
|
||||
const abortEffect = (signal: AbortSignal | undefined) =>
|
||||
signal === undefined
|
||||
? Effect.never
|
||||
@@ -40,7 +53,7 @@ const abortEffect = (signal: AbortSignal | undefined) =>
|
||||
|
||||
export const make = (options: Options = {}) => {
|
||||
const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(LLMClient.layer, ImageClient.layer).pipe(
|
||||
Layer.mergeAll(LLMClient.layer, ImageClient.layer, VideoClient.layer).pipe(
|
||||
Layer.provideMerge(options.layer ?? RequestExecutor.fetchLayer),
|
||||
),
|
||||
)
|
||||
@@ -61,19 +74,27 @@ export const make = (options: Options = {}) => {
|
||||
),
|
||||
)
|
||||
|
||||
const handle = <Response>(generation: Generation<Response>): GenerationHandle<Response> => ({
|
||||
...generation.snapshot,
|
||||
token: generation.token,
|
||||
await: (options) => run(generation.await({ poll: options?.poll }), options),
|
||||
refresh: (options) => run(generation.refresh(), options).then(handle),
|
||||
cancel: (options) => run(generation.cancel(), options),
|
||||
})
|
||||
|
||||
// The typed `generate`/`stream` overloads take a concrete input or a request, not the union; normalize once here.
|
||||
const llmRequest = (input: RequestInput | LLMRequest) => (input instanceof LLMRequest ? input : LLM.request(input))
|
||||
const imageRequest = (input: ImageRequestInput | ImageRequest) =>
|
||||
input instanceof ImageRequest ? input : Image.request(input)
|
||||
const videoRequest = (input: VideoRequestInput | VideoRequest) =>
|
||||
input instanceof VideoRequest ? input : Video.request(input)
|
||||
|
||||
return {
|
||||
run,
|
||||
llm: {
|
||||
request: LLM.request,
|
||||
generate: <const Model extends LanguageModel>(
|
||||
input: RequestInput<Model> | LLMRequest,
|
||||
options?: RunOptions,
|
||||
) => run(LLM.generate(llmRequest(input)), options),
|
||||
generate: <const Model extends LanguageModel>(input: RequestInput<Model> | LLMRequest, options?: RunOptions) =>
|
||||
run(LLM.generate(llmRequest(input)), options),
|
||||
stream: <const Model extends LanguageModel>(input: RequestInput<Model> | LLMRequest, options?: RunOptions) =>
|
||||
iterate(LLM.stream(llmRequest(input)), options),
|
||||
},
|
||||
@@ -83,10 +104,23 @@ export const make = (options: Options = {}) => {
|
||||
input: ImageRequestInput<Model> | ImageRequest,
|
||||
options?: RunOptions,
|
||||
) => run(Image.generate(imageRequest(input)), options),
|
||||
stream: <const Model extends ImageModel>(
|
||||
input: ImageRequestInput<Model> | ImageRequest,
|
||||
options?: RunOptions,
|
||||
) => iterate(Image.stream(imageRequest(input)), options),
|
||||
stream: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: RunOptions) =>
|
||||
iterate(Image.stream(imageRequest(input)), options),
|
||||
},
|
||||
video: {
|
||||
request: Video.request,
|
||||
start: <const Model extends VideoModel>(input: VideoRequestInput<Model> | VideoRequest, options?: RunOptions) =>
|
||||
run(Video.start(videoRequest(input)), options).then(handle),
|
||||
generate: <const Model extends VideoModel>(
|
||||
input: VideoRequestInput<Model> | VideoRequest,
|
||||
options?: AwaitOptions & RunOptions,
|
||||
) => run(Video.generate(videoRequest(input), { poll: options?.poll }), options),
|
||||
resume: <Options extends VideoOptions>(model: VideoModel<Options>, token: unknown, options?: RunOptions) =>
|
||||
run(Video.resume(model, token), options).then(handle),
|
||||
stream: <const Model extends VideoModel>(
|
||||
input: VideoRequestInput<Model> | VideoRequest,
|
||||
options?: AwaitOptions & RunOptions,
|
||||
) => iterate(Video.stream(videoRequest(input), { poll: options?.poll }), options),
|
||||
},
|
||||
dispose: () => runtime.dispose(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import type { Status } from "../generation.js"
|
||||
import { Media } from "../media.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { VideoModel, VideoResponse, type VideoRequestFor } from "../video.js"
|
||||
import { ProviderShared, optionalNull } from "./shared.js"
|
||||
|
||||
const ADAPTER = "fal-video"
|
||||
const NAME = "fal Video"
|
||||
const PROVIDER = ProviderID.make("fal")
|
||||
export const DEFAULT_BASE_URL = "https://queue.fal.run"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type FalVideoString<Known extends string> = Known | (string & {})
|
||||
|
||||
/**
|
||||
* Provider-native input. fal video endpoints are model-specific: `duration` is a string enum whose values differ per
|
||||
* model (`"8s"` for Veo, `"5"` for Kling), and last-frame fields are named per model (`end_image_url`,
|
||||
* `last_frame_url`, `tail_image_url`), so those pass through here instead of lowering from common fields.
|
||||
*/
|
||||
export type FalVideoOptions = {
|
||||
readonly duration?: FalVideoString<"4s" | "6s" | "8s" | "5" | "10">
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = VideoRequestFor<FalVideoOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Token and response schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** fal hands back absolute follow-up URLs on submit; they are authoritative for status, result, and cancel. */
|
||||
export const Token = Schema.Struct({
|
||||
requestID: Schema.String,
|
||||
statusURL: Schema.String,
|
||||
responseURL: Schema.String,
|
||||
cancelURL: Schema.String,
|
||||
})
|
||||
export type Token = Schema.Schema.Type<typeof Token>
|
||||
|
||||
const StartResponse = Schema.Struct({
|
||||
request_id: Schema.String,
|
||||
status_url: Schema.String,
|
||||
response_url: Schema.String,
|
||||
cancel_url: Schema.String,
|
||||
queue_position: optionalNull(Schema.Number),
|
||||
})
|
||||
|
||||
const QueueStatus = Schema.Struct({
|
||||
status: Schema.String,
|
||||
queue_position: optionalNull(Schema.Number),
|
||||
error: optionalNull(Schema.Unknown),
|
||||
})
|
||||
|
||||
const QueueResult = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
video: Schema.Struct({
|
||||
url: Schema.String,
|
||||
content_type: optionalNull(Schema.String),
|
||||
file_name: optionalNull(Schema.String),
|
||||
file_size: optionalNull(Schema.Number),
|
||||
}),
|
||||
seed: optionalNull(Schema.Number),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const STATUS = {
|
||||
IN_QUEUE: "queued",
|
||||
IN_PROGRESS: "running",
|
||||
COMPLETED: "completed",
|
||||
} as const satisfies Record<string, Status>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// fal accepts public URLs and data URIs; there is no provider file handle to forward.
|
||||
const mediaUrl = (asset: Media.Asset) =>
|
||||
ProviderShared.mediaReference(asset, undefined, NAME).pipe(Effect.map((reference) => reference.value))
|
||||
|
||||
const fromRequest = Effect.fn("FalVideo.fromRequest")(function* (request: Request) {
|
||||
if (request.frames?.last !== undefined)
|
||||
return yield* ProviderShared.unsupportedOperation({
|
||||
operation: "video.frames.last",
|
||||
provider: PROVIDER,
|
||||
route: ADAPTER,
|
||||
message: `${NAME} names the last frame per model; pass it through providerOptions (e.g. end_image_url) instead of frames.last`,
|
||||
})
|
||||
const imageUrl = request.frames?.first === undefined ? undefined : yield* mediaUrl(request.frames.first)
|
||||
const videoUrl = request.video === undefined ? undefined : yield* mediaUrl(request.video)
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
prompt: request.prompt,
|
||||
negative_prompt: request.negativePrompt,
|
||||
seed: request.seed,
|
||||
aspect_ratio: request.aspectRatio,
|
||||
resolution: request.resolution,
|
||||
generate_audio: request.audio,
|
||||
image_url: imageUrl,
|
||||
video_url: videoUrl,
|
||||
},
|
||||
request.providerOptions,
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeStart = MediaProtocol.decodeStarted(ADAPTER, NAME, StartResponse, (value) => ({
|
||||
token: {
|
||||
requestID: value.request_id,
|
||||
statusURL: value.status_url,
|
||||
responseURL: value.response_url,
|
||||
cancelURL: value.cancel_url,
|
||||
},
|
||||
snapshot: { id: value.request_id, status: "queued", position: value.queue_position ?? undefined },
|
||||
}))
|
||||
|
||||
const decodeQueueStatus = MediaProtocol.decodeJson(ADAPTER, NAME, QueueStatus)
|
||||
const decodeQueueResult = MediaProtocol.decodeJson(ADAPTER, NAME, QueueResult)
|
||||
|
||||
const decodeStatus = Effect.fn("FalVideo.decodeStatus")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
const output = yield* decodeQueueStatus(response)
|
||||
const decoded = output.value
|
||||
const status = yield* MediaProtocol.status(STATUS, decoded.status, output)
|
||||
// fal reports request failures as COMPLETED with an `error`; the response endpoint carries the details.
|
||||
const failed = status === "completed" && decoded.error !== undefined && decoded.error !== null
|
||||
return {
|
||||
id: context.token.requestID,
|
||||
status: failed ? "failed" : status,
|
||||
position: status === "queued" ? (decoded.queue_position ?? undefined) : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
const decodeResult = Effect.fn("FalVideo.decodeResult")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
const output = yield* decodeQueueResult(response)
|
||||
const { video, seed, ...rest } = output.value
|
||||
return new VideoResponse({
|
||||
videos: [Media.url(video.url, { mediaType: video.content_type ?? "video/mp4" })],
|
||||
providerMetadata: {
|
||||
fal: {
|
||||
requestId: context.token.requestID,
|
||||
seed: seed ?? undefined,
|
||||
fileName: video.file_name ?? undefined,
|
||||
fileSize: video.file_size ?? undefined,
|
||||
...rest,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
token: Token,
|
||||
unsupported: ["n", "durationSeconds", "references"],
|
||||
start: { body: { from: fromRequest }, decode: decodeStart },
|
||||
status: { path: (token) => token.statusURL, decode: decodeStatus },
|
||||
result: { path: (token) => token.responseURL, decode: decodeResult },
|
||||
cancel: { method: "PUT", path: (token) => token.cancelURL },
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
VideoModel.fromRoute<FalVideoOptions, Token>(
|
||||
{
|
||||
id: ADAPTER,
|
||||
provider: PROVIDER,
|
||||
protocol,
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
path: ({ request }) => `/${request.model.id}`,
|
||||
},
|
||||
input,
|
||||
)
|
||||
|
||||
export const FalVideo = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -0,0 +1,233 @@
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import type { Status } from "../generation.js"
|
||||
import { Media } from "../media.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { VideoModel, VideoResponse, type VideoRequestFor } from "../video.js"
|
||||
import { ProviderShared, optionalArray } from "./shared.js"
|
||||
|
||||
const ADAPTER = "google-video"
|
||||
const NAME = "Google Veo"
|
||||
const PROVIDER = ProviderID.make("google")
|
||||
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
/** Veo keeps generated files for two days; the asset carries that deadline so callers materialize in time. */
|
||||
const FILE_RETENTION = Duration.days(2)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type GoogleVideoString<Known extends string> = Known | (string & {})
|
||||
|
||||
/** Provider-native `parameters`. Common fields (`aspectRatio`, `resolution`, `durationSeconds`, `seed`) live on the request. */
|
||||
export type GoogleVideoOptions = {
|
||||
readonly personGeneration?: GoogleVideoString<"allow_all" | "allow_adult" | "dont_allow">
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = VideoRequestFor<GoogleVideoOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Token and response schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The long-running operation name, e.g. `models/veo-3.1-generate-preview/operations/abc123`. */
|
||||
export const Token = Schema.Struct({ operation: Schema.String })
|
||||
export type Token = Schema.Schema.Type<typeof Token>
|
||||
|
||||
const StartResponse = Schema.Struct({ name: Schema.String })
|
||||
|
||||
const Operation = Schema.Struct({
|
||||
done: Schema.optional(Schema.Boolean),
|
||||
error: Schema.optional(Schema.Struct({ message: Schema.optional(Schema.String) })),
|
||||
response: Schema.optional(
|
||||
Schema.Struct({
|
||||
generateVideoResponse: Schema.optional(
|
||||
Schema.Struct({
|
||||
generatedSamples: optionalArray(
|
||||
Schema.Struct({
|
||||
video: Schema.optional(
|
||||
Schema.Struct({
|
||||
uri: Schema.optional(Schema.String),
|
||||
mimeType: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
raiMediaFilteredCount: Schema.optional(Schema.Number),
|
||||
raiMediaFilteredReasons: optionalArray(Schema.String),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
metadata: Schema.optional(Schema.Unknown),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Veo takes inline media only; a prior Veo output is `Media.url` with transient auth, so materialize it first.
|
||||
const inlineMedia = (asset: Media.Asset) =>
|
||||
ProviderShared.requireInlineMedia(NAME, asset).pipe(
|
||||
Effect.map((inline) => ({ inlineData: { mimeType: inline.mime, data: inline.base64 } })),
|
||||
)
|
||||
|
||||
const fromRequest = Effect.fn("GoogleVideo.fromRequest")(function* (request: Request) {
|
||||
if (request.n !== undefined && request.n > 1)
|
||||
return yield* ProviderShared.unsupportedOperation({
|
||||
operation: "video.n",
|
||||
provider: PROVIDER,
|
||||
route: ADAPTER,
|
||||
message: `${NAME} generates one video per request; call it once per video instead of n=${request.n}`,
|
||||
})
|
||||
if (request.audio === false)
|
||||
return yield* ProviderShared.unsupportedOperation({
|
||||
operation: "video.audio",
|
||||
provider: PROVIDER,
|
||||
route: ADAPTER,
|
||||
message: `${NAME} always generates audio; audio: false cannot be honored`,
|
||||
})
|
||||
if (request.frames?.last !== undefined && request.frames.first === undefined)
|
||||
return yield* ProviderShared.invalidRequest(`${NAME} requires frames.first when frames.last is set`)
|
||||
const image = request.frames?.first === undefined ? undefined : yield* inlineMedia(request.frames.first)
|
||||
const lastFrame = request.frames?.last === undefined ? undefined : yield* inlineMedia(request.frames.last)
|
||||
const video = request.video === undefined ? undefined : yield* inlineMedia(request.video)
|
||||
const referenceImages = yield* Effect.forEach(request.references ?? [], (asset) =>
|
||||
inlineMedia(asset).pipe(Effect.map((image) => ({ image, referenceType: "asset" }))),
|
||||
)
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
instances: [
|
||||
{
|
||||
prompt: request.prompt,
|
||||
image,
|
||||
lastFrame,
|
||||
referenceImages: referenceImages.length === 0 ? undefined : referenceImages,
|
||||
video,
|
||||
},
|
||||
],
|
||||
parameters: mergeJsonRecords(
|
||||
{
|
||||
aspectRatio: request.aspectRatio,
|
||||
resolution: request.resolution,
|
||||
durationSeconds: request.durationSeconds,
|
||||
negativePrompt: request.negativePrompt,
|
||||
seed: request.seed,
|
||||
},
|
||||
request.providerOptions,
|
||||
),
|
||||
},
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeStart = MediaProtocol.decodeStarted(ADAPTER, NAME, StartResponse, (value) => ({
|
||||
token: { operation: value.name },
|
||||
snapshot: { id: value.name, status: "running" },
|
||||
}))
|
||||
|
||||
// Operations carry no status string: not done is running, done with `error` failed, otherwise completed.
|
||||
const statusOf = (operation: typeof Operation.Type): Status => {
|
||||
if (operation.done !== true) return "running"
|
||||
return operation.error === undefined ? "completed" : "failed"
|
||||
}
|
||||
|
||||
const decodeOperation = MediaProtocol.decodeJson(ADAPTER, NAME, Operation)
|
||||
|
||||
const decodeStatus = Effect.fn("GoogleVideo.decodeStatus")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
const output = yield* decodeOperation(response)
|
||||
return { id: context.token.operation, status: statusOf(output.value) }
|
||||
})
|
||||
|
||||
const decodeResult = Effect.fn("GoogleVideo.decodeResult")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
const output = yield* decodeOperation(response)
|
||||
const operation = output.value
|
||||
const status = statusOf(operation)
|
||||
if (status === "running")
|
||||
return yield* output.invalid(`${NAME} operation ${context.token.operation} has not finished`)
|
||||
if (status === "failed")
|
||||
return yield* output.ended(
|
||||
"failed",
|
||||
`${NAME} operation failed${operation.error?.message === undefined ? "" : `: ${operation.error.message}`}`,
|
||||
)
|
||||
const generated = operation.response?.generateVideoResponse
|
||||
// Downloads require the same API key as the poll; the asset carries it transiently and follows the redirect.
|
||||
const videos = yield* Effect.forEach(
|
||||
(generated?.generatedSamples ?? []).flatMap((sample) =>
|
||||
sample.video?.uri === undefined ? [] : [{ uri: sample.video.uri, mimeType: sample.video.mimeType }],
|
||||
),
|
||||
(video) =>
|
||||
MediaProtocol.expiringUrl(video.uri, FILE_RETENTION, {
|
||||
mediaType: video.mimeType ?? "video/mp4",
|
||||
headers: context.auth,
|
||||
}),
|
||||
)
|
||||
const reasons = generated?.raiMediaFilteredReasons ?? []
|
||||
const notices = reasons.map((reason) => ({
|
||||
type: "filtered" as const,
|
||||
message: `${NAME} filtered media: ${reason}`,
|
||||
providerMetadata: { google: { raiMediaFilteredReason: reason } },
|
||||
}))
|
||||
if (videos.length === 0 && (reasons.length > 0 || (generated?.raiMediaFilteredCount ?? 0) > 0))
|
||||
return yield* output.contentPolicy(
|
||||
`${NAME} filtered every video${reasons.length === 0 ? "" : `: ${reasons.join("; ")}`}`,
|
||||
)
|
||||
if (videos.length === 0) return yield* output.invalid(`${NAME} operation completed without any video`)
|
||||
return new VideoResponse({
|
||||
videos,
|
||||
notices: notices.length === 0 ? undefined : notices,
|
||||
providerMetadata: {
|
||||
google: {
|
||||
operation: context.token.operation,
|
||||
raiMediaFilteredCount: generated?.raiMediaFilteredCount,
|
||||
metadata: operation.metadata,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const operationPath = (token: Token) => `/${token.operation}`
|
||||
|
||||
export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
token: Token,
|
||||
start: { body: { from: fromRequest }, decode: decodeStart },
|
||||
status: { path: operationPath, decode: decodeStatus },
|
||||
result: { path: operationPath, decode: decodeResult },
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
VideoModel.fromRoute<GoogleVideoOptions, Token>(
|
||||
{
|
||||
id: ADAPTER,
|
||||
provider: PROVIDER,
|
||||
protocol,
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
path: ({ request }) => `/models/${request.model.id}:predictLongRunning`,
|
||||
},
|
||||
input,
|
||||
)
|
||||
|
||||
export const GoogleVideo = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -4,7 +4,7 @@ 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 { ProviderID, mergeJsonRecords, type AIError } from "../schema/index.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { JsonObject, ProviderShared, optionalNull } from "./shared.js"
|
||||
import { MediaInput } from "./utils/media-input.js"
|
||||
|
||||
@@ -70,13 +70,9 @@ const Response = Schema.Struct({
|
||||
|
||||
const isEdit = (request: Request) => (request.images?.length ?? 0) > 0
|
||||
|
||||
const reference = (asset: Media.Asset): Effect.Effect<Record<string, unknown>, AIError> => {
|
||||
const inline = asset.inline()
|
||||
if (inline) return Effect.succeed({ image_url: inline.dataUrl })
|
||||
const url = ProviderShared.mediaUrl(asset)
|
||||
if (url) return Effect.succeed({ image_url: url })
|
||||
return Effect.fail(ProviderShared.invalidRequest(`${NAME} accepts image bytes and URLs`))
|
||||
}
|
||||
// Meta has no file handles: refs are rejected even when they name this provider.
|
||||
const reference = (asset: Media.Asset) =>
|
||||
ProviderShared.mediaReference(asset, undefined, NAME).pipe(Effect.map((item) => ({ image_url: item.value })))
|
||||
|
||||
const fromRequest = Effect.fn("MetaImages.fromRequest")(function* (request: Request) {
|
||||
const images = yield* Effect.forEach(request.images ?? [], reference)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
@@ -76,6 +76,44 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
|
||||
})
|
||||
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
|
||||
|
||||
// `reasoning_details` carries two dialects. OpenRouter's `reasoning.*` entries
|
||||
// must be replayed unmodified (`index` included), so they keep every field they
|
||||
// arrived with. Kimi's OpenAI-compatible surface streams preserved thinking as
|
||||
// bare `summary` / `encrypted` entries keyed by a stream-only `index`; Kimi does
|
||||
// not document this publicly, so the handling follows Kimi Code (Kimi's own
|
||||
// client): merge summary deltas by `index`, replay without `index`, and always
|
||||
// send `reasoning_content` alongside. Anything else is dropped at the boundary.
|
||||
const OpenRouterDetailFields = {
|
||||
id: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
format: Schema.optional(Schema.String),
|
||||
index: Schema.optional(Schema.Number),
|
||||
signature: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
}
|
||||
const ReasoningDetail = Schema.Union([
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({ type: Schema.Literal("reasoning.text"), text: Schema.optional(Schema.String), ...OpenRouterDetailFields }),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("reasoning.summary"),
|
||||
summary: Schema.optional(Schema.String),
|
||||
...OpenRouterDetailFields,
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({ type: Schema.Literal("reasoning.encrypted"), data: Schema.String, ...OpenRouterDetailFields }),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
Schema.Struct({ type: Schema.Literal("summary"), summary: Schema.String, index: Schema.optional(Schema.Number) }),
|
||||
Schema.Struct({ type: Schema.Literal("encrypted"), encrypted: Schema.String, index: Schema.optional(Schema.Number) }),
|
||||
])
|
||||
type ReasoningDetail = Schema.Schema.Type<typeof ReasoningDetail>
|
||||
const decodeReasoningDetail = Schema.decodeUnknownOption(ReasoningDetail)
|
||||
const knownReasoningDetails = (details: ReadonlyArray<unknown>) =>
|
||||
details.flatMap((detail) => Option.toArray(decodeReasoningDetail(detail)))
|
||||
|
||||
// Intentionally omit Gemini's provider-specific `extra_content.google.thought_signature`
|
||||
// extension until direct Google OpenAI-compatible routing is supported here:
|
||||
// https://github.com/vercel/ai/issues/11590
|
||||
@@ -265,7 +303,9 @@ export interface ParserState {
|
||||
readonly finishReason?: FinishReasonDetails
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningField?: string
|
||||
readonly reasoningDetails: Array<unknown>
|
||||
/** A scalar reasoning field (`reasoning_content`, ...) has carried text in this stream. */
|
||||
readonly reasoningTextObserved: boolean
|
||||
readonly reasoningDetails: Array<ReasoningDetail>
|
||||
readonly reasoningDetailsObserved: boolean
|
||||
readonly reasoningEmitted: boolean
|
||||
readonly latestToolIndex?: number
|
||||
@@ -341,10 +381,21 @@ const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown,
|
||||
return Array.isArray(details) ? details : []
|
||||
})
|
||||
if (parts.some((part) => Array.isArray(part.providerMetadata?.[providerMetadataKey]?.reasoningDetails)))
|
||||
return observed
|
||||
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
|
||||
return knownReasoningDetails(observed).map(lowerReasoningDetail)
|
||||
if (isRecord(native) && Array.isArray(native.reasoning_details))
|
||||
return knownReasoningDetails(native.reasoning_details).map(lowerReasoningDetail)
|
||||
}
|
||||
|
||||
// Kimi rejects its stream-only `index` on requests
|
||||
// ("the reasoning_details ... must not contain streaming index").
|
||||
const lowerReasoningDetail = (detail: ReasoningDetail) => {
|
||||
if (detail.type === "summary") return { type: detail.type, summary: detail.summary }
|
||||
if (detail.type === "encrypted") return { type: detail.type, encrypted: detail.encrypted }
|
||||
return detail
|
||||
}
|
||||
|
||||
const isKimiDetail = (detail: { readonly type: string }) => detail.type === "summary" || detail.type === "encrypted"
|
||||
|
||||
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
|
||||
message: OpenAIChatRequestMessage,
|
||||
options: LoweringOptions,
|
||||
@@ -410,6 +461,9 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
if (observedField !== undefined) return observedField
|
||||
if (nativeReasoning !== undefined) return "reasoning_content"
|
||||
if (!fullyStructured || requireReasoning) return "reasoning_content"
|
||||
// Kimi always expects `reasoning_content` on replayed assistant messages,
|
||||
// even when thinking arrived only through structured details.
|
||||
if (details?.some(isKimiDetail)) return "reasoning_content"
|
||||
})()
|
||||
const reasoningText = (() => {
|
||||
if (configuredField !== undefined)
|
||||
@@ -881,44 +935,74 @@ const reasoningDelta = (
|
||||
return undefined
|
||||
}
|
||||
|
||||
const detailText = (details: ReadonlyArray<unknown>) => {
|
||||
const detailText = (details: ReadonlyArray<ReasoningDetail>, hideKimiSummary: boolean) => {
|
||||
const text = details.flatMap((detail) => {
|
||||
if (!isRecord(detail)) return []
|
||||
if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text) return [detail.text]
|
||||
if (detail.type === "reasoning.summary" && typeof detail.summary === "string" && detail.summary)
|
||||
return [detail.summary]
|
||||
if (detail.type === "reasoning.text") return detail.text ? [detail.text] : []
|
||||
if (detail.type === "reasoning.summary") return detail.summary ? [detail.summary] : []
|
||||
// Kimi streams the full thinking through `reasoning_content` and a separate
|
||||
// summary through details; show the summary only when nothing else does.
|
||||
if (detail.type === "summary") return detail.summary && !hideKimiSummary ? [detail.summary] : []
|
||||
return []
|
||||
})
|
||||
if (text.length > 0) return text.join("")
|
||||
}
|
||||
|
||||
const appendReasoningDetails = (result: Array<unknown>, details: ReadonlyArray<unknown>) => {
|
||||
const appendReasoningDetails = (result: Array<ReasoningDetail>, details: ReadonlyArray<ReasoningDetail>) => {
|
||||
for (const detail of details) {
|
||||
const previous = result.at(-1)
|
||||
if (
|
||||
!isRecord(previous) ||
|
||||
previous.type !== "reasoning.text" ||
|
||||
!isRecord(detail) ||
|
||||
detail.type !== "reasoning.text" ||
|
||||
conflictingReasoningTextDetails(previous, detail)
|
||||
) {
|
||||
const merged = previous === undefined ? undefined : mergeReasoningDetails(previous, detail)
|
||||
if (merged === undefined) {
|
||||
result.push(detail)
|
||||
continue
|
||||
}
|
||||
result[result.length - 1] = {
|
||||
...previous,
|
||||
...Object.fromEntries(Object.entries(detail).filter((entry) => entry[1] !== undefined)),
|
||||
text: `${typeof previous.text === "string" ? previous.text : ""}${typeof detail.text === "string" ? detail.text : ""}`,
|
||||
signature: mergeDetailValue(previous.signature, detail.signature),
|
||||
format: mergeDetailValue(previous.format, detail.format),
|
||||
}
|
||||
result[result.length - 1] = merged
|
||||
}
|
||||
}
|
||||
|
||||
const mergeDetailValue = (previous: unknown, current: unknown) =>
|
||||
// Consecutive text or summary deltas of the same kind accumulate into one
|
||||
// entry; encrypted entries are opaque and never merge.
|
||||
const mergeReasoningDetails = (previous: ReasoningDetail, detail: ReasoningDetail): ReasoningDetail | undefined => {
|
||||
if (conflictingReasoningDetails(previous, detail)) return undefined
|
||||
if (previous.type === "reasoning.text" && detail.type === "reasoning.text")
|
||||
return {
|
||||
...previous,
|
||||
...detail,
|
||||
text: `${previous.text ?? ""}${detail.text ?? ""}`,
|
||||
...mergeDetailIdentity(previous, detail),
|
||||
}
|
||||
if (previous.type === "reasoning.summary" && detail.type === "reasoning.summary")
|
||||
return {
|
||||
...previous,
|
||||
...detail,
|
||||
summary: `${previous.summary ?? ""}${detail.summary ?? ""}`,
|
||||
...mergeDetailIdentity(previous, detail),
|
||||
}
|
||||
if (previous.type === "summary" && detail.type === "summary")
|
||||
return { ...previous, ...detail, summary: previous.summary + detail.summary }
|
||||
}
|
||||
|
||||
type DetailIdentity = {
|
||||
readonly id?: string | null
|
||||
readonly index?: number
|
||||
readonly format?: string
|
||||
readonly signature?: string | null
|
||||
}
|
||||
|
||||
// The first non-empty signature and format win; a later delta may carry the
|
||||
// signature for text that streamed earlier.
|
||||
const mergeDetailIdentity = (previous: DetailIdentity, current: DetailIdentity) => {
|
||||
const signature = mergeDetailValue(previous.signature, current.signature)
|
||||
const format = mergeDetailValue(previous.format, current.format)
|
||||
return {
|
||||
...(signature === undefined ? {} : { signature }),
|
||||
...(format === undefined ? {} : { format }),
|
||||
}
|
||||
}
|
||||
|
||||
const mergeDetailValue = <T>(previous: T | undefined, current: T | undefined) =>
|
||||
previous || current || (previous !== undefined ? previous : current)
|
||||
|
||||
const conflictingReasoningTextDetails = (previous: Record<string, unknown>, current: Record<string, unknown>) =>
|
||||
const conflictingReasoningDetails = (previous: DetailIdentity, current: DetailIdentity) =>
|
||||
conflictingDetailValue(previous.id, current.id) ||
|
||||
conflictingDetailValue(previous.index, current.index) ||
|
||||
conflictingDetailValue(previous.format, current.format) ||
|
||||
@@ -930,7 +1014,7 @@ const conflictingDetailValue = (previous: unknown, current: unknown) =>
|
||||
const reasoningMetadata = (
|
||||
providerMetadataKey: string,
|
||||
field: ParserState["reasoningField"],
|
||||
details?: ReadonlyArray<unknown>,
|
||||
details?: ReadonlyArray<ReasoningDetail>,
|
||||
) => ({
|
||||
[providerMetadataKey]: {
|
||||
...(field ? { reasoningField: field } : {}),
|
||||
@@ -993,11 +1077,16 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
}
|
||||
|
||||
const reasoningField = state.reasoningField ?? reasoning?.field
|
||||
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
||||
const reasoningTextObserved = state.reasoningTextObserved || reasoning !== undefined
|
||||
const detailDelta = Array.isArray(delta?.reasoning_details)
|
||||
? knownReasoningDetails(delta.reasoning_details)
|
||||
: undefined
|
||||
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
||||
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
|
||||
const deltaMetadata = reasoningMetadata(state.providerMetadataKey, reasoningField)
|
||||
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
|
||||
const text = detailDelta?.length
|
||||
? (detailText(detailDelta, reasoningTextObserved) ?? reasoning?.text)
|
||||
: reasoning?.text
|
||||
if (text !== undefined) lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
|
||||
else if (
|
||||
reasoningDetailsObserved &&
|
||||
@@ -1093,6 +1182,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
finishReason,
|
||||
lifecycle,
|
||||
reasoningField,
|
||||
reasoningTextObserved,
|
||||
reasoningDetails: state.reasoningDetails,
|
||||
reasoningDetailsObserved,
|
||||
reasoningEmitted,
|
||||
@@ -1173,6 +1263,7 @@ export const protocol = Protocol.make({
|
||||
toolCallEvents: [],
|
||||
lifecycle: Lifecycle.initial(),
|
||||
reasoningField: request.model.compatibility?.reasoningField,
|
||||
reasoningTextObserved: false,
|
||||
reasoningDetails: [],
|
||||
reasoningDetailsObserved: false,
|
||||
reasoningEmitted: false,
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { ProviderID, mergeJsonRecords, type AIError } from "../schema/index.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { MediaInput } from "./utils/media-input.js"
|
||||
|
||||
@@ -78,17 +78,10 @@ const blob = (data: Uint8Array, mediaType: string) => {
|
||||
return new Blob([buffer], { type: mediaType })
|
||||
}
|
||||
|
||||
const reference = (asset: Media.Asset): Effect.Effect<Record<string, unknown>, AIError> => {
|
||||
const inline = asset.inline()
|
||||
if (inline) return Effect.succeed({ image_url: inline.dataUrl })
|
||||
const url = ProviderShared.mediaUrl(asset)
|
||||
if (url) return Effect.succeed({ image_url: url })
|
||||
const id = MediaInput.refID(asset, PROVIDER)
|
||||
if (id) return Effect.succeed({ file_id: id })
|
||||
return Effect.fail(
|
||||
ProviderShared.invalidRequest("OpenAI Images accepts image URLs, data URLs, bytes, and OpenAI file IDs"),
|
||||
const reference = (asset: Media.Asset) =>
|
||||
ProviderShared.mediaReference(asset, PROVIDER, NAME).pipe(
|
||||
Effect.map((item) => (item.type === "ref" ? { file_id: item.value } : { image_url: item.value })),
|
||||
)
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIImages.fromRequest")(function* (request: Request) {
|
||||
const images = request.images ?? []
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import type { Status } from "../generation.js"
|
||||
import { Media } from "../media.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { VideoModel, VideoResponse, type VideoRequestFor } from "../video.js"
|
||||
import { ProviderShared, optionalArray, optionalNull } from "./shared.js"
|
||||
|
||||
const ADAPTER = "runway-video"
|
||||
const NAME = "Runway"
|
||||
const PROVIDER = ProviderID.make("runway")
|
||||
export const DEFAULT_BASE_URL = "https://api.dev.runwayml.com/v1"
|
||||
/** Every Runway request must pin the API version. */
|
||||
export const API_VERSION = "2024-11-06"
|
||||
export const TEXT_TO_VIDEO_PATH = "/text_to_video"
|
||||
export const IMAGE_TO_VIDEO_PATH = "/image_to_video"
|
||||
export const VIDEO_TO_VIDEO_PATH = "/video_to_video"
|
||||
export const TASKS_PATH = "/tasks"
|
||||
/** Output URLs are valid for 24–48 hours; the asset carries the conservative bound. */
|
||||
const OUTPUT_RETENTION = Duration.hours(24)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type RunwayVideoString<Known extends string> = Known | (string & {})
|
||||
|
||||
/**
|
||||
* Provider-native options. Common fields lower to Runway's names: `aspectRatio` → `ratio` (Runway expects pixel
|
||||
* ratios such as `1280:720` for most models), `durationSeconds` → `duration`, `audio`, `negativePrompt`,
|
||||
* `resolution`, `references`, and `frames` → `promptImage`.
|
||||
*/
|
||||
export type RunwayVideoOptions = {
|
||||
readonly contentModeration?: { readonly publicFigureThreshold?: RunwayVideoString<"auto" | "low"> }
|
||||
readonly outputFormat?: RunwayVideoString<"mp4" | "prores" | "png_sequence">
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = VideoRequestFor<RunwayVideoOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Token and response schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Token = Schema.Struct({ taskID: Schema.String })
|
||||
export type Token = Schema.Schema.Type<typeof Token>
|
||||
|
||||
const Cost = Schema.Struct({ credits: Schema.Number })
|
||||
|
||||
const StartResponse = Schema.Struct({ id: Schema.String })
|
||||
|
||||
const Task = Schema.Struct({
|
||||
status: Schema.String,
|
||||
progress: optionalNull(Schema.Number),
|
||||
output: optionalArray(Schema.String),
|
||||
failure: optionalNull(Schema.String),
|
||||
failureCode: optionalNull(Schema.String),
|
||||
cost: Schema.optional(Cost),
|
||||
estimatedCost: Schema.optional(Cost),
|
||||
})
|
||||
|
||||
const STATUS = {
|
||||
PENDING: "queued",
|
||||
THROTTLED: "queued",
|
||||
RUNNING: "running",
|
||||
SUCCEEDED: "completed",
|
||||
FAILED: "failed",
|
||||
CANCELLED: "cancelled",
|
||||
} as const satisfies Record<string, Status>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Runway accepts HTTPS URLs, `runway://` upload URIs, and data URIs, all as one string.
|
||||
const mediaUri = (asset: Media.Asset) =>
|
||||
ProviderShared.mediaReference(asset, PROVIDER, NAME).pipe(Effect.map((reference) => reference.value))
|
||||
|
||||
const fromRequest = Effect.fn("RunwayVideo.fromRequest")(function* (request: Request) {
|
||||
const first = request.frames?.first === undefined ? undefined : yield* mediaUri(request.frames.first)
|
||||
const last = request.frames?.last === undefined ? undefined : yield* mediaUri(request.frames.last)
|
||||
const promptImage = [
|
||||
...(first === undefined ? [] : [{ uri: first, position: "first" }]),
|
||||
...(last === undefined ? [] : [{ uri: last, position: "last" }]),
|
||||
]
|
||||
const videoUri = request.video === undefined ? undefined : yield* mediaUri(request.video)
|
||||
const references = yield* Effect.forEach(request.references ?? [], (asset) =>
|
||||
mediaUri(asset).pipe(Effect.map((uri) => ({ uri }))),
|
||||
)
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
model: request.model.id,
|
||||
promptText: request.prompt,
|
||||
promptImage: promptImage.length === 0 ? undefined : promptImage,
|
||||
videoUri,
|
||||
references: references.length === 0 ? undefined : references,
|
||||
ratio: request.aspectRatio,
|
||||
duration: request.durationSeconds,
|
||||
resolution: request.resolution,
|
||||
audio: request.audio,
|
||||
negativePrompt: request.negativePrompt,
|
||||
seed: request.seed,
|
||||
},
|
||||
request.providerOptions,
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeStart = MediaProtocol.decodeStarted(ADAPTER, NAME, StartResponse, (value) => ({
|
||||
token: { taskID: value.id },
|
||||
snapshot: { id: value.id, status: "queued" },
|
||||
}))
|
||||
|
||||
const decodeTask = MediaProtocol.decodeJson(ADAPTER, NAME, Task)
|
||||
|
||||
const decodeStatus = Effect.fn("RunwayVideo.decodeStatus")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
const output = yield* decodeTask(response)
|
||||
const status = yield* MediaProtocol.status(STATUS, output.value.status, output)
|
||||
return { id: context.token.taskID, status, progress: output.value.progress ?? undefined }
|
||||
})
|
||||
|
||||
const decodeResult = Effect.fn("RunwayVideo.decodeResult")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
const output = yield* decodeTask(response)
|
||||
const task = output.value
|
||||
const status = yield* MediaProtocol.status(STATUS, task.status, output)
|
||||
if (status === "failed") {
|
||||
const code = task.failureCode ?? undefined
|
||||
const message = `${NAME} task failed${code === undefined ? "" : ` (${code})`}${task.failure ? `: ${task.failure}` : ""}`
|
||||
// Runway failure codes are dotted paths; every moderation outcome carries a SAFETY segment.
|
||||
if (code !== undefined && /(^|\.)SAFETY(\.|$)/.test(code)) return yield* output.contentPolicy(message)
|
||||
return yield* output.ended("failed", message)
|
||||
}
|
||||
if (status === "cancelled")
|
||||
return yield* output.ended("cancelled", `${NAME} task ${context.token.taskID} was cancelled`)
|
||||
if (status !== "completed") return yield* output.invalid(`${NAME} task ${context.token.taskID} has not finished`)
|
||||
const urls = task.output ?? []
|
||||
if (urls.length === 0) return yield* output.invalid(`${NAME} task succeeded without any output`)
|
||||
return new VideoResponse({
|
||||
videos: yield* Effect.forEach(urls, (url) =>
|
||||
MediaProtocol.expiringUrl(url, OUTPUT_RETENTION, { mediaType: "video/mp4" }),
|
||||
),
|
||||
usage: task.cost === undefined ? undefined : { type: "credits", credits: task.cost.credits },
|
||||
providerMetadata: {
|
||||
runway: {
|
||||
taskId: context.token.taskID,
|
||||
estimatedCredits: task.estimatedCost?.credits,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const taskPath = (token: Token) => `${TASKS_PATH}/${token.taskID}`
|
||||
|
||||
export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
token: Token,
|
||||
unsupported: ["n"],
|
||||
start: { body: { from: fromRequest }, decode: decodeStart },
|
||||
status: { path: taskPath, decode: decodeStatus },
|
||||
result: { path: taskPath, decode: decodeResult },
|
||||
cancel: { method: "DELETE", path: taskPath },
|
||||
})
|
||||
|
||||
const startPath = (request: Request) => {
|
||||
if (request.video !== undefined) return VIDEO_TO_VIDEO_PATH
|
||||
if (request.frames?.first !== undefined || request.frames?.last !== undefined) return IMAGE_TO_VIDEO_PATH
|
||||
return TEXT_TO_VIDEO_PATH
|
||||
}
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
VideoModel.fromRoute<RunwayVideoOptions, Token>(
|
||||
{
|
||||
id: ADAPTER,
|
||||
provider: PROVIDER,
|
||||
protocol,
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
headers: { "X-Runway-Version": API_VERSION },
|
||||
path: ({ request }) => startPath(request),
|
||||
},
|
||||
input,
|
||||
)
|
||||
|
||||
export const RunwayVideo = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -192,6 +192,28 @@ export const inlineRequired = (route: string, asset: Media.Asset) =>
|
||||
/** The remote URL of a `url` asset, for protocols that accept `http(s)` references natively. */
|
||||
export const mediaUrl = (asset: Media.Asset) => (asset.source.type === "url" ? asset.source.url : undefined)
|
||||
|
||||
export type MediaReference = { readonly type: "dataUrl" | "url" | "ref"; readonly value: string }
|
||||
|
||||
/**
|
||||
* The one string a provider can address an asset by: inline payloads as a data URL, `url` sources as their URL, and
|
||||
* this provider's own `ref` as its id. Other providers' refs are never forwarded and fail typed; omit `provider` for
|
||||
* APIs with no file handles at all.
|
||||
*/
|
||||
export const mediaReference = (
|
||||
asset: Media.Asset,
|
||||
provider: ProviderID | undefined,
|
||||
label: string,
|
||||
): Effect.Effect<MediaReference, AIError> => {
|
||||
const inline = asset.inline()
|
||||
if (inline) return Effect.succeed({ type: "dataUrl", value: inline.dataUrl })
|
||||
const url = mediaUrl(asset)
|
||||
if (url) return Effect.succeed({ type: "url", value: url })
|
||||
if (provider !== undefined && asset.source.type === "ref" && asset.source.provider === provider)
|
||||
return Effect.succeed({ type: "ref", value: asset.source.id })
|
||||
const accepted = provider === undefined ? "" : `, and ${provider} references`
|
||||
return Effect.fail(invalidRequest(`${label} accepts inline bytes, data URLs, http(s) URLs${accepted}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Lift a tool-result file into a `MediaPart`. Tool files carry either a data URL, an `http(s)` URL, or raw base64 in
|
||||
* `uri`; the declared `mime` wins over any data-URL prefix so tool authors control the type the model sees.
|
||||
@@ -231,11 +253,11 @@ export const errorText = (error: unknown) => {
|
||||
|
||||
/**
|
||||
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
|
||||
* decoder, optionally filters named events, and drops empty events. `[DONE]`
|
||||
* is dropped by default or retained for protocols that use it as their stream
|
||||
* boundary. Retry control events are ignored without interrupting the stream.
|
||||
* Decoder failures become provider output errors so the public error channel
|
||||
* stays `AIError`.
|
||||
* decoder, optionally filters named events, and drops empty and bare `null`
|
||||
* events. `[DONE]` is dropped by default or retained for protocols that use it
|
||||
* as their stream boundary. Retry control events are ignored without
|
||||
* interrupting the stream. Decoder failures become provider output errors so
|
||||
* the public error channel stays `AIError`.
|
||||
*/
|
||||
export const sseFraming = (
|
||||
bytes: Stream.Stream<Uint8Array, AIError>,
|
||||
@@ -265,6 +287,10 @@ export const sseFraming = (
|
||||
(event) =>
|
||||
(events === undefined || events.has(event.event)) &&
|
||||
event.data.length > 0 &&
|
||||
// Some OpenAI-compatible proxies serialize an empty flush as a bare
|
||||
// `data: null`, between events or after `[DONE]`. No protocol has a
|
||||
// null event, so it carries nothing and must not abort the stream.
|
||||
event.data !== "null" &&
|
||||
(event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message")),
|
||||
),
|
||||
Stream.map((event) => event.data),
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { ProviderID, mergeJsonRecords, type AIError } from "../schema/index.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { ProviderShared, optionalNull } from "./shared.js"
|
||||
import { MediaInput } from "./utils/media-input.js"
|
||||
|
||||
@@ -58,15 +58,12 @@ const nativeOptions = (options: XAIImageOptions | undefined) => {
|
||||
|
||||
const isEdit = (request: Request) => (request.images?.length ?? 0) > 0
|
||||
|
||||
const reference = (asset: Media.Asset): Effect.Effect<Record<string, unknown>, AIError> => {
|
||||
const inline = asset.inline()
|
||||
if (inline) return Effect.succeed({ url: inline.dataUrl, type: "image_url" as const })
|
||||
const url = ProviderShared.mediaUrl(asset)
|
||||
if (url) return Effect.succeed({ url, type: "image_url" as const })
|
||||
const id = MediaInput.refID(asset, PROVIDER)
|
||||
if (id) return Effect.succeed({ file_id: id })
|
||||
return Effect.fail(ProviderShared.invalidRequest(`${NAME} accepts image URLs, data URLs, bytes, and xAI file IDs`))
|
||||
}
|
||||
const reference = (asset: Media.Asset) =>
|
||||
ProviderShared.mediaReference(asset, PROVIDER, NAME).pipe(
|
||||
Effect.map((item) =>
|
||||
item.type === "ref" ? { file_id: item.value } : { url: item.value, type: "image_url" as const },
|
||||
),
|
||||
)
|
||||
|
||||
const fromRequest = Effect.fn("XAIImages.fromRequest")(function* (request: Request) {
|
||||
const references = yield* Effect.forEach(request.images ?? [], reference)
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import type { Status } from "../generation.js"
|
||||
import { Media } from "../media.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { ProviderID, mergeJsonRecords } from "../schema/index.js"
|
||||
import { VideoModel, VideoResponse, type VideoRequestFor } from "../video.js"
|
||||
import { ProviderShared, optionalNull } from "./shared.js"
|
||||
|
||||
const ADAPTER = "xai-video"
|
||||
const NAME = "xAI Video"
|
||||
const PROVIDER = ProviderID.make("xai")
|
||||
export const DEFAULT_BASE_URL = "https://api.x.ai/v1"
|
||||
export const PATH = "/videos/generations"
|
||||
export const EDIT_PATH = "/videos/edits"
|
||||
export const EXTEND_PATH = "/videos/extensions"
|
||||
export const STATUS_PATH = "/videos"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Provider-native options. Common fields (`frames`, `references`, `video`, `durationSeconds`, `aspectRatio`,
|
||||
* `resolution`, `audio`) live on the request. `mode` selects the endpoint a `video` input is sent to.
|
||||
*/
|
||||
export type XAIVideoOptions = {
|
||||
readonly mode?: "edit" | "extend"
|
||||
readonly reference_audios?: ReadonlyArray<{ readonly voice_id: string }>
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = VideoRequestFor<XAIVideoOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Token and response schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const Token = Schema.Struct({ requestID: Schema.String })
|
||||
export type Token = Schema.Schema.Type<typeof Token>
|
||||
|
||||
const StartResponse = Schema.Struct({ request_id: Schema.String })
|
||||
|
||||
const VideoStatus = Schema.Struct({
|
||||
status: Schema.String,
|
||||
progress: optionalNull(Schema.Number),
|
||||
video: optionalNull(
|
||||
Schema.Struct({
|
||||
url: optionalNull(Schema.String),
|
||||
duration: optionalNull(Schema.Number),
|
||||
respect_moderation: optionalNull(Schema.Boolean),
|
||||
}),
|
||||
),
|
||||
error: optionalNull(
|
||||
Schema.Struct({
|
||||
code: optionalNull(Schema.String),
|
||||
message: optionalNull(Schema.String),
|
||||
}),
|
||||
),
|
||||
model: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const STATUS = {
|
||||
pending: "running",
|
||||
done: "completed",
|
||||
failed: "failed",
|
||||
expired: "expired",
|
||||
} as const satisfies Record<string, Status>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mediaInput = (asset: Media.Asset) =>
|
||||
ProviderShared.mediaReference(asset, PROVIDER, NAME).pipe(
|
||||
Effect.map((reference) => (reference.type === "ref" ? { file_id: reference.value } : { url: reference.value })),
|
||||
)
|
||||
|
||||
const nativeOptions = (options: XAIVideoOptions | undefined) => {
|
||||
if (!options) return undefined
|
||||
const { mode: _mode, ...native } = options
|
||||
return native
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("XAIVideo.fromRequest")(function* (request: Request) {
|
||||
const image = request.frames?.first === undefined ? undefined : yield* mediaInput(request.frames.first)
|
||||
const lastFrame = request.frames?.last === undefined ? undefined : yield* mediaInput(request.frames.last)
|
||||
const video = request.video === undefined ? undefined : yield* mediaInput(request.video)
|
||||
const references = yield* Effect.forEach(request.references ?? [], mediaInput)
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
model: request.model.id,
|
||||
prompt: request.prompt,
|
||||
image,
|
||||
last_frame: lastFrame,
|
||||
reference_images: references.length === 0 ? undefined : references,
|
||||
video,
|
||||
duration: request.durationSeconds,
|
||||
aspect_ratio: request.aspectRatio,
|
||||
resolution: request.resolution,
|
||||
generate_audio: request.audio,
|
||||
},
|
||||
nativeOptions(request.providerOptions),
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeStart = MediaProtocol.decodeStarted(ADAPTER, NAME, StartResponse, (value) => ({
|
||||
token: { requestID: value.request_id },
|
||||
snapshot: { id: value.request_id, status: "running" },
|
||||
}))
|
||||
|
||||
// `progress` is undocumented but observed live as a 0..100 percentage (recorded cassette: 1 → 10 → 37 → 100).
|
||||
const fraction = (progress: number | null | undefined) =>
|
||||
progress !== undefined && progress !== null && progress >= 0 && progress <= 100 ? progress / 100 : undefined
|
||||
|
||||
const decodeVideoStatus = MediaProtocol.decodeJson(ADAPTER, NAME, VideoStatus)
|
||||
|
||||
const decodeStatus = Effect.fn("XAIVideo.decodeStatus")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
const output = yield* decodeVideoStatus(response)
|
||||
const status = yield* MediaProtocol.status(STATUS, output.value.status, output)
|
||||
return { id: context.token.requestID, status, progress: fraction(output.value.progress) }
|
||||
})
|
||||
|
||||
const decodeResult = Effect.fn("XAIVideo.decodeResult")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) {
|
||||
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(`${NAME} request ${context.token.requestID} has not finished`)
|
||||
if (status === "failed") {
|
||||
const code = decoded.error?.code ?? undefined
|
||||
const message = decoded.error?.message ?? undefined
|
||||
return yield* output.ended(
|
||||
"failed",
|
||||
`${NAME} generation failed${code === undefined ? "" : ` (${code})`}${message === undefined ? "" : `: ${message}`}`,
|
||||
)
|
||||
}
|
||||
if (status !== "completed")
|
||||
return yield* output.ended("expired", `${NAME} request ${context.token.requestID} expired`)
|
||||
// `respect_moderation: false` marks a filtered result; a URL may still be present, so report it as a notice.
|
||||
const notices =
|
||||
decoded.video?.respect_moderation === false
|
||||
? [{ type: "moderated" as const, message: `${NAME} flagged the generated video for moderation` }]
|
||||
: undefined
|
||||
const url = decoded.video?.url ?? undefined
|
||||
if (url === undefined && notices !== undefined)
|
||||
return yield* output.contentPolicy(`${NAME} withheld the video for moderation`)
|
||||
if (url === undefined) return yield* output.invalid(`${NAME} completed without a video URL`)
|
||||
const duration = decoded.video?.duration ?? undefined
|
||||
return new VideoResponse({
|
||||
videos: [
|
||||
Media.url(url, {
|
||||
mediaType: "video/mp4",
|
||||
info: duration === undefined ? undefined : { durationSeconds: duration },
|
||||
}),
|
||||
],
|
||||
notices,
|
||||
providerMetadata: { xai: { requestId: context.token.requestID, model: decoded.model ?? undefined } },
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const statusPath = (token: Token) => `${STATUS_PATH}/${token.requestID}`
|
||||
|
||||
export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
token: Token,
|
||||
unsupported: ["n", "seed", "negativePrompt"],
|
||||
start: { body: { from: fromRequest }, decode: decodeStart },
|
||||
status: { path: statusPath, decode: decodeStatus },
|
||||
result: { path: statusPath, decode: decodeResult },
|
||||
})
|
||||
|
||||
// A source video goes to `/videos/edits` unless `providerOptions.mode` asks for an extension.
|
||||
const startPath = (request: Request) => {
|
||||
if (request.video === undefined) return PATH
|
||||
return request.providerOptions?.mode === "extend" ? EXTEND_PATH : EDIT_PATH
|
||||
}
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
VideoModel.fromRoute<XAIVideoOptions, Token>(
|
||||
{
|
||||
id: ADAPTER,
|
||||
provider: PROVIDER,
|
||||
protocol,
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
path: ({ request }) => startPath(request),
|
||||
},
|
||||
input,
|
||||
)
|
||||
|
||||
export const XAIVideo = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { DEFAULT_BASE_URL, FalVideo } from "../protocols/fal-video.js"
|
||||
|
||||
export type { FalVideoOptions } from "../protocols/fal-video.js"
|
||||
|
||||
export const id = ProviderID.make("fal")
|
||||
const baseURL = DEFAULT_BASE_URL
|
||||
|
||||
export type Config = ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions.Input
|
||||
}
|
||||
|
||||
// fal authenticates with `Authorization: Key <FAL_KEY>` rather than a bearer token.
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in options && options.auth) return options.auth
|
||||
return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
|
||||
.orElse(Auth.config("FAL_KEY"))
|
||||
.pipe(Auth.scheme("Key"))
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const video = (modelID: string | ModelID) =>
|
||||
FalVideo.model({
|
||||
id: modelID,
|
||||
auth: auth(input),
|
||||
baseURL: input.baseURL ?? baseURL,
|
||||
headers: input.headers,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
video,
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const video = provider.video
|
||||
@@ -5,8 +5,10 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { HttpOptions, ProviderID, mergeHttpOptions, type ModelID } from "../schema/index.js"
|
||||
import { Gemini } from "../protocols/gemini.js"
|
||||
import { GoogleImages } from "../protocols/google-images.js"
|
||||
import { GoogleVideo } from "../protocols/google-video.js"
|
||||
|
||||
export type { GoogleImageOptions } from "../protocols/google-images.js"
|
||||
export type { GoogleVideoOptions } from "../protocols/google-video.js"
|
||||
export type GeminiOptionsInput = Gemini.OptionsInput
|
||||
export type GeminiProviderOptionsInput = Gemini.ProviderOptionsInput
|
||||
|
||||
@@ -40,18 +42,18 @@ const configuredRoute = (input: Config) => {
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const route = configuredRoute(input)
|
||||
const image = (modelID: string | ModelID) =>
|
||||
GoogleImages.model({
|
||||
id: modelID,
|
||||
auth: auth(input),
|
||||
baseURL: input.baseURL,
|
||||
headers: input.headers,
|
||||
http: mergeHttpOptions(input.http === undefined ? undefined : HttpOptions.make(input.http)),
|
||||
})
|
||||
const media = (modelID: string | ModelID) => ({
|
||||
id: modelID,
|
||||
auth: auth(input),
|
||||
baseURL: input.baseURL,
|
||||
headers: input.headers,
|
||||
http: mergeHttpOptions(input.http === undefined ? undefined : HttpOptions.make(input.http)),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => route.model<Gemini.ProviderOptionsInput>({ id: modelID }),
|
||||
image,
|
||||
image: (modelID: string | ModelID) => GoogleImages.model(media(modelID)),
|
||||
video: (modelID: string | ModelID) => GoogleVideo.model(media(modelID)),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
@@ -70,3 +72,4 @@ export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsI
|
||||
}).model(modelID)
|
||||
|
||||
export const image = provider.image
|
||||
export const video = provider.video
|
||||
|
||||
@@ -10,6 +10,7 @@ export * as CloudflareAIGateway from "./cloudflare-ai-gateway.js"
|
||||
export * as CloudflareWorkersAI from "./cloudflare-workers-ai.js"
|
||||
export * as DeepInfra from "./deepinfra.js"
|
||||
export * as DeepSeek from "./deepseek.js"
|
||||
export * as Fal from "./fal.js"
|
||||
export * as Fireworks from "./fireworks.js"
|
||||
export * as Google from "./google.js"
|
||||
export * as GoogleVertex from "./google-vertex.js"
|
||||
@@ -26,6 +27,7 @@ export * as OpenAICompatible from "./openai-compatible.js"
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
||||
export * as OpenCodeZen from "./opencode-zen.js"
|
||||
export * as OpenRouter from "./openrouter.js"
|
||||
export * as Runway from "./runway.js"
|
||||
export * as TogetherAI from "./togetherai.js"
|
||||
export * as TypeSafeAI from "./typesafe-ai.js"
|
||||
export * as VercelAIGateway from "./vercel-ai-gateway.js"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { DEFAULT_BASE_URL, RunwayVideo } from "../protocols/runway-video.js"
|
||||
|
||||
export type { RunwayVideoOptions } from "../protocols/runway-video.js"
|
||||
|
||||
export const id = ProviderID.make("runway")
|
||||
const baseURL = DEFAULT_BASE_URL
|
||||
|
||||
export type Config = ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions.Input
|
||||
}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "RUNWAYML_API_SECRET")
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const video = (modelID: string | ModelID) =>
|
||||
RunwayVideo.model({
|
||||
id: modelID,
|
||||
auth: auth(input),
|
||||
baseURL: input.baseURL ?? baseURL,
|
||||
headers: input.headers,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
video,
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const video = provider.video
|
||||
@@ -6,6 +6,7 @@ import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenResponsesChannel } from "../protocols/open-responses-channel.js"
|
||||
import { XAIResponses } from "../protocols/xai-responses.js"
|
||||
import { XAIImages } from "../protocols/xai-images.js"
|
||||
import { XAIVideo } from "../protocols/xai-video.js"
|
||||
import type { OpenAIOptionsInput } from "./openai-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
|
||||
@@ -27,6 +28,7 @@ export type Settings = ProviderPackage.Settings &
|
||||
}
|
||||
|
||||
export type { XAIImageOptions } from "../protocols/xai-images.js"
|
||||
export type { XAIVideoOptions } from "../protocols/xai-video.js"
|
||||
|
||||
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
|
||||
|
||||
@@ -87,20 +89,20 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const chatRoute = configuredChatRoute(input)
|
||||
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
|
||||
const chat = (modelID: string | ModelID) => chatRoute.model<XAIProviderOptionsInput>({ id: modelID })
|
||||
const image = (modelID: string | ModelID) =>
|
||||
XAIImages.model({
|
||||
id: modelID,
|
||||
auth: auth(input),
|
||||
baseURL: input.baseURL ?? baseURL,
|
||||
headers: input.headers,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
const media = (modelID: string | ModelID) => ({
|
||||
id: modelID,
|
||||
auth: auth(input),
|
||||
baseURL: input.baseURL ?? baseURL,
|
||||
headers: input.headers,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: responses,
|
||||
responses,
|
||||
chat,
|
||||
image,
|
||||
image: (modelID: string | ModelID) => XAIImages.model(media(modelID)),
|
||||
video: (modelID: string | ModelID) => XAIVideo.model(media(modelID)),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
@@ -121,3 +123,4 @@ export const model: ProviderPackage.Definition<
|
||||
export const responses = provider.responses
|
||||
export const chat = provider.chat
|
||||
export const image = provider.image
|
||||
export const video = provider.video
|
||||
|
||||
@@ -16,7 +16,7 @@ type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redac
|
||||
|
||||
export interface AuthInput {
|
||||
readonly request: { readonly http?: HttpOptions }
|
||||
readonly method: "POST" | "GET"
|
||||
readonly method: "POST" | "GET" | "PUT" | "DELETE"
|
||||
readonly url: string
|
||||
readonly body: string
|
||||
readonly headers: Headers.Headers
|
||||
@@ -134,6 +134,16 @@ export function bearerHeader(name: string, source?: Secret | Credential) {
|
||||
return render(source)
|
||||
}
|
||||
|
||||
/** `Authorization: <scheme> <secret>` for providers whose scheme is not `Bearer`, such as fal's `Key`. */
|
||||
export function scheme(name: string): (source: Secret | Credential) => Definition
|
||||
export function scheme(name: string, source: Secret | Credential): Definition
|
||||
export function scheme(name: string, source?: Secret | Credential) {
|
||||
const render = (input: Secret | Credential) =>
|
||||
fromCredential(credentialInput(input), (secret) => ({ authorization: `${name} ${secret}` }))
|
||||
if (source === undefined) return render
|
||||
return render(source)
|
||||
}
|
||||
|
||||
const toAIError = (error: AuthError): AIError => {
|
||||
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
|
||||
return new AIError({
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Clock, Duration, Effect, Schema } from "effect"
|
||||
import { HttpClientResponse } from "effect/unstable/http"
|
||||
import { AIError, HttpContext, InvalidProviderOutputError } from "../schema/index.js"
|
||||
import type { Snapshot, Status } from "../generation.js"
|
||||
import { Media } from "../media.js"
|
||||
import type { AuthInput } from "./auth.js"
|
||||
import {
|
||||
AIError,
|
||||
ContentPolicyError,
|
||||
HttpContext,
|
||||
InvalidProviderOutputError,
|
||||
InvalidRequestError,
|
||||
ProviderInternalError,
|
||||
} from "../schema/index.js"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bodies
|
||||
@@ -45,6 +55,70 @@ export const inline = <Request, Response>(
|
||||
...input,
|
||||
})
|
||||
|
||||
/** What `start` learned from the submission response: the route-owned handle plus the first observation. */
|
||||
export interface Started<Token> {
|
||||
readonly token: Token
|
||||
readonly snapshot: Snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* A follow-up call's inputs: the decoded token and the auth headers the route sent, so a protocol can attach them
|
||||
* to output URLs that require the same credentials to download (Veo).
|
||||
*/
|
||||
export interface PollContext<Token> {
|
||||
readonly token: Token
|
||||
readonly auth: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit, then poll. `start` posts the body to the route endpoint; `status`, `result`, and `cancel` are follow-up
|
||||
* calls addressed by the token. Paths are relative to the route base URL unless the provider hands back absolute
|
||||
* URLs (fal `status_url`), in which case they are used verbatim. `result` is always its own GET: providers that
|
||||
* return the output inside the status body (Veo, xAI, Runway) point `result.path` at the status path and decode the
|
||||
* same document, so `Generation.await` and `Video.resume(...).await()` behave identically everywhere.
|
||||
*/
|
||||
export interface Queued<Request, Response, Token> {
|
||||
readonly kind: "queued"
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
/** Common request fields this protocol cannot lower; the route rejects them before `start.body.from` runs. */
|
||||
readonly unsupported?: ReadonlyArray<keyof Request & string>
|
||||
/** Serializable handle. `Generation.token` carries the encoded form so it can be persisted and resumed elsewhere. */
|
||||
readonly token: Schema.Codec<Token, unknown>
|
||||
readonly start: {
|
||||
readonly body: { readonly from: (request: Request) => Effect.Effect<Body, AIError> }
|
||||
readonly decode: (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: DecodeContext<Request>,
|
||||
) => Effect.Effect<Started<Token>, AIError>
|
||||
}
|
||||
readonly status: {
|
||||
readonly path: (token: Token) => string
|
||||
readonly decode: (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: PollContext<Token>,
|
||||
) => Effect.Effect<Snapshot, AIError>
|
||||
}
|
||||
readonly result: {
|
||||
readonly path: (token: Token) => string
|
||||
readonly decode: (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: PollContext<Token>,
|
||||
) => Effect.Effect<Response, AIError>
|
||||
}
|
||||
readonly cancel?: {
|
||||
readonly method: AuthInput["method"]
|
||||
readonly path: (token: Token) => string
|
||||
}
|
||||
}
|
||||
|
||||
export const queued = <Request, Response, Token>(
|
||||
input: Omit<Queued<Request, Response, Token>, "kind">,
|
||||
): Queued<Request, Response, Token> => ({
|
||||
kind: "queued",
|
||||
...input,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -52,7 +126,11 @@ export const inline = <Request, Response>(
|
||||
const context = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
new HttpContext({ url: response.request.url, status: response.status, headers: response.headers })
|
||||
|
||||
/** Read a text body while retaining the original payload and HTTP context on every downstream error. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const text = Effect.fn("MediaProtocol.text")(function* (
|
||||
route: string,
|
||||
name: string,
|
||||
@@ -77,9 +155,19 @@ export const text = Effect.fn("MediaProtocol.text")(function* (
|
||||
http,
|
||||
invalid: (message: string, cause?: unknown) =>
|
||||
new AIError({ reason: new InvalidProviderOutputError({ route, message, body, http, cause }) }),
|
||||
ended: (status: Exclude<Status, "queued" | "running" | "completed">, message: string) =>
|
||||
new AIError({
|
||||
reason:
|
||||
status === "failed"
|
||||
? new ProviderInternalError({ message, body, http })
|
||||
: new InvalidRequestError({ message, body, http }),
|
||||
}),
|
||||
contentPolicy: (message: string) => new AIError({ reason: new ContentPolicyError({ message, body, http }) }),
|
||||
}
|
||||
})
|
||||
|
||||
export type Output = Effect.Success<ReturnType<typeof text>>
|
||||
|
||||
/** Read and Schema-decode a JSON body. Decode failures keep the raw body as `reason.body`. */
|
||||
export const decodeJson = <A>(route: string, name: string, schema: Schema.Codec<A, unknown>) => {
|
||||
const decode = Schema.decodeUnknownEffect(Schema.fromJsonString(schema))
|
||||
@@ -92,4 +180,33 @@ export const decodeJson = <A>(route: string, name: string, schema: Schema.Codec<
|
||||
})
|
||||
}
|
||||
|
||||
/** Decode a submission response into the token and first snapshot. */
|
||||
export const decodeStarted = <A, Token>(
|
||||
route: string,
|
||||
name: string,
|
||||
schema: Schema.Codec<A, unknown>,
|
||||
started: (value: A) => Started<Token>,
|
||||
) => {
|
||||
const decode = decodeJson(route, name, schema)
|
||||
return (response: HttpClientResponse.HttpClientResponse) =>
|
||||
decode(response).pipe(Effect.map((output) => started(output.value)))
|
||||
}
|
||||
|
||||
/** Map a provider status string through the protocol's table; unknown values are an invalid provider document. */
|
||||
export const status = <Table extends Record<string, Status>>(
|
||||
table: Table,
|
||||
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)
|
||||
}
|
||||
|
||||
/** A `url` asset whose provider-declared retention window starts now. */
|
||||
export const expiringUrl = (url: string, retention: Duration.Duration, options?: Parameters<typeof Media.url>[1]) =>
|
||||
Clock.currentTimeMillis.pipe(
|
||||
Effect.map((now) => Media.url(url, { ...options, expiresAt: now + Duration.toMillis(retention) })),
|
||||
)
|
||||
|
||||
export * as MediaProtocol from "./media-protocol.js"
|
||||
|
||||
+172
-19
@@ -1,11 +1,12 @@
|
||||
import { Effect } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Auth } from "./auth.js"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Headers, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
|
||||
import { Auth, type AuthInput } from "./auth.js"
|
||||
import { Endpoint } from "./endpoint.js"
|
||||
import type { Interface } from "./executor-service.js"
|
||||
import { MediaProtocol } from "./media-protocol.js"
|
||||
import { Generation, type Route as GenerationRoute } from "../generation.js"
|
||||
import { ProviderShared } from "../protocols/shared.js"
|
||||
import { AIError, HttpOptions, ProviderID, mergeHttpOptions } from "../schema/index.js"
|
||||
import { AIError, HttpOptions, InvalidRequestError, ProviderID, mergeHttpOptions } from "../schema/index.js"
|
||||
import { sanitizeSurrogates } from "../utils/sanitize.js"
|
||||
|
||||
export type Execute = Interface["execute"]
|
||||
@@ -26,9 +27,10 @@ export interface ModelInput {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route
|
||||
// Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** One request, one response. */
|
||||
export interface Route<Request extends MediaRequest, Response> {
|
||||
readonly id: string
|
||||
readonly provider: ProviderID
|
||||
@@ -36,39 +38,164 @@ export interface Route<Request extends MediaRequest, Response> {
|
||||
readonly generate: (request: Request, execute: Execute) => Effect.Effect<Response, AIError>
|
||||
}
|
||||
|
||||
export interface MakeInput<Request extends MediaRequest, Response> {
|
||||
/** Submit, then poll through the returned `Generation`. */
|
||||
export interface QueuedRoute<Request extends MediaRequest, Response> {
|
||||
readonly id: string
|
||||
readonly provider: ProviderID
|
||||
readonly protocol: string
|
||||
readonly start: (request: Request, execute: Execute) => Effect.Effect<Generation<Response>, AIError>
|
||||
/** Rebuild a handle from a persisted `Generation.token`; fails typed when the token is not this route's. */
|
||||
readonly resume: (
|
||||
model: MediaRequest["model"],
|
||||
token: unknown,
|
||||
execute: Execute,
|
||||
) => Effect.Effect<Generation<Response>, AIError>
|
||||
}
|
||||
|
||||
export interface Composition<Request extends MediaRequest> {
|
||||
readonly id: string
|
||||
readonly provider: string | ProviderID
|
||||
readonly protocol: MediaProtocol.Inline<Request, Response>
|
||||
readonly endpoint: Endpoint.Definition<MediaProtocol.Body, Request>
|
||||
readonly auth: Auth.Definition
|
||||
/** Deployment headers applied before transport authentication. */
|
||||
readonly headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface InlineInput<Request extends MediaRequest, Response> extends Composition<Request> {
|
||||
readonly protocol: MediaProtocol.Inline<Request, Response>
|
||||
}
|
||||
|
||||
export interface QueuedInput<Request extends MediaRequest, Response, Token> extends Composition<Request> {
|
||||
readonly protocol: MediaProtocol.Queued<Request, Response, Token>
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose an inline media protocol with an endpoint and auth into a runnable route. The route owns the transport
|
||||
* plumbing every media protocol would otherwise duplicate: option merging, surrogate sanitizing, unsupported-field rejection, URL and query
|
||||
* rendering, auth headers, JSON vs multipart encoding, and handing the response back to the protocol for decoding.
|
||||
* plumbing every media protocol would otherwise duplicate: option merging, surrogate sanitizing, unsupported-field
|
||||
* rejection, URL and query rendering, auth headers, JSON vs multipart encoding, and handing responses back to the
|
||||
* protocol.
|
||||
*/
|
||||
export const make = <Request extends MediaRequest, Response>(
|
||||
input: MakeInput<Request, Response>,
|
||||
export const inline = <Request extends MediaRequest, Response>(
|
||||
input: InlineInput<Request, Response>,
|
||||
): Route<Request, Response> => {
|
||||
const transport = makeTransport(input)
|
||||
return {
|
||||
id: input.id,
|
||||
provider: transport.provider,
|
||||
protocol: input.protocol.id,
|
||||
generate: Effect.fn(`MediaRoute.generate`)(function* (request: Request, execute: Execute) {
|
||||
const submitted = yield* transport.submit(
|
||||
request,
|
||||
{ unsupported: input.protocol.unsupported, from: input.protocol.body.from },
|
||||
execute,
|
||||
)
|
||||
return yield* input.protocol.response.decode(submitted.response, submitted.context)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose a queued media protocol the same way, adding `start`/`resume` handles whose polls reuse the route's auth,
|
||||
* deployment headers, and (for `start`) the request's `http` overlay. The token is decoded once at the boundary and
|
||||
* closed over by the resulting `Generation.Route`.
|
||||
*/
|
||||
export const queued = <Request extends MediaRequest, Response, Token>(
|
||||
input: QueuedInput<Request, Response, Token>,
|
||||
): QueuedRoute<Request, Response> => {
|
||||
const transport = makeTransport(input)
|
||||
const protocol = input.protocol
|
||||
const decodeToken = Schema.decodeUnknownEffect(protocol.token)
|
||||
// A protocol producing a token its own codec rejects is a programmer defect, not a provider error.
|
||||
const encodeToken = Schema.encodeSync(protocol.token)
|
||||
|
||||
const generationRoute = (token: Token, http: HttpOptions | undefined, execute: Execute) => {
|
||||
const poll = <A>(operation: {
|
||||
readonly path: (token: Token) => string
|
||||
readonly decode: (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.PollContext<Token>,
|
||||
) => Effect.Effect<A, AIError>
|
||||
}) =>
|
||||
transport
|
||||
.call("GET", operation.path(token), http, execute)
|
||||
.pipe(Effect.flatMap((sent) => operation.decode(sent.response, { token, auth: sent.auth })))
|
||||
const cancel = protocol.cancel
|
||||
const route: GenerationRoute<Response> = {
|
||||
status: poll(protocol.status),
|
||||
result: poll(protocol.result),
|
||||
cancel:
|
||||
cancel === undefined
|
||||
? undefined
|
||||
: transport.call(cancel.method, cancel.path(token), http, execute).pipe(Effect.asVoid),
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
const start = Effect.fn("MediaRoute.start")(function* (request: Request, execute: Execute) {
|
||||
const submitted = yield* transport.submit(
|
||||
request,
|
||||
{ unsupported: protocol.unsupported, from: protocol.start.body.from },
|
||||
execute,
|
||||
)
|
||||
const started = yield* protocol.start.decode(submitted.response, submitted.context)
|
||||
const route = generationRoute(started.token, submitted.context.request.http, execute)
|
||||
return new Generation(route, encodeToken(started.token), started.snapshot)
|
||||
})
|
||||
|
||||
const resume = Effect.fn("MediaRoute.resume")(function* (
|
||||
model: MediaRequest["model"],
|
||||
raw: unknown,
|
||||
execute: Execute,
|
||||
) {
|
||||
const token = yield* decodeToken(raw).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new AIError({
|
||||
reason: new InvalidRequestError({
|
||||
message: `${input.id} cannot resume a generation from this token`,
|
||||
cause,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const route = generationRoute(token, transport.http(model), execute)
|
||||
return new Generation(route, encodeToken(token), yield* route.status)
|
||||
})
|
||||
|
||||
return { id: input.id, provider: transport.provider, protocol: protocol.id, start, resume }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transport plumbing shared by both kinds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const makeTransport = <Request extends MediaRequest>(input: Composition<Request>) => {
|
||||
const provider = ProviderID.make(input.provider)
|
||||
const routeHttp = input.headers === undefined ? undefined : new HttpOptions({ headers: input.headers })
|
||||
const authorize = Auth.toEffect(input.auth)
|
||||
const withQuery = (url: URL, http: HttpOptions | undefined) => {
|
||||
for (const [key, value] of Object.entries(http?.query ?? {})) url.searchParams.set(key, value)
|
||||
return url
|
||||
}
|
||||
return {
|
||||
id: input.id,
|
||||
provider,
|
||||
protocol: input.protocol.id,
|
||||
generate: Effect.fn(`MediaRoute.generate`)(function* (request: Request, execute: Execute) {
|
||||
yield* rejectUnsupported(input.id, provider, request, input.protocol.unsupported)
|
||||
/** Route and model overlays; `start` additionally merges the request's own `http`. */
|
||||
http: (model: MediaRequest["model"]) => mergeHttpOptions(routeHttp, model.http),
|
||||
/** POST the protocol body to the route endpoint. */
|
||||
submit: Effect.fn("MediaRoute.submit")(function* (
|
||||
request: Request,
|
||||
protocol: {
|
||||
readonly unsupported?: ReadonlyArray<keyof Request & string>
|
||||
readonly from: (request: Request) => Effect.Effect<MediaProtocol.Body, AIError>
|
||||
},
|
||||
execute: Execute,
|
||||
) {
|
||||
yield* rejectUnsupported(input.id, provider, request, protocol.unsupported)
|
||||
const http = mergeHttpOptions(routeHttp, request.model.http, request.http)
|
||||
// Sanitize after merging so model-level overlays are covered; the model value is restored, not sanitized.
|
||||
const resolved: Request = { ...sanitizeSurrogates({ ...request, http }), model: request.model }
|
||||
const body = yield* input.protocol.body.from(resolved)
|
||||
const url = Endpoint.render(input.endpoint, { request: resolved, body })
|
||||
for (const [key, value] of Object.entries(http?.query ?? {})) url.searchParams.set(key, value)
|
||||
const body = yield* protocol.from(resolved)
|
||||
const url = withQuery(Endpoint.render(input.endpoint, { request: resolved, body }), http)
|
||||
const encoded = body.type === "json" ? ProviderShared.encodeJson(body.value) : "[multipart/form-data]"
|
||||
const baseHeaders = Headers.fromInput(http?.headers)
|
||||
const headers = yield* authorize({
|
||||
@@ -86,7 +213,33 @@ export const make = <Request extends MediaRequest, Response>(
|
||||
: HttpClientRequest.bodyFormData(body.value),
|
||||
)
|
||||
const response = yield* execute(transport)
|
||||
return yield* input.protocol.response.decode(response, { request: resolved, body })
|
||||
return { response, context: { request: resolved, body } }
|
||||
}),
|
||||
/**
|
||||
* Bodiless follow-up call (status, result, cancel) with the same auth and headers as `submit`. `auth` is only
|
||||
* what `Auth` added, so protocols can hand download credentials to output assets without deployment headers.
|
||||
*/
|
||||
call: Effect.fn("MediaRoute.call")(function* (
|
||||
method: AuthInput["method"],
|
||||
path: string,
|
||||
http: HttpOptions | undefined,
|
||||
execute: Execute,
|
||||
) {
|
||||
// Provider-issued absolute URLs (fal `status_url`) are used as-is; everything else resolves against the base.
|
||||
const url = withQuery(
|
||||
/^https?:\/\//.test(path)
|
||||
? new URL(path)
|
||||
: new URL(`${ProviderShared.trimBaseUrl(input.endpoint.baseURL ?? "")}${path}`),
|
||||
http,
|
||||
)
|
||||
for (const [key, value] of Object.entries(input.endpoint.query ?? {})) url.searchParams.set(key, value)
|
||||
const base = Headers.fromInput(http?.headers)
|
||||
const headers = yield* authorize({ request: { http }, method, url: url.toString(), body: "", headers: base })
|
||||
const response = yield* execute(
|
||||
HttpClientRequest.make(method)(url.toString()).pipe(HttpClientRequest.setHeaders(headers)),
|
||||
)
|
||||
const auth = Object.fromEntries(Object.entries(headers).filter(([key]) => !(key in base)))
|
||||
return { response, auth }
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import type { AwaitOptions, Generation } from "./generation.js"
|
||||
import { RequestExecutor } from "./route/executor.js"
|
||||
import type { AIError } from "./schema/index.js"
|
||||
import {
|
||||
isObservation,
|
||||
responseEvents,
|
||||
type VideoEvent,
|
||||
type VideoModel,
|
||||
type VideoOptions,
|
||||
type VideoRequestFor,
|
||||
type VideoResponse,
|
||||
} from "./video.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly start: <Options extends VideoOptions>(
|
||||
request: VideoRequestFor<Options>,
|
||||
) => Effect.Effect<Generation<VideoResponse>, AIError>
|
||||
readonly resume: <Options extends VideoOptions>(
|
||||
model: VideoModel<Options>,
|
||||
token: unknown,
|
||||
) => Effect.Effect<Generation<VideoResponse>, AIError>
|
||||
readonly generate: <Options extends VideoOptions>(
|
||||
request: VideoRequestFor<Options>,
|
||||
options?: AwaitOptions,
|
||||
) => Effect.Effect<VideoResponse, AIError>
|
||||
readonly stream: <Options extends VideoOptions>(
|
||||
request: VideoRequestFor<Options>,
|
||||
options?: AwaitOptions,
|
||||
) => Stream.Stream<VideoEvent, AIError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/VideoClient") {}
|
||||
|
||||
export const start = <Options extends VideoOptions>(
|
||||
request: VideoRequestFor<Options>,
|
||||
): Effect.Effect<Generation<VideoResponse>, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.start(request)
|
||||
})
|
||||
|
||||
export const resume = <Options extends VideoOptions>(
|
||||
model: VideoModel<Options>,
|
||||
token: unknown,
|
||||
): Effect.Effect<Generation<VideoResponse>, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.resume(model, token)
|
||||
})
|
||||
|
||||
export const generate = <Options extends VideoOptions>(
|
||||
request: VideoRequestFor<Options>,
|
||||
options?: AwaitOptions,
|
||||
): Effect.Effect<VideoResponse, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.generate(request, options)
|
||||
})
|
||||
|
||||
export const stream = <Options extends VideoOptions>(
|
||||
request: VideoRequestFor<Options>,
|
||||
options?: AwaitOptions,
|
||||
): Stream.Stream<VideoEvent, AIError, Service> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return client.stream(request, options)
|
||||
}),
|
||||
)
|
||||
|
||||
/** Status observations until the first terminal one, then the fetched result expanded into events. */
|
||||
const events = (generation: Generation<VideoResponse>, options: AwaitOptions | undefined) =>
|
||||
generation
|
||||
.events(options)
|
||||
.pipe(
|
||||
Stream.filter(isObservation),
|
||||
Stream.concat(Stream.fromIterableEffect(Effect.map(generation.result(), responseEvents))),
|
||||
)
|
||||
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const start = <Options extends VideoOptions>(request: VideoRequestFor<Options>) =>
|
||||
request.model.route.start(request, executor.execute)
|
||||
return Service.of({
|
||||
start,
|
||||
resume: (model, token) => model.route.resume(model, token, executor.execute),
|
||||
generate: (request, options) => start(request).pipe(Effect.flatMap((generation) => generation.await(options))),
|
||||
stream: (request, options) =>
|
||||
Stream.unwrap(start(request).pipe(Effect.map((generation) => events(generation, options)))),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const VideoClient = {
|
||||
Service,
|
||||
layer,
|
||||
start,
|
||||
resume,
|
||||
generate,
|
||||
stream,
|
||||
} as const
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Generation, type AwaitOptions, type Event as GenerationEvent } from "./generation.js"
|
||||
import { Media } from "./media.js"
|
||||
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
|
||||
import { MediaRoute } from "./route/media.js"
|
||||
import type { MediaProtocol } from "./route/media-protocol.js"
|
||||
import { AIError, HttpOptions, MediaUsage, ProviderMetadata } from "./schema/index.js"
|
||||
import { VideoClient, Service } from "./video-client.js"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type VideoOptions = Record<string, unknown>
|
||||
|
||||
export type VideoRoute<Options extends VideoOptions = VideoOptions> = MediaRoute.QueuedRoute<
|
||||
VideoRequestFor<Options>,
|
||||
VideoResponse
|
||||
>
|
||||
|
||||
export class VideoModel<Options extends VideoOptions = VideoOptions> extends MediaModel<VideoRoute<Options>, Options> {
|
||||
declare protected readonly _VideoModel: void
|
||||
|
||||
static make<Options extends VideoOptions = VideoOptions>(input: MediaModel.Input<VideoRoute<Options>>) {
|
||||
return new VideoModel<Options>(input)
|
||||
}
|
||||
|
||||
/** Compose a queued video protocol with its canonical start path into a model for one deployment. */
|
||||
static fromRoute<Options extends VideoOptions = VideoOptions, Token = unknown>(
|
||||
route: VideoModel.RouteInput<Options, Token>,
|
||||
input: MediaRoute.ModelInput,
|
||||
) {
|
||||
return new VideoModel<Options>({
|
||||
id: input.id,
|
||||
provider: route.provider,
|
||||
http: input.http,
|
||||
route: composeRoute(MediaRoute.queued, route, input),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace VideoModel {
|
||||
export type RouteInput<Options extends VideoOptions = VideoOptions, Token = unknown> = MediaModel.RouteInput<
|
||||
VideoRequestFor<Options>,
|
||||
MediaProtocol.Queued<VideoRequestFor<Options>, VideoResponse, Token>
|
||||
>
|
||||
}
|
||||
|
||||
export const VideoModelSchema = Schema.declare((value): value is VideoModel => value instanceof VideoModel, {
|
||||
expected: "Video.Model",
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type VideoAspectRatio = Media.AspectRatio
|
||||
export const VideoAspectRatio = Media.AspectRatio
|
||||
|
||||
export type VideoResolution = "480p" | "720p" | "1080p" | "4k" | (string & {})
|
||||
|
||||
/** Pinned frames. Routes that accept only a first frame fail typed when `last` is present. */
|
||||
export const VideoFrames = Schema.Struct({
|
||||
first: Schema.optional(Media.AssetSchema),
|
||||
last: Schema.optional(Media.AssetSchema),
|
||||
}).annotate({ identifier: "Video.Frames" })
|
||||
export type VideoFrames = Schema.Schema.Type<typeof VideoFrames>
|
||||
|
||||
export class VideoRequest extends Schema.Class<VideoRequest>("Video.Request")({
|
||||
model: VideoModelSchema,
|
||||
prompt: Schema.String,
|
||||
frames: Schema.optional(VideoFrames),
|
||||
/** Style or subject references that guide the output without pinning a frame. */
|
||||
references: Schema.optional(Schema.Array(Media.AssetSchema)),
|
||||
/** Source video for edit or extension routes. */
|
||||
video: Schema.optional(Media.AssetSchema),
|
||||
durationSeconds: Schema.optional(Schema.Number),
|
||||
aspectRatio: Schema.optional(VideoAspectRatio),
|
||||
resolution: Schema.optional(Schema.String),
|
||||
/** Whether to generate an audio track; routes whose audio is always on fail typed on `false`. */
|
||||
audio: Schema.optional(Schema.Boolean),
|
||||
n: Schema.optional(Schema.Int),
|
||||
seed: Schema.optional(Schema.Number),
|
||||
negativePrompt: Schema.optional(Schema.String),
|
||||
providerOptions: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
http: Schema.optional(HttpOptions),
|
||||
}) {
|
||||
declare protected readonly _VideoRequest: void
|
||||
}
|
||||
|
||||
export type VideoRequestFor<Options extends VideoOptions = VideoOptions> = Omit<
|
||||
VideoRequest,
|
||||
"model" | "providerOptions"
|
||||
> & {
|
||||
readonly model: VideoModel<Options>
|
||||
readonly providerOptions?: Options
|
||||
}
|
||||
|
||||
export type VideoModelOptions<Model> = Model extends VideoModel<infer Options> ? Options : never
|
||||
|
||||
export type VideoRequestInput<Model extends VideoModel = VideoModel> = Omit<
|
||||
ConstructorParameters<typeof VideoRequest>[0],
|
||||
"model" | "providerOptions" | "http" | "resolution"
|
||||
> & {
|
||||
readonly model: Model
|
||||
readonly resolution?: VideoResolution
|
||||
readonly providerOptions?: NoInfer<VideoModelOptions<Model>>
|
||||
readonly http?: HttpOptions.Input
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response and events
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class VideoResponse extends Schema.Class<VideoResponse>("Video.Response")({
|
||||
videos: Schema.Array(Media.AssetSchema),
|
||||
usage: Schema.optional(MediaUsage),
|
||||
notices: Schema.optional(Schema.Array(Media.Notice)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {
|
||||
get video() {
|
||||
return this.videos[0]
|
||||
}
|
||||
}
|
||||
|
||||
export const VideoQueuedEvent = Schema.Struct({
|
||||
type: Schema.tag("generation-queued"),
|
||||
id: Schema.String,
|
||||
position: Schema.optional(Schema.Number),
|
||||
}).annotate({ identifier: "Video.Event.Queued" })
|
||||
|
||||
export const VideoProgressEvent = Schema.Struct({
|
||||
type: Schema.tag("generation-progress"),
|
||||
id: Schema.String,
|
||||
progress: Schema.optional(Schema.Number),
|
||||
}).annotate({ identifier: "Video.Event.Progress" })
|
||||
|
||||
export const VideoOutputEvent = Schema.Struct({
|
||||
type: Schema.tag("video"),
|
||||
index: Schema.Number,
|
||||
video: Media.AssetSchema,
|
||||
}).annotate({ identifier: "Video.Event.Video" })
|
||||
|
||||
export const VideoFinishEvent = Schema.Struct({
|
||||
type: Schema.tag("finish"),
|
||||
usage: Schema.optional(MediaUsage),
|
||||
notices: Schema.optional(Schema.Array(Media.Notice)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "Video.Event.Finish" })
|
||||
|
||||
const videoEventTagged = Schema.Union([VideoQueuedEvent, VideoProgressEvent, VideoOutputEvent, VideoFinishEvent]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export const VideoEvent = Object.assign(videoEventTagged, {
|
||||
is: {
|
||||
generationQueued: videoEventTagged.guards["generation-queued"],
|
||||
generationProgress: videoEventTagged.guards["generation-progress"],
|
||||
video: videoEventTagged.guards.video,
|
||||
finish: videoEventTagged.guards.finish,
|
||||
},
|
||||
})
|
||||
export type VideoEvent = Schema.Schema.Type<typeof videoEventTagged>
|
||||
|
||||
/** A completed response expanded into the streaming event shape. */
|
||||
export const responseEvents = (response: VideoResponse): ReadonlyArray<VideoEvent> => [
|
||||
...response.videos.map((video, index) => VideoOutputEvent.make({ index, video })),
|
||||
VideoFinishEvent.make({
|
||||
usage: response.usage,
|
||||
notices: response.notices,
|
||||
providerMetadata: response.providerMetadata,
|
||||
}),
|
||||
]
|
||||
|
||||
/** A status observation as a video event; the terminal observation is replaced by the result events, so `none`. */
|
||||
export const isObservation = (event: GenerationEvent): event is Extract<VideoEvent, { type: "generation-queued" | "generation-progress" }> =>
|
||||
event.type !== "generation-finished"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request-shaped call API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function request<const Model extends VideoModel>(
|
||||
input: VideoRequestInput<Model>,
|
||||
): VideoRequestFor<VideoModelOptions<Model>>
|
||||
export function request(input: VideoRequest): VideoRequest
|
||||
export function request(input: VideoRequest | VideoRequestInput) {
|
||||
if (input instanceof VideoRequest) return input
|
||||
return new VideoRequest({
|
||||
...input,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
}
|
||||
|
||||
const requestEffect = (input: VideoRequest | VideoRequestInput) => tryRequest(() => request(input))
|
||||
|
||||
export function start<const Model extends VideoModel>(
|
||||
input: VideoRequestInput<Model>,
|
||||
): Effect.Effect<Generation<VideoResponse>, AIError, Service>
|
||||
export function start(input: VideoRequest): Effect.Effect<Generation<VideoResponse>, AIError, Service>
|
||||
export function start(input: VideoRequest | VideoRequestInput) {
|
||||
return requestEffect(input).pipe(Effect.flatMap((request) => VideoClient.start(request)))
|
||||
}
|
||||
|
||||
export function generate<const Model extends VideoModel>(
|
||||
input: VideoRequestInput<Model>,
|
||||
options?: AwaitOptions,
|
||||
): Effect.Effect<VideoResponse, AIError, Service>
|
||||
export function generate(input: VideoRequest, options?: AwaitOptions): Effect.Effect<VideoResponse, AIError, Service>
|
||||
export function generate(input: VideoRequest | VideoRequestInput, options?: AwaitOptions) {
|
||||
return requestEffect(input).pipe(Effect.flatMap((request) => VideoClient.generate(request, options)))
|
||||
}
|
||||
|
||||
/** Rebuild a generation handle from a persisted `Generation.token`, refreshing its status once. */
|
||||
export const resume = <Options extends VideoOptions>(
|
||||
model: VideoModel<Options>,
|
||||
token: unknown,
|
||||
): Effect.Effect<Generation<VideoResponse>, AIError, Service> => VideoClient.resume(model, token)
|
||||
|
||||
export function stream<const Model extends VideoModel>(
|
||||
input: VideoRequestInput<Model>,
|
||||
options?: AwaitOptions,
|
||||
): Stream.Stream<VideoEvent, AIError, Service>
|
||||
export function stream(input: VideoRequest, options?: AwaitOptions): Stream.Stream<VideoEvent, AIError, Service>
|
||||
export function stream(input: VideoRequest | VideoRequestInput, options?: AwaitOptions) {
|
||||
return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => VideoClient.stream(request, options))))
|
||||
}
|
||||
|
||||
export const Video = {
|
||||
request,
|
||||
start,
|
||||
generate,
|
||||
resume,
|
||||
stream,
|
||||
} as const
|
||||
@@ -1,5 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { AIError, LanguageModel, LLM, LLMClient, Media, Provider } from "@opencode/ai"
|
||||
import {
|
||||
AIError,
|
||||
Generation,
|
||||
Image,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
Media,
|
||||
Provider,
|
||||
Video,
|
||||
VideoClient,
|
||||
} from "@opencode/ai"
|
||||
import { Route, Protocol, WebSocketTransport } from "@opencode/ai/route"
|
||||
import { Provider as ProviderSubpath } from "@opencode/ai/provider"
|
||||
import {
|
||||
@@ -7,11 +18,13 @@ import {
|
||||
CloudflareAIGateway,
|
||||
CloudflareWorkersAI,
|
||||
DeepSeek,
|
||||
Fal,
|
||||
Fireworks,
|
||||
OpenCodeZen,
|
||||
OpenAI,
|
||||
OpenAICompatible,
|
||||
OpenRouter,
|
||||
Runway,
|
||||
TypeSafeAI,
|
||||
VercelAIGateway,
|
||||
XAI,
|
||||
@@ -36,6 +49,11 @@ describe("public exports", () => {
|
||||
expect(AIError).toBeFunction()
|
||||
expect(LanguageModel.make).toBeFunction()
|
||||
expect(Media.bytes).toBeFunction()
|
||||
expect(Image.generate).toBeFunction()
|
||||
expect(Video.start).toBeFunction()
|
||||
expect(Video.resume).toBeFunction()
|
||||
expect(VideoClient.layer).toBeDefined()
|
||||
expect(Generation).toBeFunction()
|
||||
expect(Provider.make).toBeFunction()
|
||||
expect(ProviderSubpath.make).toBe(Provider.make)
|
||||
expect(TestLLM.layer).toBeFunction()
|
||||
@@ -82,6 +100,10 @@ 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(XAI.configure({ apiKey: "fixture" }).video("grok-imagine-video-1.5").route.id).toBe("xai-video")
|
||||
expect(Fal.configure({ apiKey: "fixture" }).video("fal-ai/veo3.1").route.id).toBe("fal-video")
|
||||
expect(Runway.configure({ apiKey: "fixture" }).video("gen4.5").route.id).toBe("runway-video")
|
||||
expect(Runway.provider.video).toBe(Runway.video)
|
||||
})
|
||||
|
||||
test("protocol barrels expose supported low-level routes", () => {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -23,15 +23,14 @@ const scriptedRoute = (statuses: ReadonlyArray<GenerationStatus>, result: string
|
||||
progress: count / statuses.length,
|
||||
})
|
||||
const route: GenerationRoute<string> = {
|
||||
status: () => Ref.updateAndGet(polls, (count) => count + 1).pipe(Effect.map(snapshot)),
|
||||
result: (token) =>
|
||||
Effect.gen(function* () {
|
||||
const count = yield* Ref.get(polls)
|
||||
const status = snapshot(count).status
|
||||
if (status === "completed") return `${result}:${String(token)}`
|
||||
return yield* new AIError({ reason: new InvalidProviderOutputError({ message: `Generation ended ${status}` }) })
|
||||
}),
|
||||
cancel: () => Ref.set(cancelled, true),
|
||||
status: Ref.updateAndGet(polls, (count) => count + 1).pipe(Effect.map(snapshot)),
|
||||
result: Effect.gen(function* () {
|
||||
const count = yield* Ref.get(polls)
|
||||
const status = snapshot(count).status
|
||||
if (status === "completed") return result
|
||||
return yield* new AIError({ reason: new InvalidProviderOutputError({ message: `Generation ended ${status}` }) })
|
||||
}),
|
||||
cancel: Ref.set(cancelled, true),
|
||||
}
|
||||
return { route, polls, cancelled }
|
||||
})
|
||||
@@ -47,7 +46,7 @@ describe("Generation", () => {
|
||||
yield* TestClock.adjust("3 seconds")
|
||||
const result = yield* Fiber.join(fiber)
|
||||
|
||||
expect(result).toBe("done:[object Object]")
|
||||
expect(result).toBe("done")
|
||||
expect(yield* Ref.get(scripted.polls)).toBe(3)
|
||||
}),
|
||||
)
|
||||
@@ -57,7 +56,7 @@ describe("Generation", () => {
|
||||
const scripted = yield* scriptedRoute(["completed"], "done")
|
||||
yield* Ref.set(scripted.polls, 1)
|
||||
const generation = new Generation(scripted.route, "t", { id: "gen_1", status: "completed" })
|
||||
expect(yield* generation.await()).toBe("done:t")
|
||||
expect(yield* generation.await()).toBe("done")
|
||||
expect(yield* Ref.get(scripted.polls)).toBe(1)
|
||||
}),
|
||||
)
|
||||
@@ -80,6 +79,24 @@ describe("Generation", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails an event stream at the deadline even when a status poll hangs", () =>
|
||||
Effect.gen(function* () {
|
||||
const route: GenerationRoute<string> = {
|
||||
status: Effect.never,
|
||||
result: Effect.succeed("never"),
|
||||
}
|
||||
const generation = new Generation(route, "t", { id: "gen_1", status: "queued" })
|
||||
|
||||
const fiber = yield* Effect.forkChild(
|
||||
generation.events({ poll: { interval: "1 second", timeout: "5 seconds" } }).pipe(Stream.runCollect, Effect.flip),
|
||||
)
|
||||
yield* TestClock.adjust("6 seconds")
|
||||
const error = yield* Fiber.join(fiber)
|
||||
|
||||
expect(error.reason._tag).toBe("Timeout")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("surfaces the route failure body for failed generations", () =>
|
||||
Effect.gen(function* () {
|
||||
const scripted = yield* scriptedRoute(["running", "failed"], "unused")
|
||||
@@ -99,7 +116,9 @@ describe("Generation", () => {
|
||||
const scripted = yield* scriptedRoute(["queued", "running", "completed"], "done")
|
||||
const generation = new Generation(scripted.route, "t", { id: "gen_1", status: "queued" })
|
||||
|
||||
const fiber = yield* Effect.forkChild(generation.events({ poll: { interval: "1 second" } }).pipe(Stream.runCollect))
|
||||
const fiber = yield* Effect.forkChild(
|
||||
generation.events({ poll: { interval: "1 second" } }).pipe(Stream.runCollect),
|
||||
)
|
||||
yield* TestClock.adjust("3 seconds")
|
||||
const events = Array.from(yield* Fiber.join(fiber))
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { AIError, LLMEvent, Media } from "../src/index.js"
|
||||
import { RequestExecutor } from "../src/route.js"
|
||||
import { AI } from "../src/promise.js"
|
||||
import { OpenAI } from "../src/providers.js"
|
||||
import { OpenAI, Runway } from "../src/providers.js"
|
||||
import { handlerLayer } from "./lib/http.js"
|
||||
import { sseEvents } from "./lib/sse.js"
|
||||
|
||||
@@ -16,7 +16,10 @@ const chatBody = sseEvents(
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
)
|
||||
|
||||
/** Executor layer that answers chat completions with SSE text and image generations with one base64 PNG. */
|
||||
/**
|
||||
* Executor layer that answers chat completions with SSE text, image generations with one base64 PNG, and Runway video
|
||||
* tasks with a queued submission that succeeds on the second poll.
|
||||
*/
|
||||
const executor = (seen: Array<string>) =>
|
||||
RequestExecutor.layer.pipe(
|
||||
Layer.provide(
|
||||
@@ -30,6 +33,19 @@ const executor = (seen: Array<string>) =>
|
||||
})
|
||||
if (web.url.endsWith("/chat/completions"))
|
||||
return input.respond(chatBody, { headers: { "content-type": "text/event-stream" } })
|
||||
if (web.url.endsWith("/text_to_video"))
|
||||
return input.respond(JSON.stringify({ id: "task_1" }), { headers: { "content-type": "application/json" } })
|
||||
if (web.url.endsWith("/tasks/task_1")) {
|
||||
const polls = seen.filter((url) => url.endsWith("/tasks/task_1")).length
|
||||
return input.respond(
|
||||
JSON.stringify(
|
||||
polls === 1
|
||||
? { status: "RUNNING", progress: 0.5 }
|
||||
: { status: "SUCCEEDED", output: ["https://runway.test/out.mp4"], cost: { credits: 5 } },
|
||||
),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
)
|
||||
}
|
||||
return input.respond(JSON.stringify({ error: { message: "not found" } }), {
|
||||
status: 404,
|
||||
headers: { "content-type": "application/json" },
|
||||
@@ -73,6 +89,40 @@ describe("AI promise client", () => {
|
||||
await ai.dispose()
|
||||
})
|
||||
|
||||
test("starts, awaits, and resumes video generations over the same runtime", async () => {
|
||||
const seen: Array<string> = []
|
||||
const ai = AI.make({ layer: executor(seen) })
|
||||
const model = Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5")
|
||||
|
||||
const generation = await ai.video.start({ model, prompt: "A kite" })
|
||||
expect(generation.id).toBe("task_1")
|
||||
expect(generation.status).toBe("queued")
|
||||
expect(generation.token).toEqual({ taskID: "task_1" })
|
||||
|
||||
const refreshed = await generation.refresh()
|
||||
expect(refreshed.status).toBe("running")
|
||||
expect(refreshed.progress).toBe(0.5)
|
||||
|
||||
const response = await refreshed.await({ poll: { interval: 10 } })
|
||||
expect(response.video).toBeInstanceOf(Media.Asset)
|
||||
expect(response.video.source).toMatchObject({ type: "url", url: "https://runway.test/out.mp4" })
|
||||
expect(response.usage).toEqual({ type: "credits", credits: 5 })
|
||||
|
||||
const resumed = await ai.video.resume(model, JSON.parse(JSON.stringify(generation.token)))
|
||||
expect(resumed.status).toBe("completed")
|
||||
expect((await resumed.await()).videos).toHaveLength(1)
|
||||
|
||||
const events: Array<string> = []
|
||||
for await (const event of ai.video.stream({ model, prompt: "A kite" }, { poll: { interval: 10 } })) {
|
||||
events.push(event.type)
|
||||
}
|
||||
expect(events).toEqual(["video", "finish"])
|
||||
|
||||
expect(seen[0]).toBe("https://runway.test/v1/text_to_video")
|
||||
expect(seen.filter((url) => url.endsWith("/tasks/task_1")).length).toBeGreaterThanOrEqual(5)
|
||||
await ai.dispose()
|
||||
})
|
||||
|
||||
test("rethrows AIError unchanged and honors abort signals", async () => {
|
||||
const ai = AI.make({ layer: executor([]) })
|
||||
|
||||
@@ -97,6 +147,7 @@ describe("AI promise client", () => {
|
||||
test("the default client is created lazily and can be disposed", async () => {
|
||||
expect(typeof AI.ai.llm.generate).toBe("function")
|
||||
expect(typeof AI.ai.image.generate).toBe("function")
|
||||
expect(typeof AI.ai.video.start).toBe("function")
|
||||
await AI.ai.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Video } from "../../src/index.js"
|
||||
import { Fal } from "../../src/providers.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
import { videoPoll } from "./video-recording.js"
|
||||
|
||||
const model = Fal.configure({
|
||||
apiKey: process.env.FAL_KEY ?? "fixture",
|
||||
}).video("fal-ai/veo3.1/fast")
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "fal-video",
|
||||
provider: "fal",
|
||||
protocol: "fal-video",
|
||||
requires: ["FAL_KEY"],
|
||||
})
|
||||
|
||||
describe("fal Video recorded", () => {
|
||||
recorded.effect(
|
||||
"generates a video",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Video.generate(
|
||||
{
|
||||
model,
|
||||
prompt: "A single red balloon drifting slowly upward against a clear blue sky.",
|
||||
aspectRatio: "16:9",
|
||||
resolution: "720p",
|
||||
providerOptions: { duration: "4s" },
|
||||
},
|
||||
{ poll: videoPoll },
|
||||
)
|
||||
|
||||
expect(response.videos).toHaveLength(1)
|
||||
expect(response.video.source.type).toBe("url")
|
||||
expect((yield* response.video.bytes()).length).toBeGreaterThan(0)
|
||||
}),
|
||||
{ timeout: 15 * 60 * 1000 },
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Video } from "../../src/index.js"
|
||||
import { Google } from "../../src/providers.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
import { videoPoll } from "./video-recording.js"
|
||||
|
||||
const model = Google.configure({
|
||||
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture",
|
||||
}).video("veo-3.1-fast-generate-preview")
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "google-video",
|
||||
provider: "google",
|
||||
protocol: "google-video",
|
||||
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||
})
|
||||
|
||||
describe("Google Veo recorded", () => {
|
||||
recorded.effect(
|
||||
"generates a video",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Video.generate(
|
||||
{
|
||||
model,
|
||||
prompt: "A single red balloon drifting slowly upward against a clear blue sky.",
|
||||
aspectRatio: "16:9",
|
||||
durationSeconds: 4,
|
||||
},
|
||||
{ poll: videoPoll },
|
||||
)
|
||||
|
||||
expect(response.videos).toHaveLength(1)
|
||||
expect(response.video.source.type).toBe("url")
|
||||
expect(response.video.headers?.["x-goog-api-key"]).toBeString()
|
||||
expect((yield* response.video.bytes()).length).toBeGreaterThan(0)
|
||||
}),
|
||||
{ timeout: 15 * 60 * 1000 },
|
||||
)
|
||||
})
|
||||
@@ -1182,7 +1182,9 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves unknown reasoning details while using scalar display text", () =>
|
||||
// Only recognized detail shapes are retained and replayed; echoing an
|
||||
// undocumented provider payload is what breaks follow-up requests.
|
||||
it.effect("drops unknown reasoning details while using scalar display text", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } }]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
@@ -1199,16 +1201,195 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: [] },
|
||||
})
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
|
||||
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: [] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
// Kimi's coding endpoint streams the full thinking through `reasoning_content`
|
||||
// and a separate summary + encrypted blob through its own `reasoning_details`
|
||||
// dialect. The stream-only `index` must not be echoed back.
|
||||
it.effect("merges Kimi summary deltas by index and replays details without the streaming index", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning_content: "Let me" } }] },
|
||||
{ choices: [{ delta: { reasoning_content: " think" } }] },
|
||||
{ choices: [{ delta: { reasoning_details: [{ index: 0, type: "summary", summary: "Plan" }] } }] },
|
||||
{ choices: [{ delta: { reasoning_details: [{ index: 0, type: "summary", summary: " tools" }] } }] },
|
||||
{ choices: [{ delta: { reasoning_details: [{ index: 1, type: "encrypted", encrypted: "opaque" }] } }] },
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{ index: 0, id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ choices: [{ delta: {}, finish_reason: "tool_calls" }] },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const stored = [
|
||||
{ index: 0, type: "summary", summary: "Plan tools" },
|
||||
{ index: 1, type: "encrypted", encrypted: "opaque" },
|
||||
]
|
||||
expect(response.reasoning).toBe("Let me think")
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning_content", reasoningDetails: stored },
|
||||
})
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [{ id: "call_1", type: "function", function: { name: "get_time", arguments: "{}" } }],
|
||||
reasoning_content: "Let me think",
|
||||
reasoning_details: [
|
||||
{ type: "summary", summary: "Plan tools" },
|
||||
{ type: "encrypted", encrypted: "opaque" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("displays Kimi summaries and replays reasoning_content when no scalar reasoning streams", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning_details: [{ index: 0, type: "summary", summary: "Plan" }] } }] },
|
||||
{ choices: [{ delta: { reasoning_details: [{ index: 0, type: "summary", summary: " tools" }] } }] },
|
||||
{ choices: [{ delta: { reasoning_details: [{ index: 1, type: "encrypted", encrypted: "opaque" }] } }] },
|
||||
{ choices: [{ delta: { content: "Hello" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Plan tools")
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Hello",
|
||||
reasoning_content: "Plan tools",
|
||||
reasoning_details: [
|
||||
{ type: "summary", summary: "Plan tools" },
|
||||
{ type: "encrypted", encrypted: "opaque" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
// Sessions persisted before the fix already hold Kimi details with `index`.
|
||||
it.effect("strips the streaming index from previously stored Kimi details", () =>
|
||||
Effect.gen(function* () {
|
||||
const replay = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "thinking",
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
reasoningField: "reasoning_content",
|
||||
reasoningDetails: [
|
||||
{ index: 0, type: "summary", summary: "thinking" },
|
||||
{ index: 1, type: "encrypted", encrypted: "opaque" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(replay.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning_content: "thinking",
|
||||
reasoning_details: [
|
||||
{ type: "summary", summary: "thinking" },
|
||||
{ type: "encrypted", encrypted: "opaque" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges consecutive OpenRouter summary deltas and replays them unmodified", () =>
|
||||
Effect.gen(function* () {
|
||||
const merged = [
|
||||
{ type: "reasoning.summary", summary: "Plan tools", format: "openai-responses-v1", index: 0 },
|
||||
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 0 },
|
||||
]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
reasoning_details: [
|
||||
{ type: "reasoning.summary", summary: "Plan", format: "openai-responses-v1", index: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
reasoning_details: [
|
||||
{ type: "reasoning.summary", summary: " tools", format: "openai-responses-v1", index: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ choices: [{ delta: { reasoning_details: [merged[1]] } }] },
|
||||
{ choices: [{ delta: { content: "Hello" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Plan tools")
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningDetails: merged },
|
||||
})
|
||||
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: merged }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses scalar display text for signature-only reasoning details", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.text", signature: "signed", format: "provider-v2", index: 0 }]
|
||||
|
||||
@@ -375,6 +375,27 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores bare null frames between deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
deltaChunk({ role: "assistant", content: "Hello" }),
|
||||
"null",
|
||||
deltaChunk({ content: "!" }),
|
||||
"null",
|
||||
deltaChunk({}, "stop"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello!")
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "stop" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts nullable usage and preserves provider fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -393,6 +393,33 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores bare null frames between events", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
}).model("example-model")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 0, item: { type: "message", id: "msg_1" } },
|
||||
"null",
|
||||
{ type: "response.output_text.delta", output_index: 0, item_id: "msg_1", delta: "Hello" },
|
||||
"null",
|
||||
{ type: "response.output_item.done", output_index: 0, item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
"null",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.events.at(-1)).toMatchObject({ type: "finish" })
|
||||
}),
|
||||
)
|
||||
|
||||
describe("stream validation", () => {
|
||||
const request = LLM.request({
|
||||
model: configure({ apiKey: "test-key", baseURL: "https://responses.example.test/v1" }).model("example-model"),
|
||||
|
||||
@@ -315,10 +315,9 @@ describe("OpenRouter", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves opaque and duplicate continuation details", () =>
|
||||
it.effect("drops unrecognized details and preserves duplicate continuation details", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [
|
||||
{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } },
|
||||
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
|
||||
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
|
||||
]
|
||||
@@ -330,7 +329,15 @@ describe("OpenRouter", () => {
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
text: "Thinking",
|
||||
providerMetadata: { openrouter: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
providerMetadata: {
|
||||
openrouter: {
|
||||
reasoningField: "reasoning",
|
||||
reasoningDetails: [
|
||||
{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } },
|
||||
...details,
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Video } from "../../src/index.js"
|
||||
import { Runway } from "../../src/providers.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
import { videoPoll } from "./video-recording.js"
|
||||
|
||||
const model = Runway.configure({
|
||||
apiKey: process.env.RUNWAYML_API_SECRET ?? "fixture",
|
||||
}).video("gen4.5")
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "runway-video",
|
||||
provider: "runway",
|
||||
protocol: "runway-video",
|
||||
requires: ["RUNWAYML_API_SECRET"],
|
||||
})
|
||||
|
||||
describe("Runway recorded", () => {
|
||||
recorded.effect(
|
||||
"generates a video",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Video.generate(
|
||||
{
|
||||
model,
|
||||
prompt: "A single red balloon drifting slowly upward against a clear blue sky.",
|
||||
aspectRatio: "1280:720",
|
||||
durationSeconds: 2,
|
||||
},
|
||||
{ poll: videoPoll },
|
||||
)
|
||||
|
||||
expect(response.videos).toHaveLength(1)
|
||||
expect(response.video.source.type).toBe("url")
|
||||
expect(response.usage?.type).toBe("credits")
|
||||
expect((yield* response.video.bytes()).length).toBeGreaterThan(0)
|
||||
}),
|
||||
{ timeout: 15 * 60 * 1000 },
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Poll } from "../../src/index.js"
|
||||
|
||||
/**
|
||||
* Video generations take minutes live, so record with a realistic interval. Replay consumes the recorded polls in
|
||||
* cassette order regardless of timing, so it runs them back-to-back instead of sleeping between each one.
|
||||
*/
|
||||
export const videoPoll: Poll = {
|
||||
interval: process.env.RECORD === "true" ? "10 seconds" : "10 millis",
|
||||
timeout: "15 minutes",
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Video } from "../../src/index.js"
|
||||
import { XAI } from "../../src/providers.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
import { videoPoll } from "./video-recording.js"
|
||||
|
||||
const model = XAI.configure({
|
||||
apiKey: process.env.XAI_API_KEY ?? "fixture",
|
||||
}).video("grok-imagine-video-1.5")
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "xai-video",
|
||||
provider: "xai",
|
||||
protocol: "xai-video",
|
||||
requires: ["XAI_API_KEY"],
|
||||
})
|
||||
|
||||
describe("xAI Video recorded", () => {
|
||||
recorded.effect(
|
||||
"generates a video",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const generation = yield* Video.start({
|
||||
model,
|
||||
prompt: "A single red balloon drifting slowly upward against a clear blue sky.",
|
||||
aspectRatio: "16:9",
|
||||
resolution: "480p",
|
||||
durationSeconds: 2,
|
||||
})
|
||||
const observed = yield* generation.events({ poll: videoPoll }).pipe(Stream.runCollect)
|
||||
const progress = Array.from(observed).flatMap((event) =>
|
||||
event.type === "generation-progress" && event.progress !== undefined ? [event.progress] : [],
|
||||
)
|
||||
// xAI reports progress as a percentage; the route normalizes it to a monotonic 0..1 fraction.
|
||||
expect(progress.length).toBeGreaterThan(0)
|
||||
expect(progress.every((value) => value >= 0 && value <= 1)).toBe(true)
|
||||
expect(progress).toEqual([...progress].sort((a, b) => a - b))
|
||||
|
||||
const response = yield* generation.result()
|
||||
expect(response.videos).toHaveLength(1)
|
||||
expect(response.video.source.type).toBe("url")
|
||||
expect(response.video.info?.durationSeconds).toBe(2)
|
||||
expect((yield* response.video.bytes()).length).toBeGreaterThan(0)
|
||||
}),
|
||||
{ timeout: 15 * 60 * 1000 },
|
||||
)
|
||||
})
|
||||
@@ -6,9 +6,11 @@ import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { LLMClient, RequestExecutor } from "../src/route.js"
|
||||
import { ImageClient } from "../src/image-client.js"
|
||||
import { VideoClient } from "../src/video-client.js"
|
||||
import { EvaluationClient } from "../src/experimental/evaluation-client.js"
|
||||
import type { Service as EvaluationClientService } from "../src/experimental/evaluation-client.js"
|
||||
import type { Service as ImageClientService } from "../src/image-client.js"
|
||||
import type { Service as VideoClientService } from "../src/video-client.js"
|
||||
import type { Service as LLMClientService } from "../src/route/client.js"
|
||||
import type { Service as RequestExecutorService } from "../src/route/executor.js"
|
||||
import {
|
||||
@@ -24,6 +26,7 @@ type RecordedEnv =
|
||||
| RequestExecutorService
|
||||
| LLMClientService
|
||||
| ImageClientService
|
||||
| VideoClientService
|
||||
| EvaluationClientService
|
||||
| Socket.WebSocketConstructor
|
||||
|
||||
@@ -99,6 +102,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
||||
requestExecutor,
|
||||
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
VideoClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
EvaluationClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
webSocket,
|
||||
)
|
||||
|
||||
@@ -178,6 +178,20 @@ describe("AI.Usage", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sseFraming drops bare null frames and keeps other payloads", () =>
|
||||
Effect.gen(function* () {
|
||||
const frames = yield* ProviderShared.sseFraming(
|
||||
Stream.make(
|
||||
new TextEncoder().encode(
|
||||
'data: {"first":true}\n\ndata: null\n\nevent: update\ndata: null\n\ndata: "null"\n\ndata: 0\n\ndata: {"second":true}\n\ndata: [DONE]\n\ndata: null\n\n',
|
||||
),
|
||||
),
|
||||
).pipe(Stream.runCollect)
|
||||
|
||||
expect(Array.from(frames)).toEqual(['{"first":true}', '"null"', "0", '{"second":true}'])
|
||||
}),
|
||||
)
|
||||
|
||||
test("visibleOutputTokens clamps reasoning > output to zero", () => {
|
||||
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
|
||||
expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10)
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
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, Video, VideoClient, type GenerationEvent } from "../src/index.js"
|
||||
import { Fal, Google, Runway, XAI } from "../src/providers.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
import { dynamicResponse, type HandlerInput } from "./lib/http.js"
|
||||
|
||||
interface Call {
|
||||
readonly method: string
|
||||
readonly url: string
|
||||
readonly headers: Headers
|
||||
readonly body: string
|
||||
}
|
||||
|
||||
/** Record every request and tell the handler how many times this exact method+URL has been seen (1-based). */
|
||||
const observe = (calls: Array<Call>, input: HandlerInput) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
const call = { method: web.method, url: web.url, headers: web.headers, body: input.text }
|
||||
calls.push(call)
|
||||
return { call, nth: calls.filter((seen) => seen.method === call.method && seen.url === call.url).length }
|
||||
})
|
||||
|
||||
const json = (input: HandlerInput, value: unknown, init?: ResponseInit) =>
|
||||
input.respond(JSON.stringify(value), { ...init, headers: { "content-type": "application/json", ...init?.headers } })
|
||||
|
||||
const layer = (handler: Parameters<typeof dynamicResponse>[0]) =>
|
||||
VideoClient.layer.pipe(Layer.provideMerge(dynamicResponse(handler)))
|
||||
|
||||
/** Fork the polling program, let the test clock cover `seconds` of polling, and join. */
|
||||
const settle = <A, E, R>(program: Effect.Effect<A, E, R>, seconds: number) =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* Effect.forkChild(program)
|
||||
yield* TestClock.adjust(`${seconds} seconds`)
|
||||
return yield* Fiber.join(fiber)
|
||||
})
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Google Veo
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Video / Google Veo", () => {
|
||||
const google = Google.configure({
|
||||
apiKey: "test",
|
||||
baseURL: "https://google.test/v1beta",
|
||||
headers: { "x-deployment": "yes" },
|
||||
})
|
||||
const model = google.video("veo-3.1-generate-preview")
|
||||
const operation = "models/veo-3.1-generate-preview/operations/op_1"
|
||||
const fileUri = "https://generativelanguage.googleapis.com/v1beta/files/abc:download?alt=media"
|
||||
|
||||
it.effect("starts a predictLongRunning operation, polls it, and returns an authenticated download URL", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const program = Effect.gen(function* () {
|
||||
const generation = yield* Video.start({
|
||||
model,
|
||||
prompt: "A calico kitten sleeping in the sunshine",
|
||||
frames: {
|
||||
first: Media.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
|
||||
last: Media.fromDataUrl("data:image/jpeg;base64,BAUG"),
|
||||
},
|
||||
references: [Media.bytes(Uint8Array.from([7, 8, 9]), "image/png")],
|
||||
durationSeconds: 8,
|
||||
aspectRatio: "16:9",
|
||||
resolution: "1080p",
|
||||
negativePrompt: "text, watermark",
|
||||
seed: 42,
|
||||
audio: true,
|
||||
providerOptions: { personGeneration: "allow_adult", futureOption: true },
|
||||
http: { body: { parameters: { httpOption: "yes" } } },
|
||||
})
|
||||
expect(generation.id).toBe(operation)
|
||||
expect(generation.status).toBe("running")
|
||||
expect(generation.token).toEqual({ operation })
|
||||
const response = yield* generation.await({ poll: { interval: "1 second" } })
|
||||
// Download credentials stay off the wire model: not in `source`, not in JSON, only on the live instance.
|
||||
expect(response.video.source).toEqual({
|
||||
type: "url",
|
||||
url: fileUri,
|
||||
mediaType: "video/mp4",
|
||||
expiresAt: 1000 + 2 * DAY,
|
||||
})
|
||||
// Only what `Auth` added travels with the asset; deployment headers stay on the route.
|
||||
expect(response.video.headers).toEqual({ "x-goog-api-key": "test" })
|
||||
expect(JSON.stringify(response.video)).not.toContain("x-goog-api-key")
|
||||
expect(Media.from(response.video.source).headers).toBeUndefined()
|
||||
expect(response.notices).toEqual([
|
||||
{
|
||||
type: "filtered",
|
||||
message: "Google Veo filtered media: audio filtered",
|
||||
providerMetadata: { google: { raiMediaFilteredReason: "audio filtered" } },
|
||||
},
|
||||
])
|
||||
expect(response.providerMetadata).toEqual({
|
||||
google: { operation, raiMediaFilteredCount: 1, metadata: undefined },
|
||||
})
|
||||
expect(yield* response.video.bytes()).toEqual(Uint8Array.from([9, 9, 9]))
|
||||
})
|
||||
yield* settle(program, 1).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call, nth } = yield* observe(calls, input)
|
||||
expect(call.headers.get("x-goog-api-key")).toBe("test")
|
||||
if (call.url !== fileUri) expect(call.headers.get("x-deployment")).toBe("yes")
|
||||
if (call.method === "POST") {
|
||||
expect(call.url).toBe("https://google.test/v1beta/models/veo-3.1-generate-preview:predictLongRunning")
|
||||
expect(JSON.parse(call.body)).toEqual({
|
||||
instances: [
|
||||
{
|
||||
prompt: "A calico kitten sleeping in the sunshine",
|
||||
image: { inlineData: { mimeType: "image/png", data: "AQID" } },
|
||||
lastFrame: { inlineData: { mimeType: "image/jpeg", data: "BAUG" } },
|
||||
referenceImages: [
|
||||
{ image: { inlineData: { mimeType: "image/png", data: "BwgJ" } }, referenceType: "asset" },
|
||||
],
|
||||
},
|
||||
],
|
||||
parameters: {
|
||||
aspectRatio: "16:9",
|
||||
resolution: "1080p",
|
||||
durationSeconds: 8,
|
||||
negativePrompt: "text, watermark",
|
||||
seed: 42,
|
||||
personGeneration: "allow_adult",
|
||||
futureOption: true,
|
||||
httpOption: "yes",
|
||||
},
|
||||
})
|
||||
return json(input, { name: operation })
|
||||
}
|
||||
if (call.url === fileUri) return input.respond(Uint8Array.from([9, 9, 9]))
|
||||
expect(call.url).toBe(`https://google.test/v1beta/${operation}`)
|
||||
if (nth === 1) return json(input, { name: operation, done: false })
|
||||
return json(input, {
|
||||
name: operation,
|
||||
done: true,
|
||||
response: {
|
||||
generateVideoResponse: {
|
||||
generatedSamples: [{ video: { uri: fileUri } }],
|
||||
raiMediaFilteredCount: 1,
|
||||
raiMediaFilteredReasons: ["audio filtered"],
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
|
||||
"POST https://google.test/v1beta/models/veo-3.1-generate-preview:predictLongRunning",
|
||||
`GET https://google.test/v1beta/${operation}`,
|
||||
`GET https://google.test/v1beta/${operation}`,
|
||||
`GET https://google.test/v1beta/${operation}`,
|
||||
`GET ${fileUri}`,
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("surfaces an operation error as a failed generation with the provider body", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = {
|
||||
name: operation,
|
||||
done: true,
|
||||
error: { code: 3, message: "Prompt violates policy", status: "INVALID_ARGUMENT" },
|
||||
}
|
||||
const error = yield* Video.generate({ model, prompt: "nope" }).pipe(
|
||||
Effect.flip,
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(input.request.method === "POST" ? json(input, { name: operation }) : json(input, failure)),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(error.message).toBe("Google Veo operation failed: Prompt violates policy")
|
||||
expect(error.reason.body).toBe(JSON.stringify(failure))
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports fully filtered output as a content policy failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Video.generate({ model, prompt: "nope" }).pipe(
|
||||
Effect.flip,
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(
|
||||
input.request.method === "POST"
|
||||
? json(input, { name: operation })
|
||||
: json(input, {
|
||||
done: true,
|
||||
response: {
|
||||
generateVideoResponse: { raiMediaFilteredCount: 1, raiMediaFilteredReasons: ["safety"] },
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(error.reason._tag).toBe("ContentPolicy")
|
||||
expect(error.message).toContain("safety")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported inputs before any network call", () =>
|
||||
Effect.gen(function* () {
|
||||
const cases = [
|
||||
Video.generate({ model, prompt: "x", n: 2 }),
|
||||
Video.generate({ model, prompt: "x", audio: false }),
|
||||
Video.generate({ model, prompt: "x", frames: { last: Media.bytes(Uint8Array.from([1]), "image/png") } }),
|
||||
Video.generate({ model, prompt: "x", frames: { first: Media.url("https://example.test/first.png") } }),
|
||||
]
|
||||
const tags = yield* Effect.forEach(cases, (program) =>
|
||||
program.pipe(
|
||||
Effect.flip,
|
||||
Effect.map((error) => error.reason._tag),
|
||||
),
|
||||
)
|
||||
expect(tags).toEqual(["UnsupportedOperation", "UnsupportedOperation", "InvalidRequest", "InvalidRequest"])
|
||||
}).pipe(Effect.provide(layer(() => Effect.die("unsupported input reached the network")))),
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// xAI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Video / xAI", () => {
|
||||
const xai = XAI.configure({ apiKey: "test", baseURL: "https://xai.test/v1" })
|
||||
const model = xai.video("grok-imagine-video-1.5")
|
||||
|
||||
it.effect("submits a generation, reports pending progress, and returns the temporary URL", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const events: Array<GenerationEvent> = []
|
||||
const program = Effect.gen(function* () {
|
||||
const generation = yield* Video.start({
|
||||
model,
|
||||
prompt: "Make the water crash down",
|
||||
frames: {
|
||||
first: Media.fromDataUrl("data:image/png;base64,AQID"),
|
||||
last: Media.url("https://example.test/last.png"),
|
||||
},
|
||||
references: [Media.ref("xai", "file_1")],
|
||||
durationSeconds: 10,
|
||||
aspectRatio: "16:9",
|
||||
resolution: "720p",
|
||||
audio: false,
|
||||
providerOptions: { reference_audios: [{ voice_id: "eve" }], future_option: true },
|
||||
http: { headers: { "x-request": "yes" }, query: { trace: "1" } },
|
||||
})
|
||||
expect(generation.token).toEqual({ requestID: "req_1" })
|
||||
events.push(...(yield* generation.events({ poll: { interval: "1 second" } }).pipe(Stream.runCollect)))
|
||||
return yield* generation.await()
|
||||
})
|
||||
const response = yield* settle(program, 2).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call, nth } = yield* observe(calls, input)
|
||||
expect(call.headers.get("authorization")).toBe("Bearer test")
|
||||
// The request's own `http` overlay follows the generation into every poll started from it.
|
||||
expect(call.headers.get("x-request")).toBe("yes")
|
||||
if (call.method === "POST") {
|
||||
expect(call.url).toBe("https://xai.test/v1/videos/generations?trace=1")
|
||||
expect(JSON.parse(call.body)).toEqual({
|
||||
model: "grok-imagine-video-1.5",
|
||||
prompt: "Make the water crash down",
|
||||
image: { url: "data:image/png;base64,AQID" },
|
||||
last_frame: { url: "https://example.test/last.png" },
|
||||
reference_images: [{ file_id: "file_1" }],
|
||||
duration: 10,
|
||||
aspect_ratio: "16:9",
|
||||
resolution: "720p",
|
||||
generate_audio: false,
|
||||
reference_audios: [{ voice_id: "eve" }],
|
||||
future_option: true,
|
||||
})
|
||||
return json(input, { request_id: "req_1" })
|
||||
}
|
||||
expect(call.url).toBe("https://xai.test/v1/videos/req_1?trace=1")
|
||||
if (nth === 1) return json(input, { status: "pending", progress: 40 })
|
||||
return json(input, {
|
||||
status: "done",
|
||||
video: { url: "https://vidgen.x.ai/out.mp4", duration: 10, respect_moderation: true },
|
||||
model: "grok-imagine-video-1.5",
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(events).toEqual([
|
||||
{ type: "generation-progress", id: "req_1", progress: 0.4 },
|
||||
{ type: "generation-finished", id: "req_1", status: "completed" },
|
||||
])
|
||||
expect(response.video.source).toEqual({ type: "url", url: "https://vidgen.x.ai/out.mp4", mediaType: "video/mp4" })
|
||||
expect(response.video.info).toEqual({ durationSeconds: 10 })
|
||||
expect(response.notices).toBeUndefined()
|
||||
expect(response.providerMetadata).toEqual({ xai: { requestId: "req_1", model: "grok-imagine-video-1.5" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("routes a source video to edits by default and to extensions on request", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const source = Media.url("https://example.test/in.mp4")
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Video.start({ model, prompt: "brighter", video: source })
|
||||
yield* Video.start({ model, prompt: "keep going", video: source, providerOptions: { mode: "extend" } })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) => observe(calls, input).pipe(Effect.map(() => json(input, { request_id: "req_2" })))),
|
||||
),
|
||||
)
|
||||
expect(calls.map((call) => call.url)).toEqual([
|
||||
"https://xai.test/v1/videos/edits",
|
||||
"https://xai.test/v1/videos/extensions",
|
||||
])
|
||||
expect(calls.map((call) => JSON.parse(call.body))).toEqual([
|
||||
{ model: "grok-imagine-video-1.5", prompt: "brighter", video: { url: "https://example.test/in.mp4" } },
|
||||
{ model: "grok-imagine-video-1.5", prompt: "keep going", video: { url: "https://example.test/in.mp4" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
for (const terminal of [
|
||||
{
|
||||
body: { status: "failed", error: { code: "invalid_argument", message: "Prompt cannot be empty." } },
|
||||
tag: "ProviderInternal",
|
||||
message: "xAI Video generation failed (invalid_argument): Prompt cannot be empty.",
|
||||
},
|
||||
{ body: { status: "expired" }, tag: "InvalidRequest", message: "xAI Video request req_1 expired" },
|
||||
]) {
|
||||
it.effect(`surfaces ${terminal.body.status} generations with the provider body`, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Video.generate({ model, prompt: "x" }).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe(terminal.tag)
|
||||
expect(error.message).toBe(terminal.message)
|
||||
expect(error.reason.body).toBe(JSON.stringify(terminal.body))
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(
|
||||
input.request.method === "POST" ? json(input, { request_id: "req_1" }) : json(input, terminal.body),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("flags moderated results as a notice and withheld videos as a content policy failure", () =>
|
||||
Effect.gen(function* () {
|
||||
const flagged = yield* Video.generate({ model, prompt: "x" }).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(
|
||||
input.request.method === "POST"
|
||||
? json(input, { request_id: "req_1" })
|
||||
: json(input, {
|
||||
status: "done",
|
||||
video: { url: "https://vidgen.x.ai/o.mp4", respect_moderation: false },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(flagged.notices).toEqual([
|
||||
{ type: "moderated", message: "xAI Video flagged the generated video for moderation" },
|
||||
])
|
||||
const withheld = yield* Video.generate({ model, prompt: "x" }).pipe(
|
||||
Effect.flip,
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(
|
||||
input.request.method === "POST"
|
||||
? json(input, { request_id: "req_1" })
|
||||
: json(input, { status: "done", video: { respect_moderation: false } }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(withheld.reason._tag).toBe("ContentPolicy")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects seed, negativePrompt, and n before sending", () =>
|
||||
Effect.gen(function* () {
|
||||
const tags = yield* Effect.forEach(
|
||||
[
|
||||
Video.generate({ model, prompt: "x", seed: 1 }),
|
||||
Video.generate({ model, prompt: "x", negativePrompt: "blur" }),
|
||||
Video.generate({ model, prompt: "x", n: 2 }),
|
||||
],
|
||||
(program) =>
|
||||
program.pipe(
|
||||
Effect.flip,
|
||||
Effect.map((error) => error.reason._tag),
|
||||
),
|
||||
)
|
||||
expect(tags).toEqual(["UnsupportedOperation", "UnsupportedOperation", "UnsupportedOperation"])
|
||||
}).pipe(Effect.provide(layer(() => Effect.die("unsupported input reached the network")))),
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Video / fal", () => {
|
||||
const fal = Fal.configure({ apiKey: "test", baseURL: "https://queue.fal.test" })
|
||||
const model = fal.video("fal-ai/veo3.1")
|
||||
const urls = {
|
||||
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",
|
||||
}
|
||||
const submitted = {
|
||||
request_id: "r1",
|
||||
status_url: urls.status,
|
||||
response_url: urls.response,
|
||||
cancel_url: urls.cancel,
|
||||
queue_position: 2,
|
||||
}
|
||||
|
||||
it.effect("submits to the queue and follows the provider's status, response, and cancel URLs", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const program = Effect.gen(function* () {
|
||||
const generation = yield* Video.start({
|
||||
model,
|
||||
prompt: "Two person street interview",
|
||||
frames: { first: Media.url("https://example.test/first.png") },
|
||||
negativePrompt: "blur",
|
||||
seed: 7,
|
||||
aspectRatio: "9:16",
|
||||
resolution: "1080p",
|
||||
audio: true,
|
||||
providerOptions: { duration: "8s", safety_tolerance: "4" },
|
||||
})
|
||||
expect(generation.status).toBe("queued")
|
||||
expect(generation.position).toBe(2)
|
||||
expect(generation.token).toEqual({
|
||||
requestID: "r1",
|
||||
statusURL: urls.status,
|
||||
responseURL: urls.response,
|
||||
cancelURL: urls.cancel,
|
||||
})
|
||||
const queued = yield* generation.refresh()
|
||||
expect(queued.status).toBe("queued")
|
||||
expect(queued.position).toBe(1)
|
||||
const response = yield* generation.await({ poll: { interval: "1 second" } })
|
||||
yield* generation.cancel()
|
||||
return response
|
||||
})
|
||||
const response = yield* settle(program, 3).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call, nth } = yield* observe(calls, input)
|
||||
expect(call.headers.get("authorization")).toBe("Key test")
|
||||
if (call.method === "POST") {
|
||||
expect(call.url).toBe("https://queue.fal.test/fal-ai/veo3.1")
|
||||
expect(JSON.parse(call.body)).toEqual({
|
||||
prompt: "Two person street interview",
|
||||
negative_prompt: "blur",
|
||||
seed: 7,
|
||||
aspect_ratio: "9:16",
|
||||
resolution: "1080p",
|
||||
generate_audio: true,
|
||||
image_url: "https://example.test/first.png",
|
||||
duration: "8s",
|
||||
safety_tolerance: "4",
|
||||
})
|
||||
return json(input, submitted)
|
||||
}
|
||||
if (call.method === "PUT") {
|
||||
expect(call.url).toBe(urls.cancel)
|
||||
return json(input, { status: "CANCELLATION_REQUESTED" }, { status: 202 })
|
||||
}
|
||||
if (call.url === urls.response)
|
||||
return json(input, {
|
||||
video: {
|
||||
url: "https://v3.fal.media/out.mp4",
|
||||
content_type: "video/mp4",
|
||||
file_name: "out.mp4",
|
||||
file_size: 10,
|
||||
},
|
||||
seed: 7,
|
||||
has_nsfw_concepts: [false],
|
||||
})
|
||||
expect(call.url).toBe(urls.status)
|
||||
if (nth === 1) return json(input, { status: "IN_QUEUE", queue_position: 1 })
|
||||
if (nth === 2) return json(input, { status: "IN_QUEUE", queue_position: 0 })
|
||||
if (nth === 3) return json(input, { status: "IN_PROGRESS", logs: [{ message: "Generating..." }] })
|
||||
return json(input, { status: "COMPLETED", metrics: { inference_time: 3.2 } })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.video.source).toEqual({
|
||||
type: "url",
|
||||
url: "https://v3.fal.media/out.mp4",
|
||||
mediaType: "video/mp4",
|
||||
})
|
||||
expect(response.providerMetadata).toEqual({
|
||||
fal: { requestId: "r1", seed: 7, fileName: "out.mp4", fileSize: 10, has_nsfw_concepts: [false] },
|
||||
})
|
||||
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
|
||||
"POST https://queue.fal.test/fal-ai/veo3.1",
|
||||
`GET ${urls.status}`,
|
||||
`GET ${urls.status}`,
|
||||
`GET ${urls.status}`,
|
||||
`GET ${urls.status}`,
|
||||
`GET ${urls.response}`,
|
||||
`PUT ${urls.cancel}`,
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats a COMPLETED status carrying an error as failed", () =>
|
||||
Effect.gen(function* () {
|
||||
const generation = yield* Video.start({ model, prompt: "x" })
|
||||
const failed = yield* generation.refresh()
|
||||
expect(failed.status).toBe("failed")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(
|
||||
input.request.method === "POST"
|
||||
? json(input, submitted)
|
||||
: json(input, { status: "COMPLETED", error: "Invalid input", error_type: "ValidationError" }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects model-specific common fields and points at providerOptions", () =>
|
||||
Effect.gen(function* () {
|
||||
const errors = yield* Effect.forEach(
|
||||
[
|
||||
Video.generate({ model, prompt: "x", durationSeconds: 8 }),
|
||||
Video.generate({ model, prompt: "x", frames: { last: Media.url("https://example.test/last.png") } }),
|
||||
Video.generate({ model, prompt: "x", references: [Media.url("https://example.test/ref.png")] }),
|
||||
Video.generate({ model, prompt: "x", n: 2 }),
|
||||
Video.generate({ model, prompt: "x", frames: { first: Media.ref("fal", "handle") } }),
|
||||
],
|
||||
(program) => program.pipe(Effect.flip),
|
||||
)
|
||||
expect(errors.map((error) => error.reason._tag)).toEqual([
|
||||
"UnsupportedOperation",
|
||||
"UnsupportedOperation",
|
||||
"UnsupportedOperation",
|
||||
"UnsupportedOperation",
|
||||
"InvalidRequest",
|
||||
])
|
||||
expect(errors[1].message).toContain("end_image_url")
|
||||
}).pipe(Effect.provide(layer(() => Effect.die("unsupported input reached the network")))),
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runway
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Video / Runway", () => {
|
||||
const runway = Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" })
|
||||
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", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const program = Effect.gen(function* () {
|
||||
const generation = yield* Video.start({
|
||||
model,
|
||||
prompt: "The kite lifts off",
|
||||
frames: {
|
||||
first: Media.url("https://example.test/first.png"),
|
||||
last: Media.ref("runway", "runway://upload-token"),
|
||||
},
|
||||
aspectRatio: "1280:720",
|
||||
durationSeconds: 5,
|
||||
seed: 3,
|
||||
audio: true,
|
||||
providerOptions: { contentModeration: { publicFigureThreshold: "low" } },
|
||||
})
|
||||
expect(generation.status).toBe("queued")
|
||||
expect(generation.token).toEqual({ taskID: "task_1" })
|
||||
const response = yield* generation.await({ poll: { interval: "1 second" } })
|
||||
yield* generation.cancel()
|
||||
return response
|
||||
})
|
||||
const response = yield* settle(program, 3).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call, nth } = yield* observe(calls, input)
|
||||
expect(call.headers.get("authorization")).toBe("Bearer test")
|
||||
expect(call.headers.get("x-runway-version")).toBe("2024-11-06")
|
||||
if (call.method === "POST") {
|
||||
expect(call.url).toBe("https://runway.test/v1/image_to_video")
|
||||
expect(JSON.parse(call.body)).toEqual({
|
||||
model: "gen4.5",
|
||||
promptText: "The kite lifts off",
|
||||
promptImage: [
|
||||
{ uri: "https://example.test/first.png", position: "first" },
|
||||
{ uri: "runway://upload-token", position: "last" },
|
||||
],
|
||||
ratio: "1280:720",
|
||||
duration: 5,
|
||||
seed: 3,
|
||||
audio: true,
|
||||
contentModeration: { publicFigureThreshold: "low" },
|
||||
})
|
||||
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 (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 })
|
||||
return json(input, {
|
||||
id: "task_1",
|
||||
status: "SUCCEEDED",
|
||||
output: ["https://dnznrvs05pmza.cloudfront.net/out.mp4"],
|
||||
cost: { credits: 20 },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.video.source).toEqual({
|
||||
type: "url",
|
||||
url: "https://dnznrvs05pmza.cloudfront.net/out.mp4",
|
||||
mediaType: "video/mp4",
|
||||
expiresAt: 3000 + DAY,
|
||||
})
|
||||
expect(response.usage).toEqual({ type: "credits", credits: 20 })
|
||||
expect(response.providerMetadata).toEqual({ runway: { taskId: "task_1", estimatedCredits: undefined } })
|
||||
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
|
||||
"POST https://runway.test/v1/image_to_video",
|
||||
`GET ${taskUrl}`,
|
||||
`GET ${taskUrl}`,
|
||||
`GET ${taskUrl}`,
|
||||
`GET ${taskUrl}`,
|
||||
`GET ${taskUrl}`,
|
||||
`DELETE ${taskUrl}`,
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("selects text_to_video with references and video_to_video for a source video", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Video.start({
|
||||
model,
|
||||
prompt: "A robot",
|
||||
references: [Media.bytes(Uint8Array.from([1, 2, 3]), "image/png")],
|
||||
negativePrompt: "blur",
|
||||
resolution: "720p",
|
||||
})
|
||||
yield* Video.start({ model, prompt: "Restyle", video: Media.url("https://example.test/in.mp4") })
|
||||
}).pipe(
|
||||
Effect.provide(layer((input) => observe(calls, input).pipe(Effect.map(() => json(input, { id: "task_2" }))))),
|
||||
)
|
||||
expect(calls.map((call) => call.url)).toEqual([
|
||||
"https://runway.test/v1/text_to_video",
|
||||
"https://runway.test/v1/video_to_video",
|
||||
])
|
||||
expect(JSON.parse(calls[0].body)).toEqual({
|
||||
model: "gen4.5",
|
||||
promptText: "A robot",
|
||||
references: [{ uri: "data:image/png;base64,AQID" }],
|
||||
resolution: "720p",
|
||||
negativePrompt: "blur",
|
||||
})
|
||||
expect(JSON.parse(calls[1].body)).toEqual({
|
||||
model: "gen4.5",
|
||||
promptText: "Restyle",
|
||||
videoUri: "https://example.test/in.mp4",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
for (const terminal of [
|
||||
{
|
||||
body: { status: "FAILED", failure: "Input image flagged", failureCode: "SAFETY.INPUT.IMAGE" },
|
||||
tag: "ContentPolicy",
|
||||
message: "Runway task failed (SAFETY.INPUT.IMAGE): Input image flagged",
|
||||
},
|
||||
{
|
||||
body: { status: "FAILED", failure: "Something broke", failureCode: "INTERNAL.BAD_OUTPUT.CODE01" },
|
||||
tag: "ProviderInternal",
|
||||
message: "Runway task failed (INTERNAL.BAD_OUTPUT.CODE01): Something broke",
|
||||
},
|
||||
{ body: { status: "CANCELLED" }, tag: "InvalidRequest", message: "Runway task task_1 was cancelled" },
|
||||
]) {
|
||||
it.effect(`surfaces ${terminal.body.failureCode ?? terminal.body.status} with the task body`, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Video.generate({ model, prompt: "x" }).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe(terminal.tag)
|
||||
expect(error.message).toBe(terminal.message)
|
||||
expect(error.reason.body).toBe(JSON.stringify(terminal.body))
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(
|
||||
input.request.method === "POST" ? json(input, { id: "task_1" }) : json(input, terminal.body),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("rejects n before sending", () =>
|
||||
Video.generate({ model, prompt: "x", n: 2 }).pipe(
|
||||
Effect.flip,
|
||||
Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("UnsupportedOperation"))),
|
||||
Effect.provide(layer(() => Effect.die("unsupported input reached the network"))),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("resumes from a JSON round-tripped token and rejects foreign tokens", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const program = Effect.gen(function* () {
|
||||
const started = yield* Video.start({ model, prompt: "x" })
|
||||
const token: unknown = JSON.parse(JSON.stringify(started.token))
|
||||
const resumed = yield* Video.resume(model, token)
|
||||
expect(resumed.status).toBe("running")
|
||||
expect(resumed.progress).toBe(0.25)
|
||||
expect(resumed.token).toEqual({ taskID: "task_1" })
|
||||
const response = yield* resumed.await({ poll: { interval: "1 second" } })
|
||||
expect(response.videos).toHaveLength(1)
|
||||
const foreign = yield* Video.resume(model, { operation: "models/x/operations/y" }).pipe(Effect.flip)
|
||||
expect(foreign.reason._tag).toBe("InvalidRequest")
|
||||
expect(foreign.message).toContain("cannot resume")
|
||||
})
|
||||
yield* settle(program, 2).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 <= 2) return json(input, { status: "RUNNING", progress: 0.25 })
|
||||
return json(input, { status: "SUCCEEDED", output: ["https://runway.test/out.mp4"] })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(calls.filter((call) => call.method === "GET")).toHaveLength(4)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams queue and progress observations followed by the video and finish events", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const program = Video.stream({ model, prompt: "x" }, { poll: { interval: "1 second" } }).pipe(Stream.runCollect)
|
||||
const events = Array.from(
|
||||
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, {
|
||||
status: "SUCCEEDED",
|
||||
output: ["https://runway.test/out.mp4"],
|
||||
cost: { credits: 5 },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(events.map((event) => event.type)).toEqual(["generation-queued", "generation-progress", "video", "finish"])
|
||||
expect(events[1]).toEqual({ type: "generation-progress", id: "task_1", progress: 0.5 })
|
||||
expect(events[3]).toMatchObject({ type: "finish", usage: { type: "credits", credits: 5 } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails a stream with a Timeout reason once polling passes the poll deadline", () =>
|
||||
Effect.gen(function* () {
|
||||
const program = Video.stream(
|
||||
{ model, prompt: "x" },
|
||||
{ poll: { interval: "1 second", timeout: "2 seconds" } },
|
||||
).pipe(Stream.runCollect, Effect.flip)
|
||||
const error = yield* settle(program, 3).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(
|
||||
input.request.method === "POST" ? json(input, { id: "task_1" }) : json(input, { status: "RUNNING" }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(error.reason._tag).toBe("Timeout")
|
||||
expect(error.message).toContain("task_1")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
Generation,
|
||||
Media,
|
||||
Video,
|
||||
VideoClient,
|
||||
VideoModel,
|
||||
VideoResponse,
|
||||
type VideoModelOptions,
|
||||
type VideoOptions,
|
||||
type VideoRequestFor,
|
||||
type VideoRoute,
|
||||
} from "../src/index.js"
|
||||
import type { Service } from "../src/video-client.js"
|
||||
import { Anthropic, Fal, Google, OpenAI, Runway, XAI } from "../src/providers.js"
|
||||
|
||||
type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
|
||||
type Success<T> = T extends Effect.Effect<infer A, infer _E, infer _R> ? A : never
|
||||
type Equal<A, B> = [A, B] extends [B, A] ? true : false
|
||||
type Assert<T extends true> = T
|
||||
|
||||
type VeoLikeOptions = {
|
||||
readonly personGeneration?: "allow_all" | "allow_adult"
|
||||
} & Record<string, unknown>
|
||||
|
||||
declare const route: VideoRoute<VeoLikeOptions>
|
||||
const veo = VideoModel.make<VeoLikeOptions>({ id: "veo", provider: "google", route })
|
||||
// @ts-expect-error Extracted model options retain known provider fields.
|
||||
const invalidVeoOptions: VideoModelOptions<typeof veo> = { personGeneration: "everyone" }
|
||||
void invalidVeoOptions
|
||||
|
||||
Video.generate(
|
||||
{
|
||||
model: veo,
|
||||
prompt: "A kitten",
|
||||
frames: {
|
||||
first: Media.bytes(Uint8Array.from([1]), "image/png"),
|
||||
last: Media.fromDataUrl("data:image/png;base64,AQID"),
|
||||
},
|
||||
references: [Media.bytes(Uint8Array.from([1]), "image/png")],
|
||||
durationSeconds: 8,
|
||||
aspectRatio: "16:9",
|
||||
resolution: "1080p",
|
||||
audio: true,
|
||||
seed: 7,
|
||||
negativePrompt: "text",
|
||||
providerOptions: { personGeneration: "allow_adult", futureOption: true },
|
||||
},
|
||||
{ poll: { interval: "10 seconds", timeout: "10 minutes" } },
|
||||
)
|
||||
|
||||
const google = Google.configure({ apiKey: "test" }).video("veo-3.1-generate-preview")
|
||||
Video.start({ model: google, prompt: "A kitten", providerOptions: { personGeneration: "future-value" } })
|
||||
// @ts-expect-error Known Google string options retain their value kind.
|
||||
Video.start({ model: google, prompt: "A kitten", providerOptions: { personGeneration: 1 } })
|
||||
Video.generate({ model: google, prompt: "A kitten", resolution: "future-resolution" })
|
||||
|
||||
const xai = XAI.configure({ apiKey: "test" }).video("grok-imagine-video-1.5")
|
||||
Video.start({
|
||||
model: xai,
|
||||
prompt: "Waves",
|
||||
video: Media.url("https://example.com/in.mp4"),
|
||||
providerOptions: { mode: "extend", reference_audios: [{ voice_id: "eve" }], future_option: true },
|
||||
})
|
||||
// @ts-expect-error xAI modes are a closed set because the route selects an endpoint from them.
|
||||
Video.start({ model: xai, prompt: "Waves", providerOptions: { mode: "remix" } })
|
||||
|
||||
const fal = Fal.configure({ apiKey: "test" }).video("fal-ai/veo3.1")
|
||||
Video.start({ model: fal, prompt: "Interview", providerOptions: { duration: "8s", safety_tolerance: "4" } })
|
||||
Video.start({ model: fal, prompt: "Interview", providerOptions: { duration: "future-duration" } })
|
||||
// @ts-expect-error Known fal string options retain their value kind.
|
||||
Video.start({ model: fal, prompt: "Interview", providerOptions: { duration: 8 } })
|
||||
|
||||
const runway = Runway.configure({ apiKey: "test" }).video("gen4.5")
|
||||
Video.start({
|
||||
model: runway,
|
||||
prompt: "Kite",
|
||||
aspectRatio: "1280:720",
|
||||
providerOptions: { contentModeration: { publicFigureThreshold: "low" }, outputFormat: "future-format" },
|
||||
})
|
||||
// @ts-expect-error Known Runway nested options retain their value kind.
|
||||
Video.start({ model: runway, prompt: "Kite", providerOptions: { contentModeration: { publicFigureThreshold: 1 } } })
|
||||
|
||||
// @ts-expect-error Known Veo-like options are inferred from the selected model.
|
||||
Video.generate({ model: veo, prompt: "A kitten", providerOptions: { personGeneration: "everyone" } })
|
||||
|
||||
// @ts-expect-error Language models cannot be used for video requests.
|
||||
Video.generate({ model: Anthropic.configure({ apiKey: "test" }).model("claude-sonnet-4-5"), prompt: "A kitten" })
|
||||
// @ts-expect-error Image models cannot be used for video requests.
|
||||
Video.generate({ model: OpenAI.configure({ apiKey: "test" }).image("gpt-image-2"), prompt: "A kitten" })
|
||||
|
||||
// @ts-expect-error Frames are media assets, not strings.
|
||||
Video.generate({ model: google, prompt: "A kitten", frames: { first: "https://example.com/first.png" } })
|
||||
// @ts-expect-error Aspect ratios are `${w}:${h}` strings.
|
||||
Video.generate({ model: google, prompt: "A kitten", aspectRatio: "wide" })
|
||||
// @ts-expect-error Durations are numbers of seconds.
|
||||
Video.generate({ model: google, prompt: "A kitten", durationSeconds: "8s" })
|
||||
// @ts-expect-error Provider-native controls live under `providerOptions`.
|
||||
Video.generate({ model: google, prompt: "A kitten", options: { personGeneration: "allow_all" } })
|
||||
|
||||
declare const generic: VideoModel<VideoOptions>
|
||||
Video.generate({ model: generic, prompt: "A kitten", providerOptions: { arbitrary: true } })
|
||||
|
||||
const request = Video.request({ model: veo, prompt: "A kitten", providerOptions: { personGeneration: "allow_all" } })
|
||||
const typedRequest: VideoRequestFor<VeoLikeOptions> = request
|
||||
void typedRequest
|
||||
const started = VideoClient.start(request)
|
||||
type StartRequirements = Assert<Equal<Requirements<typeof started>, Service>>
|
||||
type StartSuccess = Assert<Equal<Success<typeof started>, Generation<VideoResponse>>>
|
||||
void (true satisfies StartRequirements)
|
||||
void (true satisfies StartSuccess)
|
||||
|
||||
const resumed = Video.resume(google, { operation: "models/veo/operations/op" })
|
||||
type ResumeSuccess = Assert<Equal<Success<typeof resumed>, Generation<VideoResponse>>>
|
||||
void (true satisfies ResumeSuccess)
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "C:/Projects/providers-console-signin"
|
||||
|
||||
const project = {
|
||||
id: "proj_providers_console",
|
||||
canonical: directory,
|
||||
name: "Providers console",
|
||||
vcs: "git",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
}
|
||||
|
||||
// Zen and the Console account share the id `opencode`: the provider comes from models.dev, the
|
||||
// integration is renamed by OpencodePlugin and carries the account sign-in.
|
||||
const zen = (paid: boolean) => ({
|
||||
id: "opencode",
|
||||
name: "OpenCode Zen",
|
||||
models: {
|
||||
"claude-sonnet-4-6": {
|
||||
id: "claude-sonnet-4-6",
|
||||
name: "Claude Sonnet 4.6",
|
||||
cost: { input: paid ? 3 : 0, output: 0 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const integrations = (connections: unknown[]) => [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode Console",
|
||||
methods: [
|
||||
{ id: "device", type: "oauth", label: "OpenCode Console account" },
|
||||
{ type: "key", label: "API key (service account)" },
|
||||
],
|
||||
connections,
|
||||
},
|
||||
{ id: "opencode-go", name: "OpenCode Go", methods: [{ type: "key" }], connections: [] },
|
||||
{ id: "anthropic", name: "Anthropic", methods: [{ type: "key" }], connections: [] },
|
||||
]
|
||||
|
||||
test.use({ viewport: { width: 1280, height: 900 } })
|
||||
|
||||
async function openProviders(page: Page, input: { paid: boolean; connections: unknown[] }) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project,
|
||||
provider: { all: [zen(input.paid)], connected: ["opencode"], default: {} },
|
||||
integrations: integrations(input.connections),
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
}, directory)
|
||||
await page.goto("/")
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Popular providers" })).toBeVisible()
|
||||
return settings
|
||||
}
|
||||
|
||||
test("fresh install offers the Console sign-in", async ({ page }) => {
|
||||
const settings = await openProviders(page, { paid: false, connections: [] })
|
||||
await expect(settings.getByText("No connected providers")).toBeVisible()
|
||||
await expect(settings.getByText("OpenCode Console", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("a stored Zen API key keeps the Console sign-in available", async ({ page }) => {
|
||||
const settings = await openProviders(page, {
|
||||
paid: true,
|
||||
connections: [{ type: "credential", id: "cred_v1", label: "API key", method: "key" }],
|
||||
})
|
||||
const connected = settings.locator('[data-component="connected-providers-section"]')
|
||||
await expect(connected.getByText("OpenCode Zen", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("OpenCode Console", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("a Console account hides the sign-in row", async ({ page }) => {
|
||||
const settings = await openProviders(page, {
|
||||
paid: true,
|
||||
connections: [{ type: "credential", id: "cred_account", label: "Clara Team", method: "oauth" }],
|
||||
})
|
||||
const connected = settings.locator('[data-component="connected-providers-section"]')
|
||||
await expect(connected.getByText("OpenCode Zen", { exact: true })).toBeVisible()
|
||||
// Anthropic only exists in the integration fixture, so its row proves the integration list has loaded.
|
||||
await expect(settings.getByText("Anthropic", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("OpenCode Console", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
@@ -9,6 +9,7 @@ export interface MockServerConfig {
|
||||
server?: string
|
||||
provider: unknown | (() => unknown)
|
||||
integrationMethods?: Record<string, unknown[]>
|
||||
integrations?: unknown[]
|
||||
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
|
||||
shells?: unknown[]
|
||||
configEntries?: unknown[]
|
||||
@@ -258,11 +259,13 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
model: () => Effect.succeed({ location: location(config), data: currentModels(providerConfig(config)) }),
|
||||
modelDefault: () =>
|
||||
Effect.succeed({ location: location(config), data: currentDefaultModel(providerConfig(config)) }),
|
||||
integrationList: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
integrationList: () => Effect.succeed({ location: location(config), data: config.integrations ?? [] }),
|
||||
integrationGet: (ctx) =>
|
||||
Effect.succeed({
|
||||
location: location(config),
|
||||
data: {
|
||||
data: config.integrations
|
||||
?.filter(record)
|
||||
.find((integration) => integration.id === ctx.params.integrationID) ?? {
|
||||
id: ctx.params.integrationID,
|
||||
name: ctx.params.integrationID,
|
||||
methods: config.integrationMethods?.[ctx.params.integrationID] ?? [{ type: "key", label: "API key" }],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -201,12 +201,15 @@ export function createComposerAttachments(
|
||||
cancel(id: string) {
|
||||
uploads.items().find((item) => item.id === id)?.cancel()
|
||||
},
|
||||
pick(fallback: () => void) {
|
||||
pick(fallback: () => void, done: () => void) {
|
||||
if (!input.picker) {
|
||||
fallback()
|
||||
return
|
||||
}
|
||||
void input.picker({ defaultPath: input.directory(), multiple: true }, (file) => add(file)).catch(input.onError)
|
||||
void input
|
||||
.picker({ defaultPath: input.directory(), multiple: true }, (file) => add(file))
|
||||
.then(done)
|
||||
.catch(input.onError)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
createSignal,
|
||||
For,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
Suspense,
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
@@ -551,12 +562,15 @@ export function ComposerAttachments(props: {
|
||||
const [url] = createResource(() => image().blob, resolveBlobUrl)
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={url() ?? ""}
|
||||
alt={attachment.filename}
|
||||
class="w-[58px] h-[46px] rounded-[6px] object-cover"
|
||||
onClick={() => props.onAttachmentClick?.(attachment)}
|
||||
/>
|
||||
{/* Keep loading local; the route boundary would detach the screen and drop editor focus. */}
|
||||
<Suspense fallback={<div class="w-[58px] h-[46px]" />}>
|
||||
<img
|
||||
src={url() ?? ""}
|
||||
alt={attachment.filename}
|
||||
class="w-[58px] h-[46px] rounded-[6px] object-cover"
|
||||
onClick={() => props.onAttachmentClick?.(attachment)}
|
||||
/>
|
||||
</Suspense>
|
||||
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -102,7 +102,11 @@ export function createComposerEditor(input: {
|
||||
input.view.add?.onAttach()
|
||||
return
|
||||
}
|
||||
attachments.pick(() => fileInput?.click())
|
||||
// The add menu leaves focus on its trigger, so return it to the editor once files are picked.
|
||||
attachments.pick(
|
||||
() => fileInput?.click(),
|
||||
() => restoreFocus(),
|
||||
)
|
||||
}
|
||||
const contextList = useFilteredList<ComposerSuggestion>({
|
||||
items: async (query) => {
|
||||
@@ -449,7 +453,9 @@ export function createComposerEditor(input: {
|
||||
fileInput = element
|
||||
},
|
||||
addAttachments(files: File[]) {
|
||||
if (attachments) void attachments.addAttachments(files)
|
||||
if (!attachments) return
|
||||
void attachments.addAttachments(files)
|
||||
restoreFocus()
|
||||
},
|
||||
setQuery(value: string) {
|
||||
dispatch({ type: "popover.query", value })
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("normalizeProviderList", () => {
|
||||
|
||||
describe("normalizeProjectInfo", () => {
|
||||
test("keeps the project VCS backend", () => {
|
||||
const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }
|
||||
const project = { id: "prj", canonical: "/repo", time: { created: 1, updated: 1, active: 1 }, sandboxes: [] }
|
||||
expect(normalizeProjectInfo({ ...project, vcs: "git" } as Project).vcs).toBe("git")
|
||||
expect(normalizeProjectInfo({ ...project, vcs: "hg" } as Project).vcs).toBe("hg")
|
||||
expect(normalizeProjectInfo(project as Project).vcs).toBeUndefined()
|
||||
@@ -124,7 +124,7 @@ describe("updateProjectInfo", () => {
|
||||
canonical: "/repo",
|
||||
name: "Repo",
|
||||
icon: { color: "purple" },
|
||||
time: { created: 1, updated: 2 },
|
||||
time: { created: 1, updated: 2, active: 2 },
|
||||
sandboxes: ["/repo-sandbox"],
|
||||
} satisfies Project
|
||||
|
||||
|
||||
@@ -99,13 +99,13 @@ describe("readLocalImage", () => {
|
||||
test.each([400, 401])("propagates declared API errors (%s)", async (status) => {
|
||||
const error = { _tag: "RequestError", message: "Cannot read image" }
|
||||
const { api } = setup(() => Response.json(error, { status }))
|
||||
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toEqual(error)
|
||||
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject(error)
|
||||
})
|
||||
|
||||
test("propagates missing-file errors", async () => {
|
||||
const error = { _tag: "FileNotFoundError", path: "image.png", message: "File not found: image.png" }
|
||||
const { api } = setup(() => Response.json(error, { status: 404 }))
|
||||
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toEqual(error)
|
||||
await expect(readLocalImage(api, "/repo", "image.png", new AbortController().signal)).rejects.toMatchObject(error)
|
||||
})
|
||||
|
||||
test("does not turn an unexpected HTTP status into a Blob", async () => {
|
||||
|
||||
@@ -83,21 +83,21 @@ function createGlobalModels() {
|
||||
recent: [],
|
||||
variant: {},
|
||||
})
|
||||
const [recent] = createResource(
|
||||
async () => {
|
||||
const value = store.recent
|
||||
await ready.promise
|
||||
return value
|
||||
},
|
||||
(value) => value,
|
||||
{ initialValue: [] },
|
||||
)
|
||||
// Suspend readers only until persisted state loads. Refetching on every change would put the
|
||||
// session route into its Suspense fallback, detaching the screen and resetting the timeline scroll.
|
||||
const [loaded] = createResource(async () => {
|
||||
await ready.promise
|
||||
return true
|
||||
})
|
||||
|
||||
return {
|
||||
store,
|
||||
set: setStore,
|
||||
ready,
|
||||
recent: () => recent()!,
|
||||
recent: () => {
|
||||
loaded()
|
||||
return store.recent
|
||||
},
|
||||
// Marks models visible in the picker regardless of the "latest per family" default.
|
||||
show(models: ReadonlyArray<{ providerID: string; modelID: string }>) {
|
||||
const seen = new Map(store.user.map((item, index) => [`${item.providerID}:${item.modelID}`, index]))
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useIntegrations } from "@/providers/catalog/integrations"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { CONSOLE_INTEGRATION } from "@/providers/connect/controller"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import "@/settings/settings.css"
|
||||
@@ -62,9 +63,17 @@ export const SettingsProviders: Component<{
|
||||
|
||||
const popular = createMemo(() => {
|
||||
const connectedIDs = new Set(connected().map((p) => p.id))
|
||||
// The Console account (integration `opencode`) shares its id with the Zen provider. A stored API
|
||||
// key, including one imported from a v1 auth.json, makes Zen "connected" without any account, so
|
||||
// the Popular list keeps the sign-in row until the active credential is an OAuth grant. Until the
|
||||
// integration list arrives the row is still the models.dev Zen provider, so dedupe it as before.
|
||||
const console = integrations.list().find((entry) => entry.id === CONSOLE_INTEGRATION)
|
||||
const items = providers
|
||||
.popular()
|
||||
.filter((p) => !connectedIDs.has(p.id))
|
||||
.filter((p) => {
|
||||
if (p.id !== CONSOLE_INTEGRATION || !console) return !connectedIDs.has(p.id)
|
||||
return console.connections.find((connection) => connection.type === "credential")?.method !== "oauth"
|
||||
})
|
||||
.slice()
|
||||
items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id))
|
||||
return items
|
||||
|
||||
@@ -24,7 +24,7 @@ test("settings project inventory reads metadata without acquiring directory stor
|
||||
name: `Project ${index}`,
|
||||
icon: { color: "orange" },
|
||||
commands: { start: "bun install" },
|
||||
time: { created: 1, updated: 1 },
|
||||
time: { created: 1, updated: 1, active: 1 },
|
||||
sandboxes: [],
|
||||
worktrees: [],
|
||||
}))
|
||||
|
||||
@@ -107,7 +107,7 @@ describe("withWorktreeInventory", () => {
|
||||
id: "project",
|
||||
canonical: "/repo",
|
||||
name: "Before",
|
||||
time: { created: 1, updated: 1 },
|
||||
time: { created: 1, updated: 1, active: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
OpenCodeClient,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionStructuredError,
|
||||
} from "@opencode/client/promise"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { partsToContentChunks, type ReplayPart } from "./content"
|
||||
@@ -40,6 +41,13 @@ export type TurnStart =
|
||||
|
||||
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
|
||||
export const ChildSessionUpdateMethod = "opencode/session/child_update"
|
||||
const RetryMeta = "opencode/retry"
|
||||
|
||||
type RetryStatus = {
|
||||
readonly attempt: number
|
||||
readonly nextRetryAt: string
|
||||
readonly error: SessionStructuredError
|
||||
}
|
||||
|
||||
type ChildSessionUpdateBase = {
|
||||
readonly rootSessionId: string
|
||||
@@ -97,6 +105,7 @@ export async function streamTurn(input: {
|
||||
let finish: SessionMessageAssistant["finish"]
|
||||
let executionError: { readonly type: string; readonly message: string } | undefined
|
||||
const tools = new Map<string, ToolState>()
|
||||
const retries = new Map<string, RetryStatus>()
|
||||
const children = new Map<string, ChildSession>()
|
||||
const openChildren = new Set<string>()
|
||||
let handedOff = false
|
||||
@@ -188,6 +197,18 @@ export async function streamTurn(input: {
|
||||
|
||||
if (event.type === "session.step.started") {
|
||||
if (!child) assistantMessageID = event.data.assistantMessageID
|
||||
if (retries.delete(eventSessionID))
|
||||
await send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: null } })
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.retry.scheduled") {
|
||||
const retry = {
|
||||
attempt: event.data.attempt,
|
||||
nextRetryAt: new Date(event.data.at).toISOString(),
|
||||
error: event.data.error,
|
||||
}
|
||||
retries.set(eventSessionID, retry)
|
||||
await send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: retry } })
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.text.delta") {
|
||||
@@ -381,6 +402,7 @@ export async function streamTurn(input: {
|
||||
terminal,
|
||||
control.cancelled,
|
||||
finish,
|
||||
retries.get(input.sessionID),
|
||||
)
|
||||
} catch (error) {
|
||||
streamController.abort()
|
||||
@@ -560,6 +582,7 @@ function response(
|
||||
terminal: "succeeded" | "failed" | "interrupted",
|
||||
cancelled: boolean,
|
||||
finish: SessionMessageAssistant["finish"],
|
||||
retry?: RetryStatus,
|
||||
): PromptResponse {
|
||||
const error = assistant?.error ?? executionError
|
||||
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
|
||||
@@ -582,7 +605,8 @@ function response(
|
||||
}
|
||||
: undefined
|
||||
const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type })
|
||||
return { stopReason, ...(usage ? { usage } : {}), _meta: {} }
|
||||
// Only an interrupt during backoff leaves a retry pending. Interruption clears the projected retry, so report it here.
|
||||
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
|
||||
}
|
||||
|
||||
function resolveStopReason(input: {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { confirm, intro, log, outro, spinner } from "@clack/prompts"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { Effect, FileSystem, Schedule } from "effect"
|
||||
import path from "node:path"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { RetainedImage } from "../../services/retained-image"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { handlePromptErrors, prompt, requireInteractive } from "../../ui/prompt"
|
||||
import { errorMessage } from "../../util/error"
|
||||
@@ -79,12 +80,20 @@ export default Runtime.handler(
|
||||
)
|
||||
}
|
||||
|
||||
// Links that keep an older OpenCode replaceable may still run; move them so the cache can go.
|
||||
if (process.platform === "win32") yield* RetainedImage.relocate(global.cache, global.tmp)
|
||||
const errors: string[] = []
|
||||
yield* Effect.forEach(directories, (directory) =>
|
||||
Effect.gen(function* () {
|
||||
if (directory.keep) return
|
||||
progress.start(`Removing ${directory.label}...`)
|
||||
yield* fs.remove(directory.path, { recursive: true, force: true }).pipe(
|
||||
// Windows reports a terminated service as gone before it releases its database
|
||||
// and log handles, so the first removal can race that teardown.
|
||||
Effect.retry({
|
||||
while: (error) => process.platform === "win32" && error.reason._tag === "Busy",
|
||||
schedule: Schedule.max([Schedule.spaced("250 millis"), Schedule.recurs(40)]),
|
||||
}),
|
||||
Effect.tap(() => Effect.sync(() => progress.stop(`Removed ${directory.label}`))),
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Endpoint } from "@opencode/client/effect/service"
|
||||
import { Effect } from "effect"
|
||||
import path from "node:path"
|
||||
import { Standalone } from "../services/standalone"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { reportRunError, runNonInteractiveWithOptions, type RunCommandInput } from "./run"
|
||||
|
||||
export type V1RunCommandInput = {
|
||||
@@ -53,7 +54,7 @@ export function runV1Bridge(input: V1RunCommandInput) {
|
||||
)
|
||||
}),
|
||||
),
|
||||
).catch((error) => reportRunError(input, error instanceof Error ? error.message : String(error)))
|
||||
).catch((error) => reportRunError(input, errorMessage(error)))
|
||||
}
|
||||
|
||||
function nativeInput(input: V1RunCommandInput, endpoint: Endpoint): RunCommandInput {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PersistentPty } from "@opencode/schema/persistent-pty"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { RetainedImage } from "./services/retained-image"
|
||||
import { ServiceRegistration } from "./services/service-registration"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
import { databasePath } from "./database-path"
|
||||
@@ -64,6 +65,9 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
|
||||
: undefined
|
||||
if (incumbent !== undefined) return
|
||||
// Keep a package-manager or curl install replaceable while the service runs; Desktop updates its own copy.
|
||||
if (options.mode === "service" && process.platform === "win32" && RetainedImage.installed(global.home))
|
||||
yield* RetainedImage.retain(global.cache, "service")
|
||||
const { start } = yield* Effect.promise(() => import("@opencode/server/process"))
|
||||
const environmentPassword = yield* Env.password
|
||||
// Keep the lease credential out of the environment inherited by tools.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
export * as RetainedImage from "./retained-image"
|
||||
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { randomBytes } from "node:crypto"
|
||||
import path from "node:path"
|
||||
|
||||
// Windows refuses to delete the last hard link of a running executable, and bun 1.4 spins
|
||||
// forever when its package replacement meets one. Any other link to the image can be removed,
|
||||
// so a process that must survive an installer keeps a second link to its own image while it
|
||||
// runs. Windows reuses process IDs quickly, so names carry a random suffix and only links of
|
||||
// processes that no longer exist are swept.
|
||||
const pattern = /^opencode-(?:service|upgrade)-(\d+)-[0-9a-f]+\.exe$/
|
||||
|
||||
/** Hold a second link to the running image in `directory` for the lifetime of the scope. */
|
||||
export const retain = Effect.fnUntraced(function* (directory: string, role: "service" | "upgrade") {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
return yield* Effect.acquireRelease(
|
||||
Effect.gen(function* () {
|
||||
yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.ignore)
|
||||
const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed((): string[] => []))
|
||||
yield* Effect.forEach(names, (name) =>
|
||||
Effect.gen(function* () {
|
||||
const pid = Number(pattern.exec(name)?.[1])
|
||||
if (!pid || (yield* alive(pid))) return
|
||||
yield* fs.remove(path.join(directory, name)).pipe(Effect.ignore)
|
||||
}),
|
||||
)
|
||||
const target = path.join(directory, `opencode-${role}-${process.pid}-${randomBytes(4).toString("hex")}.exe`)
|
||||
const executable = yield* fs.realPath(process.execPath).pipe(Effect.orElseSucceed(() => process.execPath))
|
||||
return yield* fs.link(executable, target).pipe(
|
||||
Effect.as(target),
|
||||
Effect.tapError((cause) => Effect.logWarning("could not link the running binary", { cause })),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
}),
|
||||
// The last link of a still-running image refuses deletion; a later sweep removes it.
|
||||
(target) => (target === undefined ? Effect.void : fs.remove(target).pipe(Effect.ignore)),
|
||||
)
|
||||
})
|
||||
|
||||
/** Move retained links out of `directory` so it can be removed; renaming a running image is allowed. */
|
||||
export const relocate = Effect.fnUntraced(function* (directory: string, destination: string) {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const names = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed((): string[] => []))
|
||||
const links = names.filter((name) => pattern.test(name))
|
||||
if (links.length === 0) return
|
||||
yield* fs.makeDirectory(destination, { recursive: true }).pipe(Effect.ignore)
|
||||
yield* Effect.forEach(links, (name) =>
|
||||
fs.rename(path.join(directory, name), path.join(destination, name)).pipe(Effect.ignore),
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Whether the running binary was installed by a package manager or the curl installer, the installs
|
||||
* bun can hang on. The updater decides the same question from the package manifest it already reads.
|
||||
*/
|
||||
export function installed(home: string) {
|
||||
const runtime = path.basename(process.execPath, path.extname(process.execPath)).toLowerCase()
|
||||
if (runtime === "bun" || runtime === "node" || runtime === "nodejs") return false
|
||||
const executable = path.resolve(process.execPath)
|
||||
return (
|
||||
executable.split(path.sep).includes("node_modules") ||
|
||||
executable === path.resolve(home, ".opencode", "bin", "opencode.exe")
|
||||
)
|
||||
}
|
||||
|
||||
// Only ESRCH means the process is gone; EPERM is a live process this user cannot open.
|
||||
const alive = (pid: number) =>
|
||||
Effect.try({ try: () => process.kill(pid, 0), catch: (cause) => cause }).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catch((cause) => Effect.succeed(!(cause instanceof Error && "code" in cause && cause.code === "ESRCH"))),
|
||||
)
|
||||
@@ -6,6 +6,7 @@ import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import { stripVTControlCharacters } from "node:util"
|
||||
import { RetainedImage } from "./retained-image"
|
||||
import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
@@ -166,14 +167,15 @@ const make = Effect.gen(function* () {
|
||||
)
|
||||
})
|
||||
|
||||
const curlBinary = path.resolve(
|
||||
global.home,
|
||||
".opencode",
|
||||
"bin",
|
||||
process.platform === "win32" ? "opencode.exe" : "opencode",
|
||||
)
|
||||
|
||||
const method = Effect.fnUntraced(function* () {
|
||||
const binary = path.join(
|
||||
global.home,
|
||||
".opencode",
|
||||
"bin",
|
||||
process.platform === "win32" ? "opencode.exe" : "opencode",
|
||||
)
|
||||
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
|
||||
if (path.resolve(process.execPath) === curlBinary) return "curl"
|
||||
const executable = yield* fs.realPath(process.execPath).pipe(Effect.orElseSucceed(() => process.execPath))
|
||||
if (
|
||||
["opencode-beta", "opencode-v2"].some((name) =>
|
||||
@@ -220,8 +222,12 @@ const make = Effect.gen(function* () {
|
||||
const command = commands[method]
|
||||
return {
|
||||
command,
|
||||
run: exec(command, "5 minutes").pipe(
|
||||
Effect.flatMap((result) => (result.code === 0 ? Effect.void : Effect.fail(new Error(resultDetail(result))))),
|
||||
run: retaining(
|
||||
method,
|
||||
exec(command, "5 minutes").pipe(
|
||||
Effect.flatMap((result) => (result.code === 0 ? Effect.void : Effect.fail(new Error(resultDetail(result))))),
|
||||
),
|
||||
global.tmp,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -288,6 +294,19 @@ const make = Effect.gen(function* () {
|
||||
fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
// On Windows the installer must delete or replace the running binary, which only works
|
||||
// while another link to it exists (see RetainedImage). Upgrades keep that link in the
|
||||
// cache; uninstall has already removed the cache, so it uses the temporary directory.
|
||||
const retaining = <A, E, R>(method: Method, effect: Effect.Effect<A, E, R>, directory = global.cache) => {
|
||||
if (process.platform !== "win32" || method === "brew") return effect
|
||||
// Only the installed binary is at stake; source checkouts run inside bun or node.
|
||||
const owned = method === "curl" ? path.resolve(process.execPath) === curlBinary : installedPackage !== undefined
|
||||
if (!owned) return effect
|
||||
return Effect.scoped(RetainedImage.retain(directory, "upgrade").pipe(Effect.andThen(effect))).pipe(
|
||||
Effect.provideService(FileSystem.FileSystem, fs),
|
||||
)
|
||||
}
|
||||
|
||||
const runUpgrade = (input: {
|
||||
readonly method: Method
|
||||
readonly command: string[]
|
||||
@@ -355,11 +374,14 @@ const make = Effect.gen(function* () {
|
||||
// Bun does not prune old versions from its shared package cache.
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const cache = yield* temporaryDirectory("update-")
|
||||
return yield* runUpgrade({
|
||||
return yield* retaining(
|
||||
method,
|
||||
command: ["bun", "install", "--global", "--trust", "--cache-dir", cache, target],
|
||||
displayCommand: ["bun", "install", "--global", "--trust", target],
|
||||
})
|
||||
runUpgrade({
|
||||
method,
|
||||
command: ["bun", "install", "--global", "--trust", "--cache-dir", cache, target],
|
||||
displayCommand: ["bun", "install", "--global", "--trust", target],
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (method === "curl") {
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
@@ -372,15 +394,18 @@ const make = Effect.gen(function* () {
|
||||
title: "Could not download the OpenCode installer",
|
||||
retry: "Check your network, then run opencode upgrade again.",
|
||||
})
|
||||
return yield* runUpgrade({
|
||||
return yield* retaining(
|
||||
method,
|
||||
command: ["bash", installer, "--version", version, "--no-modify-path"],
|
||||
displayCommand: ["opencode", "upgrade", version, "--method", "curl"],
|
||||
title: "The OpenCode installer failed",
|
||||
})
|
||||
runUpgrade({
|
||||
method,
|
||||
command: ["bash", installer, "--version", version, "--no-modify-path"],
|
||||
displayCommand: ["opencode", "upgrade", version, "--method", "curl"],
|
||||
title: "The OpenCode installer failed",
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (method === "brew") return yield* runUpgrade({ method, command: ["brew", "upgrade", packageName] })
|
||||
return yield* runUpgrade({ method, command: commands[method] })
|
||||
return yield* retaining(method, runUpgrade({ method, command: commands[method] }))
|
||||
}),
|
||||
).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LocationGetOutput, ModelRef, OpenCodeClient, SessionInfo } from "@opencode/client/promise"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { errorMessage } from "./util/error"
|
||||
|
||||
const SESSION_PAGE_LIMIT = 50
|
||||
|
||||
@@ -24,7 +25,7 @@ export class SessionTargetMutationError extends Error {
|
||||
override readonly name = "SessionTargetMutationError"
|
||||
|
||||
constructor(cause: unknown) {
|
||||
super(cause instanceof Error ? cause.message : "Session target mutation failed", { cause })
|
||||
super(errorMessage(cause), { cause })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,11 +126,9 @@ async function selectSession(input: {
|
||||
if (!selected) return { session: undefined, location }
|
||||
return {
|
||||
session: input.fork
|
||||
? await input.client.session
|
||||
.fork({ sessionID: selected.id }, ...requestOptions(input.signal))
|
||||
.catch((error) => {
|
||||
throw new SessionTargetMutationError(error)
|
||||
})
|
||||
? await input.client.session.fork({ sessionID: selected.id }, ...requestOptions(input.signal)).catch((error) => {
|
||||
throw new SessionTargetMutationError(error)
|
||||
})
|
||||
: selected,
|
||||
}
|
||||
}
|
||||
@@ -156,11 +155,7 @@ async function latestSession(
|
||||
return latestSession(client, location, page.cursor.next, signal)
|
||||
}
|
||||
|
||||
function resolveLocation(
|
||||
client: OpenCodeClient,
|
||||
location?: { directory?: string },
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
function resolveLocation(client: OpenCodeClient, location?: { directory?: string }, signal?: AbortSignal) {
|
||||
if (!location && !signal) return client.location.get()
|
||||
if (!location) return client.location.get(undefined, { signal })
|
||||
return client.location.get({ location }, ...requestOptions(signal))
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PromptResponse, SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess"
|
||||
|
||||
describe("acp retry subprocess", () => {
|
||||
test("reports provider retries during the turn and on cancellation", async () => {
|
||||
await using fixture = await createAcpFixture({
|
||||
respond: () =>
|
||||
Response.json(
|
||||
{ error: { message: "rate-limited upstream", type: "rate_limit_error" } },
|
||||
// A long backoff keeps the retry pending until the cancel lands.
|
||||
{ status: 429, headers: { "retry-after": "30" } },
|
||||
),
|
||||
})
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const prompt = acp.send<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
const scheduled = await acp.waitForNotification<SessionNotification>(
|
||||
"session/update",
|
||||
(params) => params.sessionId === session.sessionId && params.update.sessionUpdate === "session_info_update",
|
||||
)
|
||||
const retry = scheduled.params.update._meta?.["opencode/retry"]
|
||||
expect(retry).toMatchObject({ attempt: 2, error: { message: expect.stringContaining("rate-limited upstream") } })
|
||||
|
||||
await acp.notify("session/cancel", { sessionId: session.sessionId })
|
||||
const response = expectOk(await prompt.response)
|
||||
expect(response.stopReason).toBe("cancelled")
|
||||
expect(response._meta?.["opencode/retry"]).toEqual(retry)
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -70,7 +70,10 @@ description: Verifier compatibility skill.
|
||||
`
|
||||
|
||||
export async function createAcpFixture(
|
||||
options: { readonly skill?: string; readonly respond?: (request: unknown) => string | Promise<string> } = {},
|
||||
options: {
|
||||
readonly skill?: string
|
||||
readonly respond?: (request: unknown) => string | Response | Promise<string | Response>
|
||||
} = {},
|
||||
) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-"))
|
||||
const home = path.join(root, "workspace")
|
||||
@@ -93,7 +96,9 @@ export async function createAcpFixture(
|
||||
}
|
||||
const body: unknown = await request.json().catch(() => undefined)
|
||||
requests.push(body)
|
||||
return new Response(completion(await (options.respond?.(body) ?? "accepted")), {
|
||||
const result = await (options.respond?.(body) ?? "accepted")
|
||||
if (result instanceof Response) return result
|
||||
return new Response(completion(result), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
},
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { realpathSync, statSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { RetainedImage } from "../src/services/retained-image"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
|
||||
const it = testEffect(NodeServices.layer)
|
||||
// Hard links to a running image are a Windows concern, and /tmp is often another filesystem elsewhere.
|
||||
const windows = process.platform === "win32" ? it.live : it.live.skip
|
||||
|
||||
windows("retain hard-links the running image for the scope and sweeps only links of exited processes", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-retained-" })
|
||||
// pid 999999999 does not exist; pid 4 is System, alive but not openable (EPERM).
|
||||
const kept = ["opencode-service-4-aa.exe", `opencode-service-${process.pid}-bb.exe`, "unrelated.exe"]
|
||||
yield* Effect.forEach([...kept, "opencode-upgrade-999999999-cc.exe"], (name) =>
|
||||
fs.writeFileString(path.join(directory, name), name),
|
||||
)
|
||||
const image = statSync(realpathSync(process.execPath))
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const link = yield* RetainedImage.retain(directory, "upgrade")
|
||||
if (!link) return yield* Effect.die("Expected a link")
|
||||
expect(path.basename(link)).toMatch(new RegExp(`^opencode-upgrade-${process.pid}-[0-9a-f]{8}\\.exe$`))
|
||||
expect(statSync(link).ino).toBe(image.ino)
|
||||
expect(statSync(link).nlink).toBe(image.nlink + 1)
|
||||
expect((yield* fs.readDirectory(directory)).sort()).toEqual([...kept, path.basename(link)].sort())
|
||||
}),
|
||||
)
|
||||
// The running test process does not lock the image's other links, so the release removes it.
|
||||
expect((yield* fs.readDirectory(directory)).sort()).toEqual(kept.sort())
|
||||
}),
|
||||
)
|
||||
|
||||
windows("retain tolerates a directory it cannot link into", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-retained-" })
|
||||
const file = path.join(root, "not-a-directory")
|
||||
yield* fs.writeFileString(file, "")
|
||||
expect(yield* Effect.scoped(RetainedImage.retain(file, "service"))).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
windows("relocate moves only retained links out of a directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-retained-" })
|
||||
const cache = path.join(root, "cache")
|
||||
const tmp = path.join(root, "tmp")
|
||||
yield* fs.makeDirectory(cache)
|
||||
yield* fs.writeFileString(path.join(cache, "opencode-upgrade-1-ab.exe"), "link")
|
||||
yield* fs.writeFileString(path.join(cache, "models.json"), "{}")
|
||||
yield* RetainedImage.relocate(cache, tmp)
|
||||
expect(yield* fs.readDirectory(cache)).toEqual(["models.json"])
|
||||
expect(yield* fs.readDirectory(tmp)).toEqual(["opencode-upgrade-1-ab.exe"])
|
||||
}),
|
||||
)
|
||||
|
||||
windows("installed accepts package-manager and curl binaries only", () =>
|
||||
Effect.gen(function* () {
|
||||
const home = path.join("C:", "Users", "someone")
|
||||
const original = process.execPath
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => (process.execPath = original)))
|
||||
const cases = [
|
||||
[path.join(home, ".bun", "install", "global", "node_modules", "@opencode", "cli", "bin", "opencode.exe"), true],
|
||||
[path.join(home, ".opencode", "bin", "opencode.exe"), true],
|
||||
[path.join(home, "AppData", "Local", "Programs", "OpenCode", "resources", "opencode-cli.exe"), false],
|
||||
[path.join(home, "node_modules", "bun", "bin", "bun.exe"), false],
|
||||
] as const
|
||||
const results = cases.map(([executable]) => {
|
||||
process.execPath = executable
|
||||
return RetainedImage.installed(home)
|
||||
})
|
||||
expect(results).toEqual(cases.map(([, expected]) => expected))
|
||||
}),
|
||||
)
|
||||
@@ -4,7 +4,7 @@ import { AppProcess } from "@opencode/util/process"
|
||||
import { expect, spyOn, test } from "bun:test"
|
||||
import { Effect, FileSystem, PlatformError, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { existsSync } from "node:fs"
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { Updater } from "../src/services/updater"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
@@ -25,10 +25,13 @@ function fixture(
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
|
||||
const executable = path.join(root, "package", "bin", "opencode")
|
||||
const execPath = process.execPath
|
||||
const modules = path.join(root, "node_modules")
|
||||
const executable = path.join(modules, "@opencode", "cli", "bin", "opencode")
|
||||
yield* fs.makeDirectory(path.dirname(executable), { recursive: true })
|
||||
yield* fs.writeFileString(executable, "binary")
|
||||
yield* fs.writeFileString(
|
||||
path.join(root, "package", "package.json"),
|
||||
path.join(modules, "@opencode", "cli", "package.json"),
|
||||
JSON.stringify({ name, bin: { opencode: "bin/opencode" } }),
|
||||
)
|
||||
// The updater uses global fetch; scope this replacement to each install test.
|
||||
@@ -70,7 +73,7 @@ function fixture(
|
||||
}),
|
||||
)
|
||||
: fs.remove(target, options),
|
||||
realPath: (input) => (input === process.execPath ? Effect.succeed(executable) : fs.realPath(input)),
|
||||
realPath: (input) => (input === execPath ? Effect.succeed(executable) : fs.realPath(input)),
|
||||
}),
|
||||
Effect.provideService(
|
||||
AppProcess.Service,
|
||||
@@ -96,10 +99,13 @@ function fixture(
|
||||
}),
|
||||
),
|
||||
)
|
||||
return { updater, commands, global, fs }
|
||||
return { updater, commands, global, fs, executable }
|
||||
})
|
||||
}
|
||||
|
||||
const windows = process.platform === "win32" ? it.live : it.live.skip
|
||||
const unix = process.platform === "win32" ? it.live.skip : it.live
|
||||
|
||||
const installs = [
|
||||
{ method: "npm", command: ["npm", "install", "--global", "--force", "@opencode/cli@2.3.4-beta.1"] },
|
||||
{
|
||||
@@ -268,6 +274,120 @@ it.live("vp detection ignores no-match output that repeats the package name", ()
|
||||
}),
|
||||
)
|
||||
|
||||
// Links are named opencode-upgrade-<pid>-<random>.exe; read them from inside the installer run.
|
||||
const links = (directory: string) =>
|
||||
existsSync(directory) ? readdirSync(directory).filter((name) => name.startsWith("opencode-")) : []
|
||||
const upgradeLinks = (directory: string) =>
|
||||
links(directory).filter((name) => name.startsWith(`opencode-upgrade-${process.pid}-`))
|
||||
|
||||
windows("windows keeps a second link to the running binary in the cache while the installer runs", () =>
|
||||
Effect.gen(function* () {
|
||||
const layout = { executable: "", cache: "" }
|
||||
const test = yield* fixture(() => {
|
||||
expect(readFileSync(layout.executable, "utf8")).toBe("binary")
|
||||
const held = upgradeLinks(layout.cache)
|
||||
expect(held).toHaveLength(1)
|
||||
expect(readFileSync(path.join(layout.cache, held[0]), "utf8")).toBe("binary")
|
||||
return {}
|
||||
})
|
||||
layout.executable = test.executable
|
||||
layout.cache = test.global.cache
|
||||
yield* test.fs.makeDirectory(test.global.cache, { recursive: true })
|
||||
// pid 999999999 does not exist; pid 4 is System, alive but not openable (EPERM).
|
||||
yield* test.fs.writeFileString(path.join(test.global.cache, "opencode-upgrade-999999999-dead.exe"), "exited")
|
||||
yield* test.fs.writeFileString(path.join(test.global.cache, "opencode-service-4-aa.exe"), "inaccessible")
|
||||
yield* test.updater.upgrade("bun", "2.3.4")
|
||||
expect(test.commands).toHaveLength(1)
|
||||
// The installed path never disappears; the extra link is released and only dead ones are swept.
|
||||
expect(yield* test.fs.readFileString(test.executable)).toBe("binary")
|
||||
expect(links(test.global.cache)).toEqual(["opencode-service-4-aa.exe"])
|
||||
}),
|
||||
)
|
||||
|
||||
windows("windows releases the link when the installer fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const layout = { cache: "" }
|
||||
const test = yield* fixture(() => {
|
||||
expect(upgradeLinks(layout.cache)).toHaveLength(1)
|
||||
return { exitCode: 1, stderr: Buffer.from("registry denied access") }
|
||||
})
|
||||
layout.cache = test.global.cache
|
||||
const error = yield* test.updater.upgrade("npm", "2.3.4").pipe(Effect.flip)
|
||||
expect(error.message).toBe("registry denied access")
|
||||
expect(yield* test.fs.readFileString(test.executable)).toBe("binary")
|
||||
expect(links(test.global.cache)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
windows("windows keeps the uninstall link in the temporary directory, not the removed cache", () =>
|
||||
Effect.gen(function* () {
|
||||
const layout = { tmp: "" }
|
||||
const test = yield* fixture(() => {
|
||||
expect(upgradeLinks(layout.tmp)).toHaveLength(1)
|
||||
return {}
|
||||
})
|
||||
layout.tmp = test.global.tmp
|
||||
yield* test.fs.makeDirectory(test.global.cache, { recursive: true })
|
||||
const removal = test.updater.removal("bun")
|
||||
if (!removal) return yield* Effect.die("Expected bun removal command")
|
||||
yield* removal.run
|
||||
expect(test.commands).toEqual([["bun", "remove", "--global", "@opencode/cli"]])
|
||||
expect(links(test.global.cache)).toEqual([])
|
||||
expect(links(test.global.tmp)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
windows("windows links the curl binary before the installer replaces it", () =>
|
||||
Effect.gen(function* () {
|
||||
const layout = { executable: "", cache: "" }
|
||||
const test = yield* fixture((command) => {
|
||||
if (command.command === "bash") {
|
||||
expect(readFileSync(layout.executable, "utf8")).toBe("binary")
|
||||
expect(upgradeLinks(layout.cache)).toHaveLength(1)
|
||||
}
|
||||
return {}
|
||||
})
|
||||
layout.executable = path.join(test.global.home, ".opencode", "bin", "opencode.exe")
|
||||
layout.cache = test.global.cache
|
||||
yield* test.fs.makeDirectory(path.dirname(layout.executable), { recursive: true })
|
||||
yield* test.fs.writeFileString(layout.executable, "binary")
|
||||
const original = process.execPath
|
||||
process.execPath = layout.executable
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => (process.execPath = original)))
|
||||
expect(yield* test.updater.method()).toBe("curl")
|
||||
yield* test.updater.upgrade("curl", "2.3.4")
|
||||
expect(test.commands.map((command) => command[0])).toEqual(["curl", "bash"])
|
||||
expect(yield* test.fs.readFileString(layout.executable)).toBe("binary")
|
||||
expect(links(test.global.cache)).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
windows("windows leaves a source checkout's runtime alone", () =>
|
||||
Effect.gen(function* () {
|
||||
const layout = { cache: "" }
|
||||
const test = yield* fixture(() => {
|
||||
expect(links(layout.cache)).toEqual([])
|
||||
return {}
|
||||
}, "not-opencode")
|
||||
layout.cache = test.global.cache
|
||||
yield* test.updater.upgrade("bun", "2.3.4")
|
||||
expect(test.commands).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
unix("other platforms never link the running binary", () =>
|
||||
Effect.gen(function* () {
|
||||
const layout = { cache: "" }
|
||||
const test = yield* fixture(() => {
|
||||
expect(links(layout.cache)).toEqual([])
|
||||
return {}
|
||||
})
|
||||
layout.cache = test.global.cache
|
||||
yield* test.updater.upgrade("bun", "2.3.4")
|
||||
expect(yield* test.fs.readFileString(test.executable)).toBe("binary")
|
||||
}),
|
||||
)
|
||||
|
||||
test("Node distribution honors the compile-time CLI name", async () => {
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -241,6 +241,7 @@ export type SessionSwitchModelOperation<E = never> = (
|
||||
export type SessionUpdateInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly title?: string | undefined
|
||||
readonly metadata?: Session.Metadata | undefined
|
||||
readonly permissions?: Permission.Ruleset | undefined
|
||||
}
|
||||
export type SessionUpdateOutput = void
|
||||
@@ -516,6 +517,20 @@ export type SessionLogOutput =
|
||||
| undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly title: string }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly type: "session.metadata.updated"
|
||||
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
|
||||
readonly location?:
|
||||
| {
|
||||
readonly directory: AbsolutePath
|
||||
readonly workspaceID?: (string & Brand.Brand<"Workspace.ID">) | undefined
|
||||
}
|
||||
| undefined
|
||||
readonly data: { readonly sessionID: Session.ID; readonly metadata: Session.Metadata }
|
||||
}
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
readonly created: number
|
||||
|
||||
@@ -453,7 +453,7 @@ const EndpointSessionUpdate = (raw: RawClient["server.session"]) => (input: Sess
|
||||
preserveEffect<SessionUpdateOutput>()(
|
||||
raw["session.update"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { title: input["title"], permissions: input["permissions"] },
|
||||
payload: { title: input["title"], metadata: input["metadata"], permissions: input["permissions"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
|
||||
@@ -330,7 +330,8 @@ export function make(options: ClientOptions) {
|
||||
}
|
||||
|
||||
const responseError = async (response: Response, descriptor: RequestDescriptor): Promise<never> => {
|
||||
if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)
|
||||
if (descriptor.declaredStatuses.includes(response.status))
|
||||
throw declared((await json(response)) as DeclaredErrorBody)
|
||||
try {
|
||||
await response.body?.cancel()
|
||||
} catch {}
|
||||
@@ -665,7 +666,7 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "PATCH",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
|
||||
body: { title: input["title"], permissions: input["permissions"] },
|
||||
body: { title: input["title"], metadata: input["metadata"], permissions: input["permissions"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: true,
|
||||
@@ -2200,6 +2201,19 @@ async function json(response: Response): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
type DeclaredErrorBody = {
|
||||
readonly _tag?: string
|
||||
readonly message?: string
|
||||
readonly data?: { readonly message?: string }
|
||||
}
|
||||
|
||||
/** Throw declared error bodies as Errors. The body's fields stay on the error, so narrowing on `_tag` or `name` still works. */
|
||||
function declared(body: DeclaredErrorBody) {
|
||||
const error = Object.assign(new Error(body.message ?? body.data?.message), body)
|
||||
if (body._tag) error.name = body._tag
|
||||
return error
|
||||
}
|
||||
|
||||
function isContentType(response: Response, expected: string) {
|
||||
return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ export type ProjectIcon = { url?: string; override?: string; color?: string }
|
||||
|
||||
export type ProjectCommands = { start?: string }
|
||||
|
||||
export type ProjectTime = { created: number; updated: number }
|
||||
export type ProjectTime = { created: number; updated: number; active: number }
|
||||
|
||||
export type PermissionSource = { type: "tool"; messageID: string; id: string }
|
||||
|
||||
@@ -1221,6 +1221,16 @@ export type SessionMoved = {
|
||||
|
||||
export type SessionInboxMovePayload1 = { location: LocationRef; projectID: string; subpath?: string }
|
||||
|
||||
export type SessionMetadataUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.metadata.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; metadata: SessionMetadata }
|
||||
}
|
||||
|
||||
export type SessionShellStarted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -2308,6 +2318,7 @@ export type SessionEventDurable =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionMetadataUpdated
|
||||
| SessionPermissions
|
||||
| SessionViewed
|
||||
| SessionDeleted
|
||||
@@ -2370,6 +2381,7 @@ export type V2Event =
|
||||
| SessionModelSelected
|
||||
| SessionMoved
|
||||
| SessionRenamed
|
||||
| SessionMetadataUpdated
|
||||
| SessionPermissions
|
||||
| SessionViewed
|
||||
| SessionUsageUpdated
|
||||
@@ -3946,12 +3958,21 @@ export type SessionUpdateInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly title?: {
|
||||
readonly title?: string | undefined
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
|
||||
readonly permissions?:
|
||||
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
|
||||
| undefined
|
||||
}["title"]
|
||||
readonly metadata?: {
|
||||
readonly title?: string | undefined
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
|
||||
readonly permissions?:
|
||||
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
|
||||
| undefined
|
||||
}["metadata"]
|
||||
readonly permissions?: {
|
||||
readonly title?: string | undefined
|
||||
readonly metadata?: { readonly [x: string]: JsonValue } | undefined
|
||||
readonly permissions?:
|
||||
| ReadonlyArray<{ readonly action: string; readonly resource: string; readonly effect: "allow" | "deny" | "ask" }>
|
||||
| undefined
|
||||
|
||||
@@ -1788,7 +1788,7 @@ export function createData(config: CreateDataInput) {
|
||||
},
|
||||
project: {
|
||||
list() {
|
||||
return Object.values(store.project.info).toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
return Object.values(store.project.info).toSorted((a, b) => b.time.active - a.time.active)
|
||||
},
|
||||
get(projectID: string) {
|
||||
return store.project.info[projectID]
|
||||
|
||||
@@ -151,6 +151,46 @@ test("vcs.diff exposes unavailable comparisons as errors, not empty diffs", asyn
|
||||
})
|
||||
})
|
||||
|
||||
test("declared errors are thrown as Error instances that keep the body", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
Response.json(
|
||||
{ _tag: "InvalidRequestError", message: "Incompatible auth server", kind: "integration_authorization" },
|
||||
{ status: 400 },
|
||||
),
|
||||
})
|
||||
const error = await client.integration.oauth
|
||||
.connect({ integrationID: "mcp_test", methodID: "oauth", location: { directory: "/repo" } })
|
||||
.then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toBe("Incompatible auth server")
|
||||
expect(error.name).toBe("InvalidRequestError")
|
||||
expect(error.stack).toContain("InvalidRequestError: Incompatible auth server")
|
||||
expect(error).toMatchObject({ _tag: "InvalidRequestError", kind: "integration_authorization" })
|
||||
})
|
||||
|
||||
test("declared errors with a data envelope read the nested message", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
Response.json({ name: "WorktreeError", data: { message: "Worktree directory unavailable" } }, { status: 400 }),
|
||||
})
|
||||
const error = await client.worktree.create({ projectID: "prj_test" }).then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toBe("Worktree directory unavailable")
|
||||
expect(error.name).toBe("WorktreeError")
|
||||
expect(error).toMatchObject({ name: "WorktreeError", data: { message: "Worktree directory unavailable" } })
|
||||
})
|
||||
|
||||
test("project.update uses the global project contract", async () => {
|
||||
let request: Request | undefined
|
||||
const project = {
|
||||
|
||||
@@ -41,8 +41,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] A trailing comma after a rest parameter is a syntax error, with or without `"use strict"`.
|
||||
- [x] A program that begins with `"use strict"` rejects `yield` as an identifier and duplicate parameter names at
|
||||
parse time. Without it, `yield` is an ordinary binding.
|
||||
- [ ] Duplicate parameter names in non-strict code throw when the function is called, instead of binding the last
|
||||
parameter as JavaScript does.
|
||||
- [x] Duplicate parameter names in non-strict code bind the last parameter, as in JS (`function f(a, a)` called
|
||||
with `(1, 2)` sees `a === 2`).
|
||||
|
||||
## Values and literals
|
||||
|
||||
@@ -52,12 +52,15 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Object literals with shorthand, computed string/number keys, and spread following ToObject: data objects and
|
||||
arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing.
|
||||
- [x] Template literals with interpolation.
|
||||
- [x] Tagged templates: a tag applied to a template literal is called as `tag(strings, ...values)`, with the tag read
|
||||
like a callee so a member tag keeps its receiver. `strings` is an array of the cooked text with a read-only `raw`
|
||||
array of the source text; an invalid escape such as `\unicode` cooks to `undefined`. One template object per
|
||||
site, as in JS, but it is not frozen: `strings[0] = "x"` succeeds here where JS throws.
|
||||
- [x] Regular-expression literals.
|
||||
- [x] `NaN` and `Infinity` globals.
|
||||
- [ ] BigInt literals and in-interpreter BigInt arithmetic; BigInt remains invalid at JSON-like host boundaries.
|
||||
- [ ] Arbitrary Symbol primitive values and symbol-keyed properties. The confined `Symbol.iterator` and
|
||||
`Symbol.asyncIterator` keys are available only for the iterator protocols.
|
||||
- [ ] Tagged-template calls.
|
||||
- [ ] Getter and setter definitions in object literals.
|
||||
|
||||
## Bindings and destructuring
|
||||
@@ -86,7 +89,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
sources are rejected.
|
||||
- [x] Destructuring reads through the prototype chain like member access: `const { constructor } = error` and
|
||||
`const { slice } = values` find the inherited built-in.
|
||||
- [ ] Member expressions as `for...in` targets (`for (x.y in obj)`).
|
||||
- [x] Any assignment target as a `for...in` head, like `for...of`: `for (x.y in obj)`, `for (a[i++] in obj)`, and
|
||||
destructuring patterns.
|
||||
|
||||
## Statements and control flow
|
||||
|
||||
@@ -201,10 +205,18 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
creates a hole without changing its length. Deleting a non-configurable property (`length`) or
|
||||
assigning a read-only one (`Math.PI`, `fn.name`) throws a `TypeError`, as in strict mode. `delete` of a
|
||||
non-reference (`delete 0`, `delete f()`) evaluates the operand and is `true`; `delete x` on a variable throws.
|
||||
- [ ] Operators, `switch` discriminants, template interpolation, and coercion helpers such as `String` and `isNaN`
|
||||
applied to functions and namespaces; JavaScript coerces them, the interpreter rejects non-data operands.
|
||||
- [ ] ToPrimitive on object operands: operators, `Error(message)`, `Date` arguments, and `parseInt` radix should call
|
||||
`valueOf`/`toString` in spec order and surface their throws.
|
||||
- [x] Coercion helpers and template interpolation accept functions and namespaces: `String(fn)` and `${fn}` give
|
||||
`"[object Function]"` rather than the source text, `isNaN(fn)` is `true`.
|
||||
- [ ] Operators other than `===`/`!==`, `switch` discriminants and cases, and `Object.is` applied to a function,
|
||||
promise, generator, tool reference, or any object holding one anywhere inside; JavaScript compares by identity or
|
||||
coerces (`fn == null` is `false`, `fn + ""` is its source text), the interpreter throws
|
||||
`TypeError: Binary operators require data values.` The check walks both operands' whole object graphs, so
|
||||
`rows == null` on a large array is slow where `rows === null` is not.
|
||||
- [ ] ToPrimitive on program objects: operators, `Number`/`String`, `Error(message)`, `parseInt` radix, multi-argument
|
||||
`Date` construction and `Date.UTC`, and numeric built-in arguments (`Math.max`, `at`, `indexOf` start) should call
|
||||
the object's own `valueOf`/`toString` in spec order and surface their throws. Today they use the built-in form
|
||||
(`NaN`, `"[object Object]"`) and ignore own methods. Date setters and one-argument `Date` construction already
|
||||
follow ToPrimitive.
|
||||
- [x] Property keys follow ToPropertyKey: `x[null]`, `x[true]`, and objects (via their built-in string form) become
|
||||
string keys.
|
||||
|
||||
@@ -343,10 +355,12 @@ reject }` object.
|
||||
native JS, `split(undefined)` returns the whole string, and `includes`/`startsWith`/`endsWith` reject regular
|
||||
expressions with a native-style `TypeError`. Opaque runtime references still reject as data errors, and
|
||||
`repeat` still requires a finite non-negative count.
|
||||
- [x] Native no-argument parity for `match()`, `matchAll()`, and `search()`; all behave as an empty pattern. Present
|
||||
arguments must still be a regular expression or string pattern.
|
||||
- [ ] `String.raw`.
|
||||
- [ ] `match`, `search`, and `split` accept any value and coerce it (objects via `toString`), like JavaScript.
|
||||
- [x] Native no-argument parity for `match()`, `matchAll()`, and `search()`; all behave as an empty pattern.
|
||||
- [x] `String.raw`, on a template object or any `{ raw }` object; raw strings and substitutions coerce through their own
|
||||
`toString`.
|
||||
- [x] `match`, `matchAll`, `search`, and `split` read any non-RegExp argument as a pattern string, as `new RegExp(arg)`
|
||||
would: `"a1b".match(1)` matches `/1/`, `search(null)` looks for `"null"`, and `undefined` is the empty pattern.
|
||||
Objects use their built-in string form until ToPrimitive lands.
|
||||
|
||||
## Numbers and Math
|
||||
|
||||
@@ -406,8 +420,9 @@ reject }` object.
|
||||
- [x] `toLocaleString`, `toLocaleDateString`, and `toLocaleTimeString` always format as `en-US` in UTC
|
||||
(`"1/1/1970, 12:00:00 AM"`) so output does not depend on the host.
|
||||
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
|
||||
- [ ] Date setters and multi-argument construction coerce object arguments through `valueOf`/`toString` and surface
|
||||
their throws.
|
||||
- [x] Date setters and one-argument construction coerce object arguments through their own `valueOf`/`toString` and
|
||||
surface their throws.
|
||||
- [ ] Multi-argument construction and `Date.UTC` coerce object arguments the same way (see ToPrimitive above).
|
||||
- [x] Native Date loose-equality and default primitive-coercion semantics, using CodeMode's deterministic ISO string
|
||||
representation for the string primitive.
|
||||
- [x] Native `RangeError` branding for invalid `toISOString()` calls.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
Statement,
|
||||
Super,
|
||||
SwitchStatement,
|
||||
TaggedTemplateExpression,
|
||||
Literal,
|
||||
TemplateLiteral,
|
||||
ThrowStatement,
|
||||
@@ -69,6 +70,7 @@ import {
|
||||
assign,
|
||||
Callable,
|
||||
define,
|
||||
frozen,
|
||||
get,
|
||||
hostCursor,
|
||||
IteratorObj,
|
||||
@@ -270,6 +272,8 @@ export class Interpreter<R> {
|
||||
readonly pending: Pending<R>
|
||||
readonly builtins: Builtins
|
||||
readonly logs: Array<string>
|
||||
/** Template objects by site: a tag sees the same `strings` array every time its literal is evaluated, as in JS. */
|
||||
readonly templates = new WeakMap<TaggedTemplateExpression, Arr>()
|
||||
private readonly root: Frame<R>
|
||||
|
||||
constructor(options: {
|
||||
@@ -933,10 +937,10 @@ class Frame<R> {
|
||||
|
||||
const keys = self.enumerableKeys(right, node.right)
|
||||
|
||||
if (left.type !== "Identifier" && left.type !== "VariableDeclaration") {
|
||||
if (left.type === "RestElement" || left.type === "AssignmentPattern") {
|
||||
throw typeError("Unsupported for...in binding.", left)
|
||||
}
|
||||
const assignmentName = left.type === "Identifier" ? left.name : undefined
|
||||
const assignment = left.type === "VariableDeclaration" ? undefined : left
|
||||
|
||||
for (const key of keys) {
|
||||
const result = yield* Effect.gen(function* () {
|
||||
@@ -946,8 +950,8 @@ class Frame<R> {
|
||||
yield* self.declarePattern(declared.pattern, key, declared.mutable, left, true)
|
||||
} else if (declared) {
|
||||
yield* self.assignPattern(declared.pattern, key, left)
|
||||
} else if (assignmentName) {
|
||||
self.scopes.set(assignmentName, key, left)
|
||||
} else if (assignment) {
|
||||
yield* self.assignPattern(assignment, key, left)
|
||||
}
|
||||
return yield* self.evaluateStatement(node.body)
|
||||
}).pipe(
|
||||
@@ -1295,6 +1299,8 @@ class Frame<R> {
|
||||
return this.evaluateArrayExpression(node)
|
||||
case "TemplateLiteral":
|
||||
return this.evaluateTemplateLiteral(node)
|
||||
case "TaggedTemplateExpression":
|
||||
return this.evaluateTaggedTemplate(node)
|
||||
case "ConditionalExpression":
|
||||
return this.evaluateConditionalExpression(node)
|
||||
case "UpdateExpression":
|
||||
@@ -1666,6 +1672,9 @@ class Frame<R> {
|
||||
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
|
||||
}
|
||||
}
|
||||
const parameters = fn.parameters.map((parameter) =>
|
||||
parameter.type === "Identifier" ? parameter.name : undefined,
|
||||
)
|
||||
const bind = Effect.gen(function* () {
|
||||
for (const [index, parameter] of fn.parameters.entries()) {
|
||||
if (parameter.type === "RestElement") {
|
||||
@@ -1678,6 +1687,8 @@ class Frame<R> {
|
||||
)
|
||||
break
|
||||
}
|
||||
// A sloppy simple parameter list may repeat a name; the last occurrence wins, as in JS.
|
||||
if (parameter.type === "Identifier" && parameters.lastIndexOf(parameter.name) !== index) continue
|
||||
yield* invocation.declarePattern(parameter, args[index], true, parameter, true)
|
||||
}
|
||||
})
|
||||
@@ -2006,7 +2017,7 @@ class Frame<R> {
|
||||
return Effect.gen(function* () {
|
||||
for (let index = 0; index < quasis.length; index += 1) {
|
||||
const quasi = quasis[index]!
|
||||
// acorn only omits `cooked` for invalid escapes in tagged templates, which are unsupported.
|
||||
// acorn only omits `cooked` for invalid escapes, which are a parse error outside a tagged template.
|
||||
if (typeof quasi.value.cooked !== "string") {
|
||||
throw typeError("Invalid template literal quasi.", quasi)
|
||||
}
|
||||
@@ -2024,6 +2035,41 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
// `tag\`a${x}b\`` is `tag(strings, x)`: the tag is read like a callee (a member keeps its receiver), the
|
||||
// substitutions are evaluated in order, and `strings` carries the escaped source as `strings.raw`.
|
||||
private evaluateTaggedTemplate(node: TaggedTemplateExpression): Effect.Effect<Value, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const { callable, thisValue } =
|
||||
node.tag.type === "MemberExpression"
|
||||
? yield* self.readMethod(node.tag)
|
||||
: { callable: yield* self.evaluateExpression(node.tag), thisValue: undefined }
|
||||
const strings = self.ctx.templates.get(node) ?? self.createTemplateObject(node)
|
||||
const values = yield* Effect.forEach(node.quasi.expressions, (expression) => self.evaluateExpression(expression))
|
||||
return yield* self.call(callable, thisValue, [strings, ...values], node, node.tag)
|
||||
})
|
||||
}
|
||||
|
||||
private createTemplateObject(node: TaggedTemplateExpression): Arr {
|
||||
const array = this.ctx.builtins.Array
|
||||
// An invalid escape such as `\unicode` cooks to `undefined` and survives only in `raw`.
|
||||
const strings = new Arr(
|
||||
array,
|
||||
node.quasi.quasis.map((quasi) => quasi.value.cooked ?? undefined),
|
||||
)
|
||||
define(
|
||||
strings,
|
||||
"raw",
|
||||
new Arr(
|
||||
array,
|
||||
node.quasi.quasis.map((quasi) => quasi.value.raw),
|
||||
),
|
||||
frozen,
|
||||
)
|
||||
this.ctx.templates.set(node, strings)
|
||||
return strings
|
||||
}
|
||||
|
||||
private evaluateConditionalExpression(node: ConditionalExpression): Effect.Effect<Value, unknown, R> {
|
||||
return Effect.flatMap(this.evaluateExpression(node.test), (test) =>
|
||||
this.evaluateExpression(test ? node.consequent : node.alternate),
|
||||
|
||||
@@ -72,7 +72,7 @@ export const uriError = failure("URIError")
|
||||
|
||||
// Orient the agent rather than enumerate JavaScript; interpreter-support.md is the full matrix.
|
||||
export const supportedSyntaxMessage =
|
||||
"This is a restricted JavaScript-like language. Supported: plain and async functions, data literals, destructuring, standard control flow, await and Promise, and built-ins such as Array, Object, Math, JSON, Date, RegExp, Map, Set, and URL. Unsupported: classes, this, getters/setters, tagged templates, BigInt, and custom Symbols. Use plain functions and data objects instead."
|
||||
"This is a restricted JavaScript-like language. Supported: plain and async functions, data literals, destructuring, standard control flow, await and Promise, and built-ins such as Array, Object, Math, JSON, Date, RegExp, Map, Set, and URL. Unsupported: classes, this, getters/setters, BigInt, and custom Symbols. Use plain functions and data objects instead."
|
||||
|
||||
export const unsupportedSyntax = (kind: string, node: AstNode): PendingThrow =>
|
||||
new PendingThrow(
|
||||
|
||||
@@ -33,21 +33,16 @@ const escapeRegexHint =
|
||||
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
|
||||
|
||||
export const toHostRegex = (arg: Value, method: string, extraFlags = ""): RegExp => {
|
||||
// Native parity: an undefined pattern behaves as an empty pattern.
|
||||
if (arg === undefined) return new RegExp("", extraFlags)
|
||||
if (arg instanceof RegExpObj) return arg.regex
|
||||
if (typeof arg === "string") {
|
||||
try {
|
||||
return new RegExp(arg, extraFlags)
|
||||
} catch (error) {
|
||||
throw syntaxError(
|
||||
`String.${method} received the string ${JSON.stringify(arg)}, which is not a valid regular expression pattern (${regexFailureReason(error)}). ${escapeRegexHint}`,
|
||||
)
|
||||
}
|
||||
// Anything else is a pattern string, as `new RegExp(arg)` would read it: undefined is the empty pattern.
|
||||
const source = arg === undefined ? "" : coerceToString(arg)
|
||||
try {
|
||||
return new RegExp(source, extraFlags)
|
||||
} catch (error) {
|
||||
throw syntaxError(
|
||||
`String.${method} received the string ${JSON.stringify(source)}, which is not a valid regular expression pattern (${regexFailureReason(error)}). ${escapeRegexHint}`,
|
||||
)
|
||||
}
|
||||
throw typeError(
|
||||
`String.${method} expects a regular expression (a /pattern/flags literal or new RegExp(...)) or a string pattern, not ${arg === null ? "null" : typeof arg}.`,
|
||||
)
|
||||
}
|
||||
|
||||
export const matchToValue = (builtins: Builtins, match: RegExpMatchArray): Arr => {
|
||||
|
||||
@@ -4,17 +4,20 @@ import { checkArrayLength, checkStringLength } from "../interpreter/limits.js"
|
||||
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
|
||||
import {
|
||||
define,
|
||||
get,
|
||||
hidden,
|
||||
Arr,
|
||||
hostIterator,
|
||||
Obj,
|
||||
RegExpObj,
|
||||
record,
|
||||
coerceToInteger,
|
||||
coerceToNumber,
|
||||
coerceToString,
|
||||
type Value,
|
||||
} from "../interpreter/objects.js"
|
||||
import { containsOpaqueReference, typeofValue } from "../interpreter/references.js"
|
||||
import { applyCollectionCallback, isSupportedCallback } from "../interpreter/callback.js"
|
||||
import { applyCollectionCallback, isSupportedCallback, toPrimitiveString } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { matchToValue, toHostRegex } from "./regexp.js"
|
||||
import { coercion } from "./value.js"
|
||||
@@ -98,6 +101,29 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
methods(builtins, string, [
|
||||
codeUnits("fromCharCode", String.fromCharCode),
|
||||
codeUnits("fromCodePoint", String.fromCodePoint),
|
||||
[
|
||||
"raw",
|
||||
1,
|
||||
(_, args) => {
|
||||
const template = args[0]
|
||||
const raw = template instanceof Obj ? get(template, "raw") : undefined
|
||||
if (!(raw instanceof Obj)) throw typeError("String.raw expects a template object with a raw array.")
|
||||
const count = Math.max(0, coerceToInteger(get(raw, "length")))
|
||||
checkArrayLength(count)
|
||||
// Each literal is followed by its substitution, except the last literal, or when substitutions run out.
|
||||
const parts = Array.from({ length: count }, (_, index) =>
|
||||
index + 1 < count && index + 1 < args.length ? [get(raw, index), args[index + 1]] : [get(raw, index)],
|
||||
).flat()
|
||||
return Effect.map(
|
||||
Effect.forEach(parts, (part) => toPrimitiveString(ctx, part)),
|
||||
(strings) => {
|
||||
const output = strings.join("")
|
||||
checkStringLength(output.length)
|
||||
return output
|
||||
},
|
||||
)
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const self = (thisValue: Value, name: string): string => {
|
||||
|
||||
@@ -77,8 +77,6 @@ describe("new on a non-constructible callee", () => {
|
||||
expect(failure.message).toStartWith(
|
||||
"SyntaxError: Syntax 'ClassDeclaration' is not supported. This is a restricted JavaScript-like language. Supported: ",
|
||||
)
|
||||
expect(failure.message).toContain(
|
||||
"Unsupported: classes, this, getters/setters, tagged templates, BigInt, and custom Symbols.",
|
||||
)
|
||||
expect(failure.message).toContain("Unsupported: classes, this, getters/setters, BigInt, and custom Symbols.")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -964,7 +964,7 @@ describe("coercion parity: unknown static members read as undefined", () => {
|
||||
expect(await value(`return typeof Math.sum`)).toBe("undefined")
|
||||
expect(await value(`return RegExp.quote === undefined`)).toBe(true)
|
||||
expect(await value(`return Number.range === undefined`)).toBe(true)
|
||||
expect(await value(`return String.raw === undefined`)).toBe(true)
|
||||
expect(await value(`return String.dedent === undefined`)).toBe(true)
|
||||
expect(await value(`return isFinite.something === undefined`)).toBe(true)
|
||||
expect(await value(`return console.group === undefined`)).toBe(true)
|
||||
expect(await value(`return Date.moment === undefined`)).toBe(true)
|
||||
@@ -1079,3 +1079,102 @@ describe("functions are objects", () => {
|
||||
).toEqual([3, ["count"], true, true, "fn", 3, true, true, null])
|
||||
})
|
||||
})
|
||||
|
||||
describe("tagged templates", () => {
|
||||
test("the tag receives the cooked strings, their raw forms, and the substitutions in order", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const tag = (strings, ...values) => [strings, strings.raw, values, Object.keys(strings)]
|
||||
return tag\`a\${1}b\\n\${2}c\`
|
||||
`),
|
||||
).toEqual([
|
||||
["a", "b\n", "c"],
|
||||
["a", "b\\n", "c"],
|
||||
[1, 2],
|
||||
["0", "1", "2"],
|
||||
])
|
||||
})
|
||||
|
||||
test("an invalid escape cooks to undefined and keeps its raw text", async () => {
|
||||
expect(await value(`return ((strings) => [strings[0] === undefined, strings.raw[0]])\`\\unicode\``)).toEqual([
|
||||
true,
|
||||
"\\unicode",
|
||||
])
|
||||
})
|
||||
|
||||
test("each site has one template object; different sites differ", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const seen = []
|
||||
const tag = (strings) => { seen.push(strings) }
|
||||
for (let i = 0; i < 2; i++) tag\`x\${i}\`
|
||||
tag\`x\${0}\`
|
||||
return [seen[0] === seen[1], seen[0] === seen[2]]
|
||||
`),
|
||||
).toEqual([true, false])
|
||||
})
|
||||
|
||||
test("the tag is read like a callee: members, chained tags, async tags, and the not-a-function error", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const o = { tag: (strings) => strings[0].toUpperCase() }
|
||||
const chain = () => chain
|
||||
const asyncTag = async (strings, value) => strings[0] + value
|
||||
let failure
|
||||
try { (1)\`x\` } catch (error) { failure = error instanceof TypeError }
|
||||
return [o.tag\`abc\`, typeof chain\`a\`\`b\`, await asyncTag\`n=\${1}\`, failure]
|
||||
`),
|
||||
).toEqual(["ABC", "function", "n=1", true])
|
||||
})
|
||||
|
||||
test("raw is read-only", async () => {
|
||||
expect(await value(`try { ((strings) => { strings.raw = 1 })\`a\` } catch (error) { return error.name }`)).toBe(
|
||||
"TypeError",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("String.raw", () => {
|
||||
test("joins the raw strings with the substitutions", async () => {
|
||||
expect(await value(`return [String.raw\`a\\n\${1}b\`, String.raw({ raw: ["x", "y", "z"] }, 1, 2, 3)]`)).toEqual([
|
||||
"a\\n1b",
|
||||
"x1y2z",
|
||||
])
|
||||
})
|
||||
|
||||
test("extra substitutions are dropped, missing ones are skipped, and a program object's toString is used", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const shout = { toString() { return "!" } }
|
||||
return [String.raw({ raw: ["x", "y"] }, 1, 2), String.raw({ raw: ["x", "y", "z"] }, shout), String.raw({ raw: { length: 0 } })]
|
||||
`),
|
||||
).toEqual(["x1y", "x!yz", ""])
|
||||
})
|
||||
|
||||
test("a template without a raw array is a TypeError", async () => {
|
||||
const failure = await error(`String.raw(1)`)
|
||||
expect(failure.message).toContain("String.raw expects a template object with a raw array")
|
||||
})
|
||||
})
|
||||
|
||||
describe("sloppy duplicate parameters and for...in targets", () => {
|
||||
test("a repeated parameter name binds the last argument", async () => {
|
||||
expect(await value(`function f(a, b, a) { return [a, b] } return [f(1, 2, 3), f(1)]`)).toEqual([
|
||||
[3, 2],
|
||||
[null, null],
|
||||
])
|
||||
})
|
||||
|
||||
test("for...in assigns to any target: members, computed members, and patterns", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const x = {}, seen = [], a = []
|
||||
let i = 0, first
|
||||
for (x.y in { p: 1, q: 2 }) seen.push(x.y)
|
||||
for (a[i++] in { p: 1, q: 2 });
|
||||
for ([first] in { ab: 1 });
|
||||
return [seen, x.y, a, first]
|
||||
`),
|
||||
).toEqual([["p", "q"], "q", ["p", "q"], "a"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -452,10 +452,12 @@ describe("RegExp", () => {
|
||||
expect((await error(`return "aa".matchAll(/a/)`)).message).toContain("write /a/g, or use String.match")
|
||||
})
|
||||
|
||||
test("a non-pattern argument names the expected shapes", async () => {
|
||||
const err = await error(`return "abc".match(42)`)
|
||||
expect(err.message).toContain("expects a regular expression")
|
||||
expect(err.message).toContain("not number")
|
||||
test("any argument is a pattern string, as new RegExp(arg) reads it", async () => {
|
||||
expect(
|
||||
await value(
|
||||
`return ["a42b".match(42)[0], "xnullx".search(null), "abc".match(undefined), [..."1a1".matchAll(1)].length]`,
|
||||
),
|
||||
).toEqual(["42", 1, [""], 2])
|
||||
})
|
||||
|
||||
test("source and flags properties read through", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"revision": "250f204f23a9249ff204be2baec29600faae7b75",
|
||||
"directories": ["built-ins/Array/prototype", "language/statements", "built-ins/Iterator"],
|
||||
"directories": ["built-ins/Array/prototype", "built-ins/Iterator", "built-ins/String/raw", "language/expressions/tagged-template", "language/statements"],
|
||||
"harness": ["assert.js", "sta.js", "compareArray.js", "doneprintHandle.js"],
|
||||
"flags": ["module", "raw", "noStrict"],
|
||||
"boundaries": {
|
||||
|
||||
@@ -199,6 +199,8 @@ built-ins/Iterator/prototype/drop/limit-tonumber-throws.js # Expected a Test262
|
||||
built-ins/Iterator/prototype/drop/limit-tonumber.js # Iterator.prototype.drop expects a non-negative count, received NaN.
|
||||
built-ins/Iterator/prototype/take/limit-tonumber-throws.js # Expected a Test262Error but got a RangeError
|
||||
built-ins/Iterator/prototype/take/limit-tonumber.js # Iterator.prototype.take expects a non-negative count, received NaN.
|
||||
language/expressions/tagged-template/constructor-invocation.js # The called value cannot be constructed: user-defined constructors and classes are not supported.
|
||||
language/expressions/tagged-template/template-object-frozen-strict.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
language/statements/async-generator/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
language/statements/async-generator/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
language/statements/async-generator/return-undefined-implicit-and-explicit.js # Actual ["tick 1", "tick 2", "g1 ret", "g2 ret", "g3 ret", "g4 ret"] and expected ["tick 1", "g1 ret"
|
||||
@@ -213,7 +215,6 @@ language/statements/for-await-of/async-gen-decl-dstr-obj-empty-num.js # TypeErr
|
||||
language/statements/for-await-of/async-gen-decl-dstr-obj-empty-string.js # TypeError: Object destructuring requires a data object or array value, received a string.
|
||||
language/statements/for-await-of/async-gen-decl-dstr-obj-rest-number.js # TypeError: Object destructuring requires a data object or array value, received a number.
|
||||
language/statements/for-await-of/async-gen-decl-dstr-obj-rest-str-val.js # TypeError: Object destructuring requires a data object or array value, received a string.
|
||||
language/statements/for-in/head-lhs-member.js # Unsupported for...in binding.
|
||||
language/statements/for-of/dstr/array-elem-iter-rtrn-close-err.js # Iterator next must be a function.
|
||||
language/statements/for-of/dstr/array-elem-iter-thrw-close-err.js # Expected SameValue(«1», «0») to be true
|
||||
language/statements/for-of/dstr/array-elem-trlg-iter-list-thrw-close-err.js # Expected SameValue(«1», «0») to be true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.14",
|
||||
"version": "2.0.15",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user