Compare commits

...

52 Commits

Author SHA1 Message Date
Kit Langton d6639cb5ad fix(tui): harden highlight cache lifecycle 2026-08-05 14:06:35 -04:00
Kit Langton b300116d0a fix(tui): cache syntax highlights across tabs 2026-08-05 14:00:00 -04:00
Kit Langton d10b652637 refactor(core): remove unused formatter methods (#40684) 2026-08-05 13:45:23 -04:00
Kit Langton b03ca0d4e2 refactor(core): remove unused bus sequences (#40681) 2026-08-05 13:41:38 -04:00
Kit Langton 25aaea3d31 refactor(core): remove unused shell tree killer (#40680) 2026-08-05 13:38:59 -04:00
Kit Langton cae7a139bc refactor(server): remove obsolete auth header helpers (#40682) 2026-08-05 13:37:29 -04:00
Kit Langton 5ea62ab05f refactor(core): remove orphaned git change operations (#40683) 2026-08-05 13:32:03 -04:00
Kit Langton faadc05c88 refactor(core): remove unused snapshot operations (#40687) 2026-08-05 13:01:48 -04:00
Kit Langton 3e253c589e refactor(core): persist instruction updates as messages (#40679) 2026-08-05 12:55:29 -04:00
Kit Langton 0a0fc09533 refactor(core): remove unused file mutation methods (#40667) 2026-08-05 12:33:20 -04:00
Kit Langton 5aa0413fea refactor(core): remove dead fork pending copy (#40675) 2026-08-05 12:30:42 -04:00
Kit Langton 6f4c199629 fix(tui): wait for diff request in tests (#40670) 2026-08-05 16:02:22 +00:00
Kit Langton ed8e1f4654 fix(core): reconcile promoted prompt retries from messages (#40664) 2026-08-05 11:43:03 -04:00
Kit Langton 3b0195e045 fix(core): avoid eager directory snapshots (#40552) 2026-08-05 14:52:07 +00:00
Shoubhit Dash 143a776373 fix(acp): surface subagent activity (#40438) 2026-08-05 18:57:15 +05:30
Brendan Allan 76b318e990 refactor(app): gate unsupported v2 capabilities (#40381) 2026-08-05 15:15:26 +08:00
opencode-agent[bot] c0ab35c3c2 fix(app): show active search details (#40597)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-05 17:09:42 +10:00
Brendan Allan b1c1b79d15 refactor(app): migrate supported v2 APIs (#40378) 2026-08-05 14:35:02 +08:00
Brendan Allan 4eb1b37d9b fix(app): reconcile v2 session projections (#40376) 2026-08-05 13:46:55 +08:00
Brendan Allan f2b3a12910 refactor(app): own rendering contracts (#40375) 2026-08-05 13:30:50 +08:00
Aiden Cline daa998f9c3 fix(core): make xAI OAuth device-only (#40538) 2026-08-04 22:55:31 -05:00
Brendan Allan c74a0d8529 test(app): migrate e2e fixtures to v2 (#40374) 2026-08-05 11:33:18 +08:00
Aiden Cline 330ef108ce fix(core): report missing shell workdir (#40542) 2026-08-04 20:56:02 -05:00
opencode-agent[bot] f9ac6f3171 fix(ai): restore Responses tool input semantics (#40553)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 20:46:51 -05:00
opencode-agent[bot] 62e5d73d45 fix(ai): classify malformed Responses tool calls (#40549)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 20:40:36 -05:00
Kit Langton ab20a7b4a3 feat(tui): streamline tab navigation shortcuts (#40551) 2026-08-05 01:36:23 +00:00
opencode-agent[bot] ad9a95f6ee fix(ai): preserve Gemini tool finish semantics (#40546)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 20:29:06 -05:00
Aiden Cline 4ed343706d fix: retry empty incomplete streams (#40535) 2026-08-04 19:38:56 -05:00
opencode-agent[bot] 6d2eb2240a fix(app): finish tool call ID rename (#40539)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 19:26:45 -05:00
Dax Raad 53b8111121 refactor: rename tool call ID fields 2026-08-04 17:08:12 -04:00
Kit Langton 1fd817ffcf fix(client): wait for live service contenders (#40515) 2026-08-04 20:23:57 +00:00
Aiden Cline b9c118e0fe chore(core): drop unreachable AI SDK provider packages (#40504) 2026-08-04 14:37:56 -05:00
opencode-agent[bot] d1c9e39978 fix(tui): update tab titles immediately (#40318)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-08-04 14:23:29 -04:00
Aiden Cline 0b1ec457dc fix(tui): show branch beside directory (#40500) 2026-08-04 12:26:18 -05:00
Aiden Cline 9bd99b4c91 feat(vcs): publish branch updates (#40371) 2026-08-04 11:52:17 -05:00
opencode-agent[bot] 94fcb119a3 fix(core): avoid implicit output limits (#40488)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-04 11:09:23 -05:00
Aiden Cline aa820db18d feat(plugin): add session HTTP middleware (#40327) 2026-08-04 09:15:52 -05:00
Aiden Cline 42e8d11552 feat(vcs): expose branch info (#40368) 2026-08-03 23:18:13 -05:00
Aiden Cline 28d784b83a fix(core): execute tools renamed by context hooks (#40359) 2026-08-03 22:57:06 -05:00
Aiden Cline a4ad17347f fix(core): apply safe defaults to all agents (#40316) 2026-08-03 17:56:04 -05:00
Aiden Cline 93e8b75cca refactor(schema): rename agent default constructor (#40324) 2026-08-03 15:58:08 -05:00
opencode-agent[bot] 8df03aa1bc fix(core): ignore empty agent files (#40302)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-03 11:00:10 -05:00
Shoubhit Dash 8ce850e142 fix(ai): expose client service requirements (#40275) 2026-08-03 17:01:32 +05:30
Kit Langton 3f30203b72 test(core): stabilize shell integration timing (#40084) 2026-08-02 14:45:36 -04:00
opencode-agent[bot] dd014120cd fix(core): route Bedrock packages natively (#40165)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-02 11:47:35 -05:00
opencode-agent[bot] bf7f0cb521 feat(ai): add native Bedrock Mantle support (#40119)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-02 11:16:22 -05:00
opencode-agent[bot] adbd22833c fix(tui): reserve home and end for navigation (#40123)
Co-authored-by: Simon Klee <hello@simonklee.dk>
2026-08-02 04:39:23 +00:00
Aiden Cline 634b9b21dd refactor(ai): align multimodal naming (#40073) 2026-08-01 21:57:05 -05:00
Kit Langton 5d729521d3 refactor(tui): remove redundant code (#40081) 2026-08-01 14:22:48 -04:00
Kit Langton c391983ae0 fix(tui): discover plugins across config roots (#39988) 2026-08-01 14:06:58 -04:00
Kit Langton f158abd694 fix(tui): make vertical tabs responsive (#40080) 2026-08-01 14:06:13 -04:00
Kit Langton 0e3da4b4f1 feat(tui): add vertical session tabs (#40072) 2026-08-01 13:10:08 -04:00
439 changed files with 7332 additions and 4507 deletions
+7
View File
@@ -4,6 +4,13 @@
- The default branch in this repo is `dev`. - The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. - Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
## Live V2 TUI Testing
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client.
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
## Branch Names ## Branch Names
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`. Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
+20 -33
View File
@@ -88,6 +88,7 @@
"@tanstack/solid-query": "5.91.4", "@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:", "@tanstack/solid-virtual": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5", "@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1", "ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
@@ -284,13 +285,9 @@
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.18.8", "version": "1.18.8",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode-ai/console-core": "workspace:*", "@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-resource": "workspace:*", "@opencode-ai/console-resource": "workspace:*",
"ai": "catalog:",
"zod": "catalog:", "zod": "catalog:",
}, },
"devDependencies": { "devDependencies": {
@@ -298,7 +295,6 @@
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"openai": "5.11.0",
"typescript": "catalog:", "typescript": "catalog:",
}, },
}, },
@@ -361,18 +357,15 @@
"@ai-sdk/cohere": "3.0.27", "@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41", "@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104", "@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31", "@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.51", "@ai-sdk/mistral": "3.0.51",
"@ai-sdk/openai": "3.0.84",
"@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/perplexity": "3.0.26", "@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8", "@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23", "@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41", "@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39", "@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.102",
"@aws-sdk/credential-providers": "3.1057.0", "@aws-sdk/credential-providers": "3.1057.0",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:", "@effect/sql-sqlite-bun": "catalog:",
@@ -386,7 +379,6 @@
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*", "@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.9.0",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@silvia-odwyer/photon-node": "0.3.4", "@silvia-odwyer/photon-node": "0.3.4",
"@standard-schema/spec": "catalog:", "@standard-schema/spec": "catalog:",
@@ -911,7 +903,6 @@
"@opentui/solid": "catalog:", "@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"get-east-asian-width": "catalog:", "get-east-asian-width": "catalog:",
@@ -1038,7 +1029,6 @@
"@shikijs/transformers": "3.20.0", "@shikijs/transformers": "3.20.0",
"@solid-primitives/resize-observer": "2.1.5", "@solid-primitives/resize-observer": "2.1.5",
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"ai": "catalog:",
"astro": "5.7.13", "astro": "5.7.13",
"diff": "catalog:", "diff": "catalog:",
"js-base64": "3.7.7", "js-base64": "3.7.7",
@@ -1084,17 +1074,16 @@
"tree-sitter-bash", "tree-sitter-bash",
], ],
"patchedDependencies": { "patchedDependencies": {
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch", "effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.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", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
}, },
"overrides": { "overrides": {
"@opentui/core": "catalog:", "@opentui/core": "catalog:",
@@ -1220,9 +1209,9 @@
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="], "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], "@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="], "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dXzrVsLR5f6tr+U04jq4AXoRroGFBTvODnLgss0SWbzNjGGQg3XqtQ9j7rCLo6o8qbYGuAHvqUrIpUCuiscuFg=="], "@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dXzrVsLR5f6tr+U04jq4AXoRroGFBTvODnLgss0SWbzNjGGQg3XqtQ9j7rCLo6o8qbYGuAHvqUrIpUCuiscuFg=="],
@@ -4944,7 +4933,7 @@
"open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="], "open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="],
"openai": ["openai@5.11.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-+AuTc5pVjlnTuA9zvn8rA/k+1RluPIx9AD4eDcnutv6JNwHHZxIhkFy+tmMKCvmMFDQzfA/r1ujvPWB19DQkYg=="], "openai": ["openai@6.49.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
@@ -5962,12 +5951,8 @@
"@actions/http-client/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], "@actions/http-client/undici": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="],
"@ai-sdk/alibaba/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="], "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/openai": ["@ai-sdk/openai@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oAiGC9eWG7IgtdsdS74bOCnAAHarAfTJhWN9x5INwnWPekL802AvF+0I5DvLzIF1MIRmNw4N8mPSL/GUVbX9Mw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], "@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
@@ -5982,6 +5967,8 @@
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="], "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
@@ -5990,6 +5977,8 @@
"@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], "@ai-sdk/deepgram/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
@@ -6024,14 +6013,18 @@
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="], "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@ai-sdk/provider-utils": "4.0.35" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cQrN6OUn/jvsY3OdsU6Wn+ss7vp1iwIcakZKSlSRMnYqShBfyT7Qht+eqmgxs7w9ttrw6FAG6o11AiBs+iEsTA=="],
@@ -6350,10 +6343,6 @@
"@opencode-ai/console-mail/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="], "@opencode-ai/console-mail/@types/react": ["@types/react@18.0.25", "", { "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", "csstype": "^3.0.2" } }, "sha512-xD6c0KDT4m7n9uD4ZHi02lzskaiqcBxf4zi+tXZY98a04wvc0hi/TcCPC2FOESZi51Nd7tlUeOJY8RofL799/g=="],
"@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="],
"@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
"@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="], "@opencode-ai/desktop/@actions/artifact": ["@actions/artifact@4.0.0", "", { "dependencies": { "@actions/core": "^1.10.0", "@actions/github": "^6.0.1", "@actions/http-client": "^2.1.0", "@azure/core-http": "^3.0.5", "@azure/storage-blob": "^12.15.0", "@octokit/core": "^5.2.1", "@octokit/plugin-request-log": "^1.0.4", "@octokit/plugin-retry": "^3.0.9", "@octokit/request": "^8.4.1", "@octokit/request-error": "^5.1.1", "@protobuf-ts/plugin": "^2.2.3-alpha.1", "archiver": "^7.0.1", "jwt-decode": "^3.1.2", "unzip-stream": "^0.3.1" } }, "sha512-HCc2jMJRAfviGFAh0FsOR/jNfWhirxl7W6z8zDtttt0GltwxBLdEIjLiweOPFl9WbyJRW1VWnPUSAixJqcWUMQ=="],
"@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], "@opencode-ai/desktop/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
@@ -6578,6 +6567,8 @@
"babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], "babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
"blume/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"blume/@astrojs/mdx": ["@astrojs/mdx@7.0.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.1", "@astrojs/markdown-remark": "7.2.1", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.16.0", "es-module-lexer": "^2.0.0", "estree-util-visit": "^2.0.0", "hast-util-to-html": "^9.0.5", "piccolore": "^0.1.3", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", "source-map": "^0.7.6", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@astrojs/markdown-satteri": "^0.3.1", "astro": "^7.0.0" }, "optionalPeers": ["@astrojs/markdown-satteri"] }, "sha512-RxyIwU0uFam5ftwqKOjpIdhnFxZ/kEikeimLyQy3eGXbHT8WgRGzzesOIHVU8+m9TY8ag5WVOyvV24/GyqPdPQ=="], "blume/@astrojs/mdx": ["@astrojs/mdx@7.0.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.1", "@astrojs/markdown-remark": "7.2.1", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.16.0", "es-module-lexer": "^2.0.0", "estree-util-visit": "^2.0.0", "hast-util-to-html": "^9.0.5", "piccolore": "^0.1.3", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", "source-map": "^0.7.6", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@astrojs/markdown-satteri": "^0.3.1", "astro": "^7.0.0" }, "optionalPeers": ["@astrojs/markdown-satteri"] }, "sha512-RxyIwU0uFam5ftwqKOjpIdhnFxZ/kEikeimLyQy3eGXbHT8WgRGzzesOIHVU8+m9TY8ag5WVOyvV24/GyqPdPQ=="],
"blume/@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="], "blume/@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="],
@@ -6714,8 +6705,6 @@
"gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], "gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
"gitlab-ai-provider/openai": ["openai@6.49.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg=="],
"gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
@@ -7380,10 +7369,6 @@
"@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="], "@octokit/rest/@octokit/core/before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="],
"@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.38", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-/HHGmtKllqjg1OLc023v9w9kK3laW7Z6TzfZukYQWCsGBbzB9p60zTvvpXFVcs44NZBVXL3viOa1HRKUbeee8g=="],
"@opencode-ai/desktop/@actions/artifact/@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="], "@opencode-ai/desktop/@actions/artifact/@actions/core": ["@actions/core@1.11.1", "", { "dependencies": { "@actions/exec": "^1.1.1", "@actions/http-client": "^2.0.1" } }, "sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A=="],
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
@@ -7620,6 +7605,8 @@
"babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], "babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"blume/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"blume/@astrojs/mdx/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.10.1", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.1.1", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-5phcroT/vmOOrYuuAxtkbPixy5hePtlz9i8K4OeDv3dNK6/UQRuXPOSRTxIOBbUY5Sonw2UaxjbuVc43Mcir6Q=="], "blume/@astrojs/mdx/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.10.1", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.1.1", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-5phcroT/vmOOrYuuAxtkbPixy5hePtlz9i8K4OeDv3dNK6/UQRuXPOSRTxIOBbUY5Sonw2UaxjbuVc43Mcir6Q=="],
"blume/@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@7.2.1", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.1", "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-jPVNIqTvk+yKviikszv/Y1U4jGUSKpp/Nw48QZV4qjWgp70j4Lkq3lhSDRbWwCfgKvEyO9GHuVbV1dM2WYXy1w=="], "blume/@astrojs/mdx/@astrojs/markdown-remark": ["@astrojs/markdown-remark@7.2.1", "", { "dependencies": { "@astrojs/internal-helpers": "0.10.1", "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-jPVNIqTvk+yKviikszv/Y1U4jGUSKpp/Nw48QZV4qjWgp70j4Lkq3lhSDRbWwCfgKvEyO9GHuVbV1dM2WYXy1w=="],
+1 -1
View File
@@ -8,6 +8,7 @@
"packageManager": "bun@1.3.14", "packageManager": "bun@1.3.14",
"scripts": { "scripts": {
"dev": "bun run --cwd packages/cli --conditions=browser src/index.ts", "dev": "bun run --cwd packages/cli --conditions=browser src/index.ts",
"dev:live": "OPENCODE_TUI_CHANNEL=next OPENCODE_PASSWORD=\"$(opencode2 service get password)\" bun run dev --server \"$(opencode2 service status)\"",
"dev:desktop": "bun --cwd packages/desktop dev", "dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev", "dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
@@ -160,7 +161,6 @@
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.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", "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.0": "patches/pacote@21.5.0.patch",
+5 -5
View File
@@ -10,7 +10,7 @@
## Conventions ## Conventions
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Model.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many. Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness. - Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
@@ -76,7 +76,7 @@ export const route = Route.make({
}) })
``` ```
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `Model` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `LLMError`s. Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit. The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
@@ -128,7 +128,7 @@ const selected = model("gpt-5", {
}) })
``` ```
Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Keep transport choices inside the semantic entrypoint settings, so OpenAI Responses HTTP and WebSocket share one entrypoint. Provider facades may still expose named selectors such as `responsesWebSocket` for direct typed call sites; the package-like contract maps its settings to those selectors before returning an executable `Model`. Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Keep transport choices inside the semantic entrypoint settings, so OpenAI Responses HTTP and WebSocket share one entrypoint. Provider facades may still expose named selectors such as `responsesWebSocket` for direct typed call sites; the package-like contract maps its settings to those selectors before returning an executable `LanguageModel`.
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`. Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
@@ -138,10 +138,10 @@ Do not expose `Route` in provider package settings. Route composition stays an i
packages/ai/src/ packages/ai/src/
schema/ canonical Schema model, split by concern schema/ canonical Schema model, split by concern
ids.ts branded IDs, literal types, ProviderMetadata ids.ts branded IDs, literal types, ProviderMetadata
options.ts Generation/Provider/Http options, Limits, Model, cache policy options.ts Generation/Provider/Http options, Limits, LanguageModel, cache policy
messages.ts content parts, Message, ToolDefinition, LLMRequest messages.ts content parts, Message, ToolDefinition, LLMRequest
events.ts Usage, individual events, LLMEvent, LLMResponse events.ts Usage, individual events, LLMEvent, LLMResponse
errors.ts error reasons, LLMError, ToolFailure errors.ts error reasons, AIError, ToolFailure
index.ts barrel index.ts barrel
llm.ts request constructors and convenience helpers llm.ts request constructors and convenience helpers
route/ route/
+6 -6
View File
@@ -96,7 +96,7 @@ contains identity, capabilities, pricing metadata, provider-specific option
types, reusable request-behavior defaults, and hidden execution behavior. types, reusable request-behavior defaults, and hidden execution behavior.
Normal users do not need to learn the current `Route` composite. Protocol, Normal users do not need to learn the current `Route` composite. Protocol,
endpoint, auth, transport, and hooks are bound behind `Model`. endpoint, auth, transport, and hooks are bound behind `LanguageModel`.
### Request ### Request
@@ -539,7 +539,7 @@ Hosted tools do not pretend to have local handlers, and callers do not inspect a
### Run stream ### Run stream
`LLM.stream` returns an Effect `Stream<RunEvent, LLMError, Requirements>`. `LLM.stream` returns an Effect `Stream<RunEvent, AIError, Requirements>`.
Run events explicitly expose orchestration boundaries: Run events explicitly expose orchestration boundaries:
```ts ```ts
@@ -828,11 +828,11 @@ portable semantic guarantee.
## Error Model ## Error Model
The Effect error channel is a tagged domain union rather than one `LLMError` The Effect error channel is a tagged domain union rather than one `AIError`
wrapper with nested reasons. Illustrative categories: wrapper with nested reasons. Illustrative categories:
```ts ```ts
type LLMError = type AIError =
| AuthenticationError | AuthenticationError
| InvalidRequestError | InvalidRequestError
| UnsupportedCapabilityError | UnsupportedCapabilityError
@@ -1079,7 +1079,7 @@ The redesign intentionally removes or changes these current concepts:
| `LLM.generate` means one turn | `LLM.generate` means complete run | | `LLM.generate` means one turn | `LLM.generate` means complete run |
| `LLMClient.generate/stream` | `LLM.generateTurn/streamTurn` for one turn | | `LLMClient.generate/stream` | `LLM.generateTurn/streamTurn` for one turn |
| `LLMClient.layer` requirement | Standard Effect requirements exposed directly | | `LLMClient.layer` requirement | Standard Effect requirements exposed directly |
| Public `Route` mental model | Hidden behind executable `Model` | | Public `Route` mental model | Hidden behind executable `LanguageModel` |
| `Provider.make` structural helper | Experimental declarative `Provider.define` | | `Provider.make` structural helper | Experimental declarative `Provider.define` |
| Schema classes as canonical values | Plain immutable values plus schema subpath | | Schema classes as canonical values | Plain immutable values plus schema subpath |
| `LLM.updateRequest` | Object spread | | `LLM.updateRequest` | Object spread |
@@ -1089,7 +1089,7 @@ The redesign intentionally removes or changes these current concepts:
| `generateObject` | Typed `output` option on `generate` | | `generateObject` | Typed `output` option on `generate` |
| One event union for provider output | Separate `TurnEvent` and `RunEvent` unions | | One event union for provider output | Separate `TurnEvent` and `RunEvent` unions |
| `providerExecuted` dispatch check | Distinct hosted-tool constructors | | `providerExecuted` dispatch check | Distinct hosted-tool constructors |
| One wrapped `LLMError` | Tagged domain error union | | One wrapped `AIError` | Tagged domain error union |
OpenCode should migrate to `generateTurn` / `streamTurn`, preserving its durable OpenCode should migrate to `generateTurn` / `streamTurn`, preserving its durable
prompt admission, persistence, permission, tool settlement, and continuation prompt admission, persistence, permission, tool settlement, and continuation
+33 -2
View File
@@ -3,8 +3,9 @@
Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code. Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code.
```ts ```ts
import { Effect } from "effect" import { Effect, Layer } from "effect"
import { LLM, LLMClient } from "@opencode-ai/ai" import { LLM, LLMClient } from "@opencode-ai/ai"
import { RequestExecutor } from "@opencode-ai/ai/route"
import { OpenAI } from "@opencode-ai/ai/providers" import { OpenAI } from "@opencode-ai/ai/providers"
const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini") const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")
@@ -20,6 +21,10 @@ const program = Effect.gen(function* () {
const response = yield* LLMClient.generate(request) const response = yield* LLMClient.generate(request)
console.log(response.text) console.log(response.text)
}) })
const llmLayer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
``` ```
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment. Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
@@ -195,11 +200,37 @@ The hosted result is represented as a provider-executed tool call and tool resul
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes. - **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use. - **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model. - **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
- **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model. - **`LanguageModel.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams. - **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model. - **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`. - **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
## Testing
Use the deterministic test client from `@opencode-ai/ai/testing` to script provider-neutral responses and inspect
the requests sent by code under test:
```ts
import { Effect } from "effect"
import { TestLLM } from "@opencode-ai/ai/testing"
const testLLM = TestLLM.layer({
fallback: TestLLM.text("Hello from the test model", "text-1"),
})
// TestLLM.clientLayer provides LLMClient.Service and consumes TestLLM.Service.
const programWithTestClient = Effect.gen(function* () {
const result = yield* program
const test = yield* TestLLM.Service
console.log(test.requests)
return result
}).pipe(Effect.provide(TestLLM.clientLayer), Effect.provide(testLLM))
```
`TestLLM.push(...)` scripts one-shot responses, `TestLLM.always(...)` changes the fallback, and
`TestLLM.wait(...)` lets concurrent tests wait until a request has arrived. Every received canonical request is
available on the yielded `TestLLM.Service`.
## Caching ## Caching
Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there). Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there).
+23 -23
View File
@@ -60,7 +60,7 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. | | `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. | | `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. | | `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Missing | Decide native Mantle shape, likely separate from Converse because it uses OpenAI-compatible Chat/Responses semantics over Bedrock. Add package mapping and tests. | | `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
## Highest-Risk Gaps ## Highest-Risk Gaps
@@ -71,30 +71,31 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review. 5. Azure is only a provider facade, not a full runtime replacement. Native Azure exists, but the catalog runner does not select it, and token auth/resource variants need review.
6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage. 6. Provider option typing is uneven. OpenAI, Anthropic, Gemini, Bedrock, and OpenRouter each expose a small typed subset plus raw HTTP overlays; this is useful but not equivalent to AI SDK provider option coverage.
7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed. 7. Structured output is not provider-native yet. `LLM.generateObject` still uses a synthetic tool strategy, while the future design expects native structured output where reliable and tool fallback where needed.
8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Vertex xAI still needs catalog API selection; the missing native boundary is Bedrock Mantle. 8. Package/namespace boundaries for the current native loading set are explicit in docs and exports. Other exported provider facades are not catalog package entrypoints until they implement the contract. Vertex xAI still needs catalog API selection.
9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure, Vertex, and Mantle need first-class recorded scenarios before switching defaults. 9. Recorded coverage is uneven. OpenAI, Anthropic, Gemini, Bedrock Converse, Bedrock Mantle, Cloudflare, OpenRouter, and several OpenAI-compatible Chat providers have cassettes. Azure and Vertex still need first-class recorded scenarios before switching defaults.
## Native Namespace Shape ## Native Namespace Shape
These are implementation/API slices, not separate npm packages. These are implementation/API slices, not separate npm packages.
| API slice | Package-like entrypoint | Purpose | | API slice | Package-like entrypoint | Purpose |
| ----------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------- | | ----------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------- |
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. | | OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. | | OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. | | OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. | | Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. | | Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. | | Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. | | Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. | | Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. | | Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. | | Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. | | Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. | | Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
| Bedrock Mantle | Missing | AWS Bedrock Mantle OpenAI-compatible APIs. | | Bedrock Mantle Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. |
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. | | Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. |
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. | | Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
## Suggested Next Work Slices ## Suggested Next Work Slices
@@ -103,6 +104,5 @@ These are implementation/API slices, not separate npm packages.
3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling. 3. Bring Bedrock native auth/config to AI SDK parity: region, profile, default AWS credential chain, bearer token env, endpoint override, and cross-region inference profile handling.
4. Add runner/catalog mappings and recorded scenarios for the native Vertex Gemini, Chat, Responses, and Messages entrypoints. 4. Add runner/catalog mappings and recorded scenarios for the native Vertex Gemini, Chat, Responses, and Messages entrypoints.
5. Decide Chat/Responses selection for `@ai-sdk/google-vertex/xai` catalog models. 5. Decide Chat/Responses selection for `@ai-sdk/google-vertex/xai` catalog models.
6. Add Bedrock Mantle as a separate OpenAI-compatible Bedrock namespace after deciding whether it uses Chat, Responses, or both by model. 6. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples.
7. Expand typed provider options from the existing V1 lowerer knowledge in `packages/core/src/v1/config/provider-options.ts` before adding more raw overlay examples. 7. Add recorded provider tests for Azure, Vertex Gemini, Vertex Chat, Vertex Responses, Vertex Messages, and Bedrock credential-chain behavior before making native runtime the default for those packages.
8. Add recorded provider tests for Azure, Vertex Gemini, Vertex Chat, Vertex Responses, Vertex Messages, Bedrock credential-chain behavior, and Mantle before making native runtime the default for those packages.
+10 -10
View File
@@ -33,7 +33,7 @@ Keep durable identity separate from runtime capability:
- Durable identity is small serializable data like `{ providerID, modelID }` for - Durable identity is small serializable data like `{ providerID, modelID }` for
config, sessions, logs, and catalogs. config, sessions, logs, and catalogs.
- Runtime capability is a `Model` with a route value, protocol, transport, auth, - Runtime capability is a `LanguageModel` with a route value, protocol, transport, auth,
and defaults. It is allowed to contain functions and schemas. and defaults. It is allowed to contain functions and schemas.
- If persisted identity needs to become executable, resolve it through an app - If persisted identity needs to become executable, resolve it through an app
boundary first. Do not make `LLMRequest` recover behavior from a global route boundary first. Do not make `LLMRequest` recover behavior from a global route
@@ -137,7 +137,7 @@ starts hiding the real provider-specific config.
- accepts model id only - accepts model id only
- returns executable models - returns executable models
- does not accept endpoint/auth/deployment overrides - does not accept endpoint/auth/deployment overrides
4. **Model** 4. **Language Model**
- model id - model id
- route value - route value
- provider id - provider id
@@ -164,7 +164,7 @@ execution mechanism:
```ts ```ts
type ProviderFacade<APIs, Config> = { type ProviderFacade<APIs, Config> = {
readonly id: ProviderID readonly id: ProviderID
readonly model: (id: string) => Model readonly model: (id: string) => LanguageModel
readonly configure: (input?: Config) => ProviderFacade<APIs, Config> readonly configure: (input?: Config) => ProviderFacade<APIs, Config>
} & APIs } & APIs
``` ```
@@ -181,8 +181,8 @@ export const OpenAI = {
configure: configureOpenAI, configure: configureOpenAI,
} satisfies ProviderFacade< } satisfies ProviderFacade<
{ {
responses: (id: string) => Model responses: (id: string) => LanguageModel
chat: (id: string) => Model chat: (id: string) => LanguageModel
}, },
OpenAIConfig OpenAIConfig
> >
@@ -528,7 +528,7 @@ The chosen split is:
```txt ```txt
Route = execution mechanics Route = execution mechanics
Provider facade = configured route group Provider facade = configured route group
Model = selected executable model carrying route value LanguageModel = selected executable model carrying route value
App boundary = explicit durable-config -> typed-provider call App boundary = explicit durable-config -> typed-provider call
``` ```
@@ -549,13 +549,13 @@ App boundary = explicit durable-config -> typed-provider call
entrypoint maps its scoped `transport` setting before constructing the model. entrypoint maps its scoped `transport` setting before constructing the model.
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one - No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
client layer with the available transport capabilities. client layer with the available transport capabilities.
- No executable `ModelRef`. The executable handle is `Model`; durable model - No executable `ModelRef`. The executable handle is `LanguageModel`; durable model
identity stays separate and cannot execute on its own. identity stays separate and cannot execute on its own.
## Implementation Todo ## Implementation Todo
- [x] Replace the current executable `ModelRef` with `Model`. - [x] Replace the current executable `ModelRef` with `LanguageModel`.
- [x] Change `Model.route` to carry a route value, not a `RouteID` string. - [x] Change `LanguageModel.route` to carry a route value, not a `RouteID` string.
- [ ] Keep a separate durable model identity type for persisted/session/catalog - [ ] Keep a separate durable model identity type for persisted/session/catalog
data, likely `{ providerID, modelID }`, and make it clear that it cannot data, likely `{ providerID, modelID }`, and make it clear that it cannot
execute without resolver context. execute without resolver context.
@@ -566,7 +566,7 @@ App boundary = explicit durable-config -> typed-provider call
- [x] Remove endpoint/auth escape hatches from route model selection; callers must - [x] Remove endpoint/auth escape hatches from route model selection; callers must
configure endpoint/auth through `route.with(...)` or provider facades before configure endpoint/auth through `route.with(...)` or provider facades before
calling `.model(...)`. calling `.model(...)`.
- [x] Remove request-shaping defaults from `Model`; selected models now carry only - [x] Remove request-shaping defaults from `LanguageModel`; selected models now carry only
id, provider, and configured route while defaults live on routes or requests. id, provider, and configured route while defaults live on routes or requests.
- [x] Rework `LLMClient.stream` / `generate` to read - [x] Rework `LLMClient.stream` / `generate` to read
`request.model.route` directly instead of calling `registeredRoute(...)`. `request.model.route` directly instead of calling `registeredRoute(...)`.
+4 -4
View File
@@ -1,25 +1,25 @@
import { Context, Effect, Layer } from "effect" import { Context, Effect, Layer } from "effect"
import { RequestExecutor } from "./route/executor" import { RequestExecutor } from "./route/executor"
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image" import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image"
import type { LLMError } from "./schema" import type { AIError } from "./schema"
export type Execute = RequestExecutor.Interface["execute"] export type Execute = RequestExecutor.Interface["execute"]
export interface Interface { export interface Interface {
readonly generate: <Options extends ImageOptions>( readonly generate: <Options extends ImageOptions>(
request: ImageRequestFor<Options>, request: ImageRequestFor<Options>,
) => Effect.Effect<ImageResponse, LLMError> ) => Effect.Effect<ImageResponse, AIError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {} export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
export const generate = <Options extends ImageOptions>( export const generate = <Options extends ImageOptions>(
request: ImageRequestFor<Options>, request: ImageRequestFor<Options>,
): Effect.Effect<ImageResponse, LLMError> => ): Effect.Effect<ImageResponse, AIError, Service> =>
Effect.gen(function* () { Effect.gen(function* () {
const client = yield* Service const client = yield* Service
return yield* client.generate(request) return yield* client.generate(request)
}) as Effect.Effect<ImageResponse, LLMError> })
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect( export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service, Service,
+5 -8
View File
@@ -1,13 +1,10 @@
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { HttpOptions, InvalidRequestReason, LLMError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema" import { HttpOptions, InvalidRequestReason, AIError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
import { ImageClient, Service, type Execute as ImageExecute } from "./image-client" import { ImageClient, Service, type Execute as ImageExecute } from "./image-client"
export interface ImageRoute<Options extends ImageOptions = ImageOptions> { export interface ImageRoute<Options extends ImageOptions = ImageOptions> {
readonly id: string readonly id: string
readonly generate: ( readonly generate: (request: ImageRequestFor<Options>, execute: ImageExecute) => Effect.Effect<ImageResponse, AIError>
request: ImageRequestFor<Options>,
execute: ImageExecute,
) => Effect.Effect<ImageResponse, LLMError>
} }
export type ImageOptions = Record<string, unknown> export type ImageOptions = Record<string, unknown>
@@ -146,13 +143,13 @@ export function request(input: ImageRequest | ImageRequestInput) {
export function generate<const Model extends object>( export function generate<const Model extends object>(
input: ImageRequestInput<Model>, input: ImageRequestInput<Model>,
): Effect.Effect<ImageResponse, LLMError, Service> ): Effect.Effect<ImageResponse, AIError, Service>
export function generate(input: ImageRequest): Effect.Effect<ImageResponse, LLMError, Service> export function generate(input: ImageRequest): Effect.Effect<ImageResponse, AIError, Service>
export function generate(input: ImageRequest | ImageRequestInput) { export function generate(input: ImageRequest | ImageRequestInput) {
return Effect.try({ return Effect.try({
try: () => (input instanceof ImageRequest ? input : request(input)), try: () => (input instanceof ImageRequest ? input : request(input)),
catch: (error) => catch: (error) =>
new LLMError({ new AIError({
module: "Image", module: "Image",
method: "generate", method: "generate",
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }), reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
+4 -4
View File
@@ -5,8 +5,8 @@ export { Provider } from "./provider"
export { ProviderPackage } from "./provider-package" export { ProviderPackage } from "./provider-package"
export { isContextOverflow, isContextOverflowFailure } from "./provider-error" export { isContextOverflow, isContextOverflowFailure } from "./provider-error"
export type { export type {
RouteModelInput, RouteLanguageModelInput,
RouteRoutedModelInput, RouteRoutedLanguageModelInput,
Interface as LLMClientShape, Interface as LLMClientShape,
Service as LLMClientService, Service as LLMClientService,
} from "./route/client" } from "./route/client"
@@ -33,7 +33,7 @@ export type {
export * as LLM from "./llm" export * as LLM from "./llm"
export type { export type {
Definition as ProviderDefinition, Definition as ProviderDefinition,
ModelFactory as ProviderModelFactory, LanguageModelFactory as ProviderLanguageModelFactory,
ModelOptions as ProviderModelOptions, LanguageModelOptions as ProviderLanguageModelOptions,
} from "./provider" } from "./provider"
export type { Definition as ProviderPackageDefinition, Settings as ProviderPackageSettings } from "./provider-package" export type { Definition as ProviderPackageDefinition, Settings as ProviderPackageSettings } from "./provider-package"
+29 -22
View File
@@ -1,36 +1,36 @@
import { Effect, JsonSchema, Schema } from "effect" import { Effect, JsonSchema, Schema } from "effect"
import { LLMClient } from "./route/client" import { LLMClient, Service } from "./route/client"
import { import {
GenerationOptions, GenerationOptions,
HttpOptions, HttpOptions,
InvalidProviderOutputReason, InvalidProviderOutputReason,
LLMError, AIError,
LLMEvent, LLMEvent,
LLMRequest, LLMRequest,
LLMResponse, LLMResponse,
Message, Message,
Model, LanguageModel,
SystemPart, SystemPart,
ToolChoice, ToolChoice,
ToolDefinition, ToolDefinition,
type ContentPart, type ContentPart,
type ModelProviderOptions, type LanguageModelProviderOptions,
} from "./schema" } from "./schema"
import { make as makeTool, toDefinitions, type ToolSchema } from "./tool" import { make as makeTool, toDefinitions, type ToolSchema } from "./tool"
/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */ /** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */
export type RequestInput<SelectedModel extends Model = Model> = Omit< export type RequestInput<SelectedLanguageModel extends LanguageModel = LanguageModel> = Omit<
ConstructorParameters<typeof LLMRequest>[0], ConstructorParameters<typeof LLMRequest>[0],
"model" | "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions" "model" | "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions"
> & { > & {
readonly model: SelectedModel readonly model: SelectedLanguageModel
readonly system?: string | SystemPart | ReadonlyArray<SystemPart> readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart> readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
readonly messages?: ReadonlyArray<Message | Message.Input> readonly messages?: ReadonlyArray<Message | Message.Input>
readonly tools?: ReadonlyArray<ToolDefinition.Input> readonly tools?: ReadonlyArray<ToolDefinition.Input>
readonly toolChoice?: ToolChoice.Input readonly toolChoice?: ToolChoice.Input
readonly generation?: GenerationOptions.Input readonly generation?: GenerationOptions.Input
readonly providerOptions?: NoInfer<ModelProviderOptions<SelectedModel>> readonly providerOptions?: NoInfer<LanguageModelProviderOptions<SelectedLanguageModel>>
readonly http?: HttpOptions.Input readonly http?: HttpOptions.Input
} }
@@ -38,7 +38,9 @@ export const generate = LLMClient.generate
export const stream = LLMClient.stream export const stream = LLMClient.stream
export const request = <const SelectedModel extends Model>(input: RequestInput<SelectedModel>) => { export const request = <const SelectedLanguageModel extends LanguageModel>(
input: RequestInput<SelectedLanguageModel>,
) => {
const { const {
system: requestSystem, system: requestSystem,
prompt, prompt,
@@ -66,7 +68,10 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object"
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool." const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
type GenerateObjectBase<SelectedModel extends Model = Model> = Omit<RequestInput<SelectedModel>, "tools" | "toolChoice"> type GenerateObjectBase<SelectedLanguageModel extends LanguageModel = LanguageModel> = Omit<
RequestInput<SelectedLanguageModel>,
"tools" | "toolChoice"
>
export class GenerateObjectResponse<T> { export class GenerateObjectResponse<T> {
constructor( constructor(
@@ -83,13 +88,15 @@ export class GenerateObjectResponse<T> {
} }
} }
export interface GenerateObjectOptions<S extends ToolSchema<any>, SelectedModel extends Model = Model> export interface GenerateObjectOptions<
extends GenerateObjectBase<SelectedModel> { S extends ToolSchema<any>,
SelectedLanguageModel extends LanguageModel = LanguageModel,
> extends GenerateObjectBase<SelectedLanguageModel> {
readonly schema: S readonly schema: S
} }
export interface GenerateObjectDynamicOptions<SelectedModel extends Model = Model> export interface GenerateObjectDynamicOptions<SelectedLanguageModel extends LanguageModel = LanguageModel>
extends GenerateObjectBase<SelectedModel> { extends GenerateObjectBase<SelectedLanguageModel> {
/** Raw JSON Schema object describing the expected output shape. */ /** Raw JSON Schema object describing the expected output shape. */
readonly jsonSchema: JsonSchema.JsonSchema readonly jsonSchema: JsonSchema.JsonSchema
} }
@@ -108,7 +115,7 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
(event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME, (event) => LLMEvent.is.toolCall(event) && event.name === GENERATE_OBJECT_TOOL_NAME,
) )
if (!call || !LLMEvent.is.toolCall(call)) if (!call || !LLMEvent.is.toolCall(call))
return yield* new LLMError({ return yield* new AIError({
module: "LLM", module: "LLM",
method: "generateObject", method: "generateObject",
reason: new InvalidProviderOutputReason({ reason: new InvalidProviderOutputReason({
@@ -118,7 +125,7 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
const object = yield* tool._decode(call.input).pipe( const object = yield* tool._decode(call.input).pipe(
Effect.mapError( Effect.mapError(
(error) => (error) =>
new LLMError({ new AIError({
module: "LLM", module: "LLM",
method: "generateObject", method: "generateObject",
reason: new InvalidProviderOutputReason({ reason: new InvalidProviderOutputReason({
@@ -138,16 +145,16 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
* Two input modes: * Two input modes:
* *
* 1. `schema: EffectSchema<T>` — `.object` is decoded and typed as `T`. * 1. `schema: EffectSchema<T>` — `.object` is decoded and typed as `T`.
* Decode failures surface as `LLMError`. * Decode failures surface as `AIError`.
* 2. `jsonSchema: JsonSchema.JsonSchema` — `.object` is `unknown`. Use when * 2. `jsonSchema: JsonSchema.JsonSchema` — `.object` is `unknown`. Use when
* the schema is only available at runtime (MCP, plugin manifests). Caller validates. * the schema is only available at runtime (MCP, plugin manifests). Caller validates.
*/ */
export function generateObject<const SelectedModel extends Model, S extends ToolSchema<any>>( export function generateObject<const SelectedLanguageModel extends LanguageModel, S extends ToolSchema<any>>(
options: GenerateObjectOptions<S, SelectedModel>, options: GenerateObjectOptions<S, SelectedLanguageModel>,
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, LLMError> ): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, AIError, Service>
export function generateObject<const SelectedModel extends Model>( export function generateObject<const SelectedLanguageModel extends LanguageModel>(
options: GenerateObjectDynamicOptions<SelectedModel>, options: GenerateObjectDynamicOptions<SelectedLanguageModel>,
): Effect.Effect<GenerateObjectResponse<unknown>, LLMError> ): Effect.Effect<GenerateObjectResponse<unknown>, AIError, Service>
export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) { export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) {
if ("schema" in options) { if ("schema" in options) {
const { schema, ...rest } = options const { schema, ...rest } = options
+11 -13
View File
@@ -6,7 +6,7 @@ import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing" import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { import {
LLMError, AIError,
LLMEvent, LLMEvent,
mergeJsonRecords, mergeJsonRecords,
Usage, Usage,
@@ -242,16 +242,14 @@ const AnthropicUsage = Schema.StructWithRest(
cache_creation_input_tokens: optionalNull(Schema.Number), cache_creation_input_tokens: optionalNull(Schema.Number),
cache_read_input_tokens: optionalNull(Schema.Number), cache_read_input_tokens: optionalNull(Schema.Number),
server_tool_use: optionalNull( server_tool_use: optionalNull(
Schema.StructWithRest( Schema.StructWithRest(Schema.Struct({ web_search_requests: Schema.optional(Schema.Number) }), [
Schema.Struct({ web_search_requests: Schema.optional(Schema.Number) }), Schema.Record(Schema.String, Schema.Unknown),
[Schema.Record(Schema.String, Schema.Unknown)], ]),
),
), ),
output_tokens_details: optionalNull( output_tokens_details: optionalNull(
Schema.StructWithRest( Schema.StructWithRest(Schema.Struct({ thinking_tokens: Schema.optional(Schema.Number) }), [
Schema.Struct({ thinking_tokens: Schema.optional(Schema.Number) }), Schema.Record(Schema.String, Schema.Unknown),
[Schema.Record(Schema.String, Schema.Unknown)], ]),
),
), ),
}), }),
[Schema.Record(Schema.String, Schema.Unknown)], [Schema.Record(Schema.String, Schema.Unknown)],
@@ -725,8 +723,7 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
reasoningTokens, reasoningTokens,
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined), totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
providerMetadata: { providerMetadata: {
anthropic: anthropic: mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
}, },
}) })
} }
@@ -816,7 +813,8 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
if (block.type === "thinking" && block.thinking !== undefined) { if (block.type === "thinking" && block.thinking !== undefined) {
const events: LLMEvent[] = [] const events: LLMEvent[] = []
const id = `reasoning-${event.index ?? 0}` const id = `reasoning-${event.index ?? 0}`
const providerMetadata = block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature }) const providerMetadata =
block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature })
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata) const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata)
return [ return [
{ {
@@ -980,7 +978,7 @@ const providerErrorMessage = (event: AnthropicEvent): string => {
} }
const onError = (event: AnthropicEvent) => const onError = (event: AnthropicEvent) =>
new LLMError({ new AIError({
module: ADAPTER, module: ADAPTER,
method: "stream", method: "stream",
reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }), reason: classifyProviderFailure({ message: providerErrorMessage(event), code: event.error?.type }),
@@ -3,7 +3,7 @@ import { Route } from "../route/client"
import { Endpoint } from "../route/endpoint" import { Endpoint } from "../route/endpoint"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { import {
LLMError, AIError,
LLMEvent, LLMEvent,
Usage, Usage,
type CacheHint, type CacheHint,
@@ -11,7 +11,7 @@ import {
type FinishReasonDetails, type FinishReasonDetails,
type JsonSchema, type JsonSchema,
type LLMRequest, type LLMRequest,
type ModelToolSchemaCompatibility, type LanguageModelToolSchemaCompatibility,
type ProviderMetadata, type ProviderMetadata,
type ReasoningPart, type ReasoningPart,
type ToolCallPart, type ToolCallPart,
@@ -232,7 +232,7 @@ const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockTo
}) })
const lowerTools = ( const lowerTools = (
compatibility: ModelToolSchemaCompatibility | undefined, compatibility: LanguageModelToolSchemaCompatibility | undefined,
breakpoints: BedrockCache.Breakpoints, breakpoints: BedrockCache.Breakpoints,
tools: ReadonlyArray<ToolDefinition>, tools: ReadonlyArray<ToolDefinition>,
): BedrockTool[] => { ): BedrockTool[] => {
@@ -274,9 +274,7 @@ const reasoningSignature = (part: ReasoningPart) => {
const reasoningRedactedData = (part: ReasoningPart) => { const reasoningRedactedData = (part: ReasoningPart) => {
const bedrock = part.providerMetadata?.bedrock const bedrock = part.providerMetadata?.bedrock
return ProviderShared.isRecord(bedrock) && typeof bedrock.redactedData === "string" return ProviderShared.isRecord(bedrock) && typeof bedrock.redactedData === "string" ? bedrock.redactedData : undefined
? bedrock.redactedData
: undefined
} }
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({ const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
@@ -656,7 +654,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
] as const ] as const
).find((entry) => entry[1] !== undefined) ).find((entry) => entry[1] !== undefined)
if (exception) { if (exception) {
return yield* new LLMError({ return yield* new AIError({
module: ADAPTER, module: ADAPTER,
method: "stream", method: "stream",
reason: classifyProviderFailure({ reason: classifyProviderFailure({
+1
View File
@@ -444,6 +444,7 @@ const mapUsage = (usage: GeminiUsage | undefined) => {
} }
const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean): FinishReason => { const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean): FinishReason => {
if (finishReason === undefined) return hasToolCalls ? "tool-calls" : "unknown"
if (finishReason === "STOP") return hasToolCalls ? "tool-calls" : "stop" if (finishReason === "STOP") return hasToolCalls ? "tool-calls" : "stop"
if (finishReason === "MAX_TOKENS") return "length" if (finishReason === "MAX_TOKENS") return "length"
if ( if (
+3 -3
View File
@@ -11,7 +11,7 @@ import {
import { Auth, type Definition as AuthDefinition } from "../route/auth" import { Auth, type Definition as AuthDefinition } from "../route/auth"
import { import {
InvalidProviderOutputReason, InvalidProviderOutputReason,
LLMError, AIError,
Usage, Usage,
mergeHttpOptions, mergeHttpOptions,
mergeJsonRecords, mergeJsonRecords,
@@ -125,7 +125,7 @@ const nativeOptions = (options: GoogleImageOptions | undefined) => {
} }
const invalidOutput = (message: string, providerMetadata?: ProviderMetadata) => const invalidOutput = (message: string, providerMetadata?: ProviderMetadata) =>
new LLMError({ new AIError({
module: ADAPTER, module: ADAPTER,
method: "generate", method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER, providerMetadata }), reason: new InvalidProviderOutputReason({ message, route: ADAPTER, providerMetadata }),
@@ -285,7 +285,7 @@ export const model = (input: ModelInput) => {
return ImageModel.make<GoogleImageOptions>({ id: input.id, provider: "google", route, http: input.http }) return ImageModel.make<GoogleImageOptions>({ id: input.id, provider: "google", route, http: input.http })
} }
const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknown>, LLMError> => { const googleImagePart = (image: ImageInput): Effect.Effect<Record<string, unknown>, AIError> => {
if (image.type === "bytes") if (image.type === "bytes")
return Effect.succeed({ inlineData: { mimeType: image.mediaType, data: Encoding.encodeBase64(image.data) } }) return Effect.succeed({ inlineData: { mimeType: image.mediaType, data: Encoding.encodeBase64(image.data) } })
if (image.type === "file-uri") return Effect.succeed({ fileData: { mimeType: image.mediaType, fileUri: image.uri } }) if (image.type === "file-uri") return Effect.succeed({ fileData: { mimeType: image.mediaType, fileUri: image.uri } })
+4 -10
View File
@@ -3,7 +3,7 @@ import type { Content } from "@opencode-ai/schema/tool"
import { HttpTransport } from "../route/transport" import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { import {
LLMError, AIError,
LLMEvent, LLMEvent,
Usage, Usage,
type FinishReason, type FinishReason,
@@ -434,10 +434,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>( const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
(groups, part) => { (groups, part) => {
const metadata = part.providerMetadata?.[providerMetadataKey] const metadata = part.providerMetadata?.[providerMetadataKey]
const phase = const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase, extension) : undefined
ProviderShared.isRecord(metadata)
? messagePhase(metadata.phase, extension)
: undefined
const group = groups.at(-1) const group = groups.at(-1)
if (group && group.phase === phase) group.parts.push(part) if (group && group.phase === phase) group.parts.push(part)
else groups.push({ phase, parts: [part] }) else groups.push({ phase, parts: [part] })
@@ -646,10 +643,7 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
const phase = state.messagePhases[id] const phase = state.messagePhases[id]
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase }) const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata) const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [ return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) },
events,
]
} }
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => { const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
@@ -975,7 +969,7 @@ const providerErrorMessage = (event: Event, fallback: string): string => {
const providerError = (state: ParserState, event: Event, fallback: string) => { const providerError = (state: ParserState, event: Event, fallback: string) => {
const code = event.code || event.error?.code || event.response?.error?.code || undefined const code = event.code || event.error?.code || event.response?.error?.code || undefined
const message = providerErrorMessage(event, fallback) const message = providerErrorMessage(event, fallback)
return new LLMError({ return new AIError({
module: state.id, module: state.id,
method: "stream", method: "stream",
reason: classifyProviderFailure({ message, code }), reason: classifyProviderFailure({ message, code }),
+3 -3
View File
@@ -6,7 +6,7 @@ import { Endpoint } from "../route/endpoint"
import { HttpTransport } from "../route/transport" import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { import {
LLMError, AIError,
LLMEvent, LLMEvent,
Usage, Usage,
type FinishReason, type FinishReason,
@@ -555,7 +555,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a // OpenAI Chat reports `prompt_tokens` (inclusive total) with a
// cached-read and cache-write subsets, and `completion_tokens` (inclusive // cached-read and cache-write subsets, and `completion_tokens` (inclusive
// total) with a `reasoning_tokens` subset. We pass the inclusive totals // total) with a `reasoning_tokens` subset. We pass the inclusive totals
// through and derive the non-cached breakdown so the `LLM.Usage` contract is // through and derive the non-cached breakdown so the `AI.Usage` contract is
// satisfied on both sides. // satisfied on both sides.
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => { const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
if (!usage) return undefined if (!usage) return undefined
@@ -645,7 +645,7 @@ const reasoningMetadata = (field: ParserState["reasoningField"], details?: Reado
const step = (state: ParserState, event: OpenAIChatEvent) => const step = (state: ParserState, event: OpenAIChatEvent) =>
Effect.gen(function* () { Effect.gen(function* () {
if (event.error) if (event.error)
return yield* new LLMError({ return yield* new AIError({
module: ADAPTER, module: ADAPTER,
method: "stream", method: "stream",
reason: classifyProviderFailure({ reason: classifyProviderFailure({
@@ -1,11 +1,11 @@
import { Route, type RouteRoutedModelInput } from "../route/client" import { Route, type RouteRoutedLanguageModelInput } from "../route/client"
import { Endpoint } from "../route/endpoint" import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing" import { Framing } from "../route/framing"
import * as OpenAIChat from "./openai-chat" import * as OpenAIChat from "./openai-chat"
const ADAPTER = "openai-compatible-chat" const ADAPTER = "openai-compatible-chat"
export type OpenAICompatibleChatModelInput = RouteRoutedModelInput export type OpenAICompatibleChatLanguageModelInput = RouteRoutedLanguageModelInput
/** /**
* Route for non-OpenAI providers that expose an OpenAI Chat-compatible * Route for non-OpenAI providers that expose an OpenAI Chat-compatible
@@ -1,10 +1,10 @@
import { Route, type RouteRoutedModelInput } from "../route/client" import { Route, type RouteRoutedLanguageModelInput } from "../route/client"
import { Endpoint } from "../route/endpoint" import { Endpoint } from "../route/endpoint"
import { OpenResponses } from "./open-responses" import { OpenResponses } from "./open-responses"
const ADAPTER = "openai-compatible-responses" const ADAPTER = "openai-compatible-responses"
export type OpenAICompatibleResponsesModelInput = RouteRoutedModelInput export type OpenAICompatibleResponsesLanguageModelInput = RouteRoutedLanguageModelInput
/** /**
* Deployment adapter for providers that expose an Open Responses-compatible * Deployment adapter for providers that expose an Open Responses-compatible
+2 -2
View File
@@ -11,7 +11,7 @@ import {
import { Auth, type Definition as AuthDefinition } from "../route/auth" import { Auth, type Definition as AuthDefinition } from "../route/auth"
import { import {
InvalidProviderOutputReason, InvalidProviderOutputReason,
LLMError, AIError,
Usage, Usage,
mergeHttpOptions, mergeHttpOptions,
mergeJsonRecords, mergeJsonRecords,
@@ -85,7 +85,7 @@ const nativeOptions = (options: OpenAIImageOptions | undefined) => {
} }
const invalidOutput = (message: string) => const invalidOutput = (message: string) =>
new LLMError({ new AIError({
module: ADAPTER, module: ADAPTER,
method: "generate", method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }), reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
+7 -7
View File
@@ -6,7 +6,7 @@ import { Headers, HttpClientRequest } from "effect/unstable/http"
import { import {
InvalidProviderOutputReason, InvalidProviderOutputReason,
InvalidRequestReason, InvalidRequestReason,
LLMError, AIError,
type ContentPart, type ContentPart,
type LLMRequest, type LLMRequest,
type MediaPart, type MediaPart,
@@ -41,7 +41,7 @@ export interface ToolAccumulator {
* when at least one is defined. Returns `undefined` when neither input nor * when at least one is defined. Returns `undefined` when neither input nor
* output is known so routes don't publish a misleading `0`. * output is known so routes don't publish a misleading `0`.
* *
* Under the additive `LLM.Usage` contract, `inputTokens` and `outputTokens` * Under the additive `AI.Usage` contract, `inputTokens` and `outputTokens`
* are the non-cached input and visible output only. The provider-supplied * are the non-cached input and visible output only. The provider-supplied
* `total` is the source of truth when present; the computed fallback * `total` is the source of truth when present; the computed fallback
* under-counts cache and reasoning by design and exists mainly so * under-counts cache and reasoning by design and exists mainly so
@@ -88,7 +88,7 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
} }
export const eventError = (route: string, message: string, raw?: string) => export const eventError = (route: string, message: string, raw?: string) =>
new LLMError({ new AIError({
module: "ProviderShared", module: "ProviderShared",
method: "stream", method: "stream",
reason: new InvalidProviderOutputReason({ route, message, raw }), reason: new InvalidProviderOutputReason({ route, message, raw }),
@@ -238,9 +238,9 @@ export const errorText = (error: unknown) => {
* `decodeChunk` sees one JSON string per element. The SSE channel emits a * `decodeChunk` sees one JSON string per element. The SSE channel emits a
* `Retry` control event on its error channel; we drop it here (we don't * `Retry` control event on its error channel; we drop it here (we don't
* implement client-driven retries) so the public error channel stays * implement client-driven retries) so the public error channel stays
* `LLMError`. * `AIError`.
*/ */
export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.Stream<string, LLMError> => export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): Stream.Stream<string, AIError> =>
bytes.pipe( bytes.pipe(
Stream.decodeText(), Stream.decodeText(),
Stream.pipeThroughChannel(Sse.decode()), Stream.pipeThroughChannel(Sse.decode()),
@@ -257,7 +257,7 @@ export const sseFraming = (bytes: Stream.Stream<Uint8Array, LLMError>): Stream.S
* lands here. * lands here.
*/ */
export const invalidRequest = (message: string) => export const invalidRequest = (message: string) =>
new LLMError({ new AIError({
module: "ProviderShared", module: "ProviderShared",
method: "request", method: "request",
reason: new InvalidRequestReason({ message }), reason: new InvalidRequestReason({ message }),
@@ -304,7 +304,7 @@ export const unsupportedContent = (
* Build a `validate` step from a Schema decoder. Replaces the per-route * Build a `validate` step from a Schema decoder. Replaces the per-route
* lambda body `(payload) => decode(payload).pipe(Effect.mapError((e) => * lambda body `(payload) => decode(payload).pipe(Effect.mapError((e) =>
* invalid(e.message)))`. Any decode error is translated into * invalid(e.message)))`. Any decode error is translated into
* `LLMError` carrying the original parse-error message. * `AIError` carrying the original parse-error message.
*/ */
export const validateWith = export const validateWith =
<A, I, E extends { readonly message: string }>(decode: (input: I) => Effect.Effect<A, E>) => <A, I, E extends { readonly message: string }>(decode: (input: I) => Effect.Effect<A, E>) =>
@@ -22,6 +22,8 @@ const signRequest = (input: {
readonly body: string readonly body: string
readonly headers: Headers.Headers readonly headers: Headers.Headers
readonly credentials: Credentials readonly credentials: Credentials
readonly service: string
readonly name: string
}) => }) =>
Effect.tryPromise({ Effect.tryPromise({
try: async () => { try: async () => {
@@ -34,23 +36,26 @@ const signRequest = (input: {
accessKeyId: input.credentials.accessKeyId, accessKeyId: input.credentials.accessKeyId,
secretAccessKey: input.credentials.secretAccessKey, secretAccessKey: input.credentials.secretAccessKey,
sessionToken: input.credentials.sessionToken, sessionToken: input.credentials.sessionToken,
service: "bedrock", service: input.service,
}).sign() }).sign()
return Object.fromEntries(signed.headers.entries()) return Object.fromEntries(signed.headers.entries())
}, },
catch: (error) => catch: (error) =>
ProviderShared.invalidRequest( ProviderShared.invalidRequest(
`Bedrock Converse SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`, `${input.name} SigV4 signing failed: ${error instanceof Error ? error.message : String(error)}`,
), ),
}) })
/** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */ /** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
export const sigV4 = (credentials: Credentials | undefined) => export const sigV4 = (
credentials: Credentials | undefined,
options: { readonly service?: string; readonly name?: string } = {},
) =>
Auth.custom((input: AuthInput) => { Auth.custom((input: AuthInput) => {
return Effect.gen(function* () { return Effect.gen(function* () {
if (!credentials) { if (!credentials) {
return yield* ProviderShared.invalidRequest( return yield* ProviderShared.invalidRequest(
"Bedrock Converse requires either route bearer auth or AWS credentials configured on the route", `${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`,
) )
} }
const headersForSigning = Headers.set(input.headers, "content-type", "application/json") const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
@@ -59,6 +64,8 @@ export const sigV4 = (credentials: Credentials | undefined) =>
body: input.body, body: input.body,
headers: headersForSigning, headers: headersForSigning,
credentials, credentials,
service: options.service ?? "bedrock",
name: options.name ?? "Bedrock Converse",
}) })
return Headers.setAll(headersForSigning, signed) return Headers.setAll(headersForSigning, signed)
}) })
@@ -1,9 +1,9 @@
import { Effect, Encoding } from "effect" import { Effect, Encoding } from "effect"
import type { ImageInput } from "../../image" import type { ImageInput } from "../../image"
import { InvalidRequestReason, LLMError } from "../../schema" import { InvalidRequestReason, AIError } from "../../schema"
const invalid = (module: string, message: string) => const invalid = (module: string, message: string) =>
new LLMError({ new AIError({
module, module,
method: "generate", method: "generate",
reason: new InvalidRequestReason({ message }), reason: new InvalidRequestReason({ message }),
@@ -15,7 +15,7 @@ export const dataUrl = (input: Extract<ImageInput, { readonly type: "bytes" }>)
export const decodeDataUrl = ( export const decodeDataUrl = (
url: string, url: string,
module: string, module: string,
): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, LLMError> => { ): Effect.Effect<{ readonly mediaType: string; readonly data: Uint8Array } | undefined, AIError> => {
if (!url.startsWith("data:")) return Effect.succeed(undefined) if (!url.startsWith("data:")) return Effect.succeed(undefined)
const match = /^data:([^;,]+);base64,(.*)$/s.exec(url) const match = /^data:([^;,]+);base64,(.*)$/s.exec(url)
if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data")) if (!match) return Effect.fail(invalid(module, "Image data URLs must contain a MIME type and base64 data"))
@@ -1,4 +1,4 @@
import type { JsonSchema, ModelToolSchemaCompatibility } from "../../schema" import type { JsonSchema, LanguageModelToolSchemaCompatibility } from "../../schema"
import { isRecord } from "../../utils/record" import { isRecord } from "../../utils/record"
import { GeminiToolSchema } from "./gemini-tool-schema" import { GeminiToolSchema } from "./gemini-tool-schema"
@@ -69,7 +69,7 @@ const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(sche
const modelCompatibility = ( const modelCompatibility = (
schema: JsonSchema, schema: JsonSchema,
compatibility: ModelToolSchemaCompatibility | undefined, compatibility: LanguageModelToolSchemaCompatibility | undefined,
): JsonSchema => { ): JsonSchema => {
if (compatibility === undefined) return schema if (compatibility === undefined) return schema
switch (compatibility) { switch (compatibility) {
@@ -1,5 +1,5 @@
import { Effect } from "effect" import { Effect } from "effect"
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema" import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared" import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
type StreamKey = string | number type StreamKey = string | number
@@ -112,8 +112,8 @@ const appendTool = <K extends StreamKey>(
} }
} }
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError => export const isError = <K extends StreamKey>(result: AppendOutcome<K> | AIError): result is AIError =>
result instanceof LLMError result instanceof AIError
/** /**
* Register a tool call whose start event arrived before any argument deltas. * Register a tool call whose start event arrived before any argument deltas.
@@ -138,7 +138,7 @@ export const appendOrStart = <K extends StreamKey>(
key: K, key: K,
delta: { readonly id?: string; readonly name?: string; readonly text: string }, delta: { readonly id?: string; readonly name?: string; readonly text: string },
missingToolMessage: string, missingToolMessage: string,
): AppendOutcome<K> | LLMError => { ): AppendOutcome<K> | AIError => {
const current = tools[key] const current = tools[key]
const id = current?.id ?? delta.id const id = current?.id ?? delta.id
const name = current?.name ?? delta.name const name = current?.name ?? delta.name
@@ -167,7 +167,7 @@ export const appendExisting = <K extends StreamKey>(
key: K, key: K,
text: string, text: string,
missingToolMessage: string, missingToolMessage: string,
): AppendOutcome<K> | LLMError => { ): AppendOutcome<K> | AIError => {
const current = tools[key] const current = tools[key]
if (!current) return eventError(route, missingToolMessage) if (!current) return eventError(route, missingToolMessage)
if (text.length === 0) return { tools, tool: current, events: [] } if (text.length === 0) return { tools, tool: current, events: [] }
+2 -2
View File
@@ -4,7 +4,7 @@ import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type I
import { Auth, type Definition as AuthDefinition } from "../route/auth" import { Auth, type Definition as AuthDefinition } from "../route/auth"
import { import {
InvalidProviderOutputReason, InvalidProviderOutputReason,
LLMError, AIError,
Usage, Usage,
mergeHttpOptions, mergeHttpOptions,
mergeJsonRecords, mergeJsonRecords,
@@ -95,7 +95,7 @@ const nativeOptions = (options: XAIImageOptions | undefined) => {
} }
const invalidOutput = (message: string) => const invalidOutput = (message: string) =>
new LLMError({ new AIError({
module: ADAPTER, module: ADAPTER,
method: "generate", method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }), reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
+2 -2
View File
@@ -2,7 +2,7 @@ import { Effect, Schema } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http" import { Headers, HttpClientRequest } from "effect/unstable/http"
import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image" import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
import { Auth, type Definition as AuthDefinition } from "../route/auth" import { Auth, type Definition as AuthDefinition } from "../route/auth"
import { InvalidProviderOutputReason, LLMError, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema" import { InvalidProviderOutputReason, AIError, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema"
import { ProviderShared } from "./shared" import { ProviderShared } from "./shared"
import { ImageInputs } from "./utils/image-input" import { ImageInputs } from "./utils/image-input"
@@ -58,7 +58,7 @@ const nativeOptions = (options: ZAIImageOptions | undefined) => {
} }
const invalidOutput = (message: string) => const invalidOutput = (message: string) =>
new LLMError({ new AIError({
module: ADAPTER, module: ADAPTER,
method: "generate", method: "generate",
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }), reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
+3 -3
View File
@@ -3,7 +3,7 @@ import {
AuthenticationReason, AuthenticationReason,
ContentPolicyReason, ContentPolicyReason,
InvalidRequestReason, InvalidRequestReason,
LLMError, AIError,
ProviderErrorEvent, ProviderErrorEvent,
ProviderInternalReason, ProviderInternalReason,
QuotaExceededReason, QuotaExceededReason,
@@ -53,7 +53,7 @@ export const isContextOverflow = (message: string) =>
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message)) export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
export const isContextOverflowFailure = (failure: unknown) => export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError failure instanceof AIError
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow" ? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow" : Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
@@ -86,7 +86,7 @@ export interface ProviderFailure {
// Keep HTTP failures and provider-reported stream failures on one typed path so // Keep HTTP failures and provider-reported stream failures on one typed path so
// session retry policy never needs provider-specific string matching. // session retry policy never needs provider-specific string matching.
export function classifyProviderFailure(input: ProviderFailure): LLMError["reason"] { export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
const body = input.http?.body ?? "" const body = input.http?.body ?? ""
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)] const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
.filter((code): code is string => code !== undefined) .filter((code): code is string => code !== undefined)
+2 -2
View File
@@ -1,4 +1,4 @@
import type { Model, ProviderOptions } from "./schema" import type { LanguageModel, ProviderOptions } from "./schema"
export interface Settings extends Readonly<Record<string, unknown>> { export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string readonly baseURL?: string
@@ -15,7 +15,7 @@ export interface Definition<
ProviderSettings extends Settings = Settings, ProviderSettings extends Settings = Settings,
Options extends ProviderOptions = ProviderOptions, Options extends ProviderOptions = ProviderOptions,
> { > {
readonly model: (modelID: string, settings: ProviderSettings) => Model<Options> readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
} }
export * as ProviderPackage from "./provider-package" export * as ProviderPackage from "./provider-package"
+9 -9
View File
@@ -1,6 +1,6 @@
import type { Model, ModelID, ProviderID } from "./schema" import type { LanguageModel, ModelID, ProviderID } from "./schema"
export type ModelOptions = Pick<Model.Input, "defaults" | "compatibility"> export type LanguageModelOptions = Pick<LanguageModel.Input, "defaults" | "compatibility">
/** /**
* Advanced structural provider definition helper. Built-in providers should * Advanced structural provider definition helper. Built-in providers should
@@ -8,23 +8,23 @@ export type ModelOptions = Pick<Model.Input, "defaults" | "compatibility">
* chosen before model selection. The optional `apis` map remains for external * chosen before model selection. The optional `apis` map remains for external
* structural providers that expose multiple route selectors behind one provider. * structural providers that expose multiple route selectors behind one provider.
*/ */
export type ModelFactory<Options extends ModelOptions = ModelOptions> = ( export type LanguageModelFactory<Options extends LanguageModelOptions = LanguageModelOptions> = (
id: string | ModelID, id: string | ModelID,
options?: Options, options?: Options,
) => Model ) => LanguageModel
type AnyModelFactory = (...args: never[]) => Model type AnyLanguageModelFactory = (...args: never[]) => LanguageModel
export interface Definition<Factory extends AnyModelFactory = ModelFactory> { export interface Definition<Factory extends AnyLanguageModelFactory = LanguageModelFactory> {
readonly id: ProviderID readonly id: ProviderID
readonly model: Factory readonly model: Factory
readonly apis?: Record<string, AnyModelFactory> readonly apis?: Record<string, AnyLanguageModelFactory>
} }
type DefinitionShape = { type DefinitionShape = {
readonly id: ProviderID readonly id: ProviderID
readonly model: (...args: never[]) => Model readonly model: (...args: never[]) => LanguageModel
readonly apis?: Record<string, (...args: never[]) => Model> readonly apis?: Record<string, (...args: never[]) => LanguageModel>
} }
type NoExtraFields<Input, Shape> = Input & Record<Exclude<keyof Input, keyof Shape>, never> type NoExtraFields<Input, Shape> = Input & Record<Exclude<keyof Input, keyof Shape>, never>
@@ -0,0 +1,107 @@
import { Auth } from "../route/auth"
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client"
import type { ProviderPackage } from "../provider-package"
import { OpenAIChat } from "../protocols/openai-chat"
import { OpenAIResponses } from "../protocols/openai-responses"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth"
import { ProviderID, type ModelID } from "../schema"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("amazon-bedrock")
export type Config = RouteDefaultsInput & {
readonly apiKey?: string
readonly baseURL?: string
readonly credentials?: Credentials
readonly region?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: Credentials
readonly region?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
const responsesRoute = OpenAIResponses.route.with({
id: "bedrock-mantle-responses",
provider: id,
})
const chatRoute = OpenAIChat.route.with({
id: "bedrock-mantle-chat",
provider: id,
})
export const routes = [responsesRoute, chatRoute]
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) => {
const region = input.region ?? input.credentials?.region ?? "us-east-1"
const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region }
return route.with({
endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` },
auth:
input.apiKey === undefined
? BedrockAuth.sigV4(credentials, { service: "bedrock-mantle", name: "Bedrock Mantle" })
: Auth.bearer(input.apiKey),
})
}
const defaults = (input: Config) => {
const { apiKey: _, baseURL: _baseURL, credentials: _credentials, region: _region, ...rest } = input
return rest
}
export const configure = (input: Config = {}) => {
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input)
const responses = (modelID: string | ModelID) =>
configuredResponsesRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
const chat = (modelID: string | ModelID) =>
configuredChatRoute
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
return {
id,
model: chat,
chat,
responses,
configure,
}
}
export const provider = configure()
const config = (settings: Settings): Config => {
if (settings.auth === "bearer" && settings.apiKey === undefined)
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (settings.auth === "sigv4" && settings.apiKey !== undefined)
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
return {
apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey,
baseURL: settings.baseURL,
credentials: settings.credentials,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
limits: settings.limits,
providerOptions: settings.providerOptions,
region: settings.region,
}
}
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).chat(modelID)
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
settings,
) => configure(config(settings)).responses(modelID)
export const model = chatModel
@@ -0,0 +1,2 @@
export { chatModel as model } from "../amazon-bedrock-mantle"
export type { Settings } from "../amazon-bedrock-mantle"
@@ -0,0 +1,2 @@
export { chatModel as model } from "../../amazon-bedrock-mantle"
export type { Settings } from "../../amazon-bedrock-mantle"
@@ -0,0 +1,2 @@
export { responsesModel as model } from "../../amazon-bedrock-mantle"
export type { Settings } from "../../amazon-bedrock-mantle"
+2 -2
View File
@@ -14,7 +14,7 @@ const routeAuth = Auth.remove("authorization")
// (helper builds the URL) or `baseURL` directly. // (helper builds the URL) or `baseURL` directly.
type AzureURL = AtLeastOne<{ readonly resourceName: string; readonly baseURL: string }> type AzureURL = AtLeastOne<{ readonly resourceName: string; readonly baseURL: string }>
export type ModelOptions = AzureURL & export type LanguageModelOptions = AzureURL &
RouteDefaultsInput & RouteDefaultsInput &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly apiVersion?: string readonly apiVersion?: string
@@ -22,7 +22,7 @@ export type ModelOptions = AzureURL &
readonly useCompletionUrls?: boolean readonly useCompletionUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput readonly providerOptions?: OpenAIProviderOptionsInput
} }
export type Config = ModelOptions export type Config = LanguageModelOptions
export type Settings = ProviderPackage.Settings & export type Settings = ProviderPackage.Settings &
AzureURL & { AzureURL & {
+6 -6
View File
@@ -9,14 +9,14 @@ export const id = ProviderID.make("github-copilot")
// GitHub Copilot has no canonical public URL — callers (opencode, etc.) must // GitHub Copilot has no canonical public URL — callers (opencode, etc.) must
// supply `baseURL` explicitly. // supply `baseURL` explicitly.
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> & export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly baseURL: string readonly baseURL: string
readonly endpoint?: "chat" | "responses" readonly endpoint?: "chat" | "responses"
readonly providerOptions?: OpenAIProviderOptionsInput readonly providerOptions?: OpenAIProviderOptionsInput
} }
export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: ModelOptions["endpoint"]) => { export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: LanguageModelOptions["endpoint"]) => {
if (endpoint) return endpoint === "responses" if (endpoint) return endpoint === "responses"
const model = String(modelID) const model = String(modelID)
const match = /^gpt-(\d+)/.exec(model) const match = /^gpt-(\d+)/.exec(model)
@@ -29,24 +29,24 @@ export const routes = [OpenAIResponses.route, OpenAIChat.route]
const chatRoute = OpenAIChat.route.with({ provider: id }) const chatRoute = OpenAIChat.route.with({ provider: id })
const responsesRoute = OpenAIResponses.route.with({ provider: id }) const responsesRoute = OpenAIResponses.route.with({ provider: id })
const defaults = (options: ModelOptions) => { const defaults = (options: LanguageModelOptions) => {
const { apiKey: _, auth: _auth, baseURL: _baseURL, endpoint: _endpoint, ...rest } = options const { apiKey: _, auth: _auth, baseURL: _baseURL, endpoint: _endpoint, ...rest } = options
return rest return rest
} }
const configuredResponsesRoute = (options: ModelOptions) => const configuredResponsesRoute = (options: LanguageModelOptions) =>
responsesRoute.with({ responsesRoute.with({
endpoint: { baseURL: options.baseURL }, endpoint: { baseURL: options.baseURL },
auth: AuthOptions.bearer(options, []), auth: AuthOptions.bearer(options, []),
}) })
const configuredChatRoute = (options: ModelOptions) => const configuredChatRoute = (options: LanguageModelOptions) =>
chatRoute.with({ chatRoute.with({
endpoint: { baseURL: options.baseURL }, endpoint: { baseURL: options.baseURL },
auth: AuthOptions.bearer(options, []), auth: AuthOptions.bearer(options, []),
}) })
export const configure = (options: ModelOptions) => { export const configure = (options: LanguageModelOptions) => {
const responsesRoute = configuredResponsesRoute(options) const responsesRoute = configuredResponsesRoute(options)
const chatRoute = configuredChatRoute(options) const chatRoute = configuredChatRoute(options)
const responses = (modelID: string | ModelID) => const responses = (modelID: string | ModelID) =>
+1
View File
@@ -1,6 +1,7 @@
export * as Anthropic from "./anthropic" export * as Anthropic from "./anthropic"
export * as AnthropicCompatible from "./anthropic-compatible" export * as AnthropicCompatible from "./anthropic-compatible"
export * as AmazonBedrock from "./amazon-bedrock" export * as AmazonBedrock from "./amazon-bedrock"
export * as AmazonBedrockMantle from "./amazon-bedrock-mantle"
export * as Azure from "./azure" export * as Azure from "./azure"
export * as Cloudflare from "./cloudflare" export * as Cloudflare from "./cloudflare"
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare" export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
+3 -3
View File
@@ -76,7 +76,7 @@ export type OpenRouterProviderOptionsInput = ProviderOptions & {
readonly openrouter?: OpenRouterOptions readonly openrouter?: OpenRouterOptions
} }
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> & export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly baseURL?: string readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput readonly providerOptions?: OpenRouterProviderOptionsInput
@@ -175,7 +175,7 @@ export const route = Route.make({
export const routes = [route] export const routes = [route]
const configuredRoute = (input: ModelOptions) => { const configuredRoute = (input: LanguageModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return route.with({ return route.with({
...rest, ...rest,
@@ -184,7 +184,7 @@ const configuredRoute = (input: ModelOptions) => {
}) })
} }
export const configure = (input: ModelOptions = {}) => { export const configure = (input: LanguageModelOptions = {}) => {
const route = configuredRoute(input) const route = configuredRoute(input)
return { return {
id, id,
+4 -4
View File
@@ -16,7 +16,7 @@ export type XAIProviderOptionsInput = ProviderOptions & {
readonly xai?: OpenAIOptionsInput readonly xai?: OpenAIOptionsInput
} }
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> & export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly baseURL?: string readonly baseURL?: string
readonly providerOptions?: XAIProviderOptionsInput readonly providerOptions?: XAIProviderOptionsInput
@@ -53,7 +53,7 @@ export const routes = [responsesRoute, chatRoute]
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY") const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
const configuredResponsesRoute = (input: ModelOptions) => { const configuredResponsesRoute = (input: LanguageModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return responsesRoute.with({ return responsesRoute.with({
...rest, ...rest,
@@ -62,7 +62,7 @@ const configuredResponsesRoute = (input: ModelOptions) => {
}) })
} }
const configuredChatRoute = (input: ModelOptions) => { const configuredChatRoute = (input: LanguageModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return chatRoute.with({ return chatRoute.with({
...rest, ...rest,
@@ -71,7 +71,7 @@ const configuredChatRoute = (input: ModelOptions) => {
}) })
} }
export const configure = (input: ModelOptions = {}) => { export const configure = (input: LanguageModelOptions = {}) => {
const responsesRoute = configuredResponsesRoute(input) const responsesRoute = configuredResponsesRoute(input)
const chatRoute = configuredChatRoute(input) const chatRoute = configuredChatRoute(input)
const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID }) const responses = (modelID: string | ModelID) => responsesRoute.model<XAIProviderOptionsInput>({ id: modelID })
+9 -5
View File
@@ -22,13 +22,17 @@ export type ProviderAuthOption<Mode extends ApiKeyMode> =
| AuthOverride | AuthOverride
| (Mode extends "optional" ? OptionalApiKeyAuth : RequiredApiKeyAuth) | (Mode extends "optional" ? OptionalApiKeyAuth : RequiredApiKeyAuth)
export type ModelOptions<Base, Mode extends ApiKeyMode> = Omit<Base, "apiKey" | "auth"> & ProviderAuthOption<Mode> export type LanguageModelOptions<Base, Mode extends ApiKeyMode> = Omit<Base, "apiKey" | "auth"> &
ProviderAuthOption<Mode>
export type ModelArgs<Base, Mode extends ApiKeyMode> = Mode extends "optional" export type LanguageModelArgs<Base, Mode extends ApiKeyMode> = Mode extends "optional"
? readonly [options?: ModelOptions<Base, Mode>] ? readonly [options?: LanguageModelOptions<Base, Mode>]
: readonly [options: ModelOptions<Base, Mode>] : readonly [options: LanguageModelOptions<Base, Mode>]
export type ModelFactory<Base, Mode extends ApiKeyMode, Model> = (id: string, ...args: ModelArgs<Base, Mode>) => Model export type LanguageModelFactory<Base, Mode extends ApiKeyMode, LanguageModel> = (
id: string,
...args: LanguageModelArgs<Base, Mode>
) => LanguageModel
/** /**
* Require at least one of the keys in `T`. Use for option shapes where any * Require at least one of the keys in `T`. Use for option shapes where any
+7 -7
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Redacted } from "effect" import { Config, Effect, Redacted } from "effect"
import { Headers } from "effect/unstable/http" import { Headers } from "effect/unstable/http"
import { AuthenticationReason, InvalidRequestReason, LLMError, type HttpOptions } from "../schema" import { AuthenticationReason, InvalidRequestReason, AIError, type HttpOptions } from "../schema"
export class MissingCredentialError extends Error { export class MissingCredentialError extends Error {
readonly _tag = "MissingCredentialError" readonly _tag = "MissingCredentialError"
@@ -11,7 +11,7 @@ export class MissingCredentialError extends Error {
} }
export type CredentialError = MissingCredentialError | Config.ConfigError export type CredentialError = MissingCredentialError | Config.ConfigError
export type AuthError = CredentialError | LLMError export type AuthError = CredentialError | AIError
type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted> type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
export interface AuthInput { export interface AuthInput {
@@ -100,7 +100,7 @@ export const headers = (input: Headers.Input) =>
export const remove = (name: string) => auth((input) => Effect.succeed(Headers.remove(input.headers, name))) export const remove = (name: string) => auth((input) => Effect.succeed(Headers.remove(input.headers, name)))
export const custom = (apply: (input: AuthInput) => Effect.Effect<Headers.Headers, LLMError>) => auth(apply) export const custom = (apply: (input: AuthInput) => Effect.Effect<Headers.Headers, AIError>) => auth(apply)
export const passthrough = none export const passthrough = none
@@ -134,9 +134,9 @@ export function bearerHeader(name: string, source?: Secret | Credential) {
return render(source) return render(source)
} }
const toLLMError = (error: AuthError): LLMError => { const toAIError = (error: AuthError): AIError => {
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) { if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
return new LLMError({ return new AIError({
module: "Auth", module: "Auth",
method: "apply", method: "apply",
reason: reason:
@@ -150,7 +150,7 @@ const toLLMError = (error: AuthError): LLMError => {
export const toEffect = export const toEffect =
(input: Definition) => (input: Definition) =>
(authInput: AuthInput): Effect.Effect<Headers.Headers, LLMError> => (authInput: AuthInput): Effect.Effect<Headers.Headers, AIError> =>
input.apply(authInput).pipe(Effect.mapError(toLLMError)) input.apply(authInput).pipe(Effect.mapError(toAIError))
export * as Auth from "./auth" export * as Auth from "./auth"
+48 -34
View File
@@ -5,21 +5,22 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor" import { RequestExecutor } from "./executor"
import { Framing } from "./framing" import { Framing } from "./framing"
import { HttpTransport } from "./transport" import { HttpTransport } from "./transport"
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport" import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport" import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol" import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy" import { applyCachePolicy } from "../cache-policy"
import * as ProviderShared from "../protocols/shared" import * as ProviderShared from "../protocols/shared"
import type { LLMError, ProtocolID, ProviderOptions } from "../schema" import type { ProtocolID, ProviderOptions } from "../schema"
import { import {
AIError,
GenerationOptions, GenerationOptions,
HttpOptions, HttpOptions,
LLMRequest, LLMRequest,
LLMResponse, LLMResponse,
Model, LanguageModel,
ModelLimits, LanguageModelLimits,
LLMError as LLMErrorClass,
LLMEvent, LLMEvent,
InvalidProviderOutputReason,
ProviderID, ProviderID,
mergeGenerationOptions, mergeGenerationOptions,
mergeHttpOptions, mergeHttpOptions,
@@ -30,7 +31,7 @@ export interface RouteBody<Body> {
/** Schema for the validated provider-native body sent as the JSON request. */ /** Schema for the validated provider-native body sent as the JSON request. */
readonly schema: Schema.Codec<Body, unknown> readonly schema: Schema.Codec<Body, unknown>
/** Build the provider-native body from a common `LLMRequest`. */ /** Build the provider-native body from a common `LLMRequest`. */
readonly from: (request: LLMRequest) => Effect.Effect<Body, LLMError> readonly from: (request: LLMRequest) => Effect.Effect<Body, AIError>
} }
export interface Route<Body, Prepared = unknown> { export interface Route<Body, Prepared = unknown> {
@@ -45,17 +46,19 @@ export interface Route<Body, Prepared = unknown> {
readonly defaults: RouteDefaults readonly defaults: RouteDefaults
readonly body: RouteBody<Body> readonly body: RouteBody<Body>
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared> readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
readonly model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) => Model<Options> readonly model: <Options extends ProviderOptions = ProviderOptions>(
input: RouteMappedLanguageModelInput,
) => LanguageModel<Options>
readonly prepareTransport: ( readonly prepareTransport: (
body: Body, body: Body,
request: LLMRequest, request: LLMRequest,
options?: StreamOptions, options?: StreamOptions,
) => Effect.Effect<Prepared, LLMError> ) => Effect.Effect<Prepared, AIError>
readonly streamPrepared: ( readonly streamPrepared: (
prepared: Prepared, prepared: Prepared,
request: LLMRequest, request: LLMRequest,
runtime: TransportRuntime, runtime: TransportRuntime,
) => Stream.Stream<LLMEvent, LLMError> ) => Stream.Stream<LLMEvent, AIError>
} }
// Route registries intentionally erase body generics after construction. // Route registries intentionally erase body generics after construction.
@@ -66,13 +69,13 @@ export type AnyRoute = Route<any, any>
export type HttpOptionsInput = HttpOptions.Input export type HttpOptionsInput = HttpOptions.Input
export type RouteModelInput = Omit<Model.Input, "provider" | "route"> export type RouteLanguageModelInput = Omit<LanguageModel.Input, "provider" | "route">
export type RouteRoutedModelInput = Omit<Model.Input, "route"> export type RouteRoutedLanguageModelInput = Omit<LanguageModel.Input, "route">
export interface RouteDefaults { export interface RouteDefaults {
readonly headers?: Record<string, string> readonly headers?: Record<string, string>
readonly limits?: ModelLimits readonly limits?: LanguageModelLimits
readonly generation?: GenerationOptions readonly generation?: GenerationOptions
readonly providerOptions?: ProviderOptions readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions readonly http?: HttpOptions
@@ -80,7 +83,7 @@ export interface RouteDefaults {
export interface RouteDefaultsInput { export interface RouteDefaultsInput {
readonly headers?: Record<string, string> readonly headers?: Record<string, string>
readonly limits?: ModelLimits.Input readonly limits?: LanguageModelLimits.Input
readonly generation?: GenerationOptions.Input readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input readonly http?: HttpOptions.Input
@@ -94,14 +97,17 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
readonly endpoint?: EndpointPatch<Body> readonly endpoint?: EndpointPatch<Body>
} }
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(route: AnyRoute, mapped: RouteMappedModelInput) => { const makeRouteLanguageModel = <Options extends ProviderOptions = ProviderOptions>(
route: AnyRoute,
mapped: RouteMappedLanguageModelInput,
) => {
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined) const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`) if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint)) if (!endpointBaseURL(route.endpoint))
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`) throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
return Model.make<Options>({ return LanguageModel.make<Options>({
...mapped, ...mapped,
provider, provider,
route, route,
@@ -114,7 +120,7 @@ const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefault
...base, ...base,
...patch, ...patch,
headers, headers,
limits: patch.limits === undefined ? base?.limits : ModelLimits.make(patch.limits), limits: patch.limits === undefined ? base?.limits : LanguageModelLimits.make(patch.limits),
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)), generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions), providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
http: mergeHttpOptions( http: mergeHttpOptions(
@@ -150,15 +156,15 @@ export interface Interface {
} }
export interface StreamOptions { export interface StreamOptions {
readonly transform?: HttpRequestTransform readonly http?: HttpMiddleware
} }
export interface StreamMethod { export interface StreamMethod {
(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError> (request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError>
} }
export interface GenerateMethod { export interface GenerateMethod {
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError> (request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {} export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
@@ -222,11 +228,22 @@ export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => { const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
const failed = cause.reasons.find(Cause.isFailReason)?.error const failed = cause.reasons.find(Cause.isFailReason)?.error
if (failed instanceof LLMErrorClass) return failed if (failed instanceof AIError) return failed
return ProviderShared.eventError(route, message, Cause.pretty(cause)) return ProviderShared.eventError(route, message, Cause.pretty(cause))
} }
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, LLMError>) => const incompleteStreamError = (route: string) =>
new AIError({
module: "LLMClient",
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "The provider response ended unexpectedly.",
route,
}),
})
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, AIError>) =>
Stream.suspend(() => { Stream.suspend(() => {
let terminal = false let terminal = false
return events.pipe( return events.pipe(
@@ -242,7 +259,7 @@ const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent,
Effect.suspend(() => Effect.suspend(() =>
terminal terminal
? Effect.void ? Effect.void
: Effect.fail(ProviderShared.eventError(route, "Provider stream ended without a terminal finish event")), : Effect.fail(incompleteStreamError(route)),
), ),
), ),
) )
@@ -292,8 +309,8 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: mergeRouteDefaults(route.defaults, defaults), defaults: mergeRouteDefaults(route.defaults, defaults),
}) })
}, },
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) => model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
makeRouteModel<Options>(route, input), makeRouteLanguageModel<Options>(route, input),
prepareTransport: (body, request, options) => prepareTransport: (body, request, options) =>
routeInput.transport.prepare({ routeInput.transport.prepare({
body, body,
@@ -302,7 +319,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
auth: routeInput.auth ?? Auth.none, auth: routeInput.auth ?? Auth.none,
encodeBody, encodeBody,
headers: routeInput.headers, headers: routeInput.headers,
transform: options?.transform, middleware: options?.http,
}), }),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => { streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}` const route = `${request.model.provider}/${request.model.route.id}`
@@ -411,24 +428,21 @@ const generateWith = (stream: Interface["stream"]) =>
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce)) const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
const response = LLMResponse.complete(state) const response = LLMResponse.complete(state)
if (response) return response if (response) return response
return yield* ProviderShared.eventError( return yield* incompleteStreamError(`${request.model.provider}/${request.model.route.id}`)
`${request.model.provider}/${request.model.route.id}`,
"Provider stream ended without a terminal finish event",
)
}) })
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, LLMError> { export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service> {
return Stream.unwrap( return Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
return (yield* Service).stream(request, options) return (yield* Service).stream(request, options)
}), }),
) as Stream.Stream<LLMEvent, LLMError> )
} }
export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, LLMError> { export function generate(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError, Service> {
return Effect.gen(function* () { return Effect.gen(function* () {
return yield* (yield* Service).generate(request, options) return yield* (yield* Service).generate(request, options)
}) as Effect.Effect<LLMResponse, LLMError> })
} }
export const streamRequest = (request: LLMRequest, options?: StreamOptions) => export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
+27 -10
View File
@@ -12,7 +12,7 @@ import {
HttpRateLimitDetails, HttpRateLimitDetails,
HttpRequestDetails, HttpRequestDetails,
HttpResponseDetails, HttpResponseDetails,
LLMError, AIError,
TransportReason, TransportReason,
} from "../schema" } from "../schema"
import { classifyProviderFailure } from "../provider-error" import { classifyProviderFailure } from "../provider-error"
@@ -20,10 +20,19 @@ import { classifyProviderFailure } from "../provider-error"
export interface Interface { export interface Interface {
readonly execute: ( readonly execute: (
request: HttpClientRequest.HttpClientRequest, request: HttpClientRequest.HttpClientRequest,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, LLMError> middleware?: HttpMiddleware,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, AIError>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/RequestExecutor") {} export type HttpHandler = (
request: HttpClientRequest.HttpClientRequest,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error>
export type HttpMiddleware = (
request: HttpClientRequest.HttpClientRequest,
handler: HttpHandler,
) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error>
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
const BODY_LIMIT = 16_384 const BODY_LIMIT = 16_384
const REDACTED = "<redacted>" const REDACTED = "<redacted>"
@@ -220,7 +229,7 @@ const statusError =
const retryAfter = retryAfterMs(headers) const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter) const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(body, request) const details = responseBody(body, request)
return yield* new LLMError({ return yield* new AIError({
module: "RequestExecutor", module: "RequestExecutor",
method: "execute", method: "execute",
reason: classifyProviderFailure({ reason: classifyProviderFailure({
@@ -246,7 +255,7 @@ const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: u
readonly kind?: string | undefined readonly kind?: string | undefined
readonly request?: HttpClientRequest.HttpClientRequest | undefined readonly request?: HttpClientRequest.HttpClientRequest | undefined
}) => }) =>
new LLMError({ new AIError({
module: "RequestExecutor", module: "RequestExecutor",
method: "execute", method: "execute",
reason: new TransportReason({ reason: new TransportReason({
@@ -261,7 +270,7 @@ const toHttpError = (redactedNames: ReadonlyArray<string | RegExp>) => (error: u
return transportError({ message: error.message, kind: "Timeout" }) return transportError({ message: error.message, kind: "Timeout" })
} }
if (!HttpClientError.isHttpClientError(error)) { if (!HttpClientError.isHttpClientError(error)) {
return transportError({ message: "HTTP transport failed" }) return transportError({ message: error instanceof Error ? error.message : "HTTP transport failed" })
} }
const request = "request" in error ? error.request : undefined const request = "request" in error ? error.request : undefined
if (error.reason._tag === "TransportError") { if (error.reason._tag === "TransportError") {
@@ -282,12 +291,20 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const http = yield* HttpClient.HttpClient const http = yield* HttpClient.HttpClient
const executeOnce = (request: HttpClientRequest.HttpClientRequest) => const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
Effect.gen(function* () { Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames const redactedNames = yield* Headers.CurrentRedactedNames
return yield* http if (!middleware)
.execute(request) return yield* http
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames))) .execute(request)
.pipe(Effect.mapError(toHttpError(redactedNames)), Effect.flatMap(statusError(request, redactedNames)))
const response = yield* middleware(request, (input) =>
http
.execute(input)
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
).pipe(Effect.mapError(toHttpError(redactedNames)))
return yield* statusError(response.request, redactedNames)(response)
}) })
return Service.of({ return Service.of({
execute: executeOnce, execute: executeOnce,
+2 -2
View File
@@ -1,6 +1,6 @@
import type { Stream } from "effect" import type { Stream } from "effect"
import * as ProviderShared from "../protocols/shared" import * as ProviderShared from "../protocols/shared"
import type { LLMError } from "../schema" import type { AIError } from "../schema"
/** /**
* Decode a streaming HTTP response body into provider-protocol frames. * Decode a streaming HTTP response body into provider-protocol frames.
@@ -18,7 +18,7 @@ import type { LLMError } from "../schema"
*/ */
export interface Definition<Frame> { export interface Definition<Frame> {
readonly id: string readonly id: string
readonly frame: (bytes: Stream.Stream<Uint8Array, LLMError>) => Stream.Stream<Frame, LLMError> readonly frame: (bytes: Stream.Stream<Uint8Array, AIError>) => Stream.Stream<Frame, AIError>
} }
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */ /** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
+3 -3
View File
@@ -1,8 +1,8 @@
export { Route, LLMClient } from "./client" export { Route, LLMClient } from "./client"
export type { export type {
Route as RouteShape, Route as RouteShape,
RouteModelInput, RouteLanguageModelInput,
RouteRoutedModelInput, RouteRoutedLanguageModelInput,
RouteDefaults, RouteDefaults,
RouteDefaultsInput, RouteDefaultsInput,
AnyRoute, AnyRoute,
@@ -23,4 +23,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint" export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing" export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol" export type { Protocol as ProtocolDef } from "./protocol"
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport" export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport"
+3 -3
View File
@@ -1,5 +1,5 @@
import { Schema, type Effect } from "effect" import { Schema, type Effect } from "effect"
import type { LLMError, LLMEvent, LLMRequest, ProtocolID } from "../schema" import type { AIError, LLMEvent, LLMRequest, ProtocolID } from "../schema"
/** /**
* The semantic API contract of one model server family. * The semantic API contract of one model server family.
@@ -47,7 +47,7 @@ export interface ProtocolBody<Body> {
/** Schema for the validated provider-native body sent as the JSON request. */ /** Schema for the validated provider-native body sent as the JSON request. */
readonly schema: Schema.Codec<Body, unknown> readonly schema: Schema.Codec<Body, unknown>
/** Build the provider-native body from a common `LLMRequest`. */ /** Build the provider-native body from a common `LLMRequest`. */
readonly from: (request: LLMRequest) => Effect.Effect<Body, LLMError> readonly from: (request: LLMRequest) => Effect.Effect<Body, AIError>
} }
export interface ProtocolStream<Frame, Event, State> { export interface ProtocolStream<Frame, Event, State> {
@@ -56,7 +56,7 @@ export interface ProtocolStream<Frame, Event, State> {
/** Initial parser state. Called once per response with the resolved request. */ /** Initial parser state. Called once per response with the resolved request. */
readonly initial: (request: LLMRequest) => State readonly initial: (request: LLMRequest) => State
/** Translate one event into emitted `LLMEvent`s plus the next state. */ /** Translate one event into emitted `LLMEvent`s plus the next state. */
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], LLMError> readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], AIError>
/** Optional request-completion signal for transports that do not end naturally. */ /** Optional request-completion signal for transports that do not end naturally. */
readonly terminal?: (event: Event) => boolean readonly terminal?: (event: Event) => boolean
/** Optional flush emitted when the framed stream ends. */ /** Optional flush emitted when the framed stream ends. */
+10 -9
View File
@@ -3,7 +3,7 @@ import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Auth } from "../auth" import { Auth } from "../auth"
import { render as renderEndpoint } from "../endpoint" import { render as renderEndpoint } from "../endpoint"
import { Framing } from "../framing" import { Framing } from "../framing"
import type { Transport, TransportPrepareInput } from "./index" import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
import * as ProviderShared from "../../protocols/shared" import * as ProviderShared from "../../protocols/shared"
import { mergeJsonRecords, type LLMRequest } from "../../schema" import { mergeJsonRecords, type LLMRequest } from "../../schema"
@@ -19,6 +19,7 @@ export interface JsonRequestParts<Body = unknown> {
export interface HttpPrepared<Frame> { export interface HttpPrepared<Frame> {
readonly request: HttpClientRequest.HttpClientRequest readonly request: HttpClientRequest.HttpClientRequest
readonly framing: Framing.Definition<Frame> readonly framing: Framing.Definition<Frame>
readonly middleware?: HttpMiddleware
} }
const applyQuery = (url: string, query: Record<string, string> | undefined) => { const applyQuery = (url: string, query: Record<string, string> | undefined) => {
@@ -74,21 +75,21 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
prepare: (prepareInput) => prepare: (prepareInput) =>
Effect.gen(function* () { Effect.gen(function* () {
const parts = yield* jsonRequestParts({ ...prepareInput }) const parts = yield* jsonRequestParts({ ...prepareInput })
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText } const request = ProviderShared.jsonPost({
yield* (prepareInput.transform?.(request) ?? Effect.void) url: parts.url,
body: parts.bodyText,
headers: parts.headers,
})
return { return {
request: ProviderShared.jsonPost({ request,
url: request.url,
body: request.body ?? "",
headers: Headers.fromInput(request.headers),
}),
framing: input.framing, framing: input.framing,
middleware: prepareInput.middleware,
} }
}), }),
frames: (prepared, request, runtime) => frames: (prepared, request, runtime) =>
Stream.unwrap( Stream.unwrap(
runtime.http runtime.http
.execute(prepared.request) .execute(prepared.request, prepared.middleware)
.pipe( .pipe(
Effect.map((response) => Effect.map((response) =>
prepared.framing.frame( prepared.framing.frame(
+6 -18
View File
@@ -1,32 +1,19 @@
import type { Effect, Stream } from "effect" import type { Effect, Stream } from "effect"
import { Endpoint } from "../endpoint" import { Endpoint } from "../endpoint"
import { Auth } from "../auth" import { Auth } from "../auth"
import type { Interface as RequestExecutorInterface } from "../executor" import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor"
import type { Interface as WebSocketExecutorInterface } from "./websocket" import type { Interface as WebSocketExecutorInterface } from "./websocket"
import type { LLMError, LLMRequest } from "../../schema" import type { AIError, LLMRequest } from "../../schema"
export interface TransportRuntime { export interface TransportRuntime {
readonly http: RequestExecutorInterface readonly http: RequestExecutorInterface
readonly webSocket?: WebSocketExecutorInterface readonly webSocket?: WebSocketExecutorInterface
} }
export interface HttpRequest {
url: string
readonly method: string
headers: Record<string, string>
body: string | undefined
}
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
export interface Transport<Body, Prepared, Frame> { export interface Transport<Body, Prepared, Frame> {
readonly id: string readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, LLMError> readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
readonly frames: ( readonly frames: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => Stream.Stream<Frame, AIError>
prepared: Prepared,
request: LLMRequest,
runtime: TransportRuntime,
) => Stream.Stream<Frame, LLMError>
} }
export interface TransportPrepareInput<Body> { export interface TransportPrepareInput<Body> {
@@ -36,8 +23,9 @@ export interface TransportPrepareInput<Body> {
readonly auth: Auth.Definition readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string> readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly transform?: HttpRequestTransform readonly middleware?: HttpMiddleware
} }
export * as HttpTransport from "./http" export * as HttpTransport from "./http"
export type { HttpHandler, HttpMiddleware } from "../executor"
export { WebSocketExecutor, WebSocketTransport } from "./websocket" export { WebSocketExecutor, WebSocketTransport } from "./websocket"
+10 -10
View File
@@ -1,6 +1,6 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect" import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http" import { Headers } from "effect/unstable/http"
import { LLMError, TransportReason } from "../../schema" import { AIError, TransportReason } from "../../schema"
import * as HttpTransport from "./http" import * as HttpTransport from "./http"
import type { Transport } from "./index" import type { Transport } from "./index"
@@ -10,13 +10,13 @@ export interface WebSocketRequest {
} }
export interface WebSocketConnection { export interface WebSocketConnection {
readonly sendText: (message: string) => Effect.Effect<void, LLMError> readonly sendText: (message: string) => Effect.Effect<void, AIError>
readonly messages: Stream.Stream<string | Uint8Array, LLMError> readonly messages: Stream.Stream<string | Uint8Array, AIError>
readonly close: Effect.Effect<void, never> readonly close: Effect.Effect<void, never>
} }
export interface Interface { export interface Interface {
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, LLMError> readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
} }
type WebSocketConstructorWithHeaders = new ( type WebSocketConstructorWithHeaders = new (
@@ -24,14 +24,14 @@ type WebSocketConstructorWithHeaders = new (
options?: { readonly headers?: Headers.Headers }, options?: { readonly headers?: Headers.Headers },
) => globalThis.WebSocket ) => globalThis.WebSocket
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM/WebSocketExecutor") {} export class Service extends Context.Service<Service, Interface>()("@opencode/AI/WebSocketExecutor") {}
const transportError = ( const transportError = (
method: string, method: string,
message: string, message: string,
input: { readonly url?: string; readonly kind?: string } = {}, input: { readonly url?: string; readonly kind?: string } = {},
) => ) =>
new LLMError({ new AIError({
module: "WebSocketExecutor", module: "WebSocketExecutor",
method, method,
reason: new TransportReason({ message, url: input.url, kind: input.kind }), reason: new TransportReason({ message, url: input.url, kind: input.kind }),
@@ -59,7 +59,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
}), }),
) )
} }
return Effect.callback<void, LLMError>((resume, signal) => { return Effect.callback<void, AIError>((resume, signal) => {
const cleanup = () => { const cleanup = () => {
ws.removeEventListener("open", onOpen) ws.removeEventListener("open", onOpen)
ws.removeEventListener("error", onError) ws.removeEventListener("error", onError)
@@ -138,10 +138,10 @@ export const layer: Layer.Layer<Service> = Layer.succeed(Service, Service.of({ o
export const fromWebSocket = ( export const fromWebSocket = (
ws: globalThis.WebSocket, ws: globalThis.WebSocket,
input: WebSocketRequest, input: WebSocketRequest,
): Effect.Effect<WebSocketConnection, LLMError> => ): Effect.Effect<WebSocketConnection, AIError> =>
Effect.gen(function* () { Effect.gen(function* () {
yield* waitOpen(ws, input) yield* waitOpen(ws, input)
const messages = yield* Queue.bounded<string | Uint8Array, LLMError | Cause.Done<void>>(128) const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128)
const onMessage = (event: MessageEvent) => { const onMessage = (event: MessageEvent) => {
if (typeof event.data === "string") return Queue.offerUnsafe(messages, event.data) if (typeof event.data === "string") return Queue.offerUnsafe(messages, event.data)
@@ -213,7 +213,7 @@ export interface JsonPrepared {
} }
export interface JsonInput<Body, Message> { export interface JsonInput<Body, Message> {
readonly toMessage: (body: Body | Record<string, unknown>) => Effect.Effect<Message, LLMError> readonly toMessage: (body: Body | Record<string, unknown>) => Effect.Effect<Message, AIError>
readonly encodeMessage: (message: Message) => string readonly encodeMessage: (message: Message) => string
} }
+20 -19
View File
@@ -5,25 +5,25 @@ import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"]) export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({ export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("AI.HttpRequestDetails")({
method: Schema.String, method: Schema.String,
url: Schema.String, url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String), headers: Schema.Record(Schema.String, Schema.String),
}) {} }) {}
export class HttpResponseDetails extends Schema.Class<HttpResponseDetails>("LLM.HttpResponseDetails")({ export class HttpResponseDetails extends Schema.Class<HttpResponseDetails>("AI.HttpResponseDetails")({
status: Schema.Number, status: Schema.Number,
headers: Schema.Record(Schema.String, Schema.String), headers: Schema.Record(Schema.String, Schema.String),
}) {} }) {}
export class HttpRateLimitDetails extends Schema.Class<HttpRateLimitDetails>("LLM.HttpRateLimitDetails")({ export class HttpRateLimitDetails extends Schema.Class<HttpRateLimitDetails>("AI.HttpRateLimitDetails")({
retryAfterMs: Schema.optional(Schema.Number), retryAfterMs: Schema.optional(Schema.Number),
limit: Schema.optional(Schema.Record(Schema.String, Schema.String)), limit: Schema.optional(Schema.Record(Schema.String, Schema.String)),
remaining: Schema.optional(Schema.Record(Schema.String, Schema.String)), remaining: Schema.optional(Schema.Record(Schema.String, Schema.String)),
reset: Schema.optional(Schema.Record(Schema.String, Schema.String)), reset: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {} }) {}
export class HttpContext extends Schema.Class<HttpContext>("LLM.HttpContext")({ export class HttpContext extends Schema.Class<HttpContext>("AI.HttpContext")({
request: HttpRequestDetails, request: HttpRequestDetails,
response: Schema.optional(HttpResponseDetails), response: Schema.optional(HttpResponseDetails),
body: Schema.optional(Schema.String), body: Schema.optional(Schema.String),
@@ -32,7 +32,7 @@ export class HttpContext extends Schema.Class<HttpContext>("LLM.HttpContext")({
rateLimit: Schema.optional(HttpRateLimitDetails), rateLimit: Schema.optional(HttpRateLimitDetails),
}) {} }) {}
export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("LLM.Error.InvalidRequest")({ export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("AI.Error.InvalidRequest")({
_tag: Schema.tag("InvalidRequest"), _tag: Schema.tag("InvalidRequest"),
message: Schema.String, message: Schema.String,
parameter: Schema.optional(Schema.String), parameter: Schema.optional(Schema.String),
@@ -41,18 +41,18 @@ export class InvalidRequestReason extends Schema.Class<InvalidRequestReason>("LL
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
export class NoRouteReason extends Schema.Class<NoRouteReason>("LLM.Error.NoRoute")({ export class NoRouteReason extends Schema.Class<NoRouteReason>("AI.Error.NoRoute")({
_tag: Schema.tag("NoRoute"), _tag: Schema.tag("NoRoute"),
route: RouteID, route: RouteID,
provider: ProviderID, provider: ProviderID,
model: ModelID, model: ModelID,
}) { }) {
get message() { get message() {
return `No LLM route for ${this.provider}/${this.model} using ${this.route}` return `No AI route for ${this.provider}/${this.model} using ${this.route}`
} }
} }
export class AuthenticationReason extends Schema.Class<AuthenticationReason>("LLM.Error.Authentication")({ export class AuthenticationReason extends Schema.Class<AuthenticationReason>("AI.Error.Authentication")({
_tag: Schema.tag("Authentication"), _tag: Schema.tag("Authentication"),
message: Schema.String, message: Schema.String,
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]), kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
@@ -60,7 +60,7 @@ export class AuthenticationReason extends Schema.Class<AuthenticationReason>("LL
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
export class RateLimitReason extends Schema.Class<RateLimitReason>("LLM.Error.RateLimit")({ export class RateLimitReason extends Schema.Class<RateLimitReason>("AI.Error.RateLimit")({
_tag: Schema.tag("RateLimit"), _tag: Schema.tag("RateLimit"),
message: Schema.String, message: Schema.String,
retryAfterMs: Schema.optional(Schema.Number), retryAfterMs: Schema.optional(Schema.Number),
@@ -69,21 +69,21 @@ export class RateLimitReason extends Schema.Class<RateLimitReason>("LLM.Error.Ra
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("LLM.Error.QuotaExceeded")({ export class QuotaExceededReason extends Schema.Class<QuotaExceededReason>("AI.Error.QuotaExceeded")({
_tag: Schema.tag("QuotaExceeded"), _tag: Schema.tag("QuotaExceeded"),
message: Schema.String, message: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("LLM.Error.ContentPolicy")({ export class ContentPolicyReason extends Schema.Class<ContentPolicyReason>("AI.Error.ContentPolicy")({
_tag: Schema.tag("ContentPolicy"), _tag: Schema.tag("ContentPolicy"),
message: Schema.String, message: Schema.String,
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("LLM.Error.ProviderInternal")({ export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>("AI.Error.ProviderInternal")({
_tag: Schema.tag("ProviderInternal"), _tag: Schema.tag("ProviderInternal"),
message: Schema.String, message: Schema.String,
status: Schema.optional(Schema.Number), status: Schema.optional(Schema.Number),
@@ -92,7 +92,7 @@ export class ProviderInternalReason extends Schema.Class<ProviderInternalReason>
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
export class TransportReason extends Schema.Class<TransportReason>("LLM.Error.Transport")({ export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Transport")({
_tag: Schema.tag("Transport"), _tag: Schema.tag("Transport"),
message: Schema.String, message: Schema.String,
kind: Schema.optional(Schema.String), kind: Schema.optional(Schema.String),
@@ -101,16 +101,17 @@ export class TransportReason extends Schema.Class<TransportReason>("LLM.Error.Tr
}) {} }) {}
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>( export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
"LLM.Error.InvalidProviderOutput", "AI.Error.InvalidProviderOutput",
)({ )({
_tag: Schema.tag("InvalidProviderOutput"), _tag: Schema.tag("InvalidProviderOutput"),
message: Schema.String, message: Schema.String,
classification: Schema.optional(Schema.Literals(["incomplete-stream"])),
route: Schema.optional(Schema.String), route: Schema.optional(Schema.String),
raw: Schema.optional(Schema.String), raw: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata), providerMetadata: Schema.optional(ProviderMetadata),
}) {} }) {}
export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("LLM.Error.UnknownProvider")({ export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("AI.Error.UnknownProvider")({
_tag: Schema.tag("UnknownProvider"), _tag: Schema.tag("UnknownProvider"),
message: Schema.String, message: Schema.String,
status: Schema.optional(Schema.Number), status: Schema.optional(Schema.Number),
@@ -118,7 +119,7 @@ export class UnknownProviderReason extends Schema.Class<UnknownProviderReason>("
http: Schema.optional(HttpContext), http: Schema.optional(HttpContext),
}) {} }) {}
export const LLMErrorReason = Schema.Union([ export const AIErrorReason = Schema.Union([
InvalidRequestReason, InvalidRequestReason,
NoRouteReason, NoRouteReason,
AuthenticationReason, AuthenticationReason,
@@ -130,12 +131,12 @@ export const LLMErrorReason = Schema.Union([
InvalidProviderOutputReason, InvalidProviderOutputReason,
UnknownProviderReason, UnknownProviderReason,
]).pipe(Schema.toTaggedUnion("_tag")) ]).pipe(Schema.toTaggedUnion("_tag"))
export type LLMErrorReason = Schema.Schema.Type<typeof LLMErrorReason> export type AIErrorReason = Schema.Schema.Type<typeof AIErrorReason>
export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", { export class AIError extends Schema.TaggedErrorClass<AIError>()("AI.Error", {
module: Schema.String, module: Schema.String,
method: Schema.String, method: Schema.String,
reason: LLMErrorReason, reason: AIErrorReason,
}) { }) {
override readonly cause = this.reason override readonly cause = this.reason
+1 -1
View File
@@ -48,7 +48,7 @@ import { ProviderFailureClassification } from "./errors"
* — for fields we don't normalize and for billing-level audit trails. * — for fields we don't normalize and for billing-level audit trails.
* Matches the same escape-hatch field on `LLMEvent`. * Matches the same escape-hatch field on `LLMEvent`.
*/ */
export class Usage extends Schema.Class<Usage>("LLM.Usage")({ export class Usage extends Schema.Class<Usage>("AI.Usage")({
inputTokens: Schema.optional(Schema.Number), inputTokens: Schema.optional(Schema.Number),
outputTokens: Schema.optional(Schema.Number), outputTokens: Schema.optional(Schema.Number),
nonCachedInputTokens: Schema.optional(Schema.Number), nonCachedInputTokens: Schema.optional(Schema.Number),
+4 -3
View File
@@ -1,5 +1,6 @@
import { Schema } from "effect" import { Schema } from "effect"
import { LLM, ProviderMetadata } from "@opencode-ai/schema/llm" import { ProviderMetadata } from "@opencode-ai/schema/ai"
import { LLM } from "@opencode-ai/schema/llm"
export { ProviderMetadata } export { ProviderMetadata }
@@ -11,10 +12,10 @@ export type ProtocolID = Schema.Schema.Type<typeof ProtocolID>
export const RouteID = Schema.String export const RouteID = Schema.String
export type RouteID = Schema.Schema.Type<typeof RouteID> export type RouteID = Schema.Schema.Type<typeof RouteID>
export const ModelID = Schema.String.pipe(Schema.brand("LLM.ModelID")) export const ModelID = Schema.String.pipe(Schema.brand("AI.ModelID"))
export type ModelID = typeof ModelID.Type export type ModelID = typeof ModelID.Type
export const ProviderID = Schema.String.pipe(Schema.brand("LLM.ProviderID")) export const ProviderID = Schema.String.pipe(Schema.brand("AI.ProviderID"))
export type ProviderID = typeof ProviderID.Type export type ProviderID = typeof ProviderID.Type
export const ResponseID = Schema.String export const ResponseID = Schema.String
+2 -2
View File
@@ -1,7 +1,7 @@
import { Schema } from "effect" import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool" import { Tool } from "@opencode-ai/schema/tool"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids" import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options" import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, LanguageModelSchema, ProviderOptions } from "./options"
import { isRecord } from "../utils/record" import { isRecord } from "../utils/record"
const systemPartSchema = Schema.Struct({ const systemPartSchema = Schema.Struct({
@@ -263,7 +263,7 @@ export namespace ToolChoice {
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({ export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
id: Schema.optional(Schema.String), id: Schema.optional(Schema.String),
model: ModelSchema, model: LanguageModelSchema,
system: Schema.Array(SystemPart), system: Schema.Array(SystemPart),
messages: Schema.Array(Message), messages: Schema.Array(Message),
tools: Schema.Array(ToolDefinition), tools: Schema.Array(ToolDefinition),
+53 -44
View File
@@ -50,7 +50,7 @@ export const mergeProviderOptions = (
return Object.keys(result).length === 0 ? undefined : result return Object.keys(result).length === 0 ? undefined : result
} }
export class HttpOptions extends Schema.Class<HttpOptions>("LLM.HttpOptions")({ export class HttpOptions extends Schema.Class<HttpOptions>("AI.HttpOptions")({
body: Schema.optional(JsonSchema), body: Schema.optional(JsonSchema),
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
query: Schema.optional(Schema.Record(Schema.String, Schema.String)), query: Schema.optional(Schema.Record(Schema.String, Schema.String)),
@@ -121,32 +121,32 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
return Object.values(result).some((value) => value !== undefined) ? result : undefined return Object.values(result).some((value) => value !== undefined) ? result : undefined
} }
export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({ export class LanguageModelLimits extends Schema.Class<LanguageModelLimits>("LLM.LanguageModelLimits")({
context: Schema.optional(Schema.Number), context: Schema.optional(Schema.Number),
input: Schema.optional(Schema.Number), input: Schema.optional(Schema.Number),
output: Schema.optional(Schema.Number), output: Schema.optional(Schema.Number),
}) {} }) {}
export namespace ModelLimits { export namespace LanguageModelLimits {
export type Input = ModelLimits | ConstructorParameters<typeof ModelLimits>[0] export type Input = LanguageModelLimits | ConstructorParameters<typeof LanguageModelLimits>[0]
/** Normalize model limit input into the canonical `ModelLimits` class. */ /** Normalize model limit input into the canonical `LanguageModelLimits` class. */
export const make = (input: Input | undefined) => export const make = (input: Input | undefined) =>
input instanceof ModelLimits ? input : new ModelLimits(input ?? {}) input instanceof LanguageModelLimits ? input : new LanguageModelLimits(input ?? {})
} }
export class ModelDefaults extends Schema.Class<ModelDefaults>("LLM.ModelDefaults")({ export class LanguageModelDefaults extends Schema.Class<LanguageModelDefaults>("LLM.LanguageModelDefaults")({
limits: Schema.optional(ModelLimits), limits: Schema.optional(LanguageModelLimits),
generation: Schema.optional(GenerationOptions), generation: Schema.optional(GenerationOptions),
providerOptions: Schema.optional(ProviderOptions), providerOptions: Schema.optional(ProviderOptions),
http: Schema.optional(HttpOptions), http: Schema.optional(HttpOptions),
}) {} }) {}
export namespace ModelDefaults { export namespace LanguageModelDefaults {
export type Input = export type Input =
| ModelDefaults | LanguageModelDefaults
| { | {
readonly limits?: ModelLimits.Input readonly limits?: LanguageModelLimits.Input
readonly generation?: GenerationOptions.Input readonly generation?: GenerationOptions.Input
readonly providerOptions?: ProviderOptions readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input readonly http?: HttpOptions.Input
@@ -154,9 +154,9 @@ export namespace ModelDefaults {
/** Normalize selected-model request defaults without applying precedence. */ /** Normalize selected-model request defaults without applying precedence. */
export const make = (input: Input) => { export const make = (input: Input) => {
if (input instanceof ModelDefaults) return input if (input instanceof LanguageModelDefaults) return input
return new ModelDefaults({ return new LanguageModelDefaults({
limits: input.limits === undefined ? undefined : ModelLimits.make(input.limits), limits: input.limits === undefined ? undefined : LanguageModelLimits.make(input.limits),
generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation), generation: input.generation === undefined ? undefined : GenerationOptions.make(input.generation),
providerOptions: input.providerOptions, providerOptions: input.providerOptions,
http: input.http === undefined ? undefined : HttpOptions.make(input.http), http: input.http === undefined ? undefined : HttpOptions.make(input.http),
@@ -164,34 +164,39 @@ export namespace ModelDefaults {
} }
} }
export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"]) export const LanguageModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSchemaCompatibility> export type LanguageModelToolSchemaCompatibility = Schema.Schema.Type<typeof LanguageModelToolSchemaCompatibility>
export const ModelMaxTokensFieldCompatibility = Schema.Literals(["max_completion_tokens", "max_tokens"]) export const LanguageModelMaxTokensFieldCompatibility = Schema.Literals(["max_completion_tokens", "max_tokens"])
export type ModelMaxTokensFieldCompatibility = Schema.Schema.Type<typeof ModelMaxTokensFieldCompatibility> export type LanguageModelMaxTokensFieldCompatibility = Schema.Schema.Type<
typeof LanguageModelMaxTokensFieldCompatibility
>
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompatibility>(
toolSchema: Schema.optional(ModelToolSchemaCompatibility), "LLM.LanguageModelCompatibility",
)({
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String), reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(ModelMaxTokensFieldCompatibility), maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
}) {} }) {}
export namespace ModelCompatibility { export namespace LanguageModelCompatibility {
export type Input = ModelCompatibility | ConstructorParameters<typeof ModelCompatibility>[0] export type Input = LanguageModelCompatibility | ConstructorParameters<typeof LanguageModelCompatibility>[0]
/** Normalize model/upstream compatibility metadata without projecting requests. */ /** Normalize model/upstream compatibility metadata without projecting requests. */
export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input)) export const make = (input: Input) =>
input instanceof LanguageModelCompatibility ? input : new LanguageModelCompatibility(input)
} }
export class Model<Options extends ProviderOptions = ProviderOptions> { export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
declare protected readonly _ProviderOptions: Options declare protected readonly _ProviderOptions: Options
readonly id: ModelID readonly id: ModelID
readonly provider: ProviderID readonly provider: ProviderID
readonly route: AnyRoute readonly route: AnyRoute
readonly defaults?: ModelDefaults readonly defaults?: LanguageModelDefaults
readonly compatibility?: ModelCompatibility readonly compatibility?: LanguageModelCompatibility
constructor(input: Model.ConstructorInput) { constructor(input: LanguageModel.ConstructorInput) {
this.id = input.id this.id = input.id
this.provider = input.provider this.provider = input.provider
this.route = input.route this.route = input.route
@@ -199,17 +204,18 @@ export class Model<Options extends ProviderOptions = ProviderOptions> {
this.compatibility = input.compatibility this.compatibility = input.compatibility
} }
static make<Options extends ProviderOptions = ProviderOptions>(input: Model.Input) { static make<Options extends ProviderOptions = ProviderOptions>(input: LanguageModel.Input) {
return new Model<Options>({ return new LanguageModel<Options>({
id: ModelID.make(input.id), id: ModelID.make(input.id),
provider: ProviderID.make(input.provider), provider: ProviderID.make(input.provider),
route: input.route, route: input.route,
defaults: input.defaults === undefined ? undefined : ModelDefaults.make(input.defaults), defaults: input.defaults === undefined ? undefined : LanguageModelDefaults.make(input.defaults),
compatibility: input.compatibility === undefined ? undefined : ModelCompatibility.make(input.compatibility), compatibility:
input.compatibility === undefined ? undefined : LanguageModelCompatibility.make(input.compatibility),
}) })
} }
static input<Options extends ProviderOptions>(model: Model<Options>): Model.ConstructorInput { static input<Options extends ProviderOptions>(model: LanguageModel<Options>): LanguageModel.ConstructorInput {
return { return {
id: model.id, id: model.id,
provider: model.provider, provider: model.provider,
@@ -219,37 +225,40 @@ export class Model<Options extends ProviderOptions = ProviderOptions> {
} }
} }
static update<Options extends ProviderOptions>(model: Model<Options>, patch: Partial<Model.Input>) { static update<Options extends ProviderOptions>(model: LanguageModel<Options>, patch: Partial<LanguageModel.Input>) {
if (Object.keys(patch).length === 0) return model if (Object.keys(patch).length === 0) return model
return Model.make<Options>({ return LanguageModel.make<Options>({
...Model.input(model), ...LanguageModel.input(model),
...patch, ...patch,
}) })
} }
} }
export namespace Model { export namespace LanguageModel {
export type ConstructorInput = { export type ConstructorInput = {
readonly id: ModelID readonly id: ModelID
readonly provider: ProviderID readonly provider: ProviderID
readonly route: AnyRoute readonly route: AnyRoute
readonly defaults?: ModelDefaults readonly defaults?: LanguageModelDefaults
readonly compatibility?: ModelCompatibility readonly compatibility?: LanguageModelCompatibility
} }
export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & { export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & {
readonly id: string | ModelID readonly id: string | ModelID
readonly provider: string | ProviderID readonly provider: string | ProviderID
readonly defaults?: ModelDefaults.Input readonly defaults?: LanguageModelDefaults.Input
readonly compatibility?: ModelCompatibility.Input readonly compatibility?: LanguageModelCompatibility.Input
} }
} }
export type ModelInput = Model.Input export type LanguageModelInput = LanguageModel.Input
export type ModelProviderOptions<SelectedModel> = SelectedModel extends Model<infer Options> ? Options : never export type LanguageModelProviderOptions<SelectedModel> =
SelectedModel extends LanguageModel<infer Options> ? Options : never
export const ModelSchema = Schema.declare((value): value is Model => value instanceof Model, { expected: "LLM.Model" }) export const LanguageModelSchema = Schema.declare((value): value is LanguageModel => value instanceof LanguageModel, {
expected: "LLM.LanguageModel",
})
export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
type: Schema.Literals(["ephemeral", "persistent"]), type: Schema.Literals(["ephemeral", "persistent"]),
+3 -3
View File
@@ -5,13 +5,13 @@ import {
LLMEvent, LLMEvent,
LLMResponse, LLMResponse,
type FinishReasonDetails, type FinishReasonDetails,
type LLMError, type AIError,
type LLMRequest, type LLMRequest,
type UsageInput, type UsageInput,
} from "./schema" } from "./schema"
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect" import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, LLMError> export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError>
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }> export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
@@ -63,7 +63,7 @@ export const textWithUsage = (value: string, id: string, inputTokens: number) =>
export const tool = (id: string, name: string, input: unknown) => toolCalls(LLMEvent.toolCall({ id, name, input })) export const tool = (id: string, name: string, input: unknown) => toolCalls(LLMEvent.toolCall({ id, name, input }))
export const failAfter = (error: LLMError, ...events: readonly LLMEvent[]) => export const failAfter = (error: AIError, ...events: readonly LLMEvent[]) =>
Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(error))) Stream.fromIterable(events).pipe(Stream.concat(Stream.fail(error)))
export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Stream.fromIterable(events), Stream.never) export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Stream.fromIterable(events), Stream.never)
+8 -8
View File
@@ -24,7 +24,7 @@ export type ToolExecute<Parameters extends ToolSchema<any>, Success extends Tool
) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure> ) => Effect.Effect<Schema.Schema.Type<Success>, ToolFailure>
export interface ToolModelOutputInput<Parameters, Output> { export interface ToolModelOutputInput<Parameters, Output> {
readonly callID: ToolCallPart["id"] readonly id: ToolCallPart["id"]
readonly parameters: Parameters readonly parameters: Parameters
readonly output: Output readonly output: Output
} }
@@ -59,7 +59,7 @@ export interface Definition<Parameters extends ToolSchema<any>, Success extends
/** @internal */ /** @internal */
readonly _project: ( readonly _project: (
parameters: Schema.Schema.Type<Parameters>, parameters: Schema.Schema.Type<Parameters>,
callID: ToolCallPart["id"], id: ToolCallPart["id"],
output: unknown, output: unknown,
) => ToolOutputType ) => ToolOutputType
/** @internal */ /** @internal */
@@ -173,8 +173,8 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
toStructuredOutput: config.toStructuredOutput, toStructuredOutput: config.toStructuredOutput,
_decode: Effect.succeed, _decode: Effect.succeed,
_encode: Effect.succeed, _encode: Effect.succeed,
_project: (parameters, callID, output) => _project: (parameters, id, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output), project(config.toModelOutput, config.toStructuredOutput, parameters, id, output),
_legacyResult: config.toModelOutput === undefined && config.toStructuredOutput === undefined, _legacyResult: config.toModelOutput === undefined && config.toStructuredOutput === undefined,
_definition: new ToolDefinition({ _definition: new ToolDefinition({
name: "", name: "",
@@ -193,8 +193,8 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
toStructuredOutput: config.toStructuredOutput, toStructuredOutput: config.toStructuredOutput,
_decode: Schema.decodeUnknownEffect(config.parameters), _decode: Schema.decodeUnknownEffect(config.parameters),
_encode: Schema.encodeEffect(config.success), _encode: Schema.encodeEffect(config.success),
_project: (parameters, callID, output) => _project: (parameters, id, output) =>
project(config.toModelOutput, config.toStructuredOutput, parameters, callID, output), project(config.toModelOutput, config.toStructuredOutput, parameters, id, output),
_legacyResult: false, _legacyResult: false,
_definition: new ToolDefinition({ _definition: new ToolDefinition({
name: "", name: "",
@@ -239,12 +239,12 @@ const project = (
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<Tool.Content>) | undefined, toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<Tool.Content>) | undefined,
toStructuredOutput: ((output: unknown) => unknown) | undefined, toStructuredOutput: ((output: unknown) => unknown) | undefined,
parameters: unknown, parameters: unknown,
callID: ToolCallPart["id"], id: ToolCallPart["id"],
output: unknown, output: unknown,
): ToolOutputType => ): ToolOutputType =>
ToolOutput.make( ToolOutput.make(
toStructuredOutput?.(output) ?? output, toStructuredOutput?.(output) ?? output,
toModelOutput?.({ callID, parameters, output }) ?? toModelOutput?.({ id, parameters, output }) ??
(typeof output === "string" ? [{ type: "text", text: output }] : []), (typeof output === "string" ? [{ type: "text", text: output }] : []),
) )
+5 -5
View File
@@ -3,11 +3,11 @@ import { Effect, Schema, Stream } from "effect"
import { LLM, LLMRequest, LLMResponse } from "../src" import { LLM, LLMRequest, LLMResponse } from "../src"
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route" import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route"
import { compileRequest } from "../src/route/client" import { compileRequest } from "../src/route/client"
import { Model } from "../src/schema" import { LanguageModel } from "../src/schema"
import { testEffect } from "./lib/effect" import { testEffect } from "./lib/effect"
import { dynamicResponse } from "./lib/http" import { dynamicResponse } from "./lib/http"
const updateModel = (model: Model, patch: Partial<Model.Input>) => Model.update(model, patch) const updateModel = (model: LanguageModel, patch: Partial<LanguageModel.Input>) => LanguageModel.update(model, patch)
const Json = Schema.fromJsonString(Schema.Unknown) const Json = Schema.fromJsonString(Schema.Unknown)
const encodeJson = Schema.encodeSync(Json) const encodeJson = Schema.encodeSync(Json)
@@ -86,7 +86,7 @@ const configuredGemini = gemini.with({ endpoint: { baseURL: "https://fake.local"
const request = LLM.request({ const request = LLM.request({
id: "req_1", id: "req_1",
model: Model.make({ model: LanguageModel.make({
id: "fake-model", id: "fake-model",
provider: "fake-provider", provider: "fake-provider",
route: configuredFake, route: configuredFake,
@@ -133,8 +133,8 @@ describe("llm route", () => {
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* (yield* LLMClient.Service).stream(request).pipe(Stream.runDrain, Effect.flip) const error = yield* (yield* LLMClient.Service).stream(request).pipe(Stream.runDrain, Effect.flip)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
expect(error.message).toContain("Provider stream ended without a terminal finish event") expect(error.message).toContain("The provider response ended unexpectedly.")
}), }),
) )
+4 -4
View File
@@ -1,6 +1,6 @@
import { Config } from "effect" import { Config } from "effect"
import { Auth } from "../src/route" import { Auth } from "../src/route"
import type { ModelFactory } from "../src/route/auth-options" import type { LanguageModelFactory } from "../src/route/auth-options"
import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIChat from "../src/protocols/openai-chat"
import * as AmazonBedrock from "../src/providers/amazon-bedrock" import * as AmazonBedrock from "../src/providers/amazon-bedrock"
import * as Anthropic from "../src/providers/anthropic" import * as Anthropic from "../src/providers/anthropic"
@@ -23,13 +23,13 @@ type BaseOptions = {
readonly headers?: Record<string, string> readonly headers?: Record<string, string>
} }
type Model = { type LanguageModel = {
readonly id: string readonly id: string
} }
declare const auth: Auth.Definition declare const auth: Auth.Definition
declare const optionalAuthModel: ModelFactory<BaseOptions, "optional", Model> declare const optionalAuthModel: LanguageModelFactory<BaseOptions, "optional", LanguageModel>
declare const requiredAuthModel: ModelFactory<BaseOptions, "required", Model> declare const requiredAuthModel: LanguageModelFactory<BaseOptions, "required", LanguageModel>
const configApiKey = Config.redacted("OPENAI_API_KEY") const configApiKey = Config.redacted("OPENAI_API_KEY")
OpenAIChat.route.model({ id: "gpt-4.1-mini" }) OpenAIChat.route.model({ id: "gpt-4.1-mini" })
+2 -2
View File
@@ -4,12 +4,12 @@ import { Headers } from "effect/unstable/http"
import { LLM } from "../src" import { LLM } from "../src"
import { Auth } from "../src/route/auth" import { Auth } from "../src/route/auth"
import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIChat from "../src/protocols/openai-chat"
import { Model } from "../src/schema" import { LanguageModel } from "../src/schema"
import { it } from "./lib/effect" import { it } from "./lib/effect"
const request = LLM.request({ const request = LLM.request({
id: "req_auth", id: "req_auth",
model: Model.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }), model: LanguageModel.make({ id: "fake-model", provider: "fake", route: OpenAIChat.route }),
prompt: "hello", prompt: "hello",
}) })
+90 -8
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect" import { Effect, Ref, Schema } from "effect"
import { HttpClientRequest } from "effect/unstable/http" import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, mergeProviderOptions } from "../src" import { LLM, mergeProviderOptions } from "../src"
import { AnthropicMessages, OpenAIChat } from "../src/protocols" import { AnthropicMessages, OpenAIChat } from "../src/protocols"
import { Auth, LLMClient } from "../src/route" import { Auth, LLMClient } from "../src/route"
@@ -146,12 +146,16 @@ describe("request option precedence", () => {
prompt: "Say hello.", prompt: "Say hello.",
}), }),
{ {
transform: (request) => http: (request, handler) =>
Effect.sync(() => { Effect.gen(function* () {
expect(request.headers.authorization).toBe("Bearer fresh-key") return yield* handler(
request.url = "https://proxy.test/v1/chat/completions" request.pipe(
request.headers["x-plugin"] = "transformed" HttpClientRequest.setUrl("https://proxy.test/v1/chat/completions"),
request.body = JSON.stringify({ transformed: true }) HttpClientRequest.setMethod("PUT"),
HttpClientRequest.setHeader("x-plugin", "transformed"),
HttpClientRequest.bodyText(JSON.stringify({ transformed: true }), "application/custom+json"),
),
)
}), }),
}, },
).pipe( ).pipe(
@@ -160,7 +164,9 @@ describe("request option precedence", () => {
Effect.gen(function* () { Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.url).toBe("https://proxy.test/v1/chat/completions") expect(web.url).toBe("https://proxy.test/v1/chat/completions")
expect(web.method).toBe("PUT")
expect(web.headers.get("x-plugin")).toBe("transformed") expect(web.headers.get("x-plugin")).toBe("transformed")
expect(web.headers.get("content-type")).toBe("application/custom+json")
expect(decodeJson(input.text)).toEqual({ transformed: true }) expect(decodeJson(input.text)).toEqual({ transformed: true })
return input.respond(sseEvents(deltaChunk({}, "stop")), { return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" }, headers: { "content-type": "text/event-stream" },
@@ -171,6 +177,82 @@ describe("request option precedence", () => {
), ),
) )
it.effect("transforms the HTTP response before protocol decoding", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
}),
{
http: (request, handler) =>
Effect.gen(function* () {
const response = yield* handler(request)
return HttpClientResponse.fromWeb(
response.request,
new Response((yield* response.text).replace("network", "hooked"), {
status: response.status,
headers: response.headers,
}),
)
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.succeed(
input.respond(sseEvents(deltaChunk({ content: "network" }, "stop")), {
headers: { "content-type": "text/event-stream" },
}),
),
),
),
)
expect(response.text).toBe("hooked")
}),
)
it.effect("can inspect an error response and retry the native request", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const response = yield* LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("stale") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
}),
{
http: (request, handler) =>
Effect.gen(function* () {
const response = yield* handler(request)
expect(response.status).toBe(401)
return yield* handler(HttpClientRequest.setHeader(request, "authorization", "Bearer refreshed"))
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
if (input.request.headers.authorization !== "Bearer refreshed")
return input.respond("unauthorized", { status: 401 })
return input.respond(sseEvents(deltaChunk({ content: "retried" }, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
expect(response.text).toBe("retried")
expect(yield* Ref.get(attempts)).toBe(2)
}),
)
it.effect("applies raw body overlays after protocol lowering", () => it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate( LLMClient.generate(
LLM.request({ LLM.request({
+10 -2
View File
@@ -1,4 +1,12 @@
import { LLM, Message, ToolCallPart, ToolDefinition, ToolResultPart, type ContentPart, type Model } from "../src" import {
LLM,
Message,
ToolCallPart,
ToolDefinition,
ToolResultPart,
type ContentPart,
type LanguageModel,
} from "../src"
export const basicContinuation = ["system", "user-text", "assistant-text", "user-follow-up"] as const export const basicContinuation = ["system", "user-text", "assistant-text", "user-follow-up"] as const
export const toolContinuation = ["tool-call", "tool-result"] as const export const toolContinuation = ["tool-call", "tool-result"] as const
@@ -40,7 +48,7 @@ export const continuationTool = ToolDefinition.make({
export function continuationRequest(input: { export function continuationRequest(input: {
readonly id: string readonly id: string
readonly model: Model readonly model: LanguageModel
readonly features: ReadonlyArray<ContinuationFeature> readonly features: ReadonlyArray<ContinuationFeature>
readonly image?: string readonly image?: string
}) { }) {
+2 -2
View File
@@ -2,11 +2,11 @@ import { describe, expect, test } from "bun:test"
import { LLM } from "../src" import { LLM } from "../src"
import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIChat from "../src/protocols/openai-chat"
import { Endpoint } from "../src/route" import { Endpoint } from "../src/route"
import { Model } from "../src/schema" import { LanguageModel } from "../src/schema"
const request = () => const request = () =>
LLM.request({ LLM.request({
model: Model.make({ model: LanguageModel.make({
id: "model-1", id: "model-1",
provider: "test", provider: "test",
route: OpenAIChat.route, route: OpenAIChat.route,
+32 -20
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Layer, Ref } from "effect" import { Effect, Layer, Ref } from "effect"
import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Headers, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, LLMError } from "../src" import { LLM, AIError } from "../src"
import { LLMClient, RequestExecutor } from "../src/route" import { LLMClient, RequestExecutor } from "../src/route"
import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIChat from "../src/protocols/openai-chat"
import { dynamicResponse } from "./lib/http" import { dynamicResponse } from "./lib/http"
@@ -58,21 +58,33 @@ const countedResponsesLayer = (attempts: Ref.Ref<number>, responses: ReadonlyArr
), ),
) )
const expectLLMError = (error: unknown) => { const expectAIError = (error: unknown) => {
expect(error).toBeInstanceOf(LLMError) expect(error).toBeInstanceOf(AIError)
if (!(error instanceof LLMError)) throw new Error("expected LLMError") if (!(error instanceof AIError)) throw new Error("expected AIError")
return error return error
} }
const errorHttp = (error: LLMError) => ("http" in error.reason ? error.reason.http : undefined) const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
describe("RequestExecutor", () => { describe("RequestExecutor", () => {
it.effect("preserves middleware error messages", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor
.execute(request, () => Effect.fail(new Error("plugin rejected request")))
.pipe(Effect.flip)
expectAIError(error)
expect(error.reason.message).toBe("plugin rejected request")
}).pipe(Effect.provide(responsesLayer([]))),
)
it.effect("classifies context overflow responses", () => it.effect("classifies context overflow responses", () =>
Effect.gen(function* () { Effect.gen(function* () {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
}).pipe( }).pipe(
Effect.provide( Effect.provide(
@@ -90,7 +102,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ expect(error.reason).toMatchObject({
_tag: "InvalidRequest", _tag: "InvalidRequest",
classification: "payload-too-large", classification: "payload-too-large",
@@ -104,7 +116,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined() expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))), }).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
@@ -117,7 +129,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" }) expect(error.reason).toMatchObject({ _tag: "RateLimit" })
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })]))) }).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
@@ -134,7 +146,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" }) expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
}).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })]))) }).pipe(Effect.provide(responsesLayer([new Response(body, { status: 400 })])))
@@ -148,7 +160,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error).toMatchObject({ expect(error).toMatchObject({
reason: { reason: {
_tag: "RateLimit", _tag: "RateLimit",
@@ -190,7 +202,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>") expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>")
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>") expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>")
}).pipe( }).pipe(
@@ -204,7 +216,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "RateLimit" }) expect(error.reason).toMatchObject({ _tag: "RateLimit" })
expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({ expect(error.reason._tag === "RateLimit" ? error.reason.rateLimit : undefined).toEqual({
retryAfterMs: 0, retryAfterMs: 0,
@@ -237,7 +249,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" }) expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(errorHttp(error)?.rateLimit).toEqual({ expect(errorHttp(error)?.rateLimit).toEqual({
retryAfterMs: 0, retryAfterMs: 0,
@@ -280,7 +292,7 @@ describe("RequestExecutor", () => {
), ),
) )
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 }) expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status: 503 })
expect(yield* Ref.get(attempts)).toBe(1) expect(yield* Ref.get(attempts)).toBe(1)
}), }),
@@ -293,7 +305,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status }) expect(error.reason).toMatchObject({ _tag: "ProviderInternal", status })
}).pipe( }).pipe(
Effect.provide( Effect.provide(
@@ -316,7 +328,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "Authentication" }) expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(errorHttp(error)?.bodyTruncated).toBe(true) expect(errorHttp(error)?.bodyTruncated).toBe(true)
expect(errorHttp(error)?.body).toHaveLength(16_384) expect(errorHttp(error)?.body).toHaveLength(16_384)
@@ -335,7 +347,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip) const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(errorHttp(error)?.body).toContain('"key":"<redacted>"') expect(errorHttp(error)?.body).toContain('"key":"<redacted>"')
expect(errorHttp(error)?.body).toContain("api_key=<redacted>") expect(errorHttp(error)?.body).toContain("api_key=<redacted>")
expect(errorHttp(error)?.body).not.toContain("body-secret") expect(errorHttp(error)?.body).not.toContain("body-secret")
@@ -356,7 +368,7 @@ describe("RequestExecutor", () => {
const executor = yield* RequestExecutor.Service const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(secretRequest).pipe(Effect.flip) const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
expectLLMError(error) expectAIError(error)
expect(errorHttp(error)?.body).toContain("provider echoed <redacted>") expect(errorHttp(error)?.body).toContain("provider echoed <redacted>")
expect(errorHttp(error)?.body).toContain("authorization <redacted>") expect(errorHttp(error)?.body).toContain("authorization <redacted>")
expect(errorHttp(error)?.body).not.toContain("query-secret-123") expect(errorHttp(error)?.body).not.toContain("query-secret-123")
@@ -395,7 +407,7 @@ describe("RequestExecutor", () => {
Effect.flip, Effect.flip,
) )
expectLLMError(error) expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(yield* Ref.get(attempts)).toBe(1) expect(yield* Ref.get(attempts)).toBe(1)
}), }),
+3 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { ImageInput, LLM, LLMClient, Provider } from "@opencode-ai/ai" import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@opencode-ai/ai"
import { Route, Protocol } from "@opencode-ai/ai/route" import { Route, Protocol } from "@opencode-ai/ai/route"
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider" import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
import { import {
@@ -26,6 +26,8 @@ describe("public exports", () => {
expect(LLM.request).toBeFunction() expect(LLM.request).toBeFunction()
expect(LLMClient.Service).toBeFunction() expect(LLMClient.Service).toBeFunction()
expect(LLMClient.layer).toBeDefined() expect(LLMClient.layer).toBeDefined()
expect(AIError).toBeFunction()
expect(LanguageModel.make).toBeFunction()
expect(ImageInput.bytes).toBeFunction() expect(ImageInput.bytes).toBeFunction()
expect(Provider.make).toBeFunction() expect(Provider.make).toBeFunction()
expect(ProviderSubpath.make).toBe(Provider.make) expect(ProviderSubpath.make).toBe(Provider.make)
File diff suppressed because one or more lines are too long
+10
View File
@@ -1,5 +1,7 @@
import { Effect } from "effect"
import { import {
Image, Image,
ImageClient,
ImageInput, ImageInput,
ImageModel, ImageModel,
type ImageModelOptions, type ImageModelOptions,
@@ -7,8 +9,13 @@ import {
type ImageRequestFor, type ImageRequestFor,
type ImageRoute, type ImageRoute,
} from "../src" } from "../src"
import type { Service } from "../src/image-client"
import { Google, OpenAI, XAI, ZAI } from "../src/providers" import { Google, OpenAI, XAI, ZAI } from "../src/providers"
type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
type Equal<A, B> = [A, B] extends [B, A] ? true : false
type Assert<T extends true> = T
type GoogleLikeOptions = { type GoogleLikeOptions = {
readonly aspectRatio?: "1:1" | "16:9" readonly aspectRatio?: "1:1" | "16:9"
readonly imageSize?: "1K" | "2K" readonly imageSize?: "1K" | "2K"
@@ -146,6 +153,9 @@ const request = Image.request({
}) })
const typedRequest: ImageRequestFor<GoogleLikeOptions> = request const typedRequest: ImageRequestFor<GoogleLikeOptions> = request
void typedRequest void typedRequest
const generated = ImageClient.generate(request)
type GenerateRequirements = Assert<Equal<Requirements<typeof generated>, Service>>
void (true satisfies GenerateRequirements)
// @ts-expect-error Image requests no longer expose a common count option. // @ts-expect-error Image requests no longer expose a common count option.
Image.generate({ model: openai, prompt: "A lighthouse", count: 2 }) Image.generate({ model: openai, prompt: "A lighthouse", count: 2 })
+34 -6
View File
@@ -1,5 +1,11 @@
import { Schema } from "effect" import { Effect, Schema, Stream } from "effect"
import { LLM, type Model, type ModelProviderOptions, type ProviderOptions } from "../src" import {
LLM,
type LLMClientService,
type LanguageModel,
type LanguageModelProviderOptions,
type ProviderOptions,
} from "../src"
import { OpenAIChat } from "../src/protocols" import { OpenAIChat } from "../src/protocols"
interface ExampleOptions { interface ExampleOptions {
@@ -15,9 +21,19 @@ const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://example.com/v1" } }) .with({ endpoint: { baseURL: "https://example.com/v1" } })
.model<ExampleProviderOptions>({ id: "example" }) .model<ExampleProviderOptions>({ id: "example" })
type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
type StreamRequirements<T> = T extends Stream.Stream<infer _A, infer _E, infer R> ? R : never
type Equal<A, B> = [A, B] extends [B, A] ? true : false
type Assert<T extends true> = T
LLM.request({ model, prompt: "Hello", providerOptions: { example: { mode: "fast" } } }) LLM.request({ model, prompt: "Hello", providerOptions: { example: { mode: "fast" } } })
LLM.request({ model, prompt: "Hello", providerOptions: { future: { option: true } } }) LLM.request({ model, prompt: "Hello", providerOptions: { future: { option: true } } })
const generated = LLM.generate(LLM.request({ model, prompt: "Hello" }))
type GenerateRequirements = Assert<Equal<Requirements<typeof generated>, LLMClientService>>
const streamed = LLM.stream(LLM.request({ model, prompt: "Hello" }))
type StreamClientRequirements = Assert<Equal<StreamRequirements<typeof streamed>, LLMClientService>>
LLM.request({ LLM.request({
model, model,
prompt: "Hello", prompt: "Hello",
@@ -25,12 +41,20 @@ LLM.request({
providerOptions: { example: { mode: "slow" } }, providerOptions: { example: { mode: "slow" } },
}) })
LLM.generateObject({ const generatedObject = LLM.generateObject({
model, model,
prompt: "Hello", prompt: "Hello",
schema: Schema.Struct({ answer: Schema.String }), schema: Schema.Struct({ answer: Schema.String }),
providerOptions: { example: { mode: "thorough" } }, providerOptions: { example: { mode: "thorough" } },
}) })
type GenerateObjectRequirements = Assert<Equal<Requirements<typeof generatedObject>, LLMClientService>>
const generatedDynamicObject = LLM.generateObject({
model,
prompt: "Hello",
jsonSchema: { type: "object" },
})
type GenerateDynamicObjectRequirements = Assert<Equal<Requirements<typeof generatedDynamicObject>, LLMClientService>>
LLM.generateObject({ LLM.generateObject({
model, model,
@@ -40,8 +64,12 @@ LLM.generateObject({
providerOptions: { example: { mode: false } }, providerOptions: { example: { mode: false } },
}) })
declare const generic: Model declare const generic: LanguageModel
LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } }) LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } })
const options: ModelProviderOptions<typeof model> = { example: { mode: "fast" } } const options: LanguageModelProviderOptions<typeof model> = { example: { mode: "fast" } }
void options void (options satisfies LanguageModelProviderOptions<typeof model>)
void (true satisfies GenerateRequirements)
void (true satisfies StreamClientRequirements)
void (true satisfies GenerateObjectRequirements)
void (true satisfies GenerateDynamicObjectRequirements)
+13 -13
View File
@@ -6,7 +6,7 @@ import {
GenerationOptions, GenerationOptions,
LLMRequest, LLMRequest,
Message, Message,
Model, LanguageModel,
ToolCallPart, ToolCallPart,
ToolChoice, ToolChoice,
ToolDefinition, ToolDefinition,
@@ -20,13 +20,13 @@ describe("llm constructors", () => {
test("builds canonical schema classes from ergonomic input", () => { test("builds canonical schema classes from ergonomic input", () => {
const request = LLM.request({ const request = LLM.request({
id: "req_1", id: "req_1",
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }), model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
system: "You are concise.", system: "You are concise.",
prompt: "Say hello.", prompt: "Say hello.",
}) })
expect(request).toBeInstanceOf(LLMRequest) expect(request).toBeInstanceOf(LLMRequest)
expect(request.model).toBeInstanceOf(Model) expect(request.model).toBeInstanceOf(LanguageModel)
expect(request.messages[0]).toBeInstanceOf(Message) expect(request.messages[0]).toBeInstanceOf(Message)
expect(request.system).toEqual([{ type: "text", text: "You are concise." }]) expect(request.system).toEqual([{ type: "text", text: "You are concise." }])
expect(request.messages[0]?.content).toEqual([{ type: "text", text: "Say hello." }]) expect(request.messages[0]?.content).toEqual([{ type: "text", text: "Say hello." }])
@@ -37,7 +37,7 @@ describe("llm constructors", () => {
test("updates requests without spreading schema class instances", () => { test("updates requests without spreading schema class instances", () => {
const base = LLM.request({ const base = LLM.request({
id: "req_1", id: "req_1",
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }), model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
prompt: "Say hello.", prompt: "Say hello.",
}) })
const updated = LLMRequest.update(base, { const updated = LLMRequest.update(base, {
@@ -54,7 +54,7 @@ describe("llm constructors", () => {
test("keeps request options separate from route defaults", () => { test("keeps request options separate from route defaults", () => {
const request = LLM.request({ const request = LLM.request({
model: Model.make({ model: LanguageModel.make({
id: "fake-model", id: "fake-model",
provider: "fake", provider: "fake",
route: chatRoute.with({ route: chatRoute.with({
@@ -81,7 +81,7 @@ describe("llm constructors", () => {
test("updates canonical requests from the request datatype", () => { test("updates canonical requests from the request datatype", () => {
const base = LLM.request({ const base = LLM.request({
id: "req_1", id: "req_1",
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }), model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
prompt: "Say hello.", prompt: "Say hello.",
}) })
const updated = LLMRequest.update(base, { messages: [...base.messages, Message.assistant("Hi.")] }) const updated = LLMRequest.update(base, { messages: [...base.messages, Message.assistant("Hi.")] })
@@ -94,19 +94,19 @@ describe("llm constructors", () => {
}) })
test("updates canonical models from the model datatype", () => { test("updates canonical models from the model datatype", () => {
const base = Model.make({ const base = LanguageModel.make({
id: "fake-model", id: "fake-model",
provider: "fake", provider: "fake",
route: chatRoute, route: chatRoute,
}) })
const updated = Model.update(base, { const updated = LanguageModel.update(base, {
route: responsesRoute, route: responsesRoute,
defaults: { generation: { maxTokens: 20 } }, defaults: { generation: { maxTokens: 20 } },
compatibility: { toolSchema: "gemini" }, compatibility: { toolSchema: "gemini" },
}) })
const updatedInput = Model.input(updated) const updatedInput = LanguageModel.input(updated)
expect(updated).toBeInstanceOf(Model) expect(updated).toBeInstanceOf(LanguageModel)
expect(String(updated.id)).toBe("fake-model") expect(String(updated.id)).toBe("fake-model")
expect(updated.route).toBe(responsesRoute) expect(updated.route).toBe(responsesRoute)
expect(updated.defaults?.generation).toEqual({ maxTokens: 20 }) expect(updated.defaults?.generation).toEqual({ maxTokens: 20 })
@@ -114,7 +114,7 @@ describe("llm constructors", () => {
expect(updatedInput.defaults).toBe(updated.defaults) expect(updatedInput.defaults).toBe(updated.defaults)
expect(updatedInput.compatibility).toBe(updated.compatibility) expect(updatedInput.compatibility).toBe(updated.compatibility)
expect(String(updatedInput.provider)).toBe("fake") expect(String(updatedInput.provider)).toBe("fake")
expect(Model.update(updated, {})).toBe(updated) expect(LanguageModel.update(updated, {})).toBe(updated)
}) })
test("carries model defaults and compatibility through route model selection", () => { test("carries model defaults and compatibility through route model selection", () => {
@@ -155,7 +155,7 @@ describe("llm constructors", () => {
expect(ToolChoice.make("required")).toEqual(new ToolChoice({ type: "required" })) expect(ToolChoice.make("required")).toEqual(new ToolChoice({ type: "required" }))
expect( expect(
LLM.request({ LLM.request({
model: Model.make({ model: LanguageModel.make({
id: "fake-model", id: "fake-model",
provider: "fake", provider: "fake",
route: chatRoute, route: chatRoute,
@@ -181,7 +181,7 @@ describe("llm constructors", () => {
{ type: "text", text: "Use parameterized SQL.", cache: new CacheHint({ type: "ephemeral" }) }, { type: "text", text: "Use parameterized SQL.", cache: new CacheHint({ type: "ephemeral" }) },
]) ])
const request = LLM.request({ const request = LLM.request({
model: Model.make({ id: "fake-model", provider: "fake", route: chatRoute }), model: LanguageModel.make({ id: "fake-model", provider: "fake", route: chatRoute }),
system: "Initial operator prompt.", system: "Initial operator prompt.",
messages: [Message.user("Review this."), update], messages: [Message.user("Review this."), update],
}) })
@@ -23,12 +23,16 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/ai/providers/google-vertex/messages"), import("@opencode-ai/ai/providers/google-vertex/messages"),
import("@opencode-ai/ai/providers/openrouter"), import("@opencode-ai/ai/providers/openrouter"),
import("@opencode-ai/ai/providers/xai"), import("@opencode-ai/ai/providers/xai"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/chat"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/responses"),
]) ])
for (const module of modules) expect(module.model).toBeFunction() for (const module of modules) expect(module.model).toBeFunction()
expect(modules[0].model).toBe(modules[1].model) expect(modules[0].model).toBe(modules[1].model)
expect(modules[8].model).toBe(modules[9].model) expect(modules[8].model).toBe(modules[9].model)
expect(modules[12].model).toBe(modules[13].model) expect(modules[12].model).toBe(modules[13].model)
expect(modules[19].model).toBe(modules[20].model)
}) })
test("maps OpenRouter and xAI package settings onto executable models", async () => { test("maps OpenRouter and xAI package settings onto executable models", async () => {
+4 -4
View File
@@ -1,9 +1,9 @@
import { Provider } from "../src/provider" import { Provider } from "../src/provider"
import { ProviderID, type Model } from "../src/schema" import { ProviderID, type LanguageModel } from "../src/schema"
declare const model: (id: string) => Model declare const model: (id: string) => LanguageModel
declare const requiredModel: (id: string, options: { readonly baseURL: string }) => Model declare const requiredModel: (id: string, options: { readonly baseURL: string }) => LanguageModel
declare const chat: (id: string, options: { readonly apiKey: string }) => Model declare const chat: (id: string, options: { readonly apiKey: string }) => LanguageModel
Provider.make({ Provider.make({
id: ProviderID.make("example"), id: ProviderID.make("example"),
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { LLM, LLMError, Message, ToolCallPart } from "../../src" import { LLM, AIError, Message, ToolCallPart } from "../../src"
import { LLMClient } from "../../src/route" import { LLMClient } from "../../src/route"
import * as Anthropic from "../../src/providers/anthropic" import * as Anthropic from "../../src/providers/anthropic"
import { weatherToolName } from "../recorded-scenarios" import { weatherToolName } from "../recorded-scenarios"
@@ -37,7 +37,7 @@ describe("Anthropic Messages sad-path recorded", () => {
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip) const error = yield* LLMClient.generate(malformedToolOrderRequest).pipe(Effect.flip)
expect(error).toBeInstanceOf(LLMError) expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400") expect(error.message).toContain("HTTP 400")
}), }),
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http" import { HttpClientRequest } from "effect/unstable/http"
import { CacheHint, LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src" import { CacheHint, LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route" import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client" import { compileRequest } from "../../src/route/client"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages" import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
@@ -538,7 +538,8 @@ describe("Anthropic Messages route", () => {
expect(error.reason).toMatchObject({ expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput", _tag: "InvalidProviderOutput",
message: "Provider stream ended without a terminal finish event", classification: "incomplete-stream",
message: "The provider response ended unexpectedly.",
}) })
}), }),
) )
@@ -684,9 +685,7 @@ describe("Anthropic Messages route", () => {
), ),
) )
expect(response.toolCalls).toMatchObject([ expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
{ id: "call_1", name: "lookup", input: { query: "weather" } },
])
}), }),
) )
@@ -935,7 +934,7 @@ describe("Anthropic Messages route", () => {
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip) const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
expect(error).toBeInstanceOf(LLMError) expect(error).toBeInstanceOf(AIError)
expect(error.message).toContain("Invalid JSON input for anthropic-messages tool call web_search") expect(error.message).toContain("Invalid JSON input for anthropic-messages tool call web_search")
}), }),
) )
@@ -1009,7 +1008,7 @@ describe("Anthropic Messages route", () => {
Effect.flip, Effect.flip,
) )
expect(error).toBeInstanceOf(LLMError) expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400") expect(error.message).toContain("HTTP 400")
}), }),
@@ -0,0 +1,111 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM } from "../../src"
import { AmazonBedrockMantle } from "../../src/providers"
import { compileRequest, LLMClient } from "../../src/route/client"
import { it } from "../lib/effect"
import { dynamicResponse } from "../lib/http"
import { recordedTests } from "../recorded-test"
const credentials = {
region: "us-east-2",
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
}
describe("Amazon Bedrock Mantle provider", () => {
it.effect("uses Chat by default and exposes Responses", () =>
Effect.gen(function* () {
const provider = AmazonBedrockMantle.configure({ credentials })
const chat = yield* compileRequest(LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }))
const responses = yield* compileRequest(
LLM.request({ model: provider.responses("openai.gpt-oss-120b"), prompt: "Hi" }),
)
expect(chat).toMatchObject({
route: "bedrock-mantle-chat",
protocol: "openai-chat",
body: { model: "openai.gpt-oss-120b" },
})
expect(responses).toMatchObject({
route: "bedrock-mantle-responses",
protocol: "openai-responses",
body: { model: "openai.gpt-oss-120b", store: false },
})
}),
)
it.effect("uses the Mantle endpoint and signing service", () =>
Effect.gen(function* () {
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
const model = AmazonBedrockMantle.configure({ credentials, region: "us-west-1" }).responses("openai.gpt-oss-120b")
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined })
return input.respond("", { headers: { "content-type": "text/event-stream" } })
}),
),
),
Effect.flip,
)
expect(seen[0]?.url).toBe("https://bedrock-mantle.us-west-1.api.aws/v1/responses")
expect(seen[0]?.authorization).toContain("/us-west-1/bedrock-mantle/aws4_request")
}),
)
it.effect("supports bearer authentication and custom base URLs", () =>
Effect.gen(function* () {
const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
const model = AmazonBedrockMantle.configure({
apiKey: "test-key",
baseURL: "https://mantle.test/v1",
}).chat("openai.gpt-oss-safeguard-20b")
yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request)
seen.push({ url: request.url, authorization: request.headers.get("authorization") ?? undefined })
return input.respond("", { headers: { "content-type": "text/event-stream" } })
}),
),
),
Effect.flip,
)
expect(seen).toEqual([{ url: "https://mantle.test/v1/chat/completions", authorization: "Bearer test-key" }])
}),
)
})
const recorded = recordedTests({
prefix: "bedrock-mantle",
provider: "amazon-bedrock",
protocol: "openai-responses",
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
metadata: { model: "openai.gpt-oss-120b" },
})
describe("Amazon Bedrock Mantle recorded", () => {
recorded.effect("streams text", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: AmazonBedrockMantle.configure({
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
region: "us-east-1",
}).responses("openai.gpt-oss-120b"),
prompt: "Reply with exactly: hello",
generation: { maxTokens: 256, temperature: 0 },
}),
)
expect(response.text.trim().toLowerCase()).toBe("hello")
}),
)
})
+30 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src" import { LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
import { Auth, LLMClient } from "../../src/route" import { Auth, LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client" import { compileRequest } from "../../src/route/client"
import * as Gemini from "../../src/protocols/gemini" import * as Gemini from "../../src/protocols/gemini"
@@ -601,6 +601,34 @@ describe("Gemini route", () => {
}), }),
) )
it.effect("maps tool calls without a finish reason", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(
Effect.provide(
fixedResponse(
sseEvents({
candidates: [
{
content: {
role: "model",
parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
},
},
],
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
}),
),
),
)
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: undefined })
}),
)
it.effect("assigns unique ids to multiple streamed tool calls", () => it.effect("assigns unique ids to multiple streamed tool calls", () =>
Effect.gen(function* () { Effect.gen(function* () {
const body = sseEvents({ const body = sseEvents({
@@ -712,7 +740,7 @@ describe("Gemini route", () => {
Effect.flip, Effect.flip,
) )
expect(error).toBeInstanceOf(LLMError) expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(error.message).toContain("Invalid google/gemini stream event") expect(error.message).toContain("Invalid google/gemini stream event")
}), }),
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { LLM, LLMEvent, LLMResponse, Model } from "../../src" import { LLM, LLMEvent, LLMResponse, LanguageModel } from "../../src"
import { OpenAIChat } from "../../src/protocols/openai-chat" import { OpenAIChat } from "../../src/protocols/openai-chat"
import * as OpenAICompatible from "../../src/providers/openai-compatible" import * as OpenAICompatible from "../../src/providers/openai-compatible"
import * as OpenRouter from "../../src/providers/openrouter" import * as OpenRouter from "../../src/providers/openrouter"
@@ -12,7 +12,7 @@ import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop
const cases = [ const cases = [
{ {
name: "OpenRouter", name: "OpenRouter",
model: Model.update( model: LanguageModel.update(
OpenRouter.configure({ OpenRouter.configure({
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture", apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } }, providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } },
@@ -25,7 +25,7 @@ const cases = [
}, },
{ {
name: "Vercel AI Gateway", name: "Vercel AI Gateway",
model: Model.update( model: LanguageModel.update(
OpenAICompatible.configure({ OpenAICompatible.configure({
provider: "vercel-ai-gateway", provider: "vercel-ai-gateway",
baseURL: "https://ai-gateway.vercel.sh/v1", baseURL: "https://ai-gateway.vercel.sh/v1",
+15 -10
View File
@@ -4,11 +4,11 @@ import { HttpClientRequest } from "effect/unstable/http"
import { import {
HttpOptions, HttpOptions,
LLM, LLM,
LLMError, AIError,
LLMEvent, LLMEvent,
LLMRequest, LLMRequest,
Message, Message,
Model, LanguageModel,
ToolCallPart, ToolCallPart,
ToolDefinition, ToolDefinition,
Usage, Usage,
@@ -104,7 +104,7 @@ describe("OpenAI Chat route", () => {
Effect.gen(function* () { Effect.gen(function* () {
const prepared = yield* compileRequest( const prepared = yield* compileRequest(
LLM.request({ LLM.request({
model: Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }), model: LanguageModel.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }),
messages: [ messages: [
Message.assistant([ Message.assistant([
{ {
@@ -130,7 +130,7 @@ describe("OpenAI Chat route", () => {
Effect.gen(function* () { Effect.gen(function* () {
const error = yield* compileRequest( const error = yield* compileRequest(
LLM.request({ LLM.request({
model: Model.update(model, { compatibility: { reasoningField: "content" } }), model: LanguageModel.update(model, { compatibility: { reasoningField: "content" } }),
messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])], messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])],
}), }),
).pipe(Effect.flip) ).pipe(Effect.flip)
@@ -171,7 +171,9 @@ describe("OpenAI Chat route", () => {
it.effect("adds native query params to the Chat Completions URL", () => it.effect("adds native query params to the Chat Completions URL", () =>
LLMClient.generate( LLMClient.generate(
LLMRequest.update(request, { LLMRequest.update(request, {
model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }), model: LanguageModel.update(model, {
route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }),
}),
}), }),
).pipe( ).pipe(
Effect.provide( Effect.provide(
@@ -624,7 +626,7 @@ describe("OpenAI Chat route", () => {
it.effect("parses and replays a configured custom reasoning field", () => it.effect("parses and replays a configured custom reasoning field", () =>
Effect.gen(function* () { Effect.gen(function* () {
const custom = Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }) const custom = LanguageModel.update(model, { compatibility: { reasoningField: "vendor_reasoning" } })
const response = yield* LLMClient.generate(LLMRequest.update(request, { model: custom })).pipe( const response = yield* LLMClient.generate(LLMRequest.update(request, { model: custom })).pipe(
Effect.provide( Effect.provide(
fixedResponse( fixedResponse(
@@ -1134,9 +1136,12 @@ describe("OpenAI Chat route", () => {
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }, { type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
]) ])
expect(events.filter(LLMEvent.is.toolCall)).toEqual([]) expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(streamError.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) expect(streamError.reason).toMatchObject({
expect(streamError.message).toContain("Provider stream ended without a terminal finish event") _tag: "InvalidProviderOutput",
expect(error.message).toContain("Provider stream ended without a terminal finish event") classification: "incomplete-stream",
})
expect(streamError.message).toContain("The provider response ended unexpectedly.")
expect(error.message).toContain("The provider response ended unexpectedly.")
}), }),
) )
@@ -1172,7 +1177,7 @@ describe("OpenAI Chat route", () => {
Effect.flip, Effect.flip,
) )
expect(error).toBeInstanceOf(LLMError) expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400") expect(error.message).toContain("HTTP 400")
}), }),
@@ -3,11 +3,11 @@ import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http" import { Headers, HttpClientRequest } from "effect/unstable/http"
import { import {
LLM, LLM,
LLMError, AIError,
LLMEvent, LLMEvent,
LLMRequest, LLMRequest,
Message, Message,
Model, LanguageModel,
ToolCallPart, ToolCallPart,
ToolDefinition, ToolDefinition,
ToolResultPart, ToolResultPart,
@@ -304,7 +304,9 @@ describe("OpenAI Responses route", () => {
Effect.gen(function* () { Effect.gen(function* () {
yield* LLMClient.generate( yield* LLMClient.generate(
LLMRequest.update(request, { LLMRequest.update(request, {
model: Model.update(model, { route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }) }), model: LanguageModel.update(model, {
route: model.route.with({ endpoint: { query: { "api-version": "v1" } } }),
}),
}), }),
).pipe( ).pipe(
Effect.provide( Effect.provide(
@@ -985,9 +987,7 @@ describe("OpenAI Responses route", () => {
for (const event of events) { for (const event of events) {
const error = yield* LLMClient.generate(request).pipe( const error = yield* LLMClient.generate(request).pipe(
Effect.provide( Effect.provide(fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } }))),
fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } })),
),
Effect.flip, Effect.flip,
) )
expect(error.reason._tag).toBe("InvalidProviderOutput") expect(error.reason._tag).toBe("InvalidProviderOutput")
@@ -1843,7 +1843,7 @@ describe("OpenAI Responses route", () => {
Effect.flip, Effect.flip,
) )
expect(error).toBeInstanceOf(LLMError) expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "rate_limit_exceeded: Slow down" }) expect(error.reason).toMatchObject({ _tag: "RateLimit", message: "rate_limit_exceeded: Slow down" })
}), }),
) )
@@ -2037,7 +2037,7 @@ describe("OpenAI Responses route", () => {
Effect.flip, Effect.flip,
) )
expect(error).toBeInstanceOf(LLMError) expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400") expect(error.message).toContain("HTTP 400")
}), }),
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect, Schema, Stream } from "effect" import { Effect, Schema, Stream } from "effect"
import { LLM, LLMResponse, Message, ToolDefinition, type Model } from "../../src" import { LLM, LLMResponse, Message, ToolDefinition, type LanguageModel } from "../../src"
import { AmazonBedrock, Anthropic, Google, OpenAI, XAI } from "../../src/providers" import { AmazonBedrock, Anthropic, Google, OpenAI, XAI } from "../../src/providers"
import { LLMClient } from "../../src/route" import { LLMClient } from "../../src/route"
import { Tool } from "../../src/tool" import { Tool } from "../../src/tool"
@@ -28,7 +28,7 @@ const targets: ReadonlyArray<{
readonly requires: string readonly requires: string
readonly filename: string readonly filename: string
readonly maxTokens: number readonly maxTokens: number
readonly model: Model readonly model: LanguageModel
}> = [ }> = [
{ {
id: "openai", id: "openai",
+2 -2
View File
@@ -1,7 +1,7 @@
import type { HttpRecorder } from "@opencode-ai/http-recorder" import type { HttpRecorder } from "@opencode-ai/http-recorder"
import { describe } from "bun:test" import { describe } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import type { Model } from "../src" import type { LanguageModel } from "../src"
import { goldenScenarioTags, goldenScenarioTitle, runGoldenScenario, type GoldenScenarioID } from "./recorded-scenarios" import { goldenScenarioTags, goldenScenarioTitle, runGoldenScenario, type GoldenScenarioID } from "./recorded-scenarios"
import { recordedTests } from "./recorded-test" import { recordedTests } from "./recorded-test"
import { kebab } from "./recorded-utils" import { kebab } from "./recorded-utils"
@@ -22,7 +22,7 @@ type ScenarioInput =
type TargetInput = { type TargetInput = {
readonly name: string readonly name: string
readonly model: Model readonly model: LanguageModel
readonly protocol?: string readonly protocol?: string
readonly requires?: ReadonlyArray<string> readonly requires?: ReadonlyArray<string>
readonly transport?: Transport readonly transport?: Transport
+4 -4
View File
@@ -12,7 +12,7 @@ import {
toDefinitions, toDefinitions,
type ContentPart, type ContentPart,
type FinishReason, type FinishReason,
type Model, type LanguageModel,
} from "../src" } from "../src"
import { LLMClient } from "../src/route" import { LLMClient } from "../src/route"
import { Tool } from "../src/tool" import { Tool } from "../src/tool"
@@ -54,7 +54,7 @@ export const weatherRuntimeTool = Tool.make({
export const weatherToolLoopRequest = (input: { export const weatherToolLoopRequest = (input: {
readonly id: string readonly id: string
readonly model: Model readonly model: LanguageModel
readonly system?: string readonly system?: string
readonly maxTokens?: number readonly maxTokens?: number
readonly temperature?: number | false readonly temperature?: number | false
@@ -73,7 +73,7 @@ export const weatherToolLoopRequest = (input: {
export const goldenWeatherToolLoopRequest = (input: { export const goldenWeatherToolLoopRequest = (input: {
readonly id: string readonly id: string
readonly model: Model readonly model: LanguageModel
readonly maxTokens?: number readonly maxTokens?: number
readonly temperature?: number | false readonly temperature?: number | false
}) => }) =>
@@ -163,7 +163,7 @@ export const expectGoldenWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) =>
export interface GoldenScenarioContext { export interface GoldenScenarioContext {
readonly id: string readonly id: string
readonly model: Model readonly model: LanguageModel
readonly maxTokens?: number readonly maxTokens?: number
readonly temperature?: number | false readonly temperature?: number | false
} }
+28 -8
View File
@@ -1,11 +1,21 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Schema } from "effect" import { Effect, Schema } from "effect"
import * as OpenAIChat from "../src/protocols/openai-chat" import * as OpenAIChat from "../src/protocols/openai-chat"
import * as OpenAIResponses from "../src/protocols/openai-responses" import * as OpenAIResponses from "../src/protocols/openai-responses"
import { ContentPart, LLMEvent, LLMRequest, Model, ModelID, ProviderID, Usage } from "../src/schema" import {
AIError,
ContentPart,
InvalidRequestReason,
LLMEvent,
LLMRequest,
LanguageModel,
ModelID,
ProviderID,
Usage,
} from "../src/schema"
import { ProviderShared } from "../src/protocols/shared" import { ProviderShared } from "../src/protocols/shared"
const model = new Model({ const model = new LanguageModel({
id: ModelID.make("fake-model"), id: ModelID.make("fake-model"),
provider: ProviderID.make("fake-provider"), provider: ProviderID.make("fake-provider"),
route: OpenAIChat.route, route: OpenAIChat.route,
@@ -33,7 +43,7 @@ describe("llm schema", () => {
test("accepts custom route ids", () => { test("accepts custom route ids", () => {
const decoded = decodeLLMRequest({ const decoded = decodeLLMRequest({
model: Model.update(model, { route: OpenAIResponses.route }), model: LanguageModel.update(model, { route: OpenAIResponses.route }),
system: [], system: [],
messages: [], messages: [],
tools: [], tools: [],
@@ -51,9 +61,7 @@ describe("llm schema", () => {
expect( expect(
LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 1 } }).usage, LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 1 } }).usage,
).toBeInstanceOf(Usage) ).toBeInstanceOf(Usage)
expect(LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }).usage).toBeInstanceOf( expect(LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }).usage).toBeInstanceOf(Usage)
Usage,
)
}) })
test("content part tagged union exposes guards", () => { test("content part tagged union exposes guards", () => {
@@ -62,7 +70,7 @@ describe("llm schema", () => {
}) })
}) })
describe("LLM.Usage", () => { describe("AI.Usage", () => {
test("subtractTokens clamps non-sensical breakdowns to zero", () => { test("subtractTokens clamps non-sensical breakdowns to zero", () => {
// Defense against a provider reporting cached_tokens > prompt_tokens or // Defense against a provider reporting cached_tokens > prompt_tokens or
// reasoning_tokens > completion_tokens — the negative would otherwise // reasoning_tokens > completion_tokens — the negative would otherwise
@@ -88,3 +96,15 @@ describe("LLM.Usage", () => {
expect(new Usage({}).visibleOutputTokens).toBe(0) expect(new Usage({}).visibleOutputTokens).toBe(0)
}) })
}) })
test("AI errors expose the shared runtime tag", async () => {
const error = new AIError({
module: "test",
method: "call",
reason: new InvalidRequestReason({ message: "invalid" }),
})
expect(error._tag).toBe("AI.Error")
expect(
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
).toBe("caught")
})
+1 -1
View File
@@ -169,7 +169,7 @@ describe("LLMClient tools", () => {
LLMEvent.toolCall({ id: "call_projected", name: "projected", input: { prefix: "count" } }), LLMEvent.toolCall({ id: "call_projected", name: "projected", input: { prefix: "count" } }),
) )
expect(calls).toEqual([{ callID: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }]) expect(calls).toEqual([{ id: "call_projected", parameters: { prefix: "count" }, output: { count: "2" } }])
expect(dispatched.result).toEqual({ type: "text", value: "count:2" }) expect(dispatched.result).toEqual({ type: "text", value: "count:2" })
expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] }) expect(dispatched.output).toEqual({ structured: { count: "2" }, content: [{ type: "text", text: "count:2" }] })
expect(dispatched.events).toEqual([ expect(dispatched.events).toEqual([
+2 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test" import { describe, expect } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { LLMError } from "../src/schema" import { AIError } from "../src/schema"
import { ToolStream } from "../src/protocols/utils/tool-stream" import { ToolStream } from "../src/protocols/utils/tool-stream"
import { it } from "./lib/effect" import { it } from "./lib/effect"
@@ -67,7 +67,7 @@ describe("ToolStream", () => {
Effect.gen(function* () { Effect.gen(function* () {
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool") const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
expect(error).toBeInstanceOf(LLMError) expect(error).toBeInstanceOf(AIError)
if (ToolStream.isError(error)) expect(error.reason.message).toBe("missing tool") if (ToolStream.isError(error)) expect(error.reason.message).toBe("missing tool")
}), }),
) )
+2 -2
View File
@@ -27,8 +27,8 @@ Tool.make({
parameters: Schema.Struct({ city: Schema.String }), parameters: Schema.Struct({ city: Schema.String }),
success: Schema.Struct({ forecast: Schema.NumberFromString }), success: Schema.Struct({ forecast: Schema.NumberFromString }),
execute: () => Effect.succeed({ forecast: 1 }), execute: () => Effect.succeed({ forecast: 1 }),
toModelOutput: ({ callID, parameters, output }) => [ toModelOutput: ({ id, parameters, output }) => [
{ type: "text", text: `${callID}:${parameters.city}:${output.forecast}` }, { type: "text", text: `${id}:${parameters.city}:${output.forecast}` },
], ],
}) })
@@ -45,6 +45,10 @@ describe("timeline fixture validation", () => {
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/) expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1) expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
}) })
test("uses the projected tool ID as its call ID", () => {
expect(toolPart("call_1", "read", "running", {})).toMatchObject({ id: "call_1", callID: "call_1" })
})
}) })
if (false) { if (false) {
@@ -4,15 +4,13 @@ import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
import { SessionV1 } from "@opencode-ai/schema/session-v1" import { SessionV1 } from "@opencode-ai/schema/session-v1"
import type { import type {
AssistantMessage, AssistantMessage,
GlobalEvent,
Message, Message,
Part, Part,
Session,
SessionStatus,
ToolPart, ToolPart,
ToolState, ToolState,
UserMessage, UserMessage,
} from "@opencode-ai/sdk/v2/client" } from "../../../src/types"
import type { SessionV1Info, SessionStatus } from "@opencode-ai/client/promise"
import { expect, type Page } from "@playwright/test" import { expect, type Page } from "@playwright/test"
import { Schema } from "effect" import { Schema } from "effect"
import { mockOpenCodeServer } from "../../utils/mock-server" import { mockOpenCodeServer } from "../../utils/mock-server"
@@ -27,18 +25,29 @@ export const assistantID = "msg_1001_timeline_assistant"
export const title = "Timeline visual stability" export const title = "Timeline visual stability"
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" } export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
type TimelinePayload = Extract< type Session = SessionV1Info
GlobalEvent["payload"], type GlobalEvent = {
{ directory: string
type: project?: string
| "message.updated" workspace?: string
| "message.removed" payload: {
| "message.part.updated" id: string
| "message.part.removed" type: string
| "message.part.delta" properties: Record<string, unknown>
| "session.status"
} }
> }
type TimelineProperties = {
"message.updated": { sessionID: string; info: Message }
"message.removed": { sessionID: string; messageID: string }
"message.part.updated": { sessionID: string; part: Part; time: number }
"message.part.removed": { sessionID: string; messageID: string; partID: string }
"message.part.delta": { sessionID: string; messageID: string; partID: string; field: string; delta: string }
"session.status": { sessionID: string; status: SessionStatus }
}
type TimelinePayload = {
[Type in keyof TimelineProperties]: { id: string; type: Type; properties: TimelineProperties[Type] }
}[keyof TimelineProperties]
type DeepReadonly<Value> = Value extends readonly unknown[] type DeepReadonly<Value> = Value extends readonly unknown[]
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> } ? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
@@ -97,7 +106,6 @@ export async function setupTimeline(
locale?: string locale?: string
deviceScaleFactor?: number deviceScaleFactor?: number
seedHistory?: boolean seedHistory?: boolean
protocol?: "v1" | "v2"
} = {}, } = {},
) { ) {
const sessions = input.sessions ?? [session()] const sessions = input.sessions ?? [session()]
@@ -115,7 +123,7 @@ export async function setupTimeline(
retry: input.eventRetry ?? 20, retry: input.eventRetry ?? 20,
}) })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: input.protocol, protocol: "v2",
directory, directory,
project: project(), project: project(),
provider: provider(), provider: provider(),
@@ -235,7 +243,7 @@ export function event(type: TimelinePayload["type"], properties: TimelinePayload
} }
export function validateTimelineEvent(input: unknown): TimelineEvent { export function validateTimelineEvent(input: unknown): TimelineEvent {
return decodeEvent(input, decodeOptions) return decodeEvent(input, decodeOptions) as TimelineEvent
} }
export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] { export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
@@ -460,7 +468,7 @@ export function toolPart(
input: Record<string, unknown>, input: Record<string, unknown>,
options: ToolOptions<ToolStatus> = {}, options: ToolOptions<ToolStatus> = {},
): Omit<ToolPart, "sessionID" | "messageID"> { ): Omit<ToolPart, "sessionID" | "messageID"> {
const base = { id, type: "tool" as const, callID: `call_${id}`, tool } const base = { id, type: "tool" as const, callID: id, tool }
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } } if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
if (state === "running") if (state === "running")
return { return {
@@ -1,6 +1,121 @@
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import type { Page, Route } from "@playwright/test" import type { Page, Route } from "@playwright/test"
import { mockOpenCodeServer } from "../../utils/mock-server" import { currentMessage, mockOpenCodeServer } from "../../utils/mock-server"
test("preserves current messages", () => {
const message = {
id: "msg_current",
type: "user",
time: { created: 1 },
text: "current",
files: [{ data: "e30=", mime: "application/json", source: { type: "inline" } }],
} satisfies SessionMessageInfo
expect(currentMessage(message)).toBe(message)
})
test("converts rich legacy messages to current message types", () => {
expect(
currentMessage({
info: { id: "msg_user", role: "user", time: { created: 1 } },
parts: [
{ type: "text", text: "Use @src/a.ts with @explore" },
{
type: "file",
mime: "application/json",
filename: "data.json",
url: "data:application/json;base64,e30=",
},
{
type: "file",
mime: "text/plain",
filename: "a.ts",
url: "src/a.ts",
source: { type: "file", text: { value: "@src/a.ts", start: 4, end: 13 } },
},
{ type: "agent", name: "explore", source: { value: "@explore", start: 19, end: 27 } },
],
}),
).toEqual({
id: "msg_user",
type: "user",
time: { created: 1 },
text: "Use @src/a.ts with @explore",
files: [
{ data: "e30=", mime: "application/json", name: "data.json", source: { type: "inline" } },
{
data: "",
mime: "text/plain",
name: "a.ts",
source: { type: "uri", uri: "src/a.ts" },
mention: { text: "@src/a.ts", start: 4, end: 13 },
},
],
agents: [{ name: "explore", mention: { text: "@explore", start: 19, end: 27 } }],
})
expect(
currentMessage({
info: {
id: "msg_assistant",
role: "assistant",
time: { created: 2, completed: 5 },
agent: "explore",
modelID: "model",
providerID: "provider",
variant: "high",
cost: 0.5,
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
finish: "tool-calls",
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
},
parts: [
{ type: "text", text: "Answer" },
{ type: "reasoning", text: "Thinking", time: { start: 2, end: 3 } },
{
id: "prt_tool",
callID: "call_tool",
type: "tool",
tool: "read",
state: {
status: "completed",
input: { filePath: "src/a.ts" },
output: "contents",
metadata: { title: "a.ts" },
time: { start: 3, end: 4 },
},
},
],
}),
).toEqual({
id: "msg_assistant",
type: "assistant",
time: { created: 2, completed: 5 },
agent: "explore",
model: { id: "model", providerID: "provider", variant: "high" },
cost: 0.5,
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
finish: "tool-calls",
error: { type: "MessageAbortedError", message: "Stopped" },
content: [
{ type: "text", text: "Answer" },
{ type: "reasoning", text: "Thinking", time: { created: 2, completed: 3 } },
{
type: "tool",
id: "call_tool",
name: "read",
time: { created: 3, ran: 3, completed: 4 },
state: {
status: "completed",
input: { filePath: "src/a.ts" },
content: [{ type: "text", text: "contents" }],
metadata: { title: "a.ts" },
},
},
],
})
})
test("applies message latency after a list response gate is released", async () => { test("applies message latency after a list response gate is released", async () => {
const events: string[] = [] const events: string[] = []
@@ -30,7 +145,7 @@ test("applies message latency after a list response gate is released", async ()
}) })
const response = handler!({ const response = handler!({
request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }), request: () => ({ url: () => "http://127.0.0.1:4096/api/session/session/message" }),
fulfill: () => { fulfill: () => {
events.push("fulfill") events.push("fulfill")
return Promise.resolve() return Promise.resolve()
@@ -85,21 +85,17 @@ async function mockServers(page: Page, requests: string[]) {
const current = url.origin === serverA ? sessionA : sessionB const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") if (url.pathname === "/api/event")
return sse(route) return sse(route)
if (url.pathname === "/global/health") return json(route, {}, 404)
if (url.pathname === "/api/health") return json(route, { pid: 1 }) if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === "/api/session/active") return json(route, { data: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, []) return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (url.pathname === "/provider") if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@@ -3,7 +3,7 @@ import { expect, test, type Page, type Route } from "@playwright/test"
import { installSseTransport } from "../utils/sse-transport" import { installSseTransport } from "../utils/sse-transport"
import { currentSession } from "../utils/mock-server" import { currentSession } from "../utils/mock-server"
const serverA = "http://127.0.0.1:4096" const serverA = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const serverB = "http://127.0.0.1:4097" const serverB = "http://127.0.0.1:4097"
const directoryA = "C:/server-a" const directoryA = "C:/server-a"
const directoryB = "/home/server-b" const directoryB = "/home/server-b"
@@ -32,7 +32,7 @@ test("session settings use the remote server context", async ({ page }) => {
.poll(() => .poll(() =>
permissionRequests.some((request) => { permissionRequests.some((request) => {
const url = new URL(request) const url = new URL(request)
return url.origin === serverB && url.searchParams.get("directory") === directoryB return url.origin === serverB && url.searchParams.get("location[directory]") === directoryB
}), }),
) )
.toBe(true) .toBe(true)
@@ -67,7 +67,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
.poll(() => .poll(() =>
permissionRequests.some((request) => { permissionRequests.some((request) => {
const url = new URL(request) const url = new URL(request)
return url.origin === serverA && url.searchParams.get("directory") === directoryA return url.origin === serverA && url.searchParams.get("location[directory]") === directoryA
}), }),
) )
.toBe(true) .toBe(true)
@@ -99,10 +99,10 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
.toEqual([ .toEqual([
{ {
origin: serverA, origin: serverA,
directory: directoryA, directory: undefined,
sessionID: sessionA.id, sessionID: sessionA.id,
permissionID: "permission-background-a", permissionID: "permission-background-a",
body: { response: "once" }, body: { reply: "once" },
}, },
]) ])
@@ -127,17 +127,17 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
.toEqual([ .toEqual([
{ {
origin: serverA, origin: serverA,
directory: directoryA, directory: undefined,
sessionID: sessionA.id, sessionID: sessionA.id,
permissionID: "permission-background-a", permissionID: "permission-background-a",
body: { response: "once" }, body: { reply: "once" },
}, },
{ {
origin: serverA, origin: serverA,
directory: directoryA, directory: undefined,
sessionID: childSessionA.id, sessionID: childSessionA.id,
permissionID: "permission-background-a-child", permissionID: "permission-background-a-child",
body: { response: "once" }, body: { reply: "once" },
}, },
]) ])
}) })
@@ -168,8 +168,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
const remote = url.origin === serverB const remote = url.origin === serverB
const directory = remote ? directoryB : directoryA const directory = remote ? directoryB : directoryA
const sessions = remote ? [sessionB] : [sessionA, childSessionA] const sessions = remote ? [sessionB] : [sessionA, childSessionA]
const requestDirectory = url.searchParams.get("directory") const requestDirectory = url.searchParams.get("location[directory]")
const response = url.pathname.match(/^\/session\/([^/]+)\/permissions\/([^/]+)$/) const response = url.pathname.match(/^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/)
if (route.request().method() === "POST" && response) { if (route.request().method() === "POST" && response) {
permissionResponses.push({ permissionResponses.push({
origin: url.origin, origin: url.origin,
@@ -181,13 +181,21 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
return json(route, true) return json(route, true)
} }
if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") if (url.pathname === "/api/event")
return sse(route) return sse(route)
if (url.pathname === "/global/health") return json(route, { healthy: true }) if (url.pathname === "/api/provider")
if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent") return json(route, {
return json(route, { data: [] }) location: { directory },
if (url.pathname === "/api/model/default") return json(route, { data: null }) data: [{ id: remote ? "server-b" : "server-a", name: remote ? "Server B Provider" : "Server A Provider", package: "test" }],
if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname)) })
if (url.pathname === "/api/model") return json(route, { location: { directory }, data: [model(remote)] })
if (url.pathname === "/api/model/default") return json(route, { location: { directory }, data: model(remote) })
if (url.pathname === "/api/agent") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/permission/request") {
permissionRequests.push(url.toString())
return json(route, { location: { directory }, data: [] })
}
if (["/api/command", "/api/reference", "/api/question/request"].includes(url.pathname))
return json(route, { location: { directory }, data: [] }) return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] }) if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] })
if (url.pathname === "/api/mcp/resource") if (url.pathname === "/api/mcp/resource")
@@ -211,8 +219,6 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`)) if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
return json(route, { data: [], cursor: {} }) return json(route, { data: [], cursor: {} })
const current = sessions.find((session) => url.pathname === `/session/${session.id}`)
if (current) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, []) if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
@@ -222,7 +228,6 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
} }
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname)) if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
return json(route, []) return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a")) if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
if (url.pathname === "/project" || url.pathname === "/project/current") { if (url.pathname === "/project" || url.pathname === "/project/current") {
@@ -288,6 +293,25 @@ function provider(id: string) {
} }
} }
function model(remote: boolean) {
const id = remote ? "server-b" : "server-a"
const name = remote ? "Server B" : "Server A"
return {
id,
modelID: id,
providerID: id,
name: `${name} Model`,
family: id,
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [],
time: { released: Date.now() },
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
status: "active",
enabled: true,
limit: { context: 200_000, output: 32_000 },
}
}
function json(route: Route, body: unknown, status = 200) { function json(route: Route, body: unknown, status = 200) {
return route.fulfill({ return route.fulfill({
status, status,
@@ -58,22 +58,18 @@ async function mockServers(page: Page) {
const current = url.origin === serverA ? sessionA : sessionB const current = url.origin === serverA ? sessionA : sessionB
const directory = url.searchParams.get("directory") const directory = url.searchParams.get("directory")
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") if (url.pathname === "/api/event")
return sse(route, url.pathname === "/api/event") return sse(route, url.pathname === "/api/event")
if (url.pathname === "/global/health") return json(route, {}, 404)
if (url.pathname === "/api/health") return json(route, { pid: 1 }) if (url.pathname === "/api/health") return json(route, { pid: 1 })
if (url.pathname === "/api/session/active") if (url.pathname === "/api/session/active")
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} }) return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
if (url.pathname === `/session/${current.id}`) return json(route, current)
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
if (url.pathname === `/session/${current.id}/message`) return json(route, [])
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
return json(route, []) return json(route, [])
if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {})
if (url.pathname === "/provider") if (url.pathname === "/provider")
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
@@ -14,6 +14,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
test("opens and searches project files inline", async ({ page }) => { test("opens and searches project files inline", async ({ page }) => {
const searches: { query: string; dirs?: string; limit?: number }[] = [] const searches: { query: string; dirs?: string; limit?: number }[] = []
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -127,7 +128,7 @@ test("opens and searches project files inline", async ({ page }) => {
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "") await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveAttribute("data-selected", "")
await expect(sidebarToggle).toBeEnabled() await expect(sidebarToggle).toBeEnabled()
await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible() await expect(panel.getByText("contents:src/nested.ts", { exact: true })).toBeVisible()
expect(searches).toContainEqual({ query: "nested", dirs: "false", limit: 200 }) expect(searches).toContainEqual({ query: "nested", dirs: "file", limit: 200 })
await panel.getByRole("button", { name: "Open file" }).click() await panel.getByRole("button", { name: "Open file" }).click()
await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1)
@@ -19,36 +19,25 @@ test("restores review mode and selected file per session", async ({ page }) => {
await expectSessionTitle(page, titleA) await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click() await page.getByRole("button", { name: "Toggle review" }).click()
await selectMode(page, "Git changes", "Branch changes") await selectFile(page, "alpha.ts")
await selectFile(page, "beta.ts")
await switchSession(page, titleB) await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await selectFile(page, "gamma.ts") await selectFile(page, "gamma.ts")
await switchSession(page, titleA) await switchSession(page, titleA)
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts")
await selectMode(page, "Branch changes", "Git changes")
await expectSelectedFile(page, "alpha.ts") await expectSelectedFile(page, "alpha.ts")
await selectMode(page, "Git changes", "Branch changes")
await expectSelectedFile(page, "beta.ts")
await page.reload() await page.reload()
await expectSessionTitle(page, titleA) await expectSessionTitle(page, titleA)
await expect(page.getByRole("button", { name: "Branch changes" })).toBeVisible() await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await expectSelectedFile(page, "beta.ts") await expectSelectedFile(page, "alpha.ts")
await switchSession(page, titleB) await switchSession(page, titleB)
await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible() await expect(page.getByRole("button", { name: "Git changes" })).toBeVisible()
await expectSelectedFile(page, "gamma.ts") await expectSelectedFile(page, "gamma.ts")
}) })
async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
await page.getByRole("option", { name: next }).dispatchEvent("click")
}
async function selectFile(page: Page, file: string) { async function selectFile(page: Page, file: string) {
await page.getByRole("button", { name: file }).click() await page.getByRole("button", { name: file }).click()
await expectSelectedFile(page, file) await expectSelectedFile(page, file)
@@ -65,7 +54,7 @@ async function switchSession(page: Page, title: string) {
async function setup(page: Page) { async function setup(page: Page) {
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v1", protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -89,22 +78,27 @@ async function setup(page: Page) {
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
pageMessages: () => ({ items: [] }), pageMessages: () => ({ items: [] }),
}) })
await page.route(/\/vcs(?:\?.*)?$/, (route) => await page.route(/\/api\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ branch: "feature", default_branch: "dev" }), body: JSON.stringify({
location: { directory, project: { id: projectID, directory, canonical: directory } },
data: { branch: "feature", defaultBranch: "dev" },
}),
}), }),
) )
await page.route("**/vcs/diff**", (route) => await page.route("**/api/vcs/diff**", (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify( body: JSON.stringify({
new URL(route.request().url()).searchParams.get("mode") === "branch" location: { directory, project: { id: projectID, directory, canonical: directory } },
? [diff("src/alpha.ts"), diff("src/beta.ts")] data:
: [diff("src/alpha.ts"), diff("src/gamma.ts")], new URL(route.request().url()).searchParams.get("mode") === "branch"
), ? [diff("src/alpha.ts"), diff("src/beta.ts")]
: [diff("src/alpha.ts"), diff("src/gamma.ts")],
}),
}), }),
) )
await page.addInitScript( await page.addInitScript(
@@ -25,7 +25,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
let detailFailures = 1 let detailFailures = 1
await page.setViewportSize({ width: 1400, height: 900 }) await page.setViewportSize({ width: 1400, height: 900 })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v1", protocol: "v2",
directory, directory,
project: { project: {
id: projectID, id: projectID,
@@ -62,33 +62,32 @@ test("keeps the review tree and terminal sized when both panels are open", async
events: () => events.splice(0, 1), events: () => events.splice(0, 1),
eventRetry: 16, eventRetry: 16,
}) })
await page.route(/\/vcs(?:\?.*)?$/, (route) => await page.route(/\/api\/vcs(?:\?.*)?$/, (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ body: JSON.stringify({
branch: "review-pane-performance", location: { directory, project: { id: projectID, directory, canonical: directory } },
default_branch: "dev", data: { branch: "review-pane-performance", defaultBranch: "dev" },
}), }),
}), }),
) )
await page.route("**/vcs/diff**", (route) => { await page.route("**/api/vcs/diff**", (route) => {
const url = new URL(route.request().url()) const url = new URL(route.request().url())
const scope = url.searchParams.get("directory")?.replaceAll("\\", "/") const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/")
const detail = scope?.endsWith("/src/branch/d00027") const detail = scope?.endsWith("/src/branch/d00027")
if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" }) if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" })
return route.fulfill({ return route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
body: JSON.stringify( body: JSON.stringify({
url.searchParams.get("mode") === "branch" location: { directory, project: { id: projectID, directory, canonical: directory } },
? detail data: detail
? branchDiffs ? branchDiffs
.filter((diff) => diff.file.startsWith("src/branch/d00027/")) .filter((diff) => diff.file.startsWith("src/branch/d00027/"))
.map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion)) .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion))
: branchDiffs : branchDiffs,
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)), }),
),
}) })
}) })
await page.route("**/pty*", (route) => await page.route("**/pty*", (route) =>
@@ -109,7 +108,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
}), }),
}), }),
) )
await page.route("**/pty/pty_review_terminal*", (route) => await page.route("**/api/pty/pty_review_terminal*", (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@@ -127,7 +126,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
}), }),
}), }),
) )
await page.route("**/pty/pty_review_terminal/connect-token*", (route) => await page.route("**/api/pty/pty_review_terminal/connect-token*", (route) =>
route.fulfill({ route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@@ -137,7 +136,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
}), }),
}), }),
) )
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined) await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined)
await page.addInitScript(() => { await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem( localStorage.setItem(
@@ -149,9 +148,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title) await expectSessionTitle(page, title)
await expect(page.locator("#review-panel")).toBeVisible() await expect(page.locator("#review-panel")).toBeVisible()
await expectTree(page, 8, "git-0.ts") await expectTree(page, 2_773, "action.yml")
await selectMode(page, "Git changes", "Branch changes")
await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740") await expect(page.locator("#session-side-panel-review-tab")).toHaveText("Files Changed 2740")
await page.keyboard.press("Control+Backquote") await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible() await expect(page.locator("#terminal-panel")).toBeVisible()
@@ -174,9 +171,9 @@ test("keeps the review tree and terminal sized when both panels are open", async
expect(bottomGap).toBeLessThanOrEqual(16) expect(bottomGap).toBeLessThanOrEqual(16)
const lazyDiff = page.waitForRequest((request) => { const lazyDiff = page.waitForRequest((request) => {
const url = new URL(request.url()) const url = new URL(request.url())
return ( return (
url.pathname === "/vcs/diff" && url.pathname === "/api/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
) )
}) })
await lastFile.click() await lastFile.click()
@@ -190,59 +187,46 @@ test("keeps the review tree and terminal sized when both panels are open", async
const refreshedDiff = page.waitForRequest((request) => { const refreshedDiff = page.waitForRequest((request) => {
const url = new URL(request.url()) const url = new URL(request.url())
return ( return (
url.pathname === "/vcs/diff" && url.pathname === "/api/vcs/diff" &&
url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true
) )
}) })
sessionStatus[sessionID] = { type: "idle" } sessionStatus[sessionID] = { type: "idle" }
events.push(statusEvent("idle")) events.push(statusEvent("idle"))
await refreshedDiff await refreshedDiff
await expect(preview).toContainText("after-2") await expect(preview).toContainText("after-2")
await selectMode(page, "Branch changes", "Git changes")
await expectTree(page, 8, "git-0.ts")
await page.getByRole("button", { name: "git-0.ts" }).click()
await selectMode(page, "Git changes", "Branch changes")
await expectTree(page, 2_773, "action.yml")
const filter = page.getByRole("searchbox", { name: "Filter files" }) const filter = page.getByRole("searchbox", { name: "Filter files" })
await filter.fill("generated-2738") await filter.fill("generated-2738")
await expectTree(page, 1, "generated-2738.ts") await expectTree(page, 1, "generated-2738.ts")
await filter.fill("") await filter.fill("")
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "generated-2738.ts")
await page.getByRole("button", { name: "Toggle file tree" }).click() await page.getByRole("button", { name: "Toggle file tree" }).click()
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0) await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveCount(0)
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0) await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(0)
await page.getByRole("button", { name: "Toggle file tree" }).click() await page.getByRole("button", { name: "Toggle file tree" }).click()
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "generated-2738.ts")
await page.keyboard.press("Control+Backquote") await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toHaveCount(0) await expect(page.locator("#terminal-panel")).toHaveCount(0)
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "generated-2738.ts")
await page.keyboard.press("Control+Backquote") await page.keyboard.press("Control+Backquote")
await expect(page.locator("#terminal-panel")).toBeVisible() await expect(page.locator("#terminal-panel")).toBeVisible()
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "generated-2738.ts")
await page.getByRole("button", { name: "Toggle review" }).click() await page.getByRole("button", { name: "Toggle review" }).click()
await expect(page.locator("#review-panel")).toHaveCount(0) await expect(page.locator("#review-panel")).toHaveCount(0)
await page.getByRole("button", { name: "Toggle review" }).click() await page.getByRole("button", { name: "Toggle review" }).click()
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "generated-2738.ts")
await page.setViewportSize({ width: 1_000, height: 700 }) await page.setViewportSize({ width: 1_000, height: 700 })
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "generated-2738.ts")
await expectStackGeometry(page) await expectStackGeometry(page)
await page.setViewportSize({ width: 1_000, height: 120 }) await page.setViewportSize({ width: 1_000, height: 120 })
await page.setViewportSize({ width: 1_400, height: 900 }) await page.setViewportSize({ width: 1_400, height: 900 })
await expectTree(page, 2_773, "action.yml") await expectTree(page, 2_773, "generated-2738.ts")
await expectStackGeometry(page) await expectStackGeometry(page)
}) })
async function selectMode(page: Page, current: string, next: string) {
await page.getByRole("button", { name: current }).click()
const option = page.getByRole("option", { name: next })
await expect(option).toBeVisible()
await option.click()
}
async function expectTree(page: Page, total: number, file: string) { async function expectTree(page: Page, total: number, file: string) {
await expectMountedTree(page, total) await expectMountedTree(page, total)
await expect(page.getByRole("button", { name: file })).toBeVisible() await expect(page.getByRole("button", { name: file })).toBeVisible()
@@ -52,7 +52,7 @@ const editPart = {
sessionID, sessionID,
messageID: assistantMessageID, messageID: assistantMessageID,
type: "tool", type: "tool",
callID: "call_edit_regression", callID: editPartID,
tool: "edit", tool: "edit",
state: { state: {
status: "completed", status: "completed",
@@ -10,7 +10,6 @@ import {
status, status,
textPart, textPart,
title, title,
userID,
userMessage, userMessage,
} from "../performance/timeline-stability/fixture" } from "../performance/timeline-stability/fixture"
import { mockOpenCodeServer } from "../utils/mock-server" import { mockOpenCodeServer } from "../utils/mock-server"
@@ -19,18 +18,22 @@ import { expectSessionTitle } from "../utils/waits"
const initialPageSize = 20 const initialPageSize = 20
const historyPageSize = 200 const historyPageSize = 200
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, return [
parentID: userID, userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }),
created: 1700000001000 + index * 1_000, assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
completed: index < initialPageSize, id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
}), parentID: id,
) created: 1700000001000 + index * 2_000,
const messages = [userMessage(), ...assistants] completed: index < initialPageSize,
}),
]
}).flat()
const assistants = messages.filter((message) => message.info.role === "assistant")
const lastAssistant = assistants.at(-1)! const lastAssistant = assistants.at(-1)!
const lastPartID = assistants.at(-1)!.parts[0]!.id const lastPartID = `${assistants.at(-1)!.info.id}:text:0`
const userPartID = `prt_${userID}_text` const userPartID = `${messages.at(-2)!.info.id}:text:0`
const completed = { const completed = {
...lastAssistant.info, ...lastAssistant.info,
time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 }, time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
@@ -59,6 +62,7 @@ for (const scenario of scenarios) {
retry: 20, retry: 20,
}) })
await mockOpenCodeServer(page, { await mockOpenCodeServer(page, {
protocol: "v2",
directory, directory,
project: project(), project: project(),
provider: { provider: {
@@ -154,15 +158,23 @@ for (const scenario of scenarios) {
await expectSessionTitle(page, title) await expectSessionTitle(page, title)
await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible() await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible() await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2) const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await viewport.hover()
const deadline = Date.now() + 10_000
while (requests.filter((request) => request.phase === "start").length < 2) {
if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
await page.mouse.wheel(0, -240)
await page.waitForTimeout(20)
}
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1) expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
expect(sequence.slice(0, 4)).toEqual([ expect(sequence.slice(0, 3)).toEqual([
"messages:start:latest", "messages:start:latest",
"messages:end:latest", "messages:end:latest",
`message:${userID}`,
`messages:start:${messages.at(-initialPageSize)!.info.id}`, `messages:start:${messages.at(-initialPageSize)!.info.id}`,
]) ])
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize) await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount(
initialPageSize / 2,
)
await page.evaluate(() => { await page.evaluate(() => {
;( ;(
window as Window & { window as Window & {
@@ -174,7 +186,9 @@ for (const scenario of scenarios) {
expect(await visibleContentHidden(page)).toBe(false) expect(await visibleContentHidden(page)).toBe(false)
const beforeHistory = await probeSamples(page) const beforeHistory = await probeSamples(page)
history.resolve() history.resolve()
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length) await expect
.poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count())
.toBeGreaterThan(initialPageSize / 2)
await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2) await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
await waitForProbeSamples(page, beforeHistory) await waitForProbeSamples(page, beforeHistory)
@@ -182,7 +196,7 @@ for (const scenario of scenarios) {
{ before: undefined, limit: initialPageSize }, { before: undefined, limit: initialPageSize },
{ before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize }, { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
]) ])
expect(roots).toEqual([{ sessionID, messageID: userID }]) expect(roots).toEqual([])
const message = messageUpdated(scenario.info) const message = messageUpdated(scenario.info)
const idle = status("idle") const idle = status("idle")
@@ -103,7 +103,7 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
await timeline.send(status("idle"), 350) await timeline.send(status("idle"), 350)
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0) await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() await expect(page.locator('[data-timeline-part-id="prt_recovered"]')).toContainText("Recovered response")
}) })
function lines(count: number) { function lines(count: number) {
@@ -89,7 +89,6 @@ test.describe("session timeline projection", () => {
const aborted = assistantMessage( const aborted = assistantMessage(
[ [
{ id: "prt_before_abort", type: "text", text: "Before interruption" }, { id: "prt_before_abort", type: "text", text: "Before interruption" },
{ id: "prt_compaction", type: "compaction", auto: true },
], ],
{ {
id: "msg_1001_assistant_aborted", id: "msg_1001_assistant_aborted",
@@ -122,13 +121,13 @@ test.describe("session timeline projection", () => {
await scroller.evaluate((element) => (element.scrollTop = 0)) await scroller.evaluate((element) => (element.scrollTop = 0))
await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1) await expect(page.locator('[data-timeline-row="TurnDivider"]')).toHaveCount(1)
await expect(page.getByText("Session compacted", { exact: true })).toBeVisible() await expect(page.getByText("Before interruption", { exact: true })).toBeVisible()
await expect(page.getByText("Visible provider failure")).toBeVisible() await expect(page.getByText("Visible provider failure")).toBeVisible()
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight)) await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
}) })
test("renders comment strips and historical diff summary overflow", async ({ page }) => { test("renders legacy synthetic comments as ordinary V2 user text", async ({ page }) => {
const user = userMessage( const user = userMessage(
[ [
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", { userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
@@ -159,10 +158,14 @@ test.describe("session timeline projection", () => {
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
await scroller.evaluate((element) => (element.scrollTop = 0)) await scroller.evaluate((element) => (element.scrollTop = 0))
await expect(page.locator('[data-timeline-row="CommentStrip"]')).toBeVisible() await expect(
await expect(page.getByText("Keep this stable", { exact: true })).toBeVisible() page.getByText(
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() "The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable Continue after the comment",
await expect(page.getByText(/show all/i)).toBeVisible() { exact: true },
),
).toBeVisible()
await expect(page.locator('[data-timeline-row="CommentStrip"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
}) })
test("renders interruption independently when the turn is not compacted", async ({ page }) => { test("renders interruption independently when the turn is not compacted", async ({ page }) => {
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test" import { expect, test } from "@playwright/test"
import { import {
assistantID,
assistantMessage, assistantMessage,
reasoningPart, reasoningPart,
setupTimeline, setupTimeline,
@@ -70,7 +71,7 @@ for (const profile of profiles) {
await timeline.send(status("busy"), 150) await timeline.send(status("busy"), 150)
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(profile.thinking ? 1 : 0)
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(profile.body ? 1 : 0) await expect(page.locator(`[data-timeline-part-id="${assistantID}:reasoning:0"]`)).toHaveCount(profile.body ? 1 : 0)
if (!profile.summaries && profile.reasoning.trim()) { if (!profile.summaries && profile.reasoning.trim()) {
await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible() await expect(page.getByText("Inspecting stability", { exact: true })).toBeVisible()
} }
@@ -89,5 +90,5 @@ test("does not infer reasoning visibility from provider identity", async ({ page
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0) await expect(page.locator('[data-timeline-part-id*="reasoning"]')).toHaveCount(0)
await expect(page.locator('[data-timeline-part-id="prt_provider_text"]')).toBeVisible() await expect(page.locator(`[data-timeline-part-id="${assistantID}:text:0"]`)).toBeVisible()
}) })
@@ -23,10 +23,11 @@ test("groups singleton and separated context operations at correct boundaries",
] ]
await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] }) await setupTimeline(page, { messages: [userMessage(), assistantMessage(parts)] })
await expect(page.locator('[data-timeline-part-ids="prt_boundary_01_read"]')).toBeVisible() await expect(
await expect(page.locator('[data-timeline-part-ids="prt_boundary_03_glob,prt_boundary_04_grep"]')).toBeVisible() page.locator('[data-timeline-part-ids="prt_boundary_01_read,prt_boundary_03_glob,prt_boundary_04_grep"]'),
).toBeVisible()
await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible() await expect(page.locator('[data-timeline-part-ids="prt_boundary_06_list"]')).toBeVisible()
await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(5) await expect(page.locator('[data-timeline-row="AssistantPart"]')).toHaveCount(4)
}) })
test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => { test("reducer-hardening: converges when idle arrives before final part and message completion", async ({ page }) => {

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