Compare commits

...
Author SHA1 Message Date
Kit Langton 07b357de04 refactor(opencode): drop AppRuntime from instance-runtime
Builds a local ManagedRuntime over InstanceLayer.layer with the shared
process-wide memoMap, so the four exported Promise helpers (load,
disposeInstance, disposeAllInstances, reloadInstance) no longer go
through AppRuntime. Service identity is preserved via memoMap; every
caller's behavior is unchanged.

One step in the broader AppRuntime removal. All public-facing exports
stay the same — callers in src/cli/bootstrap, src/cli/cmd/tui/worker,
src/acp/runtime, and the test fixtures don't need to change.
2026-05-20 20:50:49 -04:00
Kit Langton ddbd119dcb test: drop AppRuntime usage from tests
Removes all direct AppRuntime usage from packages/opencode/test —
`grep -r AppRuntime packages/opencode/test` now returns nothing.

Two patterns are applied:

1. Event tests rewritten in the httpapi-cors.test.ts style. The two
   /event SSE tests now serve HttpApiApp.routes on
   NodeHttpServer.layerTest and hit them via HttpClient. Pub/sub
   identity with the in-process routes is preserved via a new opt-in
   `testEffectShared` (in test/lib/effect.ts) that builds the test
   layer through the shared process-wide memoMap so Bus.defaultLayer
   resolves to the same Bus.Service the routes subscribed to.

   The SSE reader helpers move to test/lib/sse.ts and use HttpClient +
   Effect.Stream + Queue<SseEvent>.

   The D7 diagnostic case is removed: the AppRuntime-vs-test-runtime
   distinction it diagnosed no longer exists.

2. Surgical swap in the remaining four files
   (provider/{amazon-bedrock,provider}, session/llm,
   control-plane/workspace). Each `AppRuntime.runPromise(...)` becomes a
   module-level `ManagedRuntime.make(Service.defaultLayer, { memoMap })`.
   The shared memoMap preserves service identity, so behavior is
   unchanged.

Tests: event (9/9), amazon-bedrock (19/19), provider (84/84),
workspace (35/35); 147 pass across the 5 affected files. The 3
pre-existing failures in session/llm.test.ts are independent of this
change (verified by stashing the diff).
2026-05-20 20:50:32 -04:00
opencode-agent[bot] bd41dac88f chore: update nix node_modules hashes 2026-05-21 00:26:08 +00:00
Kit Langton 41f6daf96a Refactor LLM route-first provider API (#28523) 2026-05-20 20:15:52 -04:00
Kit Langton 5381795844 feat(effect-drizzle-sqlite): add vendored sqlite adapter (#28547) 2026-05-20 20:09:07 -04:00
Frank 7b9d7a7b7d sync 2026-05-20 19:45:59 -04:00
Frank 7b0fd9fed2 zen: grok build 0.1 2026-05-20 19:38:37 -04:00
opencode-agent[bot] b4a01cc3cd chore: generate 2026-05-20 22:37:29 +00:00
James Long a6e1aa085f fix(tui): default new sessions always to local project (#28541) 2026-05-20 18:35:37 -04:00
opencode 6a9cbe7de0 sync release versions for v1.15.6 2026-05-20 21:44:08 +00:00
opencode-agent[bot] 0e17c4f299 chore: update nix node_modules hashes 2026-05-20 21:01:29 +00:00
opencode-agent[bot] 7c121d48b9 chore: generate 2026-05-20 20:56:18 +00:00
Aiden Cline f5a8202b41 fix(tui): simplify thinking toggle styling (#28487) 2026-05-20 15:54:29 -05:00
Kit Langton 43c24d8d0f fix(tui): gate Zed context on terminal env (#28517) 2026-05-20 16:52:33 -04:00
opencode-agent[bot] 09603ed52f chore: generate 2026-05-20 20:50:28 +00:00
Kit Langton 38e3b4087b test(server): port event-diagnostics to Effect runner (#28520) 2026-05-20 16:48:41 -04:00
opencode-agent[bot] eb52362e96 chore: generate 2026-05-20 20:45:33 +00:00
Sebastian 2969a513ce upgrade opentui to 0.2.15 (#28510) 2026-05-20 22:44:09 +02:00
opencode-agent[bot] de672fd214 chore: generate 2026-05-20 20:23:54 +00:00
James Long d0779d2aca feat(tui): collapse directories when possible in file tree (#28512) 2026-05-20 16:22:04 -04:00
opencode-agent[bot] 650594e801 chore: update nix node_modules hashes 2026-05-20 20:13:43 +00:00
James Long 58143c4b07 feat(tui): focus first file in file tree (#28513) 2026-05-20 16:04:45 -04:00
Kit Langton 4cbeacbc76 chore: update Drizzle RC (#28506) 2026-05-20 15:55:36 -04:00
opencode-agent[bot] b0ca0419be chore: generate 2026-05-20 19:25:52 +00:00
Shoubhit Dash 4308dd75fb fix(httpapi): expose v2 catalog errors (#28498) 2026-05-21 00:53:35 +05:30
opencode-agent[bot] 7690481fc1 chore: update nix node_modules hashes 2026-05-20 19:23:14 +00:00
Kit Langton ec6d42d41d chore: update Effect beta (#28505) 2026-05-20 15:09:19 -04:00
opencode-agent[bot] 3553754083 chore: generate 2026-05-20 19:09:16 +00:00
Simon Klee ed839846d1 run: replace subagent tabs with on-demand picker (#28508)
Move subagent navigation into the existing palette: a
"View subagents" command entry, a dedicated picker panel, and a
Down-arrow shortcut from the empty composer.
2026-05-20 21:07:35 +02:00
Kit Langton ba803dd89a fix(sdk): unbreak typecheck on dev after v2 error widening (#28503) 2026-05-20 14:29:19 -04:00
opencode-agent[bot] 0e118d1961 chore: generate 2026-05-20 17:57:38 +00:00
Shoubhit Dash 40e73c4910 fix(httpapi): expose v2 request errors (#28495) 2026-05-20 23:23:23 +05:30
opencode-agent[bot] 9559e2425b chore: generate 2026-05-20 17:29:32 +00:00
Frank ac76af8c56 zen: add gemini 3.5 flash 2026-05-20 13:20:54 -04:00
Victor Navarro b67b03e0ae chore: simplify referral codes (#28491) 2026-05-20 19:15:20 +02:00
Sebastian ef82426e28 handle permanent file plugin errors (#27344) 2026-05-20 18:47:30 +02:00
OpeOginni 14e9e5d9d6 fix(enterprise): message nav hovercard and active state (#23964) 2026-05-21 00:06:13 +08:00
Dax 9324ef0d08 fix(cli): default console login url (#28474) 2026-05-20 11:34:56 -04:00
opencode-agent[bot] 8940572098 chore: generate 2026-05-20 15:18:18 +00:00
James Long 17d66ee4fe feat(tui): initial impl of diff viewer (#28476) 2026-05-20 11:16:56 -04:00
opencode-agent[bot] 13006d6d7c chore: generate 2026-05-20 07:10:40 +00:00
Simon Klee 539b118690 run: add shell mode to prompt (#28315)
Press `!` on an empty prompt to enter shell mode and run a command
through session.shell instead of sending a message
2026-05-20 09:09:12 +02:00
opencode-agent[bot] 11f7e5a1b0 chore: generate 2026-05-20 06:41:26 +00:00
Brendan Allan 38b406fb35 app: Initial tabs impl (#28436) 2026-05-20 14:40:06 +08:00
opencode-agent[bot] 4702cddb3e chore: generate 2026-05-20 04:29:32 +00:00
Luke Parker 82c5d45601 Add Windows desktop app menu (#28420) 2026-05-20 14:28:15 +10:00
333 changed files with 27627 additions and 4855 deletions
+8
View File
@@ -4,6 +4,14 @@
- 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.
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility. - Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
## Commits and PR Titles
Use conventional commit-style messages and PR titles: `type(scope): summary`.
Valid types are `feat`, `fix`, `docs`, `chore`, `refactor`, and `test`. Scopes are optional; use the affected package or area when helpful, e.g. `core`, `opencode`, `tui`, `app`, `desktop`, `sdk`, or `plugin`.
Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributing guide`, `chore(sdk): regenerate types`.
## Style Guide ## Style Guide
### General Principles ### General Principles
+64 -43
View File
@@ -29,7 +29,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -84,7 +84,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -119,7 +119,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -146,7 +146,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -168,7 +168,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -192,7 +192,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"version": "1.15.5", "version": "1.15.6",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -253,7 +253,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -305,9 +305,23 @@
"@parcel/watcher-win32-x64": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1",
}, },
}, },
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.15.5",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
"@effect/sql-sqlite-bun": "catalog:",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -337,7 +351,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -353,7 +367,7 @@
}, },
"packages/http-recorder": { "packages/http-recorder": {
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"effect": "catalog:", "effect": "catalog:",
@@ -366,7 +380,7 @@
}, },
"packages/llm": { "packages/llm": {
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@smithy/eventstream-codec": "4.2.14", "@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2", "@smithy/util-utf8": "4.2.2",
@@ -384,7 +398,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.15.5", "version": "1.15.6",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -522,7 +536,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"effect": "catalog:", "effect": "catalog:",
@@ -538,9 +552,9 @@
"typescript": "catalog:", "typescript": "catalog:",
}, },
"peerDependencies": { "peerDependencies": {
"@opentui/core": ">=0.2.14", "@opentui/core": ">=0.2.15",
"@opentui/keymap": ">=0.2.14", "@opentui/keymap": ">=0.2.15",
"@opentui/solid": ">=0.2.14", "@opentui/solid": ">=0.2.15",
}, },
"optionalPeers": [ "optionalPeers": [
"@opentui/core", "@opentui/core",
@@ -560,7 +574,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -575,7 +589,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -610,7 +624,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
@@ -659,7 +673,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -715,17 +729,18 @@
}, },
"catalog": { "catalog": {
"@cloudflare/workers-types": "4.20251008.0", "@cloudflare/workers-types": "4.20251008.0",
"@effect/opentelemetry": "4.0.0-beta.65", "@effect/opentelemetry": "4.0.0-beta.66",
"@effect/platform-node": "4.0.0-beta.65", "@effect/platform-node": "4.0.0-beta.66",
"@effect/sql-sqlite-bun": "4.0.0-beta.66",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@lydell/node-pty": "1.2.0-beta.10", "@lydell/node-pty": "1.2.0-beta.10",
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.14", "@opentui/core": "0.2.15",
"@opentui/keymap": "0.2.14", "@opentui/keymap": "0.2.15",
"@opentui/solid": "0.2.14", "@opentui/solid": "0.2.15",
"@pierre/diffs": "1.1.0-beta.18", "@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1", "@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0", "@sentry/solid": "10.36.0",
@@ -747,9 +762,9 @@
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.65", "effect": "4.0.0-beta.66",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
@@ -1108,11 +1123,13 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.65", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.65" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-0CD2fSsXrDM7FP2WFkbGJO1DwMqWR3UKHh6oBDXPHAPA+RsJSKoh3pLQsbQfldLuKnhOy87Bv0v9r9IdrIHCQw=="], "@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.66", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.66" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-LU3ejAzJS+4P+Qtfn9ULnsGcIPmx1tUUB2ZswFRL+EolD8US7zMljHTwGuQRUBJOjDwt7wFCMN5AR512vdY8FQ=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.65", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.65", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.65", "ioredis": "^5.7.0" } }, "sha512-QQy3KRcMwP0TngQdfQGl2u1zp03B7k7DuF5SNS8aZhD0dDBpKZpCwFad1ODY5qdY3ycPgMwBwKRRK7y/aw0C9w=="], "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.66", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.66", "mime": "^4.1.0", "undici": "^8.0.2" }, "peerDependencies": { "effect": "^4.0.0-beta.66", "ioredis": "^5.7.0" } }, "sha512-s/0RgaQFuszzdorRnX1PwEQNnSOi+JgMJo3zEe9O2NR3sosMhTr0Uk+1AF6bUOI9uJ2CPT3KpTIIU7q5/TpOkg=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.65", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.65" } }, "sha512-3rY8F3WLEax6Hj08GI/OvDIH+KqjfxH7RM2bAMfgR75NgRmwDtny1P49PtPkoRjH5dcdtThThtsvE4X9OTZkpQ=="], "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.66", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.66" } }, "sha512-+ymrhBnESv/hmn5SKTe2//IY9Ox/hGPeoogEWhW47ZGyhFI5eMYFxdEUBa+3IAV05rrBzrxON9lynu68n0DM7w=="],
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.66", "", { "peerDependencies": { "effect": "^4.0.0-beta.66" } }, "sha512-UYsrAb/5T0ZRypeN9Kmv3/ZInibGCjM6dtoiAWtfG+xKyuq8N05wmuVCXB0+XgVmUBxDWjw/S1fu4ivS0vZVuw=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -1542,6 +1559,8 @@
"@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"], "@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"],
"@opencode-ai/effect-drizzle-sqlite": ["@opencode-ai/effect-drizzle-sqlite@workspace:packages/effect-drizzle-sqlite"],
"@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"], "@opencode-ai/enterprise": ["@opencode-ai/enterprise@workspace:packages/enterprise"],
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"], "@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
@@ -1592,23 +1611,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.14", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.14", "@opentui/core-darwin-x64": "0.2.14", "@opentui/core-linux-arm64": "0.2.14", "@opentui/core-linux-x64": "0.2.14", "@opentui/core-win32-arm64": "0.2.14", "@opentui/core-win32-x64": "0.2.14" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-17YCr3BqM9mhi/DdNVM+omgmrKQNIl0G5RzoaTFOHe4+OAhG+W3iooYi+WdsekJWSUOEwZqDRz0QBTZhOtgZsQ=="], "@opentui/core": ["@opentui/core@0.2.15", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.15", "@opentui/core-darwin-x64": "0.2.15", "@opentui/core-linux-arm64": "0.2.15", "@opentui/core-linux-x64": "0.2.15", "@opentui/core-win32-arm64": "0.2.15", "@opentui/core-win32-x64": "0.2.15" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-YGHttdZWScMcSvtYgZkLR6VhUO1OoUiQzwYjZgIusf5eCkPLD8PapH+PTMVqAiX16CHO6JxfMlkHv5qDiHAccQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iS4NZQkOKX2EP5rsNjDcU7inDLcKhPaSBn8ENjDXKx2smOh7p/rgM2qlEaiLI3njtL784QoF+nxTzSXbEI6+Jw=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-ft4ZwYHCV0VtRMwQtHH5mAgwqRLHEXP26DWcwtCZWDEHDvghClBR0cj9UZLH5JAKn/j7ds5hZDCCZz+nUiEHYA=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-t/EKD4+rlzWuwYAa6NzGCmiBOHvF+hzjNwExj+dnSqX5wK7TU+VHl+N2iYUl4VhhJK94kPP6BnrF5GcHnZGFLg=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-Lvqmd92UZ+KZVnr0xU0jYj4XqnCSsBQJHS/FpYkJgZSAN7/4NmPlgMvQXIGW8a3BcFaGRKe8LuGpqM2E4oaX0Q=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Jnuud29daaEoZNEp80dxUDLyUcwLr+g6SruHPyyWerOe7J10JE1ihJNkDlXLT7T49xdBGYRQlRkuNGwRfZWx5A=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.14", "", { "os": "win32", "cpu": "x64" }, "sha512-2ZUNh7yaAMUwAOK8oFEO28qXqPFrWPGGD0KHK1Gp97Th9XuVZniMLtbkbrFtDRh15+PVj7MyrG0N967W7rLr1A=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SVMVgnC7LVEm+yVZKdmmhRBj/xAT94PanT+UCcHxaCWK+OLmv/AX+ohHq2m0odup6iXcEqj+7mAltO9fgJLFIg=="],
"@opentui/keymap": ["@opentui/keymap@0.2.14", "", { "dependencies": { "@opentui/core": "0.2.14" }, "peerDependencies": { "@opentui/react": "0.2.14", "@opentui/solid": "0.2.14", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-Jd4F3S98D8bJcr41jk7KcsFwwQTA0GCNKb+LoDMkPwv00k+NV6XcrXPB3QlNeM/JrVbe55pyGaT/ynZklGYHRw=="], "@opentui/keymap": ["@opentui/keymap@0.2.15", "", { "dependencies": { "@opentui/core": "0.2.15" }, "peerDependencies": { "@opentui/react": "0.2.15", "@opentui/solid": "0.2.15", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wqQp6y7P2jZZJiOMwupxjGryuSWCs+njjglwW/xny9J17gomBmUvTIcIIWNG0Jv+EGO9ScBzCScGlwBHFhHyYw=="],
"@opentui/solid": ["@opentui/solid@0.2.14", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.14", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-tSWiiwdh/J+crkjwHgd26HXAlJHYocwkN/eUoqSH3+Y/uO3c3qRnbzQz7zzds+j4XDQU/8e9QcZshYLvQT9c7w=="], "@opentui/solid": ["@opentui/solid@0.2.15", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.15", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-CViepAjsCWXwrLndMt+qlLo7cooVX7DXwSJHNizw7mfrRJtOPzSYJZCIk1vF4IJTWffCHygoYMe3uSeKvzAcbw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2974,9 +2993,9 @@
"dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="], "dotenv-expand": ["dotenv-expand@11.0.7", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA=="],
"drizzle-kit": ["drizzle-kit@1.0.0-beta.19-d95b7a4", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-M0sqc+42TYBod6kEZ3AsW6+JWe3+76gR1aDFbHH5DmuLKEwewmbzlhBG6qnvV6YA1cIIbkuam3dC7r6PREOCXw=="], "drizzle-kit": ["drizzle-kit@1.0.0-rc.2", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "get-tsconfig": "^4.13.6", "jiti": "^2.6.1" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-TRxUmj1wDA2QCt3GvuhfamvIa66wJ7+MzSxBMKkpRtYScjHTumT9BE+x6daSzuEacSrPEuUH5/cW1uo5RkoPIg=="],
"drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="], "drizzle-orm": ["drizzle-orm@1.0.0-rc.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql-pg": ">=4.0.0-beta.58 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.58 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-UXYDkbplF5wX0hwxll+80QhEwUvAJLBu+tAK/d4fna18kLE6VuliAzufF/ieDEIJeSnLRYgtmsXD6x1Xuy1kIg=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
@@ -2990,7 +3009,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.65", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw=="], "effect": ["effect@4.0.0-beta.66", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.6.0", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.9", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^13.0.0", "yaml": "^2.8.3" } }, "sha512-4arEr62cziFa8BBVDUwJCJJmaVepXf/kRg7KtC0h8+bufngscrHbwWFhr9c+HonwOF+31U3iD3xUJmw9KzX7Dw=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -5678,6 +5697,8 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"db0/drizzle-orm": ["drizzle-orm@1.0.0-beta.19-d95b7a4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-bZZKKeoRKrMVU6zKTscjrSH0+WNb1WEi3N0Jl4wEyQ7aQpTgHzdYY6IJQ1P0M74HuSJVeX4UpkFB/S6dtqLEJg=="],
"defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="], "defaults/clone": ["clone@1.0.4", "", {}, "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="],
"dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
+9 -2
View File
@@ -2,6 +2,7 @@ import { SECRET } from "./secret"
import { domain } from "./stage" import { domain } from "./stage"
const description = "Managed by SST (Don't edit in Honeycomb UI)" const description = "Managed by SST (Don't edit in Honeycomb UI)"
const alertsDisabled = $app.stage !== "production"
const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", { const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`, name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`,
@@ -79,7 +80,7 @@ IF(
filters, filters,
}, },
], ],
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 200), DIV($FAILED, $TOTAL), 0)" }], formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 150), DIV($FAILED, $TOTAL), 0)" }],
timeRange: 900, timeRange: 900,
}).json }).json
} }
@@ -122,7 +123,7 @@ const providerHttpErrorsQuery = () => {
}, },
], ],
formulas: [ formulas: [
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 200), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" }, { name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 150), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
], ],
timeRange: 900, timeRange: 900,
}).json }).json
@@ -159,6 +160,7 @@ const modelLowTpsQuery = (product: "go" | "zen") => {
new honeycomb.Trigger("IncreasedModelHttpErrorsGo", { new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
name: "Increased Model HTTP Errors [Go]", name: "Increased Model HTTP Errors [Go]",
description, description,
disabled: alertsDisabled,
queryJson: modelHttpErrorsQuery("go"), queryJson: modelHttpErrorsQuery("go"),
alertType: "on_change", alertType: "on_change",
frequency: 300, frequency: 300,
@@ -178,6 +180,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
new honeycomb.Trigger("IncreasedModelHttpErrorsZen", { new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
name: "Increased Model HTTP Errors [Zen]", name: "Increased Model HTTP Errors [Zen]",
description, description,
disabled: alertsDisabled,
queryJson: modelHttpErrorsQuery("zen"), queryJson: modelHttpErrorsQuery("zen"),
alertType: "on_change", alertType: "on_change",
frequency: 300, frequency: 300,
@@ -197,6 +200,7 @@ new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
new honeycomb.Trigger("LowModelTpsGo", { new honeycomb.Trigger("LowModelTpsGo", {
name: "Low Model TPS [Go]", name: "Low Model TPS [Go]",
description, description,
disabled: alertsDisabled,
queryJson: modelLowTpsQuery("go"), queryJson: modelLowTpsQuery("go"),
alertType: "on_change", alertType: "on_change",
frequency: 600, frequency: 600,
@@ -216,6 +220,7 @@ new honeycomb.Trigger("LowModelTpsGo", {
new honeycomb.Trigger("LowModelTpsZen", { new honeycomb.Trigger("LowModelTpsZen", {
name: "Low Model TPS [Zen]", name: "Low Model TPS [Zen]",
description, description,
disabled: alertsDisabled,
queryJson: modelLowTpsQuery("zen"), queryJson: modelLowTpsQuery("zen"),
alertType: "on_change", alertType: "on_change",
frequency: 600, frequency: 600,
@@ -235,6 +240,7 @@ new honeycomb.Trigger("LowModelTpsZen", {
new honeycomb.Trigger("IncreasedProviderHttpErrors", { new honeycomb.Trigger("IncreasedProviderHttpErrors", {
name: "Increased Provider HTTP Errors", name: "Increased Provider HTTP Errors",
description, description,
disabled: alertsDisabled,
queryJson: providerHttpErrorsQuery(), queryJson: providerHttpErrorsQuery(),
alertType: "on_change", alertType: "on_change",
frequency: 300, frequency: 300,
@@ -254,6 +260,7 @@ new honeycomb.Trigger("IncreasedProviderHttpErrors", {
new honeycomb.Trigger("IncreasedFreeTierRequests", { new honeycomb.Trigger("IncreasedFreeTierRequests", {
name: "Increased Free Tier Requests", name: "Increased Free Tier Requests",
description, description,
disabled: alertsDisabled,
queryJson: honeycomb.getQuerySpecificationOutput({ queryJson: honeycomb.getQuerySpecificationOutput({
calculations: [{ op: "COUNT" }], calculations: [{ op: "COUNT" }],
filters: [ filters: [
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-FI1mX42vJuYdUDdWevlfHz+OcYkDn/I/HUbHE/jdQvs=", "x86_64-linux": "sha256-kCSAVPQgJROcvnnwf0Cn6PuYL25hYgTasJeBJlmnFgQ=",
"aarch64-linux": "sha256-3CQzzKnh/4Zf5vyn56yR5P3ULsW7K7Fr8/RQpekEJDk=", "aarch64-linux": "sha256-prY27Ek2QhW+4OvBJ3bHHkUDoLTA4mD3KQmOQqSbAuo=",
"aarch64-darwin": "sha256-XPDVHMxlPpXlf43BRqNnwF809unk6iE8tvd0o92d0/w=", "aarch64-darwin": "sha256-0yIqnnjreVHTgGZLrKFpT9Cc2B2LNfmYcRByaCu7tiU=",
"x86_64-darwin": "sha256-dFXTi13RSgL62lMsep1EoE/KSEPF7Oh31PVdxW1tkzg=" "x86_64-darwin": "sha256-n+urvMRozB9nO5D3qyCweSa5HExFk1YGEzOt2445LEE="
} }
} }
+9 -8
View File
@@ -28,16 +28,17 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@effect/opentelemetry": "4.0.0-beta.65", "@effect/opentelemetry": "4.0.0-beta.66",
"@effect/platform-node": "4.0.0-beta.65", "@effect/platform-node": "4.0.0-beta.66",
"@effect/sql-sqlite-bun": "4.0.0-beta.66",
"@npmcli/arborist": "9.4.0", "@npmcli/arborist": "9.4.0",
"@types/bun": "1.3.13", "@types/bun": "1.3.13",
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.14", "@opentui/core": "0.2.15",
"@opentui/keymap": "0.2.14", "@opentui/keymap": "0.2.15",
"@opentui/solid": "0.2.14", "@opentui/solid": "0.2.15",
"ulid": "3.0.1", "ulid": "3.0.1",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1", "@types/luxon": "3.7.1",
@@ -53,9 +54,9 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.65", "effect": "4.0.0-beta.66",
"ai": "6.0.168", "ai": "6.0.168",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"hono": "4.10.7", "hono": "4.10.7",
+2 -1
View File
@@ -1,10 +1,11 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.15.5", "version": "1.15.6",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./desktop-menu": "./src/desktop-menu.ts",
"./vite": "./vite.js", "./vite": "./vite.js",
"./index.css": "./src/index.css" "./index.css": "./src/index.css"
}, },
+369 -145
View File
@@ -1,18 +1,23 @@
import { createEffect, createMemo, Show, untrack } from "solid-js" import { createEffect, createMemo, For, mapArray, Match, Show, startTransition, Switch, untrack } from "solid-js"
import { createStore } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme/context" import { useTheme } from "@opencode-ai/ui/theme/context"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history" import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useGlobalSync } from "@/context/global-sync"
import { decodeDirectory } from "@/pages/directory-layout"
import { iife } from "@opencode-ai/core/util/iife"
type TauriDesktopWindow = { type TauriDesktopWindow = {
startDragging?: () => Promise<void> startDragging?: () => Promise<void>
@@ -39,6 +44,8 @@ const titlebarHeight = 40
const minTitlebarZoom = 0.25 const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each. const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
export function Titlebar() { export function Titlebar() {
const layout = useLayout() const layout = useLayout()
const platform = usePlatform() const platform = usePlatform()
@@ -52,6 +59,7 @@ export function Titlebar() {
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const web = createMemo(() => platform.platform === "web") const web = createMemo(() => platform.platform === "web")
const zoom = () => platform.webviewZoom?.() ?? 1 const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom()) const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
@@ -175,162 +183,378 @@ export function Titlebar() {
return ( return (
<header <header
class="h-10 shrink-0 bg-background-base relative overflow-hidden" class="h-10 shrink-0 bg-background-base relative overflow-hidden flex flex-row"
style={{ "min-height": minHeight() }} style={{ "min-height": minHeight(), "padding-left": mac() ? `${84 / zoom()}px` : 0 }}
data-tauri-drag-region data-tauri-drag-region
onMouseDown={drag} onMouseDown={drag}
onDblClick={maximize} onDblClick={maximize}
> >
<div <Switch>
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center" <Match when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
style={{ zoom: counterZoom() }} {(_) => {
> const globalSync = useGlobalSync()
<div const navigate = useNavigate()
classList={{
"flex items-center min-w-0": true, type Tab = { dir: string; sessionId: string; params: any; href: string }
"pl-2": !mac(),
}} const [tabsStore, tabsStoreActions] = iife(() => {
> const [store, setStore] = createStore<Tab[]>(
<Show when={mac()}> iife(() => {
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} /> if (!params.dir || !params.id) return []
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center"> return [
<IconButton {
icon="menu" dir: decodeDirectory(params.dir) ?? "",
variant="ghost" sessionId: params.id,
class="titlebar-icon rounded-md" params: { id: params.id, dir: params.dir },
onClick={layout.mobileSidebar.toggle} href: makeSessionHref(params.dir, params.id),
aria-label={language.t("sidebar.menu.toggle")} },
aria-expanded={layout.mobileSidebar.opened()} ]
/> }),
</div> )
</Show>
<Show when={!mac()}> const actions = {
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center"> addTab: (tab: Tab) => {
<IconButton setStore(
icon="menu" produce((tabs) => {
variant="ghost" if (tabs.some((t) => t.href === tab.href)) return
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle} tabs.push(tab)
aria-label={language.t("sidebar.menu.toggle")} }),
aria-expanded={layout.mobileSidebar.opened()} )
/> },
</div> removeTab: (href: string) => {
</Show> startTransition(() => {
<div class="flex items-center gap-1 shrink-0"> setStore(
<TooltipKeybind produce((tabs) => {
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"} const index = tabs.findIndex((t) => t.href === href)
placement="bottom" if (index === -1) return
title={language.t("command.sidebar.toggle")} tabs.splice(index, 1)
keybind={command.keybind("sidebar.toggle")} const nextTab = tabs[index] ?? tabs[tabs.length - 1]
> if (nextTab) navigate(nextTab.href)
<Button else navigate("/")
variant="ghost" }),
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border" )
onClick={layout.sidebar.toggle} })
aria-label={language.t("command.sidebar.toggle")} },
aria-expanded={layout.sidebar.opened()} }
>
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} /> return [store, actions]
</Button> })
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0"> createEffect(() => {
<Show when={params.dir}> const params = useParams()
<div if (!(params.dir && params.id)) return
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined} tabsStoreActions.addTab({
dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id,
params: { id: params.id, dir: params.dir },
href: makeSessionHref(params.dir, params.id),
})
})
const tabsEnriched = iife(() => {
const base = mapArray(
() => tabsStore,
(tab) => {
const sync = globalSync.createDirSyncContext(tab.dir)
const session = sync.session.get(tab.sessionId)
return session ? { ...tab, info: session } : null
},
)
return () => base().flatMap((s) => (s ? [s] : []))
})
return (
<div class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3">
<ChannelIndicator />
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<IconButtonV2
as="a"
href="/"
variant="ghost-muted"
size="large"
class="!w-8"
state={!!useMatch(() => "/")() ? "pressed" : undefined}
> >
<div <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
class="transition-opacity" <path
classList={{ d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z"
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(), stroke="currentColor"
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(), stroke-miterlimit="10"
}} stroke-linecap="square"
> />
<TooltipKeybind </svg>
placement="bottom" </IconButtonV2>
title={language.t("command.session.new")} <div class="flex flex-row items-center gap-2">
keybind={command.keybind("session.new")} <For each={tabsEnriched()}>
openDelay={2000} {(tab, i) => (
<>
{i() !== 0 && <div class="w-[1.5px] h-3 rounded-full bg-[var(--v2-background-bg-layer-02)]" />}
<TabNavItem
href={tab.href}
title={tab.info.title}
onClose={() => tabsStoreActions.removeTab(tab.href)}
hideClose={tabsEnriched().length < 2}
/>
</>
)}
</For>
</div>
<button>
<div class="p-1.5">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
> >
<Button <path
variant="ghost" d="M7.99978 2.88867V13.1109M2.88867 7.99978H13.1109"
icon={creating() ? "new-session-active" : "new-session"} stroke="#808080"
class="titlebar-icon w-8 h-6 p-0 box-border" stroke-linejoin="round"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
/> />
</TooltipKeybind> </svg>
</div> </div>
</button>
<div class="flex-1" />
{/*<button class="px-2.5 py-1.5 bg-[rgba(0,0,0,0.08)] rounded-[6px]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<path
d="M10.4443 2.44436V13.5555M1.55546 13.5554H14.4443V2.44434H1.55542L1.55546 13.5554Z"
stroke="#3A3A3A"
/>
</svg>
</button>*/}
</div>
)
}}
</Match>
<Match when>
<div
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
style={{ zoom: counterZoom() }}
>
<div
classList={{
"flex items-center min-w-0": true,
"pl-2": !mac(),
}}
>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<Show when={mac()}>
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div> </div>
</Show> </Show>
<div <Show when={!mac()}>
class="flex items-center shrink-0" <div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
classList={{ <IconButton
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir, icon="menu"
"duration-180 ease-out": !layout.sidebar.opened(), variant="ghost"
"duration-180 ease-in": layout.sidebar.opened(), class="titlebar-icon rounded-md"
}} onClick={layout.mobileSidebar.toggle}
> aria-label={language.t("sidebar.menu.toggle")}
<Show when={hasProjects() && nav()}> aria-expanded={layout.mobileSidebar.opened()}
<div class="flex items-center gap-0 transition-transform"> />
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}> </div>
<Button </Show>
variant="ghost" <div class="flex items-center gap-1 shrink-0">
icon="chevron-left" <TooltipKeybind
class="titlebar-icon w-6 h-6 p-0 box-border" class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
disabled={!canBack()} placement="bottom"
onClick={back} title={language.t("command.sidebar.toggle")}
aria-label={language.t("common.goBack")} keybind={command.keybind("sidebar.toggle")}
/> >
</Tooltip> <Button
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}> variant="ghost"
<Button class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
variant="ghost" onClick={layout.sidebar.toggle}
icon="chevron-right" aria-label={language.t("command.sidebar.toggle")}
class="titlebar-icon w-6 h-6 p-0 box-border" aria-expanded={layout.sidebar.opened()}
disabled={!canForward()} >
onClick={forward} <Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
aria-label={language.t("common.goForward")} </Button>
/> </TooltipKeybind>
</Tooltip> <div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<div
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
>
<div
class="transition-opacity"
classList={{
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
}}
>
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
>
<Button
variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
/>
</TooltipKeybind>
</div>
</div>
</Show>
<div
class="flex items-center shrink-0"
classList={{
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<ChannelIndicator />
</div> </div>
</Show> </div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
</div>
)}
</div> </div>
</div> </div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">
<div
id="opencode-titlebar-center"
class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full"
/>
</div>
<div
classList={{
"flex items-center min-w-0 justify-end": true,
"pr-2": !windows(),
}}
data-tauri-drag-region
onMouseDown={drag}
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
{!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />}
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div>
</div> </div>
</div> </Match>
</Switch>
<div class="min-w-0 flex items-center justify-center pointer-events-none">
<div id="opencode-titlebar-center" class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full" />
</div>
<div
classList={{
"flex items-center min-w-0 justify-end": true,
"pr-2": !windows(),
}}
data-tauri-drag-region
onMouseDown={drag}
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
{!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />}
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div>
</div>
</header> </header>
) )
} }
function TabNavItem(props: { href: string; title: string; hideClose?: boolean; onClose: () => void }) {
const match = useMatch(() => props.href)
const isActive = () => !!match()
return (
<div
class="group flex flex-row items-center max-w-60 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] bg-[var(--tab-bg)] h-7 rounded-[6px] relative overflow-hidden"
data-active={isActive()}
>
<a
href={props.href}
class="w-full h-full pl-1.5 flex-1 max-w-full flex flex-row items-center overflow-hidden font-medium"
>
{props.title}
</a>
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
<div
class="absolute inset-0 bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
style={{
"--inactive-bg": "linear-gradient(to right, transparent 0%, var(--tab-bg) 80%)",
"--active-bg": "linear-gradient(90deg, transparent 0%, var(--tab-bg) 25%)",
}}
/>
<IconButtonV2
size="small"
variant="ghost-muted"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
onClick={props.onClose}
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor" />
</svg>
}
/>
</div>
</div>
)
}
function ChannelIndicator() {
return (
<>
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
</div>
)}
</>
)
}
@@ -0,0 +1,111 @@
import { Show, type JSX } from "solid-js"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { useCommand } from "@/context/command"
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
import { usePlatform } from "@/context/platform"
export function WindowsAppMenu(props: {
command: ReturnType<typeof useCommand>
platform: ReturnType<typeof usePlatform>
}) {
let lastFocused: HTMLElement | undefined
const rememberFocus = () => {
const active = document.activeElement
lastFocused = active instanceof HTMLElement ? active : undefined
}
const commandDisabled = (id: string) => {
const option = props.command.options.find((option) => option.id === id)
if (!option) return true
return option.disabled ?? false
}
const runCommand = (id: string) => {
if (commandDisabled(id)) return
props.command.trigger(id)
}
const runAction = (action: DesktopMenuAction) => {
if (action.startsWith("edit.") && lastFocused?.isConnected) lastFocused.focus({ preventScroll: true })
void props.platform.runDesktopMenuAction?.(action)
}
const runEntry = (entry: DesktopMenuEntry) => {
if (entry.type === "separator") return
if (entry.command) {
runCommand(entry.command)
return
}
if (entry.action) {
runAction(entry.action)
return
}
if (entry.href) props.platform.openLink(entry.href)
}
return (
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
<DropdownMenu.Trigger
as={IconButton}
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md shrink-0"
aria-label="OpenCode menu"
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="desktop-app-menu">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="desktop-app-menu-heading">OpenCode</DropdownMenu.GroupLabel>
{DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => (
<DesktopMenuSubmenu label={menu.label}>
{menu.items
?.filter((entry) => desktopMenuVisible(entry, "windows"))
.map((entry) =>
entry.type === "separator" ? (
<DropdownMenu.Separator />
) : (
<DesktopMenuItem
label={entry.label ?? ""}
keybind={entry.command ? props.command.keybind(entry.command) : entry.accelerator?.windows}
disabled={entry.command ? commandDisabled(entry.command) : false}
onSelect={() => runEntry(entry)}
/>
),
)}
</DesktopMenuSubmenu>
))}
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
)
}
function DesktopMenuSubmenu(props: { label: string; children: JSX.Element }) {
return (
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>
<span data-slot="dropdown-menu-item-label">{props.label}</span>
<span data-slot="desktop-app-menu-chevron">
<Icon name="chevron-right" size="small" />
</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal>
<DropdownMenu.SubContent class="desktop-app-menu">{props.children}</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
)
}
function DesktopMenuItem(props: { label: string; keybind?: string; disabled?: boolean; onSelect: () => void }) {
return (
<DropdownMenu.Item disabled={props.disabled} onSelect={props.onSelect}>
<DropdownMenu.ItemLabel>{props.label}</DropdownMenu.ItemLabel>
<Show when={props.keybind}>
<span data-slot="desktop-app-menu-keybind">{props.keybind}</span>
</Show>
</DropdownMenu.Item>
)
}
+596
View File
@@ -0,0 +1,596 @@
import { batch, createMemo } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import {
clearSessionPrefetch,
getSessionPrefetch,
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { useGlobalSync } from "./global-sync"
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key)
if (pending) return pending
const promise = task().finally(() => {
map.delete(key)
})
map.set(key, promise)
return promise
}
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const map = new Map(a.map((item) => [item.id, item] as const))
for (const item of b) map.set(item.id, item)
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
}
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
}
type OptimisticAddInput = {
sessionID: string
message: Message
parts: Part[]
}
type OptimisticRemoveInput = {
sessionID: string
messageID: string
}
type OptimisticItem = {
message: Message
parts: Part[]
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
let changed = false
for (const part of want) {
const result = Binary.search(next, part.id, (item) => item.id)
if (result.found) continue
next.splice(result.index, 0, part)
changed = true
}
if (!changed) return parts
return next
}
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, mergeParts(current, item.parts))
}
return {
cursor: page.cursor,
complete: page.complete,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
confirmed,
}
}
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.message.id, (m) => m.id)
messages.splice(result.index, 0, input.message)
} else {
draft.message[input.sessionID] = [input.message]
}
draft.part[input.message.id] = sortParts(input.parts)
}
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
delete draft.part[input.messageID]
}
function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return [input.message]
const result = Binary.search(messages, input.message.id, (m) => m.id)
const next = [...messages]
next.splice(result.index, 0, input.message)
return next
})
setStore("part", input.message.id, sortParts(input.parts))
}
function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return messages
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (!result.found) return messages
const next = [...messages]
next.splice(result.index, 1)
return next
})
setStore("part", (part: Record<string, Part[] | undefined>) => {
if (!(input.messageID in part)) return part
const next = { ...part }
delete next[input.messageID]
return next
})
}
export const createDirSyncContext = (client: OpencodeClient, directory: string) => {
const globalSync = useGlobalSync()
type Child = ReturnType<(typeof globalSync)["child"]>
type Setter = Child[1]
const current = createMemo(() => globalSync.child(directory))
const target = (directory?: string) => {
if (!directory || directory === directory) return current()
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80
const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const maxDirs = 30
const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
})
const getSession = (sessionID: string) => {
const store = current()[0]
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
}
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
const key = keyFor(directory, sessionID)
const list = optimistic.get(key)
if (list) {
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
return
}
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
}
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
const key = keyFor(directory, sessionID)
if (!messageID) {
optimistic.delete(key)
return
}
const list = optimistic.get(key)
if (!list) return
list.delete(messageID)
if (list.size === 0) optimistic.delete(key)
}
const getOptimistic = (directory: string, sessionID: string) => [
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
]
const seenFor = (directory: string) => {
const existing = seen.get(directory)
if (existing) {
seen.delete(directory)
seen.set(directory, existing)
return existing
}
const created = new Set<string>()
seen.set(directory, created)
while (seen.size > maxDirs) {
const first = seen.keys().next().value
if (!first) break
const stale = [...(seen.get(first) ?? [])]
seen.delete(first)
const [, setStore] = globalSync.child(first, { bootstrap: false })
evict(first, setStore, stale)
}
return created
}
const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
for (const sessionID of sessionIDs) {
clearOptimistic(directory, sessionID)
}
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
const key = keyFor(directory, sessionID)
delete draft.limit[key]
delete draft.cursor[key]
delete draft.complete[key]
delete draft.loading[key]
}
}),
)
}
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs)
for (const sessionID of sessionIDs) {
globalSync.todo.set(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, sessionIDs)
}),
)
clearMeta(directory, sessionIDs)
}
const touch = (directory: string, setStore: Setter, sessionID: string) => {
const stale = pickSessionCacheEvictions({
seen: seenFor(directory),
keep: sessionID,
limit: SESSION_CACHE_LIMIT,
})
evict(directory, setStore, stale)
}
const fetchMessages = async (input: { client: typeof client; sessionID: string; limit: number; before?: string }) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return {
session,
part,
cursor,
complete: !cursor,
}
}
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
const loadMessages = async (input: {
directory: string
client: typeof client
setStore: Setter
sessionID: string
limit: number
before?: string
mode?: "replace" | "prepend"
}) => {
const key = keyFor(input.directory, input.sessionID)
if (meta.loading[key]) return
setMeta("loading", key, true)
await fetchMessages(input)
.then((page) => {
if (!tracked(input.directory, input.sessionID)) return
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID)
}
const [store] = globalSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) {
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
if (filtered.length) input.setStore("part", p.id, filtered)
}
setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete)
setSessionPrefetch({
directory: input.directory,
sessionID: input.sessionID,
limit: message.length,
cursor: next.cursor,
complete: next.complete,
})
})
})
.finally(() => {
setMeta(
produce((draft) => {
if (!tracked(input.directory, input.sessionID)) {
delete draft.loading[key]
return
}
draft.loading[key] = false
}),
)
})
}
return {
get data() {
return current()[0]
},
get set(): Setter {
return current()[1]
},
get status() {
return current()[0].status
},
get ready() {
return current()[0].status !== "loading"
},
get project() {
const store = current()[0]
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
if (match.found) return globalSync.data.project[match.index]
return undefined
},
session: {
get: getSession,
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const _directory = input.directory ?? directory
const [, setStore] = target(input.directory)
setOptimistic(_directory, input.sessionID, { message: input.message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
const _directory = input.directory ?? directory
const [, setStore] = target(input.directory)
clearOptimistic(_directory, input.sessionID, input.messageID)
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
},
},
addOptimisticMessage(input: {
sessionID: string
messageID: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}) {
const message: Message = {
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: { ...input.model, variant: input.variant },
}
const [, setStore] = target()
setOptimistic(directory, input.sessionID, { message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
sessionID: input.sessionID,
message,
parts: input.parts,
})
},
async sync(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID)
touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(directory, sessionID)
if (pending) {
await pending
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
}
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const messagesReq =
cached && !opts?.force
? Promise.resolve()
: loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
await Promise.all([sessionReq, messagesReq])
})
},
async diff(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
}),
)
},
async todo(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = globalSync.data.session_todo[sessionID]
if (existing !== undefined) {
if (cached === undefined) {
globalSync.todo.set(sessionID, existing)
}
if (!opts?.force) return
}
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
if (!tracked(directory, sessionID)) return
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
}),
)
},
history: {
more(sessionID: string) {
const store = current()[0]
const key = keyFor(directory, sessionID)
if (store.message[sessionID] === undefined) return false
if (meta.limit[key] === undefined) return false
if (meta.complete[key]) return false
return !!meta.cursor[key]
},
loading(sessionID: string) {
const key = keyFor(directory, sessionID)
return meta.loading[key] ?? false
},
async loadMore(sessionID: string, count?: number) {
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? historyMessagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]
if (!before) return
await loadMessages({
directory,
client,
setStore,
sessionID,
limit: step,
before,
mode: "prepend",
})
},
},
evict(sessionID: string, _directory = directory) {
const [, setStore] = globalSync.child(_directory)
seenFor(_directory).delete(sessionID)
evict(_directory, setStore, [sessionID])
},
fetch: async (count = 10) => {
const [store, setStore] = globalSync.child(directory)
setStore("limit", (x) => x + count)
await client.session.list().then((x) => {
const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
setStore("session", reconcile(sessions, { key: "id" }))
})
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
const [, setStore] = globalSync.child(directory)
await client.session.update({ sessionID, time: { archived: Date.now() } })
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session.splice(match.index, 1)
}),
)
},
},
absolute,
get directory() {
return current()[0].path.directory
},
}
}
+24
View File
@@ -36,6 +36,7 @@ import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from
import { createRefreshQueue } from "./global-sync/queue" import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils" import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/utils/path-key" import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -431,6 +432,9 @@ function createGlobalSync() {
}, },
})) }))
const dirSyncContexts = new Map<string, ReturnType<typeof createDirSyncContext>>()
const dirSyncContextRefCounts = new Map<string, number>()
return { return {
data: globalStore, data: globalStore,
set, set,
@@ -449,6 +453,26 @@ function createGlobalSync() {
todo: { todo: {
set: setSessionTodo, set: setSessionTodo,
}, },
createDirSyncContext: (directory: string) => {
onCleanup(() => {
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) - 1)
if (dirSyncContextRefCounts.get(directory) === 0) {
dirSyncContexts.delete(directory)
dirSyncContextRefCounts.delete(directory)
}
})
const cached = dirSyncContexts.get(directory)
if (cached) {
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) + 1)
return cached
}
const ctx = createDirSyncContext(globalSDK.createClient({ directory, throwOnError: true }), directory)
dirSyncContexts.set(directory, ctx)
dirSyncContextRefCounts.set(directory, 1)
return ctx
},
} }
} }
+4
View File
@@ -1,6 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage" import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js" import type { Accessor } from "solid-js"
import type { DesktopMenuAction } from "../desktop-menu"
import { ServerConnection } from "./server" import { ServerConnection } from "./server"
type PickerPaths = string | string[] | null type PickerPaths = string | string[] | null
@@ -82,6 +83,9 @@ export type Platform = {
/** Webview zoom level (desktop only) */ /** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number> webviewZoom?: Accessor<number>
/** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
/** Check if an editor app exists (desktop only) */ /** Check if an editor app exists (desktop only) */
checkAppExists?(appName: string): Promise<boolean> checkAppExists?(appName: string): Promise<boolean>
+1 -454
View File
@@ -1,19 +1,8 @@
import { batch, createMemo } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/core/util/binary" import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import {
clearSessionPrefetch,
getSessionPrefetch,
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -172,448 +161,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const sdk = useSDK() const sdk = useSDK()
type Child = ReturnType<(typeof globalSync)["child"]> return globalSync.createDirSyncContext(sdk.directory)
type Setter = Child[1]
const current = createMemo(() => globalSync.child(sdk.directory))
const target = (directory?: string) => {
if (!directory || directory === sdk.directory) return current()
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80
const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const maxDirs = 30
const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
})
const getSession = (sessionID: string) => {
const store = current()[0]
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
}
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
const key = keyFor(directory, sessionID)
const list = optimistic.get(key)
if (list) {
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
return
}
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
}
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
const key = keyFor(directory, sessionID)
if (!messageID) {
optimistic.delete(key)
return
}
const list = optimistic.get(key)
if (!list) return
list.delete(messageID)
if (list.size === 0) optimistic.delete(key)
}
const getOptimistic = (directory: string, sessionID: string) => [
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
]
const seenFor = (directory: string) => {
const existing = seen.get(directory)
if (existing) {
seen.delete(directory)
seen.set(directory, existing)
return existing
}
const created = new Set<string>()
seen.set(directory, created)
while (seen.size > maxDirs) {
const first = seen.keys().next().value
if (!first) break
const stale = [...(seen.get(first) ?? [])]
seen.delete(first)
const [, setStore] = globalSync.child(first, { bootstrap: false })
evict(first, setStore, stale)
}
return created
}
const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
for (const sessionID of sessionIDs) {
clearOptimistic(directory, sessionID)
}
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
const key = keyFor(directory, sessionID)
delete draft.limit[key]
delete draft.cursor[key]
delete draft.complete[key]
delete draft.loading[key]
}
}),
)
}
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs)
for (const sessionID of sessionIDs) {
globalSync.todo.set(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, sessionIDs)
}),
)
clearMeta(directory, sessionIDs)
}
const touch = (directory: string, setStore: Setter, sessionID: string) => {
const stale = pickSessionCacheEvictions({
seen: seenFor(directory),
keep: sessionID,
limit: SESSION_CACHE_LIMIT,
})
evict(directory, setStore, stale)
}
const fetchMessages = async (input: {
client: typeof sdk.client
sessionID: string
limit: number
before?: string
}) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return {
session,
part,
cursor,
complete: !cursor,
}
}
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
const loadMessages = async (input: {
directory: string
client: typeof sdk.client
setStore: Setter
sessionID: string
limit: number
before?: string
mode?: "replace" | "prepend"
}) => {
const key = keyFor(input.directory, input.sessionID)
if (meta.loading[key]) return
setMeta("loading", key, true)
await fetchMessages(input)
.then((page) => {
if (!tracked(input.directory, input.sessionID)) return
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID)
}
const [store] = globalSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) {
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
if (filtered.length) input.setStore("part", p.id, filtered)
}
setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete)
setSessionPrefetch({
directory: input.directory,
sessionID: input.sessionID,
limit: message.length,
cursor: next.cursor,
complete: next.complete,
})
})
})
.finally(() => {
setMeta(
produce((draft) => {
if (!tracked(input.directory, input.sessionID)) {
delete draft.loading[key]
return
}
draft.loading[key] = false
}),
)
})
}
return {
get data() {
return current()[0]
},
get set(): Setter {
return current()[1]
},
get status() {
return current()[0].status
},
get ready() {
return current()[0].status !== "loading"
},
get project() {
const store = current()[0]
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
if (match.found) return globalSync.data.project[match.index]
return undefined
},
session: {
get: getSession,
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory)
setOptimistic(directory, input.sessionID, { message: input.message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory)
clearOptimistic(directory, input.sessionID, input.messageID)
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
},
},
addOptimisticMessage(input: {
sessionID: string
messageID: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}) {
const message: Message = {
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: { ...input.model, variant: input.variant },
}
const [, setStore] = target()
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
sessionID: input.sessionID,
message,
parts: input.parts,
})
},
async sync(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID)
touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(directory, sessionID)
if (pending) {
await pending
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
}
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const messagesReq =
cached && !opts?.force
? Promise.resolve()
: loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
await Promise.all([sessionReq, messagesReq])
})
},
async diff(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
}),
)
},
async todo(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = globalSync.data.session_todo[sessionID]
if (existing !== undefined) {
if (cached === undefined) {
globalSync.todo.set(sessionID, existing)
}
if (!opts?.force) return
}
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
if (!tracked(directory, sessionID)) return
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
}),
)
},
history: {
more(sessionID: string) {
const store = current()[0]
const key = keyFor(sdk.directory, sessionID)
if (store.message[sessionID] === undefined) return false
if (meta.limit[key] === undefined) return false
if (meta.complete[key]) return false
return !!meta.cursor[key]
},
loading(sessionID: string) {
const key = keyFor(sdk.directory, sessionID)
return meta.loading[key] ?? false
},
async loadMore(sessionID: string, count?: number) {
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? historyMessagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]
if (!before) return
await loadMessages({
directory,
client,
setStore,
sessionID,
limit: step,
before,
mode: "prepend",
})
},
},
evict(sessionID: string, directory = sdk.directory) {
const [, setStore] = globalSync.child(directory)
seenFor(directory).delete(sessionID)
evict(directory, setStore, [sessionID])
},
fetch: async (count = 10) => {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
setStore("limit", (x) => x + count)
await client.session.list().then((x) => {
const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
setStore("session", reconcile(sessions, { key: "id" }))
})
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
await client.session.update({ sessionID, time: { archived: Date.now() } })
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session.splice(match.index, 1)
}),
)
},
},
absolute,
get directory() {
return current()[0].path.directory
},
}
}, },
}) })
+221
View File
@@ -0,0 +1,221 @@
export type DesktopMenuPlatform = "macos" | "windows"
export type DesktopMenuAction =
| "app.checkForUpdates"
| "app.relaunch"
| "edit.undo"
| "edit.redo"
| "edit.cut"
| "edit.copy"
| "edit.paste"
| "edit.delete"
| "edit.selectAll"
| "view.reload"
| "view.toggleDevTools"
| "view.resetZoom"
| "view.zoomIn"
| "view.zoomOut"
| "view.toggleFullscreen"
| "window.new"
| "window.close"
| "window.minimize"
| "window.toggleMaximize"
export type DesktopMenuRole =
| "about"
| "close"
| "copy"
| "cut"
| "hide"
| "hideOthers"
| "paste"
| "quit"
| "redo"
| "reload"
| "resetZoom"
| "selectAll"
| "toggleDevTools"
| "togglefullscreen"
| "undo"
| "unhide"
| "windowMenu"
| "zoomIn"
| "zoomOut"
export type DesktopMenuItem = {
type: "item"
label?: string
command?: string
action?: DesktopMenuAction
role?: DesktopMenuRole
href?: string
accelerator?: Partial<Record<DesktopMenuPlatform, string>>
enabled?: "updater"
platforms?: DesktopMenuPlatform[]
}
export type DesktopMenuSeparator = {
type: "separator"
platforms?: DesktopMenuPlatform[]
}
export type DesktopMenuEntry = DesktopMenuItem | DesktopMenuSeparator
export type DesktopMenu = {
id: string
label: string
role?: DesktopMenuRole
items?: DesktopMenuEntry[]
platforms?: DesktopMenuPlatform[]
}
export const DESKTOP_MENU: DesktopMenu[] = [
{
id: "app",
label: "OpenCode",
platforms: ["macos"],
items: [
{ type: "item", role: "about" },
{ type: "item", label: "Check for Updates...", action: "app.checkForUpdates", enabled: "updater" },
{ type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
{ type: "item", label: "Reload Webview", action: "view.reload" },
{ type: "item", label: "Restart", action: "app.relaunch" },
{ type: "separator" },
{ type: "item", role: "hide" },
{ type: "item", role: "hideOthers" },
{ type: "item", role: "unhide" },
{ type: "separator" },
{ type: "item", role: "quit" },
],
},
{
id: "file",
label: "File",
items: [
{
type: "item",
label: "New Session",
command: "session.new",
accelerator: { macos: "Shift+Cmd+S" },
},
{ type: "item", label: "Open Project...", command: "project.open", accelerator: { macos: "Cmd+O" } },
{
type: "item",
label: "Settings",
command: "settings.open",
accelerator: { windows: "Ctrl+," },
platforms: ["windows"],
},
{
type: "item",
label: "New Window",
action: "window.new",
accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" },
},
{ type: "separator" },
{ type: "item", label: "Close Window", action: "window.close", role: "close" },
],
},
{
id: "edit",
label: "Edit",
items: [
{ type: "item", label: "Undo", action: "edit.undo", role: "undo", accelerator: { windows: "Ctrl+Z" } },
{ type: "item", label: "Redo", action: "edit.redo", role: "redo", accelerator: { windows: "Ctrl+Y" } },
{ type: "separator" },
{ type: "item", label: "Cut", action: "edit.cut", role: "cut", accelerator: { windows: "Ctrl+X" } },
{ type: "item", label: "Copy", action: "edit.copy", role: "copy", accelerator: { windows: "Ctrl+C" } },
{ type: "item", label: "Paste", action: "edit.paste", role: "paste", accelerator: { windows: "Ctrl+V" } },
{ type: "item", label: "Delete", action: "edit.delete" },
{
type: "item",
label: "Select All",
action: "edit.selectAll",
role: "selectAll",
accelerator: { windows: "Ctrl+A" },
},
],
},
{
id: "view",
label: "View",
items: [
{ type: "item", label: "Toggle Sidebar", command: "sidebar.toggle", accelerator: { macos: "Cmd+B" } },
{ type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } },
{ type: "item", label: "Toggle File Tree", command: "fileTree.toggle" },
{ type: "separator" },
{ type: "item", label: "Reload", action: "view.reload", role: "reload" },
{ type: "item", label: "Toggle Developer Tools", action: "view.toggleDevTools", role: "toggleDevTools" },
{ type: "separator" },
{
type: "item",
label: "Actual Size",
action: "view.resetZoom",
role: "resetZoom",
accelerator: { windows: "Ctrl+0" },
},
{ type: "item", label: "Zoom In", action: "view.zoomIn", role: "zoomIn", accelerator: { windows: "Ctrl++" } },
{ type: "item", label: "Zoom Out", action: "view.zoomOut", role: "zoomOut", accelerator: { windows: "Ctrl+-" } },
{ type: "separator" },
{ type: "item", label: "Toggle Full Screen", action: "view.toggleFullscreen", role: "togglefullscreen" },
],
},
{
id: "go",
label: "Go",
items: [
{ type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
{ type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
{ type: "separator" },
{ type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } },
{ type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } },
{ type: "separator" },
{
type: "item",
label: "Previous Project",
command: "project.previous",
accelerator: { macos: "Cmd+Option+Up" },
},
{
type: "item",
label: "Next Project",
command: "project.next",
accelerator: { macos: "Cmd+Option+Down" },
},
],
},
{
id: "window",
label: "Window",
role: "windowMenu",
items: [
{ type: "item", label: "Minimize", action: "window.minimize" },
{ type: "item", label: "Maximize", action: "window.toggleMaximize" },
{ type: "separator" },
{ type: "item", label: "Close Window", action: "window.close" },
],
},
{
id: "help",
label: "Help",
items: [
{ type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" },
{ type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" },
{ type: "separator" },
{
type: "item",
label: "Share Feedback",
href: "https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml",
},
{
type: "item",
label: "Report a Bug",
href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml",
},
],
},
]
export function desktopMenuVisible(item: { platforms?: DesktopMenuPlatform[] }, platform: DesktopMenuPlatform) {
return !item.platforms || item.platforms.includes(platform)
}
+58
View File
@@ -1,4 +1,5 @@
@import "@opencode-ai/ui/styles/tailwind"; @import "@opencode-ai/ui/styles/tailwind";
@import "@opencode-ai/ui/v2/styles/tailwind.css";
@font-face { @font-face {
font-family: "JetBrainsMono Nerd Font Mono"; font-family: "JetBrainsMono Nerd Font Mono";
@@ -53,6 +54,63 @@
container-name: getting-started; container-name: getting-started;
} }
[data-component="dropdown-menu-content"].desktop-app-menu,
[data-component="dropdown-menu-sub-content"].desktop-app-menu {
min-width: 160px;
padding: 2px;
}
[data-component="dropdown-menu-content"].desktop-app-menu {
width: 160px;
}
[data-component="dropdown-menu-sub-content"].desktop-app-menu {
width: max-content;
min-width: 240px;
max-width: min(320px, calc(100vw - 24px));
}
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-group-label"] {
display: flex;
align-items: center;
height: 28px;
padding: 0 12px;
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-medium);
line-height: 1;
color: var(--text-weak);
}
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item"],
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"],
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item"],
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"] {
min-height: 28px;
padding: 0 12px;
gap: 8px;
font-weight: var(--font-weight-regular);
line-height: 1;
}
[data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"],
[data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"] {
white-space: nowrap;
}
[data-slot="desktop-app-menu-keybind"] {
margin-left: auto;
color: var(--text-weak);
font-size: var(--font-size-x-small);
font-weight: var(--font-weight-regular);
white-space: nowrap;
}
[data-slot="desktop-app-menu-chevron"] {
display: flex;
margin-left: auto;
color: var(--icon-base);
}
[data-component="getting-started-actions"] { [data-component="getting-started-actions"] {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+11 -1
View File
@@ -8,6 +8,7 @@ import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync" import { SyncProvider, useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { Schema } from "effect"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const location = useLocation() const location = useLocation()
@@ -40,6 +41,15 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
) )
} }
export const ProjectDirString = Schema.String.pipe(Schema.brand("ProjectDirString"))
export type ProjectDirString = Schema.Schema.Type<typeof ProjectDirString>
export function decodeDirectory(dir: string): ProjectDirString | undefined {
const decoded = decode64(dir)
if (!decoded) return
return ProjectDirString.make(decoded)
}
export default function Layout(props: ParentProps) { export default function Layout(props: ParentProps) {
const params = useParams() const params = useParams()
const language = useLanguage() const language = useLanguage()
@@ -48,7 +58,7 @@ export default function Layout(props: ParentProps) {
const resolved = createMemo(() => { const resolved = createMemo(() => {
if (!params.dir) return "" if (!params.dir) return ""
return decode64(params.dir) ?? "" return decodeDirectory(params.dir) ?? ""
}) })
createEffect(() => { createEffect(() => {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.15.5", "version": "1.15.6",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -10,6 +10,7 @@ import {
IconAnthropic, IconAnthropic,
IconArcee, IconArcee,
IconGemini, IconGemini,
IconDeepSeek,
IconMiniMax, IconMiniMax,
IconMoonshotAI, IconMoonshotAI,
IconNvidia, IconNvidia,
@@ -27,6 +28,7 @@ const getModelLab = (modelId: string) => {
if (modelId.startsWith("claude")) return "Anthropic" if (modelId.startsWith("claude")) return "Anthropic"
if (modelId.startsWith("gpt")) return "OpenAI" if (modelId.startsWith("gpt")) return "OpenAI"
if (modelId.startsWith("gemini")) return "Google" if (modelId.startsWith("gemini")) return "Google"
if (modelId.startsWith("deepseek")) return "DeepSeek"
if (modelId.startsWith("kimi")) return "Moonshot AI" if (modelId.startsWith("kimi")) return "Moonshot AI"
if (modelId.startsWith("glm")) return "Z.ai" if (modelId.startsWith("glm")) return "Z.ai"
if (modelId.startsWith("qwen")) return "Alibaba" if (modelId.startsWith("qwen")) return "Alibaba"
@@ -47,7 +49,19 @@ const getModelsInfo = query(async (workspaceID: string) => {
.filter(([id, _model]) => !id.startsWith("alpha-")) .filter(([id, _model]) => !id.startsWith("alpha-"))
.filter(([id, _model]) => !id.endsWith(":global")) .filter(([id, _model]) => !id.endsWith(":global"))
.sort(([idA, modelA], [idB, modelB]) => { .sort(([idA, modelA], [idB, modelB]) => {
const priority = ["big-pickle", "minimax", "grok", "claude", "gpt", "gemini"] const priority = [
"big-pickle",
"claude",
"gpt",
"gemini",
"deepseek",
"glm",
"kimi",
"qwen",
"grok",
"minimax",
"mimo",
]
const getPriority = (id: string) => { const getPriority = (id: string) => {
const index = priority.findIndex((p) => id.startsWith(p)) const index = priority.findIndex((p) => id.startsWith(p))
return index === -1 ? Infinity : index return index === -1 ? Infinity : index
@@ -136,6 +150,8 @@ export function ModelSection() {
return <IconAnthropic width={16} height={16} /> return <IconAnthropic width={16} height={16} />
case "Google": case "Google":
return <IconGemini width={16} height={16} /> return <IconGemini width={16} height={16} />
case "DeepSeek":
return <IconDeepSeek width={16} height={16} />
case "Moonshot AI": case "Moonshot AI":
return <IconMoonshotAI width={16} height={16} /> return <IconMoonshotAI width={16} height={16} />
case "Z.ai": case "Z.ai":
@@ -13,7 +13,7 @@ export function createRateLimiter(
if (!zenApiKey) return if (!zenApiKey) return
const dict = i18n(localeFromRequest(request)) const dict = i18n(localeFromRequest(request))
const LIMIT = rateLimit ?? 500 const LIMIT = rateLimit ?? 1000
const yyyyMMddHHmm = new Date(Date.now()) const yyyyMMddHHmm = new Date(Date.now())
.toISOString() .toISOString()
.replace(/[^0-9]/g, "") .replace(/[^0-9]/g, "")
@@ -0,0 +1,13 @@
CREATE TABLE `referral_code` (
`workspace_id` varchar(30) PRIMARY KEY,
`code` varchar(10) NOT NULL,
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`time_deleted` timestamp(3),
CONSTRAINT `code` UNIQUE INDEX(`code`)
);
--> statement-breakpoint
DROP INDEX `referral_invitee_account_id` ON `referral`;--> statement-breakpoint
DROP INDEX `referral_code` ON `workspace`;--> statement-breakpoint
CREATE UNIQUE INDEX `invitee_account_id` ON `referral` (`invitee_account_id`);--> statement-breakpoint
ALTER TABLE `workspace` DROP COLUMN `referral_code`;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.15.5", "version": "1.15.6",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+22 -30
View File
@@ -3,7 +3,7 @@ import { and, asc, eq, isNull, sql, Database } from "./drizzle"
import { Actor } from "./actor" import { Actor } from "./actor"
import { Identifier } from "./identifier" import { Identifier } from "./identifier"
import { LiteTable } from "./schema/billing.sql" import { LiteTable } from "./schema/billing.sql"
import { ReferralRewardTable, ReferralTable } from "./schema/referral.sql" import { ReferralCodeTable, ReferralRewardTable, ReferralTable } from "./schema/referral.sql"
import { AuthTable } from "./schema/auth.sql" import { AuthTable } from "./schema/auth.sql"
import { UserTable } from "./schema/user.sql" import { UserTable } from "./schema/user.sql"
import { WorkspaceTable } from "./schema/workspace.sql" import { WorkspaceTable } from "./schema/workspace.sql"
@@ -26,38 +26,29 @@ export namespace Referral {
} }
function generateCode() { function generateCode() {
return ulid().slice(-CODE_LENGTH) return ulid().slice(-CODE_LENGTH).toUpperCase()
} }
async function ensureCode(workspaceID = Actor.workspace()) { async function ensureCode(workspaceID = Actor.workspace()) {
return Database.transaction(async (tx) => { return Database.use(async (db) => {
const existing = await tx const existing = await db
.select({ code: WorkspaceTable.referralCode }) .select({ code: ReferralCodeTable.code })
.from(WorkspaceTable) .from(ReferralCodeTable)
.where(and(eq(WorkspaceTable.id, workspaceID), isNull(WorkspaceTable.timeDeleted))) .where(eq(ReferralCodeTable.workspaceID, workspaceID))
.then((rows) => rows[0]) .then((rows) => rows[0])
if (!existing) throw new Error("Workspace not found") if (existing) return { code: existing.code }
if (existing.code) return { code: existing.code }
for (const _ of Array.from({ length: 5 })) { await db.insert(ReferralCodeTable).ignore().values({
await tx workspaceID,
.update(WorkspaceTable) code: generateCode(),
.set({ referralCode: generateCode() }) })
.where(
and(
eq(WorkspaceTable.id, workspaceID),
isNull(WorkspaceTable.referralCode),
isNull(WorkspaceTable.timeDeleted),
),
)
const created = await tx const created = await db
.select({ code: WorkspaceTable.referralCode }) .select({ code: ReferralCodeTable.code })
.from(WorkspaceTable) .from(ReferralCodeTable)
.where(and(eq(WorkspaceTable.id, workspaceID), isNull(WorkspaceTable.timeDeleted))) .where(eq(ReferralCodeTable.workspaceID, workspaceID))
.then((rows) => rows[0]) .then((rows) => rows[0])
if (created?.code) return { code: created.code } if (created) return { code: created.code }
}
throw new Error("Failed to generate referral code") throw new Error("Failed to generate referral code")
}) })
@@ -300,9 +291,10 @@ export namespace Referral {
return Database.transaction(async (tx) => { return Database.transaction(async (tx) => {
const code = await tx const code = await tx
.select({ workspaceID: WorkspaceTable.id }) .select({ workspaceID: ReferralCodeTable.workspaceID })
.from(WorkspaceTable) .from(ReferralCodeTable)
.where(and(eq(WorkspaceTable.referralCode, referralCode), isNull(WorkspaceTable.timeDeleted))) .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, ReferralCodeTable.workspaceID))
.where(and(eq(ReferralCodeTable.code, referralCode), isNull(WorkspaceTable.timeDeleted)))
.then((rows) => rows[0]) .then((rows) => rows[0])
if (!code) throw new Error("Referral code invalid") if (!code) throw new Error("Referral code invalid")
@@ -1,7 +1,17 @@
import { bigint, mysqlTable, primaryKey, uniqueIndex } from "drizzle-orm/mysql-core" import { bigint, mysqlTable, primaryKey, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types" import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
import { workspaceIndexes } from "./workspace.sql" import { workspaceIndexes } from "./workspace.sql"
export const ReferralCodeTable = mysqlTable(
"referral_code",
{
workspaceID: ulid("workspace_id").notNull(),
code: varchar("code", { length: 10 }).notNull(),
...timestamps,
},
(table) => [primaryKey({ columns: [table.workspaceID] }), uniqueIndex("code").on(table.code)],
)
export const ReferralTable = mysqlTable( export const ReferralTable = mysqlTable(
"referral", "referral",
{ {
@@ -9,7 +19,7 @@ export const ReferralTable = mysqlTable(
...timestamps, ...timestamps,
inviteeAccountID: ulid("invitee_account_id").notNull(), inviteeAccountID: ulid("invitee_account_id").notNull(),
}, },
(table) => [...workspaceIndexes(table), uniqueIndex("referral_invitee_account_id").on(table.inviteeAccountID)], (table) => [...workspaceIndexes(table), uniqueIndex("invitee_account_id").on(table.inviteeAccountID)],
) )
export const ReferralRewardTable = mysqlTable( export const ReferralRewardTable = mysqlTable(
@@ -6,11 +6,10 @@ export const WorkspaceTable = mysqlTable(
{ {
id: ulid("id").notNull().primaryKey(), id: ulid("id").notNull().primaryKey(),
slug: varchar("slug", { length: 255 }), slug: varchar("slug", { length: 255 }),
referralCode: varchar("referral_code", { length: 10 }),
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
...timestamps, ...timestamps,
}, },
(table) => [uniqueIndex("slug").on(table.slug), uniqueIndex("referral_code").on(table.referralCode)], (table) => [uniqueIndex("slug").on(table.slug)],
) )
export function workspaceIndexes(table: any) { export function workspaceIndexes(table: any) {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.15.5", "version": "1.15.6",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.15.5", "version": "1.15.6",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.15.5", "version": "1.15.6",
"name": "@opencode-ai/core", "name": "@opencode-ai/core",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -111,7 +111,7 @@ describe("cross-spawn spawner", () => {
ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }), ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }),
), ),
) )
expect(out).toBe(tmp.path) expect(yield* Effect.promise(() => fs.realpath(out))).toBe(yield* Effect.promise(() => fs.realpath(tmp.path)))
}), }),
) )
@@ -119,7 +119,9 @@ describe("cross-spawn spawner", () => {
"fails for invalid cwd", "fails for invalid cwd",
Effect.gen(function* () { Effect.gen(function* () {
const exit = yield* Effect.exit( const exit = yield* Effect.exit(
ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" }).asEffect(), ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
svc.spawn(ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" })),
),
) )
expect(Exit.isFailure(exit)).toBe(true) expect(Exit.isFailure(exit)).toBe(true)
}), }),
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.15.5", "version": "1.15.6",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
@@ -0,0 +1,84 @@
import { BrowserWindow } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import { createMainWindow, updateTitlebar } from "./windows"
export type DesktopMenuActionHandlers = Partial<{
checkForUpdates: () => void
relaunch: () => void
}>
export function runDesktopMenuAction(
win: BrowserWindow | null,
action: DesktopMenuAction,
handlers: DesktopMenuActionHandlers = {},
) {
switch (action) {
case "app.checkForUpdates":
handlers.checkForUpdates?.()
return
case "app.relaunch":
handlers.relaunch?.()
return
case "window.new":
createMainWindow()
return
case "window.close":
win?.close()
return
case "window.minimize":
win?.minimize()
return
case "window.toggleMaximize":
if (win?.isMaximized()) {
win.unmaximize()
return
}
win?.maximize()
return
case "view.reload":
win?.reload()
return
case "view.toggleDevTools":
win?.webContents.toggleDevTools()
return
case "view.resetZoom":
setZoom(win, 1)
return
case "view.zoomIn":
setZoom(win, (win?.webContents.getZoomFactor() ?? 1) + 0.2)
return
case "view.zoomOut":
setZoom(win, (win?.webContents.getZoomFactor() ?? 1) - 0.2)
return
case "view.toggleFullscreen":
win?.setFullScreen(!win.isFullScreen())
return
case "edit.undo":
win?.webContents.undo()
return
case "edit.redo":
win?.webContents.redo()
return
case "edit.cut":
win?.webContents.cut()
return
case "edit.copy":
win?.webContents.copy()
return
case "edit.paste":
win?.webContents.paste()
return
case "edit.delete":
win?.webContents.delete()
return
case "edit.selectAll":
win?.webContents.selectAll()
return
}
}
function setZoom(win: BrowserWindow | null, value: number) {
if (!win) return
win.webContents.setZoomFactor(Math.min(Math.max(value, 0.2), 10))
updateTitlebar(win)
}
+4 -2
View File
@@ -345,11 +345,13 @@ const main = Effect.gen(function* () {
mainWindow = createMainWindow() mainWindow = createMainWindow()
if (mainWindow) { if (mainWindow) {
createMenu({ createMenu({
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id), trigger: (id) => {
const win = BrowserWindow.getFocusedWindow() ?? mainWindow
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => { checkForUpdates: () => {
void checkForUpdates(true, killSidecar) void checkForUpdates(true, killSidecar)
}, },
reload: () => mainWindow?.reload(),
relaunch: () => { relaunch: () => {
void killSidecar().finally(() => { void killSidecar().finally(() => {
app.relaunch() app.relaunch()
+5
View File
@@ -1,6 +1,7 @@
import { execFile } from "node:child_process" import { execFile } from "node:child_process"
import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron" import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { import type {
InitStep, InitStep,
@@ -10,6 +11,7 @@ import type {
WindowConfig, WindowConfig,
WslConfig, WslConfig,
} from "../preload/types" } from "../preload/types"
import { runDesktopMenuAction } from "./desktop-menu-actions"
import { getStore } from "./store" import { getStore } from "./store"
import { setTitlebar, updateTitlebar } from "./windows" import { setTitlebar, updateTitlebar } from "./windows"
@@ -198,6 +200,9 @@ export function registerIpcHandlers(deps: Deps) {
if (!win) return if (!win) return
setTitlebar(win, theme) setTitlebar(win, theme)
}) })
ipcMain.handle("run-desktop-menu-action", (event: IpcMainInvokeEvent, action: DesktopMenuAction) => {
runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action)
})
} }
export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) { export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) {
+52 -126
View File
@@ -1,141 +1,67 @@
import { Menu, shell } from "electron" import { BrowserWindow, Menu, shell } from "electron"
import type { MenuItemConstructorOptions } from "electron"
import {
DESKTOP_MENU,
desktopMenuVisible,
type DesktopMenuEntry,
type DesktopMenuRole,
} from "@opencode-ai/app/desktop-menu"
import { UPDATER_ENABLED } from "./constants" import { UPDATER_ENABLED } from "./constants"
import { createMainWindow } from "./windows" import { runDesktopMenuAction } from "./desktop-menu-actions"
type Deps = { type Deps = {
trigger: (id: string) => void trigger: (id: string) => void
checkForUpdates: () => void checkForUpdates: () => void
reload: () => void
relaunch: () => void relaunch: () => void
} }
export function createMenu(deps: Deps) { export function createMenu(deps: Deps) {
if (process.platform !== "darwin") return if (process.platform !== "darwin") return
const template: Electron.MenuItemConstructorOptions[] = [ const template = DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "macos")).map((menu) => {
{ if (menu.role) return { role: nativeRole(menu.role) }
label: "OpenCode", return {
submenu: [ label: menu.label,
{ role: "about" }, submenu: menu.items
{ ?.filter((entry) => desktopMenuVisible(entry, "macos"))
label: "Check for Updates...", .map((entry) => nativeItem(entry, deps)),
enabled: UPDATER_ENABLED, }
click: () => deps.checkForUpdates(), })
},
{
label: "Settings",
accelerator: "Cmd+,",
click: () => deps.trigger("settings.open"),
},
{
label: "Reload Webview",
click: () => deps.reload(),
},
{
label: "Restart",
click: () => deps.relaunch(),
},
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ role: "unhide" },
{ type: "separator" },
{ role: "quit" },
],
},
{
label: "File",
submenu: [
{ label: "New Session", accelerator: "Shift+Cmd+S", click: () => deps.trigger("session.new") },
{ label: "Open Project...", accelerator: "Cmd+O", click: () => deps.trigger("project.open") },
{
label: "New Window",
accelerator: "Cmd+Shift+N",
click: () => createMainWindow(),
},
{ type: "separator" },
{ role: "close" },
],
},
{
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{ role: "selectAll" },
],
},
{
label: "View",
submenu: [
{ label: "Toggle Sidebar", accelerator: "Cmd+B", click: () => deps.trigger("sidebar.toggle") },
{ label: "Toggle Terminal", accelerator: "Ctrl+`", click: () => deps.trigger("terminal.toggle") },
{ label: "Toggle File Tree", click: () => deps.trigger("fileTree.toggle") },
{ type: "separator" },
{ role: "reload" },
{ role: "toggleDevTools" },
{ type: "separator" },
{ role: "resetZoom" },
{ role: "zoomIn" },
{ role: "zoomOut" },
{ type: "separator" },
{ role: "togglefullscreen" },
],
},
{
label: "Go",
submenu: [
{ label: "Back", accelerator: "Cmd+[", click: () => deps.trigger("common.goBack") },
{ label: "Forward", accelerator: "Cmd+]", click: () => deps.trigger("common.goForward") },
{ type: "separator" },
{
label: "Previous Session",
accelerator: "Option+Up",
click: () => deps.trigger("session.previous"),
},
{
label: "Next Session",
accelerator: "Option+Down",
click: () => deps.trigger("session.next"),
},
{ type: "separator" },
{
label: "Previous Project",
accelerator: "Cmd+Option+Up",
click: () => deps.trigger("project.previous"),
},
{
label: "Next Project",
accelerator: "Cmd+Option+Down",
click: () => deps.trigger("project.next"),
},
],
},
{ role: "windowMenu" },
{
label: "Help",
submenu: [
{ label: "OpenCode Documentation", click: () => shell.openExternal("https://opencode.ai/docs") },
{ label: "Support Forum", click: () => shell.openExternal("https://discord.com/invite/opencode") },
{ type: "separator" },
{ type: "separator" },
{
label: "Share Feedback",
click: () =>
shell.openExternal("https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml"),
},
{
label: "Report a Bug",
click: () => shell.openExternal("https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml"),
},
],
},
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template)) Menu.setApplicationMenu(Menu.buildFromTemplate(template))
} }
function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions {
if (entry.type === "separator") return { type: "separator" }
if (entry.role) return { role: nativeRole(entry.role) }
const item: MenuItemConstructorOptions = {
label: entry.label,
accelerator: entry.accelerator?.macos,
enabled: entry.enabled === "updater" ? UPDATER_ENABLED : undefined,
}
if (entry.command) {
const command = entry.command
item.click = () => deps.trigger(command)
}
if (entry.action) {
const action = entry.action
item.click = () =>
runDesktopMenuAction(BrowserWindow.getFocusedWindow(), action, {
checkForUpdates: deps.checkForUpdates,
relaunch: deps.relaunch,
})
}
if (entry.href) {
const href = entry.href
item.click = () => shell.openExternal(href)
}
return item
}
function nativeRole(role: DesktopMenuRole) {
return role as NonNullable<MenuItemConstructorOptions["role"]>
}
+1
View File
@@ -61,6 +61,7 @@ const api: ElectronAPI = {
getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"), getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor), setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme), setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"), loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail), runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
checkUpdate: () => ipcRenderer.invoke("check-update"), checkUpdate: () => ipcRenderer.invoke("check-update"),
+3 -1
View File
@@ -1,3 +1,5 @@
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" } export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" }
export type ServerReadyData = { export type ServerReadyData = {
@@ -14,7 +16,6 @@ export type LinuxDisplayBackend = "wayland" | "auto"
export type TitlebarTheme = { export type TitlebarTheme = {
mode: "light" | "dark" mode: "light" | "dark"
} }
export type WindowConfig = { export type WindowConfig = {
updaterEnabled: boolean updaterEnabled: boolean
} }
@@ -71,6 +72,7 @@ export type ElectronAPI = {
getZoomFactor: () => Promise<number> getZoomFactor: () => Promise<number>
setZoomFactor: (factor: number) => Promise<void> setZoomFactor: (factor: number) => Promise<void>
setTitlebar: (theme: TitlebarTheme) => Promise<void> setTitlebar: (theme: TitlebarTheme) => Promise<void>
runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
loadingWindowComplete: () => void loadingWindowComplete: () => void
runUpdater: (alertOnFail: boolean) => Promise<void> runUpdater: (alertOnFail: boolean) => Promise<void>
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }> checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
+19 -1
View File
@@ -21,7 +21,7 @@ import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js
import { render } from "solid-js/web" import { render } from "solid-js/web"
import pkg from "../../package.json" import pkg from "../../package.json"
import { initI18n, t } from "./i18n" import { initI18n, t } from "./i18n"
import { webviewZoom } from "./webview-zoom" import { resetZoom, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
import "./styles.css" import "./styles.css"
import { useTheme } from "@opencode-ai/ui/theme" import { useTheme } from "@opencode-ai/ui/theme"
@@ -100,6 +100,22 @@ const createPlatform = (): Platform => {
return window.api.wslPath(result, "linux").catch(() => result) as any return window.api.wslPath(result, "linux").catch(() => result) as any
} }
const runDesktopMenuAction: Platform["runDesktopMenuAction"] = (action) => {
switch (action) {
case "view.resetZoom":
resetZoom()
return
case "view.zoomIn":
zoomIn()
return
case "view.zoomOut":
zoomOut()
return
}
return window.api.runDesktopMenuAction(action)
}
const storage = (() => { const storage = (() => {
const cache = new Map<string, AsyncStorage>() const cache = new Map<string, AsyncStorage>()
@@ -254,6 +270,8 @@ const createPlatform = (): Platform => {
webviewZoom, webviewZoom,
runDesktopMenuAction,
checkAppExists: async (appName: string) => { checkAppExists: async (appName: string) => {
return window.api.checkAppExists(appName) return window.api.checkAppExists(appName)
}, },
@@ -33,23 +33,27 @@ const applyZoom = (next: number) => {
}) })
} }
const resetZoom = () => applyZoom(1)
const zoomIn = () => applyZoom(clamp(requestedZoom + 0.2))
const zoomOut = () => applyZoom(clamp(requestedZoom - 0.2))
window.addEventListener("keydown", (event) => { window.addEventListener("keydown", (event) => {
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
if (event.key === "-") { if (event.key === "-") {
event.preventDefault() event.preventDefault()
applyZoom(clamp(requestedZoom - 0.2)) zoomOut()
return return
} }
if (event.key === "=" || event.key === "+") { if (event.key === "=" || event.key === "+") {
event.preventDefault() event.preventDefault()
applyZoom(clamp(requestedZoom + 0.2)) zoomIn()
return return
} }
if (event.key === "0") { if (event.key === "0") {
event.preventDefault() event.preventDefault()
applyZoom(1) resetZoom()
} }
}) })
export { webviewZoom } export { webviewZoom, resetZoom, zoomIn, zoomOut }
+19
View File
@@ -0,0 +1,19 @@
# Effect Drizzle SQLite
This package vendors a Drizzle Effect SQLite adapter for this repo.
- Keep this package generic: Drizzle + Effect + SQLite only.
- Do not add opencode-specific tables, paths, migrations, post-commit hooks, or domain storage APIs here.
- Runtime code should depend on generic `effect/unstable/sql/SqlClient`, not a specific SQLite driver.
- Concrete SQLite clients such as `@effect/sql-sqlite-bun` belong in tests or examples unless this package intentionally adds a driver-specific helper.
- Preserve Drizzle adapter naming and behavior where possible so this can be replaced by upstream `drizzle-orm/effect-sqlite` later.
- If touching copied Drizzle internals, compare with current `drizzle-orm@1.0.0-rc.2` declarations and runtime JS.
- If touching Effect APIs, verify against `/Users/kit/code/open-source/effect-smol`.
Useful entry points:
- `src/effect-sqlite/driver.ts`: creates the Effect-backed Drizzle database with `make` and `makeWithDefaults`.
- `src/effect-sqlite/session.ts`: adapts generic Effect `SqlClient` execution and transactions to Drizzle SQLite sessions.
- `src/sqlite-core/effect/*`: Effect-yieldable SQLite query builders.
- `src/internal/drizzle-utils.ts`: local typed shims for Drizzle runtime internals that RC2 does not expose in declarations.
- `examples/basic.ts`: minimal usage example with Bun SQLite.
@@ -0,0 +1,92 @@
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { eq } from "drizzle-orm"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Schema from "effect/Schema"
import { EffectDrizzleSqlite } from "../src"
const users = sqliteTable("users", {
id: integer().primaryKey({ autoIncrement: true }),
name: text().notNull(),
})
type User = typeof users.$inferSelect
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
type DatabaseShape = Effect.Success<typeof makeDatabase>
const sqliteLayer = SqliteClient.layer({ filename: ":memory:", disableWAL: true })
class Database extends Context.Service<Database, DatabaseShape>()("@opencode/example/Database") {
static layer = Layer.effect(Database, makeDatabase).pipe(Layer.provide(sqliteLayer))
}
class UserStoreError extends Schema.TaggedErrorClass<UserStoreError>()("UserStoreError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect),
}) {}
const mapStoreError = (message: string) => (cause: unknown) => new UserStoreError({ message, cause })
interface UserStoreShape {
migrate(): Effect.Effect<void, UserStoreError>
create(name: string): Effect.Effect<void, UserStoreError>
rename(from: string, to: string): Effect.Effect<void, UserStoreError>
list(): Effect.Effect<User[], UserStoreError>
}
class UserStore extends Context.Service<UserStore, UserStoreShape>()("@opencode/example/UserStore") {
static layer = Layer.effect(
UserStore,
Effect.gen(function* () {
const db = yield* Database
return UserStore.of({
migrate: Effect.fn("UserStore.migrate")(function* () {
yield* EffectDrizzleSqlite.migrate(db, { migrationsFolder: `${import.meta.dirname}/migrations` }).pipe(
Effect.mapError((cause) => new UserStoreError({ message: "Failed to migrate users", cause })),
)
}),
create: Effect.fn("UserStore.create")(function* (name: string) {
yield* db
.insert(users)
.values({ name })
.pipe(Effect.asVoid, Effect.mapError(mapStoreError("Failed to create user")))
}),
rename: Effect.fn("UserStore.rename")(function* (from: string, to: string) {
yield* db
.transaction(
Effect.fnUntraced(function* (tx) {
yield* tx.insert(users).values({ name: from })
yield* tx.update(users).set({ name: to }).where(eq(users.name, from))
}),
{ behavior: "immediate" },
)
.pipe(Effect.asVoid, Effect.mapError(mapStoreError("Failed to rename user")))
}),
list: Effect.fn("UserStore.list")(function* () {
return yield* db
.select()
.from(users)
.pipe(Effect.mapError(mapStoreError("Failed to list users")))
}),
})
}),
).pipe(Layer.provide(Database.layer))
}
const program = Effect.gen(function* () {
const userStore = yield* UserStore
yield* userStore.migrate()
yield* userStore.create("Ada")
yield* userStore.rename("Grace", "Grace Hopper")
return yield* userStore.list()
})
const rows = await Effect.runPromise(program.pipe(Effect.provide(UserStore.layer)))
console.log(rows)
@@ -0,0 +1,4 @@
CREATE TABLE users (
id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
name text NOT NULL
);
@@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.15.5",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
},
"exports": {
".": "./src/index.ts",
"./effect-sqlite": "./src/effect-sqlite/index.ts",
"./effect-sqlite/migrator": "./src/effect-sqlite/migrator.ts",
"./sqlite-core/effect": "./src/sqlite-core/effect/index.ts"
},
"devDependencies": {
"@effect/sql-sqlite-bun": "catalog:",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
},
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:"
}
}
@@ -0,0 +1,77 @@
/* oxlint-disable */
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import { SqlClient } from "effect/unstable/sql/SqlClient"
import { EffectCache } from "drizzle-orm/cache/core/cache-effect"
import { EffectLogger } from "drizzle-orm/effect-core"
import { entityKind } from "drizzle-orm/entity"
import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations"
import { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import { SQLiteEffectDatabase } from "../sqlite-core/effect/db"
import type { DrizzleConfig } from "drizzle-orm/utils"
import { jitCompatCheck } from "../internal/drizzle-utils"
import { type EffectSQLiteQueryEffectHKT, type EffectSQLiteRunResult, EffectSQLiteSession } from "./session"
export class EffectSQLiteDatabase<TRelations extends AnyRelations = EmptyRelations> extends SQLiteEffectDatabase<
EffectSQLiteQueryEffectHKT,
EffectSQLiteRunResult,
TRelations
> {
static override readonly [entityKind]: string = "EffectSQLiteDatabase"
}
export type EffectDrizzleSQLiteConfig<TRelations extends AnyRelations = EmptyRelations> = Omit<
DrizzleConfig<Record<string, never>, TRelations>,
"cache" | "logger" | "schema"
>
export const DefaultServices = Layer.merge(EffectCache.Default, EffectLogger.Default)
/**
* Creates an EffectSQLiteDatabase instance.
*
* Requires a generic Effect `SqlClient`, `EffectLogger`, and `EffectCache` services to be provided.
* Drizzle only depends on the generic `SqlClient`; install and provide a compatible SQLite provider such as
* `@effect/sql-sqlite-node`, `@effect/sql-sqlite-bun`, or another package that exposes `SqlClient`.
*
* @example
* ```ts
* import { SqliteClient } from '@effect/sql-sqlite-node';
* import * as SQLiteDrizzle from 'drizzle-orm/effect-sqlite';
* import * as Effect from 'effect/Effect';
*
* const db = yield* SQLiteDrizzle.make({ relations }).pipe(
* Effect.provide(SQLiteDrizzle.DefaultServices),
* Effect.provide(SqliteClient.layer({ filename: 'sqlite.db' })),
* );
* ```
*/
export const make = Effect.fn("SQLiteDrizzle.make")(function* <TRelations extends AnyRelations = EmptyRelations>(
config: EffectDrizzleSQLiteConfig<TRelations> = {},
) {
const client = yield* SqlClient
const cache = yield* EffectCache
const logger = yield* EffectLogger
const dialect = new SQLiteAsyncDialect()
const relations = config.relations ?? ({} as TRelations)
const session = new EffectSQLiteSession(client, dialect, relations, {
logger,
cache,
useJitMappers: jitCompatCheck(config.jit),
})
const db = new EffectSQLiteDatabase(dialect, session, relations) as EffectSQLiteDatabase<TRelations> & {
$client: SqlClient
}
db.$client = client
db.$cache.invalidate = cache.onMutate
return db
})
/**
* Convenience function that creates an EffectSQLiteDatabase with `DefaultServices` already provided.
*/
export const makeWithDefaults = <TRelations extends AnyRelations = EmptyRelations>(
config: EffectDrizzleSQLiteConfig<TRelations> = {},
) => make(config).pipe(Effect.provide(DefaultServices))
@@ -0,0 +1,4 @@
/* oxlint-disable */
export { EffectLogger } from "drizzle-orm/effect-core"
export * from "./driver"
export * from "./session"
@@ -0,0 +1,14 @@
/* oxlint-disable */
import type { MigrationConfig } from "drizzle-orm/migrator"
import { readMigrationFiles } from "drizzle-orm/migrator"
import type { AnyRelations } from "drizzle-orm/relations"
import { migrate as coreMigrate } from "../sqlite-core/effect/session"
import type { EffectSQLiteDatabase } from "./driver"
export function migrate<TRelations extends AnyRelations>(
db: EffectSQLiteDatabase<TRelations>,
config: MigrationConfig,
) {
const migrations = readMigrationFiles(config)
return coreMigrate(migrations, db.session, config)
}
@@ -0,0 +1,214 @@
/* oxlint-disable */
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as Exit from "effect/Exit"
import * as Scope from "effect/Scope"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import type { SqlError } from "effect/unstable/sql/SqlError"
import type { EffectCacheShape } from "drizzle-orm/cache/core/cache-effect"
import type { WithCacheConfig } from "drizzle-orm/cache/core/types"
import type { EffectDrizzleQueryError } from "drizzle-orm/effect-core/errors"
import type { EffectLoggerShape } from "drizzle-orm/effect-core/logger"
import type { QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind } from "drizzle-orm/entity"
import type { AnyRelations } from "drizzle-orm/relations"
import type { RelationalQueryMapperConfig } from "drizzle-orm/relations"
import type { Query } from "drizzle-orm/sql/sql"
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import { SQLiteEffectPreparedQuery, SQLiteEffectSession, SQLiteEffectTransaction } from "../sqlite-core/effect/session"
import type { SelectedFieldsOrdered } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { PreparedQueryConfig, SQLiteExecuteMethod, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
export interface EffectSQLiteQueryEffectHKT extends QueryEffectHKTBase {
readonly error: EffectDrizzleQueryError
readonly context: never
}
export type EffectSQLiteRunResult = readonly never[]
export interface EffectSQLiteSessionOptions {
logger: EffectLoggerShape
cache: EffectCacheShape
useJitMappers?: boolean
}
export class EffectSQLiteSession<TRelations extends AnyRelations> extends SQLiteEffectSession<
EffectSQLiteQueryEffectHKT,
EffectSQLiteRunResult,
TRelations
> {
static override readonly [entityKind]: string = "EffectSQLiteSession"
constructor(
private client: SqlClient,
dialect: SQLiteAsyncDialect,
protected relations: TRelations,
private options: EffectSQLiteSessionOptions,
) {
super(dialect)
}
override prepareQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
query: Query,
fields: SelectedFieldsOrdered | undefined,
executeMethod: SQLiteExecuteMethod,
customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown,
queryMetadata?: {
type: "select" | "update" | "delete" | "insert"
tables: string[]
},
cacheConfig?: WithCacheConfig,
): SQLiteEffectPreparedQuery<T, EffectSQLiteQueryEffectHKT> {
return new SQLiteEffectPreparedQuery<T, EffectSQLiteQueryEffectHKT>(
(params, method) => this.execute(query, params, method),
query,
this.options.logger,
this.options.cache,
queryMetadata,
cacheConfig,
fields,
executeMethod,
this.options.useJitMappers,
customResultMapper,
undefined,
undefined,
this.isInTransaction(),
)
}
override prepareRelationalQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
query: Query,
fields: SelectedFieldsOrdered | undefined,
executeMethod: SQLiteExecuteMethod,
customResultMapper: (rows: Record<string, unknown>[], mapColumnValue?: (value: unknown) => unknown) => unknown,
config: RelationalQueryMapperConfig,
): SQLiteEffectPreparedQuery<T, EffectSQLiteQueryEffectHKT, true> {
return new SQLiteEffectPreparedQuery<T, EffectSQLiteQueryEffectHKT, true>(
(params, method) => this.execute(query, params, method),
query,
this.options.logger,
this.options.cache,
undefined,
undefined,
fields,
executeMethod,
this.options.useJitMappers,
customResultMapper,
true,
config,
this.isInTransaction(),
)
}
private execute(query: Query, params: unknown[], method: SQLiteExecuteMethod | "values") {
const statement = this.client.unsafe(query.sql, params)
if (method === "values") return statement.values
if (method === "get") return statement.withoutTransform.pipe(Effect.map((rows) => rows[0]))
return statement.withoutTransform
}
private isInTransaction() {
return Effect.serviceOption(this.client.transactionService).pipe(Effect.map((option) => option._tag === "Some"))
}
private executeTransactionStatement(connection: Effect.Success<SqlClient["reserve"]>, query: string) {
return connection.executeUnprepared(query, [], undefined).pipe(Effect.asVoid)
}
private withTransaction<A, E, R>(effect: Effect.Effect<A, E, R>, config: SQLiteTransactionConfig | undefined) {
return Effect.uninterruptibleMask((restore) =>
Effect.withFiber<A, E | SqlError, R>((fiber) => {
const services = fiber.context
const connectionOption = Context.getOption(services, this.client.transactionService)
const connection: Effect.Effect<
readonly [Scope.Closeable | undefined, Effect.Success<SqlClient["reserve"]>],
SqlError
> =
connectionOption._tag === "Some"
? Effect.succeed([undefined, connectionOption.value[0]] as const)
: Scope.make().pipe(
Effect.flatMap((scope) =>
Scope.provide(this.client.reserve, scope).pipe(
Effect.map((connection) => [scope, connection] as const),
Effect.catch((error) =>
Scope.close(scope, Exit.fail(error)).pipe(Effect.andThen(Effect.fail(error))),
),
),
),
)
const id = connectionOption._tag === "Some" ? connectionOption.value[1] + 1 : 0
return connection.pipe(
Effect.flatMap(([scope, connection]) =>
this.executeTransactionStatement(
connection,
id === 0 ? `begin ${config?.behavior ?? "deferred"}` : `savepoint effect_sql_${id}`,
).pipe(
Effect.flatMap(() =>
Effect.provideContext(
restore(effect),
Context.add(services, this.client.transactionService, [connection, id]),
),
),
Effect.exit,
Effect.flatMap((exit) => {
const finalize = Exit.isSuccess(exit)
? id === 0
? this.executeTransactionStatement(connection, "commit").pipe(
// SQLite keeps the transaction open after deferred constraint commit failures.
Effect.catch((error) =>
this.executeTransactionStatement(connection, "rollback").pipe(
Effect.catch(() => Effect.void),
Effect.andThen(Effect.fail(error)),
),
),
)
: this.executeTransactionStatement(connection, `release savepoint effect_sql_${id}`)
: id === 0
? this.executeTransactionStatement(connection, "rollback")
: this.executeTransactionStatement(connection, `rollback to savepoint effect_sql_${id}`).pipe(
Effect.andThen(
this.executeTransactionStatement(connection, `release savepoint effect_sql_${id}`),
),
)
const scoped = scope === undefined ? finalize : Effect.ensuring(finalize, Scope.close(scope, exit))
return scoped.pipe(Effect.flatMap(() => exit))
}),
),
),
)
}),
)
}
override transaction<A, E, R>(
transaction: (tx: EffectSQLiteTransaction<TRelations>) => Effect.Effect<A, E, R>,
config?: SQLiteTransactionConfig,
): Effect.Effect<A, E | SqlError, R> {
const { dialect, relations } = this
return this.withTransaction(
Effect.gen({ self: this }, function* () {
const tx = new EffectSQLiteTransaction<TRelations>(dialect, this, relations)
return yield* transaction(tx)
}),
config,
)
}
}
export class EffectSQLiteTransaction<TRelations extends AnyRelations> extends SQLiteEffectTransaction<
EffectSQLiteQueryEffectHKT,
EffectSQLiteRunResult,
TRelations
> {
static override readonly [entityKind]: string = "EffectSQLiteTransaction"
override transaction: <A, E, R>(
transaction: (
tx: SQLiteEffectTransaction<EffectSQLiteQueryEffectHKT, EffectSQLiteRunResult, TRelations>,
) => Effect.Effect<A, E, R>,
) => Effect.Effect<A, SqlError | E, R> = (tx) => this.session.transaction(tx)
}
@@ -0,0 +1,6 @@
export { EffectLogger } from "drizzle-orm/effect-core"
export * from "./effect-sqlite/driver"
export * from "./effect-sqlite/session"
export { migrate } from "./effect-sqlite/migrator"
export * as EffectDrizzleSqlite from "."
@@ -0,0 +1,127 @@
/* oxlint-disable */
import { Column, getColumnTable } from "drizzle-orm/column"
import { is } from "drizzle-orm/entity"
import type { JoinNullability } from "drizzle-orm/query-builders/select.types"
import { Param, SQL } from "drizzle-orm/sql/sql"
import type { SelectedFieldsOrdered } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { SQLiteUpdateSetSource } from "drizzle-orm/sqlite-core/query-builders/update"
import type { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import { SQLiteViewBase } from "drizzle-orm/sqlite-core/view-base"
import { Subquery } from "drizzle-orm/subquery"
import { Table, getTableName } from "drizzle-orm/table"
import type { UpdateSet } from "drizzle-orm/utils"
import { ViewBaseConfig } from "drizzle-orm/view-common"
const TableSymbol = (
Table as unknown as {
Symbol: { Columns: symbol; IsAlias: symbol; Name: symbol; BaseName: symbol }
}
).Symbol
export function getTableColumnsRuntime(table: SQLiteTable) {
return (table as unknown as Record<symbol, Record<string, Column>>)[TableSymbol.Columns]
}
export function getViewSelectedFieldsRuntime(view: SQLiteViewBase) {
return (view as unknown as Record<symbol, { selectedFields: Record<string, unknown>; name: string }>)[ViewBaseConfig]
}
export function jitCompatCheck(isEnabled: boolean | undefined) {
if (!isEnabled) return false
try {
return new Function("input", '"use strict"; return input;')(true) === true
} catch {
return false
}
}
export function orderSelectedFields<TColumn extends Column>(
fields: Record<string, unknown>,
pathPrefix?: string[],
): SelectedFieldsOrdered {
return Object.entries(fields).flatMap(([name, field]) => {
const path = pathPrefix ? [...pathPrefix, name] : [name]
if (is(field, Column) || is(field, SQL) || is(field, SQL.Aliased) || is(field, Subquery)) {
return [{ path, field }] as SelectedFieldsOrdered
}
if (is(field, Table)) return orderSelectedFields(getTableColumnsRuntime(field as SQLiteTable), path)
return orderSelectedFields(field as Record<string, unknown>, path)
}) as SelectedFieldsOrdered
}
export function mapUpdateSet<TTable extends SQLiteTable>(table: TTable, values: SQLiteUpdateSetSource<TTable>) {
const entries = Object.entries(values).filter(([, value]) => value !== undefined)
if (entries.length === 0) throw new Error("No values to set")
return Object.fromEntries(
entries.map(([key, value]) => [
key,
is(value, SQL) || is(value, Column) ? value : new Param(value, getTableColumnsRuntime(table)[key]),
]),
) as UpdateSet
}
export function mapResultRow(
columns: SelectedFieldsOrdered,
row: unknown[],
joinsNotNullableMap: Record<string, boolean> | undefined,
) {
const nullifyMap: Record<string, string | false> = {}
const result: Record<string, unknown> = {}
columns.forEach((column, columnIndex) => {
const decoder = (
is(column.field, Column)
? column.field
: is(column.field, SQL)
? (column.field as unknown as { decoder: { mapFromDriverValue(value: unknown): unknown } }).decoder
: is(column.field, Subquery)
? (column.field._.sql as unknown as { decoder: { mapFromDriverValue(value: unknown): unknown } }).decoder
: (column.field.sql as unknown as { decoder: { mapFromDriverValue(value: unknown): unknown } }).decoder
) as {
mapFromDriverValue(value: unknown): unknown
}
const rawValue = row[columnIndex]
const value = rawValue === null ? null : decoder.mapFromDriverValue(rawValue)
const objectName = column.path[0]
let node = result
column.path.forEach((pathChunk, pathChunkIndex) => {
if (pathChunkIndex === column.path.length - 1) {
node[pathChunk] = value
return
}
node[pathChunk] = (node[pathChunk] ?? {}) as Record<string, unknown>
node = node[pathChunk] as Record<string, unknown>
})
if (joinsNotNullableMap && is(column.field, Column) && column.path.length === 2 && objectName) {
const tableName = getTableName(getColumnTable(column.field))
nullifyMap[objectName] =
!(objectName in nullifyMap) && value === null
? tableName
: typeof nullifyMap[objectName] === "string" && nullifyMap[objectName] !== tableName
? false
: nullifyMap[objectName]
}
})
Object.entries(nullifyMap).forEach(([objectName, tableName]) => {
if (typeof tableName === "string" && !joinsNotNullableMap?.[tableName]) result[objectName] = null
})
return result
}
export function getTableLikeName(table: SQLiteTable | Subquery | SQLiteViewBase | SQL) {
if (is(table, Subquery)) return table._.alias
if (is(table, SQLiteViewBase)) return getViewSelectedFieldsRuntime(table).name
if (is(table, SQL)) return undefined
return (table as unknown as Record<symbol, string | boolean>)[
(table as unknown as Record<symbol, string | boolean>)[TableSymbol.IsAlias]
? TableSymbol.Name
: TableSymbol.BaseName
] as string
}
export type { JoinNullability }
@@ -0,0 +1,58 @@
/* oxlint-disable */
import type * as Effect from "effect/Effect"
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind } from "drizzle-orm/entity"
import { SQL, sql, type SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import type { SQLiteView } from "drizzle-orm/sqlite-core/view"
import type { SQLiteEffectSession } from "./session"
function buildSQLiteEmbeddedCount(source: SQLiteTable | SQLiteView | SQL | SQLWrapper, filters?: SQL<unknown>) {
return sql<number>`(select count(*) from ${source}${sql.raw(" where ").if(filters)}${filters})`
}
function buildSQLiteCount(source: SQLiteTable | SQLiteView | SQL | SQLWrapper, filters?: SQL<unknown>) {
return sql<number>`select count(*) from ${source}${sql.raw(" where ").if(filters)}${filters}`
}
export interface SQLiteEffectCountBuilder<TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase>
extends SQL<number>,
SQLWrapper<number>,
Effect.Effect<number, TEffectHKT["error"], TEffectHKT["context"]> {}
export class SQLiteEffectCountBuilder<TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase> extends SQL<number> {
static override readonly [entityKind]: string = "SQLiteEffectCountBuilder"
private sql: SQL<number>
private session: SQLiteEffectSession<TEffectHKT, any, any>
constructor(params: {
source: SQLiteTable | SQLiteView | SQL | SQLWrapper
filters?: SQL<unknown>
session: SQLiteEffectSession<TEffectHKT, any, any>
}) {
super(buildSQLiteEmbeddedCount(params.source, params.filters).queryChunks)
this.session = params.session
this.sql = buildSQLiteCount(params.source, params.filters)
}
execute(placeholderValues?: Record<string, unknown>) {
return this.session
.prepareQuery<{
type: "async"
execute: number
run: unknown
all: unknown
get: unknown
values: unknown
}>(this.session.dialect.sqlToQuery(this.sql), undefined, "all", (rows) => {
const v = rows[0]?.[0]
if (typeof v === "number") return v
return v ? Number(v) : 0
})
.execute(placeholderValues)
}
}
applyEffectWrapper(SQLiteEffectCountBuilder)
@@ -0,0 +1,296 @@
/* oxlint-disable */
import { Effect } from "effect"
import type { SqlError } from "effect/unstable/sql/SqlError"
import type { EffectCacheShape } from "drizzle-orm/cache/core/cache-effect"
import type { MutationOption } from "drizzle-orm/cache/core/cache"
import type { QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind } from "drizzle-orm/entity"
import type { TypedQueryBuilder } from "drizzle-orm/query-builders/query-builder"
import type { AnyRelations, EmptyRelations } from "drizzle-orm/relations"
import { SelectionProxyHandler } from "drizzle-orm/selection-proxy"
import { type ColumnsSelection, type SQL, sql, type SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import { QueryBuilder } from "drizzle-orm/sqlite-core/query-builders/query-builder"
import type { SelectedFields } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
import type { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import type { SQLiteViewBase } from "drizzle-orm/sqlite-core/view-base"
import { WithSubquery } from "drizzle-orm/subquery"
import type { WithBuilder } from "drizzle-orm/sqlite-core/subquery"
import { SQLiteEffectCountBuilder } from "./count"
import { SQLiteEffectDeleteBase } from "./delete"
import { SQLiteEffectInsertBuilder } from "./insert"
import { SQLiteEffectRelationalQueryBuilder } from "./query"
import { SQLiteEffectRaw } from "./raw"
import { SQLiteEffectSelectBuilder } from "./select"
import type { SQLiteEffectSelectBase } from "./select"
import type { SQLiteEffectSession, SQLiteEffectTransaction } from "./session"
import { SQLiteEffectUpdateBuilder } from "./update"
export class SQLiteEffectDatabase<
TEffectHKT extends QueryEffectHKTBase,
TRunResult,
TRelations extends AnyRelations = EmptyRelations,
> {
static readonly [entityKind]: string = "SQLiteEffectDatabase"
declare readonly _: {
readonly relations: TRelations
readonly session: SQLiteEffectSession<TEffectHKT, TRunResult, TRelations>
}
query: {
[K in keyof TRelations]: SQLiteEffectRelationalQueryBuilder<TRelations, TRelations[K], TEffectHKT>
}
constructor(
/** @internal */
readonly dialect: SQLiteAsyncDialect,
/** @internal */
readonly session: SQLiteEffectSession<TEffectHKT, TRunResult, TRelations>,
relations: TRelations,
readonly rowModeRQB?: boolean,
readonly forbidJsonb?: boolean,
) {
this._ = {
relations,
session,
}
this.query = {} as (typeof this)["query"]
for (const [tableName, relation] of Object.entries(relations)) {
;(this.query as SQLiteEffectDatabase<TEffectHKT, TRunResult, AnyRelations>["query"])[tableName] =
new SQLiteEffectRelationalQueryBuilder(
relations,
relations[relation.name]!.table as SQLiteTable,
relation,
dialect,
session,
rowModeRQB,
forbidJsonb,
)
}
this.$cache = {
invalidate: (_params: MutationOption) => Effect.void,
}
}
$with: WithBuilder = (alias: string, selection?: ColumnsSelection) => {
const self = this
const as = (
qb:
| TypedQueryBuilder<ColumnsSelection | undefined>
| SQL
| ((qb: QueryBuilder) => TypedQueryBuilder<ColumnsSelection | undefined> | SQL),
) => {
if (typeof qb === "function") {
qb = qb(new QueryBuilder(self.dialect))
}
return new Proxy(
new WithSubquery(
qb.getSQL(),
selection ??
(("getSelectedFields" in qb
? ((qb as { getSelectedFields(): SelectedFields | undefined }).getSelectedFields() ?? {})
: {}) as SelectedFields),
alias,
true,
),
new SelectionProxyHandler({ alias, sqlAliasedBehavior: "alias", sqlBehavior: "error" }),
)
}
return { as }
}
$cache: { invalidate: EffectCacheShape["onMutate"] }
$count(source: SQLiteTable | SQLiteViewBase | SQL | SQLWrapper, filters?: SQL<unknown>) {
return new SQLiteEffectCountBuilder({ source, filters, session: this.session })
}
with(...queries: WithSubquery[]) {
const self = this
function select(): SQLiteEffectSelectBuilder<undefined, TRunResult, TEffectHKT>
function select<TSelection extends SelectedFields>(
fields: TSelection,
): SQLiteEffectSelectBuilder<TSelection, TRunResult, TEffectHKT>
function select(
fields?: SelectedFields,
): SQLiteEffectSelectBuilder<SelectedFields | undefined, TRunResult, TEffectHKT> {
return new SQLiteEffectSelectBuilder({
fields: fields ?? undefined,
session: self.session,
dialect: self.dialect,
withList: queries,
})
}
function selectDistinct(): SQLiteEffectSelectBuilder<undefined, TRunResult, TEffectHKT>
function selectDistinct<TSelection extends SelectedFields>(
fields: TSelection,
): SQLiteEffectSelectBuilder<TSelection, TRunResult, TEffectHKT>
function selectDistinct(
fields?: SelectedFields,
): SQLiteEffectSelectBuilder<SelectedFields | undefined, TRunResult, TEffectHKT> {
return new SQLiteEffectSelectBuilder({
fields: fields ?? undefined,
session: self.session,
dialect: self.dialect,
withList: queries,
distinct: true,
})
}
function update<TTable extends SQLiteTable>(
table: TTable,
): SQLiteEffectUpdateBuilder<TTable, TRunResult, TEffectHKT> {
return new SQLiteEffectUpdateBuilder(table, self.session, self.dialect, queries)
}
function insert<TTable extends SQLiteTable>(
into: TTable,
): SQLiteEffectInsertBuilder<TTable, TRunResult, TEffectHKT> {
return new SQLiteEffectInsertBuilder(into, self.session, self.dialect, queries)
}
function delete_<TTable extends SQLiteTable>(
from: TTable,
): SQLiteEffectDeleteBase<TTable, TRunResult, undefined, false, never, TEffectHKT> {
return new SQLiteEffectDeleteBase(from, self.session, self.dialect, queries)
}
return { select, selectDistinct, update, insert, delete: delete_ }
}
select(): SQLiteEffectSelectBuilder<undefined, TRunResult, TEffectHKT>
select<TSelection extends SelectedFields>(
fields: TSelection,
): SQLiteEffectSelectBuilder<TSelection, TRunResult, TEffectHKT>
select(fields?: SelectedFields): SQLiteEffectSelectBuilder<SelectedFields | undefined, TRunResult, TEffectHKT> {
return new SQLiteEffectSelectBuilder({ fields: fields ?? undefined, session: this.session, dialect: this.dialect })
}
selectDistinct(): SQLiteEffectSelectBuilder<undefined, TRunResult, TEffectHKT>
selectDistinct<TSelection extends SelectedFields>(
fields: TSelection,
): SQLiteEffectSelectBuilder<TSelection, TRunResult, TEffectHKT>
selectDistinct(
fields?: SelectedFields,
): SQLiteEffectSelectBuilder<SelectedFields | undefined, TRunResult, TEffectHKT> {
return new SQLiteEffectSelectBuilder({
fields: fields ?? undefined,
session: this.session,
dialect: this.dialect,
distinct: true,
})
}
update<TTable extends SQLiteTable>(table: TTable): SQLiteEffectUpdateBuilder<TTable, TRunResult, TEffectHKT> {
return new SQLiteEffectUpdateBuilder(table, this.session, this.dialect)
}
insert<TTable extends SQLiteTable>(into: TTable): SQLiteEffectInsertBuilder<TTable, TRunResult, TEffectHKT> {
return new SQLiteEffectInsertBuilder(into, this.session, this.dialect)
}
delete<TTable extends SQLiteTable>(
from: TTable,
): SQLiteEffectDeleteBase<TTable, TRunResult, undefined, false, never, TEffectHKT> {
return new SQLiteEffectDeleteBase(from, this.session, this.dialect)
}
private raw<TResult>(
query: SQLWrapper | string,
action: "all" | "get" | "run" | "values",
execute: (query: SQL) => Effect.Effect<TResult, TEffectHKT["error"], TEffectHKT["context"]>,
): SQLiteEffectRaw<TResult, TEffectHKT> {
const sequel = typeof query === "string" ? sql.raw(query) : query.getSQL()
return new SQLiteEffectRaw(
() => execute(sequel),
() => sequel,
action,
this.dialect,
(result) => result,
)
}
run(query: SQLWrapper | string): SQLiteEffectRaw<TRunResult, TEffectHKT> {
return this.raw(query, "run", (sequel) => this.session.run(sequel))
}
all<T = unknown>(query: SQLWrapper | string): SQLiteEffectRaw<T[], TEffectHKT> {
return this.raw(query, "all", (sequel) => this.session.all(sequel))
}
get<T = unknown>(query: SQLWrapper | string): SQLiteEffectRaw<T | undefined, TEffectHKT> {
return this.raw(query, "get", (sequel) => this.session.get(sequel))
}
values<T extends unknown[] = unknown[]>(query: SQLWrapper | string): SQLiteEffectRaw<T[], TEffectHKT> {
return this.raw(query, "values", (sequel) => this.session.values(sequel))
}
transaction: <A, E, R>(
transaction: (tx: SQLiteEffectTransaction<TEffectHKT, TRunResult, TRelations>) => Effect.Effect<A, E, R>,
config?: SQLiteTransactionConfig,
) => Effect.Effect<A, E | SqlError, R> = (tx, config) => this.session.transaction(tx, config)
}
export type SQLiteEffectWithReplicas<Q> = Q & { $primary: Q; $replicas: Q[] }
export const withReplicas = <
TEffectHKT extends QueryEffectHKTBase,
TRunResult,
TRelations extends AnyRelations,
Q extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations>,
>(
primary: Q,
replicas: [Q, ...Q[]],
getReplica: (replicas: Q[]) => Q = () => replicas[Math.floor(Math.random() * replicas.length)]!,
): SQLiteEffectWithReplicas<Q> => {
const select: Q["select"] = (...args: []) => getReplica(replicas).select(...args)
const selectDistinct: Q["selectDistinct"] = (...args: []) => getReplica(replicas).selectDistinct(...args)
const $count: Q["$count"] = (...args: [any]) => getReplica(replicas).$count(...args)
const _with: Q["with"] = (...args: []) => getReplica(replicas).with(...args)
const $with = ((...args: [string] | [string, ColumnsSelection]) =>
args.length === 1
? getReplica(replicas).$with(args[0])
: getReplica(replicas).$with(args[0], args[1])) as Q["$with"]
const update: Q["update"] = (...args: [any]) => primary.update(...args)
const insert: Q["insert"] = (...args: [any]) => primary.insert(...args)
const $delete: Q["delete"] = (...args: [any]) => primary.delete(...args)
const run: Q["run"] = (...args: [any]) => primary.run(...args)
const all: Q["all"] = (...args: [any]) => primary.all(...args)
const get: Q["get"] = (...args: [any]) => primary.get(...args)
const values: Q["values"] = (...args: [any]) => primary.values(...args)
const transaction: Q["transaction"] = (...args: [any]) => primary.transaction(...args)
return {
...primary,
update,
insert,
delete: $delete,
run,
all,
get,
values,
transaction,
$primary: primary,
$replicas: replicas,
select,
selectDistinct,
$count,
$with,
with: _with,
get query() {
return getReplica(replicas).query
},
}
}
export type AnySQLiteEffectDatabase = SQLiteEffectDatabase<any, any, any>
export type AnySQLiteEffectSelectBase = SQLiteEffectSelectBase<any, any, any, any, any, any, any, any, any, any>
@@ -0,0 +1,261 @@
/* oxlint-disable */
import type * as Effect from "effect/Effect"
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind } from "drizzle-orm/entity"
import type { SelectResultFields } from "drizzle-orm/query-builders/select.types"
import type { RunnableQuery } from "drizzle-orm/runnable-query"
import { SelectionProxyHandler } from "drizzle-orm/selection-proxy"
import type { Placeholder, Query, SQL, SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SQLiteDeleteConfig } from "drizzle-orm/sqlite-core/query-builders/delete"
import type { SelectedFieldsFlat } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { PreparedQueryConfig } from "drizzle-orm/sqlite-core/session"
import { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import { extractUsedTable } from "drizzle-orm/sqlite-core/utils"
import type { Subquery } from "drizzle-orm/subquery"
import { type DrizzleTypeError, type ValueOrArray } from "drizzle-orm/utils"
import type { SQLiteColumn } from "drizzle-orm/sqlite-core/columns/common"
import { getTableColumnsRuntime, orderSelectedFields } from "../../internal/drizzle-utils"
import type { SQLiteEffectPreparedQuery, SQLiteEffectSession } from "./session"
export type SQLiteEffectDeleteWithout<
T extends AnySQLiteEffectDelete,
TDynamic extends boolean,
K extends keyof T & string,
> = TDynamic extends true
? T
: Omit<
SQLiteEffectDeleteBase<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["returning"],
TDynamic,
T["_"]["excludedMethods"] | K,
T["_"]["effectHKT"]
>,
T["_"]["excludedMethods"] | K
>
export type SQLiteEffectDeleteReturningAll<
T extends AnySQLiteEffectDelete,
TDynamic extends boolean,
> = SQLiteEffectDeleteWithout<
SQLiteEffectDeleteBase<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["table"]["$inferSelect"],
T["_"]["dynamic"],
T["_"]["excludedMethods"],
T["_"]["effectHKT"]
>,
TDynamic,
"returning"
>
export type SQLiteEffectDeleteReturning<
T extends AnySQLiteEffectDelete,
TDynamic extends boolean,
TSelectedFields extends SelectedFieldsFlat,
> = SQLiteEffectDeleteWithout<
SQLiteEffectDeleteBase<
T["_"]["table"],
T["_"]["runResult"],
SelectResultFields<TSelectedFields>,
T["_"]["dynamic"],
T["_"]["excludedMethods"],
T["_"]["effectHKT"]
>,
TDynamic,
"returning"
>
export type SQLiteEffectDeleteExecute<T extends AnySQLiteEffectDelete> = T["_"]["returning"] extends undefined
? T["_"]["runResult"]
: T["_"]["returning"][]
export type SQLiteEffectDeletePrepare<
T extends AnySQLiteEffectDelete,
TEffectHKT extends QueryEffectHKTBase = T["_"]["effectHKT"],
> = SQLiteEffectPreparedQuery<
PreparedQueryConfig & {
run: T["_"]["runResult"]
all: T["_"]["returning"] extends undefined
? DrizzleTypeError<".all() cannot be used without .returning()">
: T["_"]["returning"][]
get: T["_"]["returning"] extends undefined
? DrizzleTypeError<".get() cannot be used without .returning()">
: T["_"]["returning"] | undefined
values: T["_"]["returning"] extends undefined
? DrizzleTypeError<".values() cannot be used without .returning()">
: any[][]
execute: SQLiteEffectDeleteExecute<T>
},
TEffectHKT
>
export type SQLiteEffectDeleteDynamic<T extends AnySQLiteEffectDelete> = SQLiteEffectDelete<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["returning"],
T["_"]["effectHKT"]
>
export type SQLiteEffectDelete<
TTable extends SQLiteTable = SQLiteTable,
TRunResult = unknown,
TReturning extends Record<string, unknown> | undefined = undefined,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> = SQLiteEffectDeleteBase<TTable, TRunResult, TReturning, true, never, TEffectHKT>
export type AnySQLiteEffectDelete = SQLiteEffectDeleteBase<any, any, any, any, any, any>
export interface SQLiteEffectDeleteBase<
TTable extends SQLiteTable,
TRunResult,
TReturning extends Record<string, unknown> | undefined = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">,
SQLWrapper,
Effect.Effect<
TReturning extends undefined ? TRunResult : TReturning[],
TEffectHKT["error"],
TEffectHKT["context"]
> {
readonly _: {
dialect: "sqlite"
readonly table: TTable
readonly resultType: "async"
readonly runResult: TRunResult
readonly returning: TReturning
readonly dynamic: TDynamic
readonly excludedMethods: _TExcludedMethods
readonly result: TReturning extends undefined ? TRunResult : TReturning[]
readonly effectHKT: TEffectHKT
}
}
export class SQLiteEffectDeleteBase<
TTable extends SQLiteTable,
TRunResult,
TReturning extends Record<string, unknown> | undefined = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
implements RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">, SQLWrapper
{
static readonly [entityKind]: string = "SQLiteEffectDelete"
/** @internal */
config: SQLiteDeleteConfig
constructor(
private table: TTable,
private effectSession: SQLiteEffectSession<TEffectHKT, TRunResult, any>,
private effectDialect: SQLiteDialect,
withList?: Subquery[],
) {
this.config = { table, withList }
}
where(where: SQL | undefined): SQLiteEffectDeleteWithout<this, TDynamic, "where"> {
this.config.where = where
return this as any
}
orderBy(
builder: (deleteTable: TTable) => ValueOrArray<SQLiteColumn | SQL | SQL.Aliased>,
): SQLiteEffectDeleteWithout<this, TDynamic, "orderBy">
orderBy(...columns: (SQLiteColumn | SQL | SQL.Aliased)[]): SQLiteEffectDeleteWithout<this, TDynamic, "orderBy">
orderBy(
...columns:
| [(deleteTable: TTable) => ValueOrArray<SQLiteColumn | SQL | SQL.Aliased>]
| (SQLiteColumn | SQL | SQL.Aliased)[]
): SQLiteEffectDeleteWithout<this, TDynamic, "orderBy"> {
if (typeof columns[0] === "function") {
const orderBy = columns[0](
new Proxy(
getTableColumnsRuntime(this.config.table),
new SelectionProxyHandler({ sqlAliasedBehavior: "alias", sqlBehavior: "sql" }),
) as any,
)
this.config.orderBy = Array.isArray(orderBy) ? orderBy : [orderBy]
return this as any
}
this.config.orderBy = columns as (SQLiteColumn | SQL | SQL.Aliased)[]
return this as any
}
limit(limit: number | Placeholder): SQLiteEffectDeleteWithout<this, TDynamic, "limit"> {
this.config.limit = limit
return this as any
}
returning(): SQLiteEffectDeleteReturningAll<this, TDynamic>
returning<TSelectedFields extends SelectedFieldsFlat>(
fields: TSelectedFields,
): SQLiteEffectDeleteReturning<this, TDynamic, TSelectedFields>
returning(
fields: SelectedFieldsFlat = getTableColumnsRuntime(this.table),
): SQLiteEffectDeleteReturning<this, TDynamic, any> | SQLiteEffectDeleteReturningAll<this, TDynamic> {
this.config.returning = orderSelectedFields<SQLiteColumn>(fields)
return this as any
}
/** @internal */
getSQL(): SQL {
return this.effectDialect.buildDeleteQuery(this.config)
}
toSQL(): Query {
return this.effectDialect.sqlToQuery(this.getSQL())
}
/** @internal */
_prepare(isOneTimeQuery = true): SQLiteEffectDeletePrepare<this, TEffectHKT> {
return this.effectSession[isOneTimeQuery ? "prepareOneTimeQuery" : "prepareQuery"](
this.effectDialect.sqlToQuery(this.getSQL()),
this.config.returning,
this.config.returning ? "all" : "run",
undefined,
{
type: "delete",
tables: extractUsedTable(this.config.table),
},
) as SQLiteEffectDeletePrepare<this, TEffectHKT>
}
prepare(): SQLiteEffectDeletePrepare<this, TEffectHKT> {
return this._prepare(false)
}
run: ReturnType<this["prepare"]>["run"] = (placeholderValues) => {
return this._prepare().run(placeholderValues)
}
all: ReturnType<this["prepare"]>["all"] = (placeholderValues) => {
return this._prepare().all(placeholderValues)
}
get: ReturnType<this["prepare"]>["get"] = (placeholderValues) => {
return this._prepare().get(placeholderValues)
}
values: ReturnType<this["prepare"]>["values"] = (placeholderValues) => {
return this._prepare().values(placeholderValues)
}
execute: ReturnType<this["prepare"]>["execute"] = (placeholderValues) => {
return this._prepare().execute(placeholderValues)
}
$dynamic(): SQLiteEffectDeleteDynamic<this> {
return this as any
}
}
applyEffectWrapper(SQLiteEffectDeleteBase)
@@ -0,0 +1,10 @@
/* oxlint-disable */
export * from "./count"
export * from "./db"
export * from "./delete"
export * from "./insert"
export * from "./query"
export * from "./raw"
export * from "./select"
export * from "./session"
export * from "./update"
@@ -0,0 +1,349 @@
/* oxlint-disable */
import type * as Effect from "effect/Effect"
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind, is } from "drizzle-orm/entity"
import type { SelectResultFields } from "drizzle-orm/query-builders/select.types"
import type { RunnableQuery } from "drizzle-orm/runnable-query"
import type { Query, SQLWrapper } from "drizzle-orm/sql/sql"
import { Param, SQL, sql } from "drizzle-orm/sql/sql"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { IndexColumn } from "drizzle-orm/sqlite-core/indexes"
import type {
SQLiteInsertConfig,
SQLiteInsertSelectQueryBuilder,
SQLiteInsertValue,
} from "drizzle-orm/sqlite-core/query-builders/insert"
import type { SelectedFieldsFlat } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { PreparedQueryConfig } from "drizzle-orm/sqlite-core/session"
import { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import { extractUsedTable } from "drizzle-orm/sqlite-core/utils"
import type { Subquery } from "drizzle-orm/subquery"
import { type DrizzleTypeError, haveSameKeys } from "drizzle-orm/utils"
import type { SQLiteColumn } from "drizzle-orm/sqlite-core/columns/common"
import { QueryBuilder } from "drizzle-orm/sqlite-core/query-builders/query-builder"
import type { SQLiteUpdateSetSource } from "drizzle-orm/sqlite-core/query-builders/update"
import { getTableColumnsRuntime, mapUpdateSet, orderSelectedFields } from "../../internal/drizzle-utils"
import type { SQLiteEffectPreparedQuery, SQLiteEffectSession } from "./session"
export type SQLiteEffectInsertWithout<
T extends AnySQLiteEffectInsert,
TDynamic extends boolean,
K extends keyof T & string,
> = TDynamic extends true
? T
: Omit<
SQLiteEffectInsertBase<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["returning"],
TDynamic,
T["_"]["excludedMethods"] | K,
T["_"]["effectHKT"]
>,
T["_"]["excludedMethods"] | K
>
export type SQLiteEffectInsertReturning<
T extends AnySQLiteEffectInsert,
TDynamic extends boolean,
TSelectedFields extends SelectedFieldsFlat,
> = SQLiteEffectInsertWithout<
SQLiteEffectInsertBase<
T["_"]["table"],
T["_"]["runResult"],
SelectResultFields<TSelectedFields>,
TDynamic,
T["_"]["excludedMethods"],
T["_"]["effectHKT"]
>,
TDynamic,
"returning"
>
export type SQLiteEffectInsertReturningAll<
T extends AnySQLiteEffectInsert,
TDynamic extends boolean,
> = SQLiteEffectInsertWithout<
SQLiteEffectInsertBase<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["table"]["$inferSelect"],
TDynamic,
T["_"]["excludedMethods"],
T["_"]["effectHKT"]
>,
TDynamic,
"returning"
>
export type SQLiteEffectInsertDynamic<T extends AnySQLiteEffectInsert> = SQLiteEffectInsert<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["returning"],
T["_"]["effectHKT"]
>
export type SQLiteEffectInsertOnConflictDoUpdateConfig<T extends AnySQLiteEffectInsert> = {
target: IndexColumn | IndexColumn[]
/** @deprecated - use either `targetWhere` or `setWhere` */
where?: SQL
targetWhere?: SQL
setWhere?: SQL
set: SQLiteUpdateSetSource<T["_"]["table"]>
}
export type SQLiteEffectInsertExecute<T extends AnySQLiteEffectInsert> = T["_"]["returning"] extends undefined
? T["_"]["runResult"]
: T["_"]["returning"][]
export type SQLiteEffectInsertPrepare<
T extends AnySQLiteEffectInsert,
TEffectHKT extends QueryEffectHKTBase = T["_"]["effectHKT"],
> = SQLiteEffectPreparedQuery<
PreparedQueryConfig & {
run: T["_"]["runResult"]
all: T["_"]["returning"] extends undefined
? DrizzleTypeError<".all() cannot be used without .returning()">
: T["_"]["returning"][]
get: T["_"]["returning"] extends undefined
? DrizzleTypeError<".get() cannot be used without .returning()">
: T["_"]["returning"]
values: T["_"]["returning"] extends undefined
? DrizzleTypeError<".values() cannot be used without .returning()">
: any[][]
execute: SQLiteEffectInsertExecute<T>
},
TEffectHKT
>
export type SQLiteEffectInsert<
TTable extends SQLiteTable = SQLiteTable,
TRunResult = unknown,
TReturning = any,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> = SQLiteEffectInsertBase<TTable, TRunResult, TReturning, true, never, TEffectHKT>
export type AnySQLiteEffectInsert = SQLiteEffectInsertBase<any, any, any, any, any, any>
export class SQLiteEffectInsertBuilder<
TTable extends SQLiteTable,
TRunResult,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> {
static readonly [entityKind]: string = "SQLiteEffectInsertBuilder"
constructor(
protected table: TTable,
protected session: SQLiteEffectSession<TEffectHKT, TRunResult, any>,
protected dialect: SQLiteDialect,
private withList?: Subquery[],
) {}
values(
value: SQLiteInsertValue<TTable>,
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
values(
values: SQLiteInsertValue<TTable>[],
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
values(
values: SQLiteInsertValue<TTable> | SQLiteInsertValue<TTable>[],
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT> {
values = Array.isArray(values) ? values : [values]
if (values.length === 0) {
throw new Error("values() must be called with at least one value")
}
const mappedValues = values.map((entry) => {
const result: Record<string, Param | SQL> = {}
const cols = getTableColumnsRuntime(this.table)
for (const colKey of Object.keys(entry)) {
const colValue = entry[colKey as keyof typeof entry]
result[colKey] = is(colValue, SQL) ? colValue : new Param(colValue, cols[colKey])
}
return result
})
return new SQLiteEffectInsertBase(this.table, mappedValues, this.session, this.dialect, this.withList)
}
select(
selectQuery: (qb: QueryBuilder) => SQLiteInsertSelectQueryBuilder<TTable>,
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
select(
selectQuery: (qb: QueryBuilder) => SQL,
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
select(selectQuery: SQL): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
select(
selectQuery: SQLiteInsertSelectQueryBuilder<TTable>,
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT>
select(
selectQuery:
| SQL
| SQLiteInsertSelectQueryBuilder<TTable>
| ((qb: QueryBuilder) => SQLiteInsertSelectQueryBuilder<TTable> | SQL),
): SQLiteEffectInsertBase<TTable, TRunResult, undefined, false, never, TEffectHKT> {
const select = typeof selectQuery === "function" ? selectQuery(new QueryBuilder()) : selectQuery
if (!is(select, SQL) && !haveSameKeys(getTableColumnsRuntime(this.table), select._.selectedFields)) {
throw new Error(
"Insert select error: selected fields are not the same or are in a different order compared to the table definition",
)
}
return new SQLiteEffectInsertBase(this.table, select, this.session, this.dialect, this.withList, true)
}
}
export interface SQLiteEffectInsertBase<
TTable extends SQLiteTable,
TRunResult,
TReturning = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends SQLWrapper,
RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">,
Effect.Effect<
TReturning extends undefined ? TRunResult : TReturning[],
TEffectHKT["error"],
TEffectHKT["context"]
> {
readonly _: {
readonly dialect: "sqlite"
readonly table: TTable
readonly resultType: "async"
readonly runResult: TRunResult
readonly returning: TReturning
readonly dynamic: TDynamic
readonly excludedMethods: _TExcludedMethods
readonly result: TReturning extends undefined ? TRunResult : TReturning[]
readonly effectHKT: TEffectHKT
}
}
export class SQLiteEffectInsertBase<
TTable extends SQLiteTable,
TRunResult,
TReturning = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
implements RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">, SQLWrapper
{
static readonly [entityKind]: string = "SQLiteEffectInsert"
/** @internal */
config: SQLiteInsertConfig<TTable>
constructor(
private table: TTable,
values: SQLiteInsertConfig["values"],
private effectSession: SQLiteEffectSession<TEffectHKT, TRunResult, any>,
private effectDialect: SQLiteDialect,
withList?: Subquery[],
select?: boolean,
) {
this.config = { table, values: values as any, withList, select }
}
returning(): SQLiteEffectInsertReturningAll<this, TDynamic>
returning<TSelectedFields extends SelectedFieldsFlat>(
fields: TSelectedFields,
): SQLiteEffectInsertReturning<this, TDynamic, TSelectedFields>
returning(
fields: SelectedFieldsFlat = getTableColumnsRuntime(this.config.table),
): SQLiteEffectInsertWithout<AnySQLiteEffectInsert, TDynamic, "returning"> {
this.config.returning = orderSelectedFields<SQLiteColumn>(fields)
return this as any
}
onConflictDoNothing(config: { target?: IndexColumn | IndexColumn[]; where?: SQL } = {}): this {
if (!this.config.onConflict) this.config.onConflict = []
if (config.target === undefined) {
this.config.onConflict.push(sql` on conflict do nothing`)
return this
}
const targetSql = Array.isArray(config.target) ? sql`${config.target}` : sql`${[config.target]}`
const whereSql = config.where ? sql` where ${config.where}` : sql``
this.config.onConflict.push(sql` on conflict ${targetSql} do nothing${whereSql}`)
return this
}
onConflictDoUpdate(config: SQLiteEffectInsertOnConflictDoUpdateConfig<this>): this {
if (config.where && (config.targetWhere || config.setWhere)) {
throw new Error(
'You cannot use both "where" and "targetWhere"/"setWhere" at the same time - "where" is deprecated, use "targetWhere" or "setWhere" instead.',
)
}
if (!this.config.onConflict) this.config.onConflict = []
const whereSql = config.where ? sql` where ${config.where}` : undefined
const targetWhereSql = config.targetWhere ? sql` where ${config.targetWhere}` : undefined
const setWhereSql = config.setWhere ? sql` where ${config.setWhere}` : undefined
const targetSql = Array.isArray(config.target) ? sql`${config.target}` : sql`${[config.target]}`
const setSql = this.effectDialect.buildUpdateSet(
this.config.table,
mapUpdateSet(this.config.table, config.set as SQLiteUpdateSetSource<TTable>),
)
this.config.onConflict.push(
sql` on conflict ${targetSql}${targetWhereSql} do update set ${setSql}${whereSql}${setWhereSql}`,
)
return this
}
/** @internal */
getSQL(): SQL {
return this.effectDialect.buildInsertQuery(this.config)
}
toSQL(): Query {
return this.effectDialect.sqlToQuery(this.getSQL())
}
/** @internal */
_prepare(isOneTimeQuery = true): SQLiteEffectInsertPrepare<this, TEffectHKT> {
return this.effectSession[isOneTimeQuery ? "prepareOneTimeQuery" : "prepareQuery"](
this.effectDialect.sqlToQuery(this.getSQL()),
this.config.returning,
this.config.returning ? "all" : "run",
undefined,
{
type: "insert",
tables: extractUsedTable(this.config.table),
},
) as SQLiteEffectInsertPrepare<this, TEffectHKT>
}
prepare(): SQLiteEffectInsertPrepare<this, TEffectHKT> {
return this._prepare(false)
}
run: ReturnType<this["prepare"]>["run"] = (placeholderValues) => {
return this._prepare().run(placeholderValues)
}
all: ReturnType<this["prepare"]>["all"] = (placeholderValues) => {
return this._prepare().all(placeholderValues)
}
get: ReturnType<this["prepare"]>["get"] = (placeholderValues) => {
return this._prepare().get(placeholderValues)
}
values: ReturnType<this["prepare"]>["values"] = (placeholderValues) => {
return this._prepare().values(placeholderValues)
}
execute: ReturnType<this["prepare"]>["execute"] = (placeholderValues) => {
return this._prepare().execute(placeholderValues)
}
$dynamic(): SQLiteEffectInsertDynamic<this> {
return this as any
}
}
applyEffectWrapper(SQLiteEffectInsertBase)
@@ -0,0 +1,198 @@
/* oxlint-disable */
import type * as Effect from "effect/Effect"
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind } from "drizzle-orm/entity"
import {
type BuildQueryResult,
type BuildRelationalQueryResult,
type DBQueryConfig,
makeDefaultRqbMapper,
type TableRelationalConfig,
type TablesRelationalConfig,
} from "drizzle-orm/relations"
import type { RunnableQuery } from "drizzle-orm/runnable-query"
import { type Query, type SQL, sql, type SQLWrapper } from "drizzle-orm/sql/sql"
import type { KnownKeysOnly } from "drizzle-orm/utils"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { PreparedQueryConfig } from "drizzle-orm/sqlite-core/session"
import type { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import type { SQLiteEffectPreparedQuery, SQLiteEffectSession } from "./session"
export class SQLiteEffectRelationalQueryBuilder<
TSchema extends TablesRelationalConfig,
TFields extends TableRelationalConfig,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> {
static readonly [entityKind]: string = "SQLiteEffectRelationalQueryBuilderV2"
constructor(
private schema: TSchema,
private table: SQLiteTable,
private tableConfig: TableRelationalConfig,
private dialect: SQLiteDialect,
private session: SQLiteEffectSession<TEffectHKT, any, any>,
private rowMode?: boolean,
private forbidJsonb?: boolean,
) {}
findMany<TConfig extends DBQueryConfig<"many", TSchema, TFields>>(
config?: KnownKeysOnly<TConfig, DBQueryConfig<"many", TSchema, TFields>>,
): SQLiteEffectRelationalQuery<BuildQueryResult<TSchema, TFields, TConfig>[], TEffectHKT> {
return new SQLiteEffectRelationalQuery(
this.schema,
this.table,
this.tableConfig,
this.dialect,
this.session,
(config as DBQueryConfig<"many"> | undefined) ?? true,
"many",
this.rowMode,
this.forbidJsonb,
)
}
findFirst<TConfig extends DBQueryConfig<"one", TSchema, TFields>>(
config?: KnownKeysOnly<TConfig, DBQueryConfig<"one", TSchema, TFields>>,
): SQLiteEffectRelationalQuery<BuildQueryResult<TSchema, TFields, TConfig> | undefined, TEffectHKT> {
return new SQLiteEffectRelationalQuery(
this.schema,
this.table,
this.tableConfig,
this.dialect,
this.session,
(config as DBQueryConfig<"one"> | undefined) ?? true,
"first",
this.rowMode,
this.forbidJsonb,
)
}
}
export interface SQLiteEffectRelationalQuery<TResult, TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase>
extends Effect.Effect<TResult, TEffectHKT["error"], TEffectHKT["context"]>,
RunnableQuery<TResult, "sqlite">,
SQLWrapper {}
export class SQLiteEffectRelationalQuery<TResult, TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase>
implements RunnableQuery<TResult, "sqlite">, SQLWrapper
{
static readonly [entityKind]: string = "SQLiteEffectRelationalQueryV2"
declare readonly _: {
readonly dialect: "sqlite"
readonly type: "async"
readonly result: TResult
}
/** @internal */
mode: "many" | "first"
/** @internal */
table: SQLiteTable
constructor(
private schema: TablesRelationalConfig,
table: SQLiteTable,
private tableConfig: TableRelationalConfig,
private dialect: SQLiteDialect,
private session: SQLiteEffectSession<TEffectHKT, any, any>,
private config: DBQueryConfig<"many" | "one"> | true,
mode: "many" | "first",
private rowMode?: boolean,
private forbidJsonb?: boolean,
) {
this.mode = mode
this.table = table
}
/** @internal */
getSQL(): SQL {
return this._getQuery().sql
}
/** @internal */
_prepare(
isOneTimeQuery = true,
): SQLiteEffectPreparedQuery<
PreparedQueryConfig & { all: TResult; get: TResult; execute: TResult },
TEffectHKT,
true
> {
const { query, builtQuery } = this._toSQL()
const mapperConfig = {
isFirst: this.mode === "first",
parseJson: !this.rowMode,
parseJsonIfString: false,
rootJsonMappers: true,
selection: query.selection,
}
return this.session[isOneTimeQuery ? "prepareOneTimeRelationalQuery" : "prepareRelationalQuery"](
builtQuery,
undefined,
this.mode === "first" ? "get" : "all",
makeDefaultRqbMapper(mapperConfig),
mapperConfig,
) as SQLiteEffectPreparedQuery<
PreparedQueryConfig & { all: TResult; get: TResult; execute: TResult },
TEffectHKT,
true
>
}
prepare(): SQLiteEffectPreparedQuery<
PreparedQueryConfig & { all: TResult; get: TResult; execute: TResult },
TEffectHKT,
true
> {
return this._prepare(false)
}
private _getQuery() {
const jsonb = this.forbidJsonb ? sql`json` : sql`jsonb`
const query = this.dialect.buildRelationalQuery({
schema: this.schema,
table: this.table,
tableConfig: this.tableConfig,
queryConfig: this.config,
mode: this.mode,
isNested: this.rowMode,
jsonb,
})
if (this.rowMode) {
const jsonColumns = sql.join(
query.selection.map((s) => {
return sql`${sql.raw(this.dialect.escapeString(s.key))}, ${
s.selection ? sql`${jsonb}(${sql.identifier(s.key)})` : sql.identifier(s.key)
}`
}),
sql`, `,
)
query.sql = sql`select json_object(${jsonColumns}) as ${sql.identifier("r")} from (${query.sql}) as ${sql.identifier(
"t",
)}`
}
return query
}
private _toSQL(): { query: BuildRelationalQueryResult; builtQuery: Query } {
const query = this._getQuery()
const builtQuery = this.dialect.sqlToQuery(query.sql)
return { query, builtQuery }
}
toSQL(): Query {
return this._toSQL().builtQuery
}
execute(placeholderValues?: Record<string, unknown>) {
return this.mode === "first" ? this._prepare().get(placeholderValues) : this._prepare().all(placeholderValues)
}
}
applyEffectWrapper(SQLiteEffectRelationalQuery)
@@ -0,0 +1,49 @@
/* oxlint-disable */
import type * as Effect from "effect/Effect"
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind } from "drizzle-orm/entity"
import type { RunnableQuery } from "drizzle-orm/runnable-query"
import type { PreparedQuery } from "drizzle-orm/session"
import type { Query, SQL, SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
type SQLiteEffectRawAction = "all" | "get" | "values" | "run"
export interface SQLiteEffectRaw<TResult, TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase>
extends Effect.Effect<TResult, TEffectHKT["error"], TEffectHKT["context"]>,
RunnableQuery<TResult, "sqlite">,
SQLWrapper {}
export class SQLiteEffectRaw<TResult, TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase>
implements RunnableQuery<TResult, "sqlite">, SQLWrapper, PreparedQuery
{
static readonly [entityKind]: string = "SQLiteEffectRaw"
declare readonly _: {
readonly dialect: "sqlite"
readonly result: TResult
}
constructor(
public execute: () => Effect.Effect<TResult, TEffectHKT["error"], TEffectHKT["context"]>,
/** @internal */
public getSQL: () => SQL,
private action: SQLiteEffectRawAction,
private dialect: SQLiteAsyncDialect,
private mapBatchResult: (result: unknown) => unknown,
) {}
getQuery(): Query & { method: SQLiteEffectRawAction } {
return { ...this.dialect.sqlToQuery(this.getSQL()), method: this.action }
}
mapResult(result: unknown, isFromBatch?: boolean) {
return isFromBatch ? this.mapBatchResult(result) : result
}
_prepare(): PreparedQuery {
return this
}
}
applyEffectWrapper(SQLiteEffectRaw)
@@ -0,0 +1,279 @@
/* oxlint-disable */
import type * as Effect from "effect/Effect"
import type { CacheConfig } from "drizzle-orm/cache/core/types"
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind, is } from "drizzle-orm/entity"
import type {
BuildSubquerySelection,
GetSelectTableName,
GetSelectTableSelection,
JoinNullability,
SelectMode,
SelectResult,
} from "drizzle-orm/query-builders/select.types"
import { SQL } from "drizzle-orm/sql/sql"
import type { ColumnsSelection, SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteColumn } from "drizzle-orm/sqlite-core/columns"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import { SQLiteSelectQueryBuilderBase } from "drizzle-orm/sqlite-core/query-builders/select"
import type {
CreateSQLiteSelectFromBuilderMode,
SelectedFields,
SQLiteSelectConfig,
SQLiteSelectHKTBase,
} from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import { SQLiteViewBase } from "drizzle-orm/sqlite-core/view-base"
import { Subquery } from "drizzle-orm/subquery"
import { type Assume, getTableColumns } from "drizzle-orm/utils"
import { getViewSelectedFieldsRuntime, orderSelectedFields } from "../../internal/drizzle-utils"
import type { SQLiteEffectPreparedQuery, SQLiteEffectSession } from "./session"
export type SQLiteEffectSelectPrepare<
T extends AnySQLiteEffectSelect,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> = SQLiteEffectPreparedQuery<
{
type: "async"
run: T["_"]["runResult"]
all: T["_"]["result"]
get: T["_"]["result"][number] | undefined
values: any[][]
execute: T["_"]["result"]
},
TEffectHKT
>
export class SQLiteEffectSelectBuilder<
TSelection extends SelectedFields | undefined,
TRunResult,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TBuilderMode extends "db" | "qb" = "db",
> {
static readonly [entityKind]: string = "SQLiteEffectSelectBuilder"
private fields: TSelection
private session: SQLiteEffectSession<TEffectHKT, TRunResult, any> | undefined
private dialect: SQLiteDialect
private withList: Subquery[] | undefined
private distinct: boolean | undefined
constructor(config: {
fields: TSelection
session: SQLiteEffectSession<TEffectHKT, TRunResult, any> | undefined
dialect: SQLiteDialect
withList?: Subquery[]
distinct?: boolean
}) {
this.fields = config.fields
this.session = config.session
this.dialect = config.dialect
this.withList = config.withList
this.distinct = config.distinct
}
from<TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL>(
source: TFrom,
): TBuilderMode extends "db"
? SQLiteEffectSelectBase<
GetSelectTableName<TFrom>,
TRunResult,
TSelection extends undefined ? GetSelectTableSelection<TFrom> : TSelection,
TSelection extends undefined ? "single" : "partial",
GetSelectTableName<TFrom> extends string ? Record<GetSelectTableName<TFrom>, "not-null"> : {},
false,
never,
SelectResult<
TSelection extends undefined ? GetSelectTableSelection<TFrom> : TSelection,
TSelection extends undefined ? "single" : "partial",
GetSelectTableName<TFrom> extends string ? Record<GetSelectTableName<TFrom>, "not-null"> : {}
>[],
BuildSubquerySelection<
TSelection extends undefined ? GetSelectTableSelection<TFrom> : TSelection,
GetSelectTableName<TFrom> extends string ? Record<GetSelectTableName<TFrom>, "not-null"> : {}
>,
TEffectHKT
>
: CreateSQLiteSelectFromBuilderMode<
TBuilderMode,
GetSelectTableName<TFrom>,
"async",
TRunResult,
TSelection extends undefined ? GetSelectTableSelection<TFrom> : TSelection,
TSelection extends undefined ? "single" : "partial"
> {
const isPartialSelect = !!this.fields
let fields: SelectedFields
if (this.fields) {
fields = this.fields
} else if (is(source, Subquery)) {
fields = Object.fromEntries(
Object.keys(source._.selectedFields).map((key) => [
key,
source[key as unknown as keyof typeof source] as unknown as SelectedFields[string],
]),
)
} else if (is(source, SQLiteViewBase)) {
fields = getViewSelectedFieldsRuntime(source).selectedFields as SelectedFields
} else if (is(source, SQL)) {
fields = {}
} else {
fields = getTableColumns<SQLiteTable>(source)
}
return new SQLiteEffectSelectBase({
table: source,
fields,
isPartialSelect,
session: this.session as any,
dialect: this.dialect,
withList: this.withList,
distinct: this.distinct,
}) as any
}
}
export interface SQLiteEffectSelectHKT<TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase>
extends SQLiteSelectHKTBase {
_type: SQLiteEffectSelectBase<
this["tableName"],
this["runResult"],
Assume<this["selection"], ColumnsSelection>,
this["selectMode"],
Assume<this["nullabilityMap"], Record<string, JoinNullability>>,
this["dynamic"],
this["excludedMethods"],
Assume<this["result"], any[]>,
Assume<this["selectedFields"], ColumnsSelection>,
TEffectHKT
>
}
export interface SQLiteEffectSelectBase<
TTableName extends string | undefined,
TRunResult,
TSelection extends ColumnsSelection,
TSelectMode extends SelectMode = "single",
TNullabilityMap extends Record<string, JoinNullability> = TTableName extends string
? Record<TTableName, "not-null">
: {},
TDynamic extends boolean = false,
TExcludedMethods extends string = never,
TResult extends any[] = SelectResult<TSelection, TSelectMode, TNullabilityMap>[],
TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends SQLiteSelectQueryBuilderBase<
SQLiteEffectSelectHKT<TEffectHKT>,
TTableName,
"async",
TRunResult,
TSelection,
TSelectMode,
TNullabilityMap,
TDynamic,
TExcludedMethods,
TResult,
TSelectedFields
>,
Effect.Effect<TResult, TEffectHKT["error"], TEffectHKT["context"]> {}
export class SQLiteEffectSelectBase<
TTableName extends string | undefined,
TRunResult,
TSelection extends ColumnsSelection,
TSelectMode extends SelectMode = "single",
TNullabilityMap extends Record<string, JoinNullability> = TTableName extends string
? Record<TTableName, "not-null">
: {},
TDynamic extends boolean = false,
TExcludedMethods extends string = never,
TResult extends any[] = SelectResult<TSelection, TSelectMode, TNullabilityMap>[],
TSelectedFields extends ColumnsSelection = BuildSubquerySelection<TSelection, TNullabilityMap>,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
extends SQLiteSelectQueryBuilderBase<
SQLiteEffectSelectHKT<TEffectHKT>,
TTableName,
"async",
TRunResult,
TSelection,
TSelectMode,
TNullabilityMap,
TDynamic,
TExcludedMethods,
TResult,
TSelectedFields
>
implements SQLWrapper
{
static override readonly [entityKind]: string = "SQLiteEffectSelect"
private get effectConfig() {
return (this as unknown as { config: SQLiteSelectConfig }).config
}
/** @internal */
getSQL(): SQL {
return this.dialect.buildSelectQuery(this.effectConfig)
}
/** @internal */
_prepare(isOneTimeQuery = true): SQLiteEffectSelectPrepare<this, TEffectHKT> {
if (!this.session) {
throw new Error("Cannot execute a query on a query builder. Please use a database instance instead.")
}
const session = this.session as unknown as SQLiteEffectSession<TEffectHKT, TRunResult, any>
const query = session[isOneTimeQuery ? "prepareOneTimeQuery" : "prepareQuery"](
this.dialect.sqlToQuery(this.getSQL()),
orderSelectedFields<SQLiteColumn>(this.effectConfig.fields),
"all",
undefined,
{
type: "select",
tables: [...this.usedTables],
},
this.cacheConfig,
)
query.joinsNotNullableMap = this.joinsNotNullableMap
return query as ReturnType<this["prepare"]>
}
$withCache(config?: { config?: CacheConfig; tag?: string; autoInvalidate?: boolean } | false) {
this.cacheConfig =
config === undefined
? { config: {}, enabled: true, autoInvalidate: true }
: config === false
? { enabled: false }
: { enabled: true, autoInvalidate: true, ...config }
return this
}
prepare(): SQLiteEffectSelectPrepare<this, TEffectHKT> {
return this._prepare(false)
}
run: ReturnType<this["prepare"]>["run"] = (placeholderValues) => {
return this._prepare().run(placeholderValues)
}
all: ReturnType<this["prepare"]>["all"] = (placeholderValues) => {
return this._prepare().all(placeholderValues)
}
get: ReturnType<this["prepare"]>["get"] = (placeholderValues) => {
return this._prepare().get(placeholderValues)
}
values: ReturnType<this["prepare"]>["values"] = (placeholderValues) => {
return this._prepare().values(placeholderValues)
}
execute: ReturnType<this["prepare"]>["execute"] = (placeholderValues) => {
return this._prepare().execute(placeholderValues)
}
}
applyEffectWrapper(SQLiteEffectSelectBase)
export type AnySQLiteEffectSelect = SQLiteEffectSelectBase<any, any, any, any, any, any, any, any, any, any>
@@ -0,0 +1,490 @@
/* oxlint-disable */
import * as Cause from "effect/Cause"
import * as Effect from "effect/Effect"
import type { SqlError } from "effect/unstable/sql/SqlError"
import type { EffectCacheShape } from "drizzle-orm/cache/core/cache-effect"
import { NoopCache, strategyFor } from "drizzle-orm/cache/core/cache"
import type { WithCacheConfig } from "drizzle-orm/cache/core/types"
import { MigratorInitError } from "drizzle-orm/effect-core/errors"
import { EffectDrizzleQueryError, EffectTransactionRollbackError } from "drizzle-orm/effect-core/errors"
import type { EffectLoggerShape } from "drizzle-orm/effect-core/logger"
import type { QueryEffectHKTBase, QueryEffectKind } from "drizzle-orm/effect-core/query-effect"
import { entityKind, is } from "drizzle-orm/entity"
import type { MigrationConfig, MigrationMeta } from "drizzle-orm/migrator"
import { getMigrationsToRun } from "drizzle-orm/migrator.utils"
import type {
AnyRelations,
EmptyRelations,
RelationalQueryMapperConfig,
RelationalRowsMapper,
} from "drizzle-orm/relations"
import { makeJitRqbMapper } from "drizzle-orm/relations"
import type { PreparedQuery } from "drizzle-orm/session"
import { fillPlaceholders, type Query, type SQL, sql } from "drizzle-orm/sql/sql"
import type { SQLiteAsyncDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SelectedFieldsOrdered } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { PreparedQueryConfig, SQLiteExecuteMethod, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
import { upgradeIfNeeded } from "../../up-migrations/effect-sqlite"
import { assertUnreachable, makeJitQueryMapper, type RowsMapper } from "drizzle-orm/utils"
import { mapResultRow } from "../../internal/drizzle-utils"
import { SQLiteEffectDatabase } from "./db"
type MigrationConfigWithInit = MigrationConfig & { init?: boolean }
type SQLiteEffectExecuteMethod = SQLiteExecuteMethod | "values"
export class SQLiteEffectPreparedQuery<
T extends PreparedQueryConfig,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TIsRqbV2 extends boolean = false,
> implements PreparedQuery
{
static readonly [entityKind]: string = "SQLiteEffectPreparedQuery"
/** @internal */
joinsNotNullableMap?: Record<string, boolean>
private jitMapper?: RowsMapper<any> | RelationalRowsMapper<any>
private cacheConfig: WithCacheConfig | undefined
private effectExecuteMethod: SQLiteExecuteMethod
constructor(
private executor: (
params: unknown[],
executeMethod: SQLiteEffectExecuteMethod,
) => Effect.Effect<unknown, unknown, unknown>,
protected query: Query,
private logger: EffectLoggerShape,
private cache: EffectCacheShape,
private queryMetadata:
| {
type: "select" | "update" | "delete" | "insert"
tables: string[]
}
| undefined,
cacheConfig: WithCacheConfig | undefined,
private fields: SelectedFieldsOrdered | undefined,
executeMethod: SQLiteExecuteMethod,
private useJitMappers: boolean | undefined,
private customResultMapper?: (
rows: TIsRqbV2 extends true ? Record<string, unknown>[] : unknown[][],
mapColumnValue?: (value: unknown) => unknown,
) => unknown,
private isRqbV2Query?: TIsRqbV2,
private rqbConfig?: RelationalQueryMapperConfig,
private isInTransaction: Effect.Effect<boolean> = Effect.succeed(false),
) {
this.effectExecuteMethod = executeMethod
this.cacheConfig =
cache.strategy() === "all" && cacheConfig === undefined ? { enabled: true, autoInvalidate: true } : cacheConfig
if (!this.cacheConfig?.enabled) {
this.cacheConfig = undefined
}
}
run(placeholderValues?: Record<string, unknown>): QueryEffectKind<TEffectHKT, T["run"]>
run(placeholderValues?: Record<string, unknown>): any {
return this.executeWithCache<T["run"]>(placeholderValues, "run")
}
all(placeholderValues?: Record<string, unknown>): QueryEffectKind<TEffectHKT, T["all"]>
all(placeholderValues?: Record<string, unknown>): any {
if (this.isRqbV2Query) return this.allRqbV2(placeholderValues)
if (!this.fields && !this.customResultMapper) {
return this.executeWithCache<T["all"]>(placeholderValues, "all")
}
return this.executeWithCache<T["values"], T["all"]>(
placeholderValues,
"values",
(rows) => this.mapAllResult(rows) as T["all"],
)
}
get(placeholderValues?: Record<string, unknown>): QueryEffectKind<TEffectHKT, T["get"]>
get(placeholderValues?: Record<string, unknown>): any {
if (this.isRqbV2Query) return this.getRqbV2(placeholderValues)
if (!this.fields && !this.customResultMapper) {
return this.executeWithCache<T["get"]>(placeholderValues, "get")
}
return this.executeWithCache<T["values"], T["get"]>(
placeholderValues,
"values",
(rows) => this.mapGetResult(rows) as T["get"],
)
}
values(placeholderValues?: Record<string, unknown>): QueryEffectKind<TEffectHKT, T["values"]>
values(placeholderValues?: Record<string, unknown>): any {
return this.executeWithCache<T["values"]>(placeholderValues, "values")
}
execute(placeholderValues?: Record<string, unknown>): QueryEffectKind<TEffectHKT, T["execute"]>
execute(placeholderValues?: Record<string, unknown>): any {
return this[this.effectExecuteMethod](placeholderValues) as QueryEffectKind<TEffectHKT, T["execute"]>
}
mapRunResult(result: unknown, _isFromBatch?: boolean): unknown {
return result
}
mapAllResult(rows: unknown, isFromBatch?: boolean): unknown {
if (isFromBatch) {
rows = Array.isArray(rows) ? rows : []
}
if (!this.fields && !this.customResultMapper) {
return rows
}
if (this.isRqbV2Query) {
return this.useJitMappers
? (this.jitMapper =
(this.jitMapper as RelationalRowsMapper<T["all"]>) ?? makeJitRqbMapper<T["all"]>(this.rqbConfig!))(
rows as Record<string, unknown>[],
)
: (this.customResultMapper as (rows: Record<string, unknown>[]) => unknown)(rows as Record<string, unknown>[])
}
if (this.customResultMapper) {
return (this.customResultMapper as (rows: unknown[][]) => unknown)(rows as unknown[][]) as T["all"]
}
return this.useJitMappers
? (this.jitMapper =
(this.jitMapper as RowsMapper<T["all"]>) ??
makeJitQueryMapper<T["all"]>(this.fields!, this.joinsNotNullableMap))(rows as unknown[][])
: (rows as unknown[][]).map((row) => mapResultRow(this.fields!, row, this.joinsNotNullableMap))
}
mapGetResult(rows: unknown, isFromBatch?: boolean): unknown {
if (isFromBatch) {
rows = Array.isArray(rows) ? rows : []
}
if (!this.fields && !this.customResultMapper) {
return Array.isArray(rows) ? rows[0] : rows
}
const row = Array.isArray(rows) ? rows[0] : rows
if (!row) return undefined
if (this.isRqbV2Query) {
return this.useJitMappers
? (this.jitMapper =
(this.jitMapper as RelationalRowsMapper<T["get"][]>) ?? makeJitRqbMapper<T["get"][]>(this.rqbConfig!))([
row as Record<string, unknown>,
])
: (this.customResultMapper as (rows: Record<string, unknown>[]) => unknown)([row as Record<string, unknown>])
}
if (this.customResultMapper) {
return (this.customResultMapper as (rows: unknown[][]) => unknown)([row as unknown[]]) as T["get"]
}
return this.useJitMappers
? (this.jitMapper =
(this.jitMapper as RowsMapper<T["get"][]>) ??
makeJitQueryMapper<T["get"][]>(this.fields!, this.joinsNotNullableMap))([row as unknown[]])[0]
: mapResultRow(this.fields!, row as unknown[], this.joinsNotNullableMap)
}
private allRqbV2(placeholderValues?: Record<string, unknown>) {
return this.executeWithCache<unknown[], T["all"]>(
placeholderValues,
"all",
(rows) => this.mapAllResult(rows) as T["all"],
)
}
private getRqbV2(placeholderValues?: Record<string, unknown>) {
return this.executeWithCache<unknown, T["get"] | undefined>(placeholderValues, "get", (row) =>
row === undefined ? undefined : (this.mapGetResult(row) as T["get"]),
)
}
private executeWithCache<A, B = A>(
placeholderValues: Record<string, unknown> | undefined,
executeMethod: SQLiteEffectExecuteMethod,
mapResult?: (result: A) => B,
) {
return Effect.gen({ self: this }, function* () {
const params = fillPlaceholders(this.query.params, placeholderValues ?? {})
yield* this.logger.logQuery(this.query.sql, params)
return yield* this.queryWithCache(
this.query.sql,
params,
Effect.suspend(() => this.executor(params, executeMethod) as Effect.Effect<A, unknown, unknown>),
mapResult,
)
})
}
private mapCachedResult<A, B>(result: A, mapResult: ((result: A) => B) | undefined) {
if (!mapResult) return Effect.succeed(result as unknown as B)
return Effect.try({
try: () => mapResult(result),
catch: (cause) => cause,
})
}
private queryWithCache<A, E, R, B = A>(
queryString: string,
params: unknown[],
query: Effect.Effect<A, E, R>,
mapResult?: (result: A) => B,
) {
return Effect.gen({ self: this }, function* () {
if (this.queryMetadata?.type === "select" && this.cacheConfig?.enabled && (yield* this.isInTransaction)) {
return yield* this.mapCachedResult(yield* query, mapResult)
}
const cacheStrat: Awaited<ReturnType<typeof strategyFor>> = !is(this.cache.cache, NoopCache)
? yield* Effect.tryPromise(() => strategyFor(queryString, params, this.queryMetadata, this.cacheConfig))
: { type: "skip" as const }
if (cacheStrat.type === "skip") {
return yield* this.mapCachedResult(yield* query, mapResult)
}
if (cacheStrat.type === "invalidate") {
const result = yield* query
yield* this.cache.onMutate({ tables: cacheStrat.tables })
return yield* this.mapCachedResult(result, mapResult)
}
if (cacheStrat.type === "try") {
if (yield* this.isInTransaction) {
return yield* this.mapCachedResult(yield* query, mapResult)
}
const { tables, key, isTag, autoInvalidate, config } = cacheStrat
const fromCache: any[] | undefined = yield* this.cache.get(key, tables, isTag, autoInvalidate)
if (typeof fromCache !== "undefined") {
return yield* this.mapCachedResult(fromCache as unknown as A, mapResult)
}
const result = yield* query
yield* this.cache.put(key, result, autoInvalidate ? tables : [], isTag, config)
return yield* this.mapCachedResult(result, mapResult)
}
assertUnreachable(cacheStrat)
}).pipe(
Effect.catch((e) => {
return Effect.fail(new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) }))
}),
)
}
getQuery(): Query {
return this.query
}
mapResult(response: unknown, isFromBatch?: boolean) {
switch (this.effectExecuteMethod) {
case "run": {
return this.mapRunResult(response, isFromBatch)
}
case "all": {
return this.mapAllResult(response, isFromBatch)
}
case "get": {
return this.mapGetResult(response, isFromBatch)
}
}
}
}
export abstract class SQLiteEffectSession<
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
TRunResult = unknown,
TRelations extends AnyRelations = EmptyRelations,
> {
static readonly [entityKind]: string = "SQLiteEffectSession"
constructor(readonly dialect: SQLiteAsyncDialect) {}
abstract prepareQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
query: Query,
fields: SelectedFieldsOrdered | undefined,
executeMethod: SQLiteExecuteMethod,
customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown,
queryMetadata?: {
type: "select" | "update" | "delete" | "insert"
tables: string[]
},
cacheConfig?: WithCacheConfig,
): SQLiteEffectPreparedQuery<T, TEffectHKT>
prepareOneTimeQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
query: Query,
fields: SelectedFieldsOrdered | undefined,
executeMethod: SQLiteExecuteMethod,
customResultMapper?: (rows: unknown[][], mapColumnValue?: (value: unknown) => unknown) => unknown,
queryMetadata?: {
type: "select" | "update" | "delete" | "insert"
tables: string[]
},
cacheConfig?: WithCacheConfig,
): SQLiteEffectPreparedQuery<T, TEffectHKT> {
return this.prepareQuery(query, fields, executeMethod, customResultMapper, queryMetadata, cacheConfig)
}
abstract prepareRelationalQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
query: Query,
fields: SelectedFieldsOrdered | undefined,
executeMethod: SQLiteExecuteMethod,
customResultMapper: (rows: Record<string, unknown>[], mapColumnValue?: (value: unknown) => unknown) => unknown,
config: RelationalQueryMapperConfig,
): SQLiteEffectPreparedQuery<T, TEffectHKT, true>
prepareOneTimeRelationalQuery<T extends PreparedQueryConfig = PreparedQueryConfig>(
query: Query,
fields: SelectedFieldsOrdered | undefined,
executeMethod: SQLiteExecuteMethod,
customResultMapper: (rows: Record<string, unknown>[], mapColumnValue?: (value: unknown) => unknown) => unknown,
config: RelationalQueryMapperConfig,
): SQLiteEffectPreparedQuery<T, TEffectHKT, true> {
return this.prepareRelationalQuery(query, fields, executeMethod, customResultMapper, config)
}
run(query: SQL): QueryEffectKind<TEffectHKT, TRunResult>
run(query: SQL): any {
return this.prepareQuery<PreparedQueryConfig & { run: TRunResult; execute: TRunResult }>(
this.dialect.sqlToQuery(query),
undefined,
"run",
).run()
}
all<T = unknown>(query: SQL): QueryEffectKind<TEffectHKT, T[]>
all<T = unknown>(query: SQL): any {
return this.prepareQuery<PreparedQueryConfig & { all: T[]; execute: T[] }>(
this.dialect.sqlToQuery(query),
undefined,
"all",
).all()
}
get<T = unknown>(query: SQL): QueryEffectKind<TEffectHKT, T | undefined>
get<T = unknown>(query: SQL): any {
return this.prepareQuery<PreparedQueryConfig & { get: T | undefined; execute: T | undefined }>(
this.dialect.sqlToQuery(query),
undefined,
"get",
).get()
}
values<T extends unknown[] = unknown[]>(query: SQL): QueryEffectKind<TEffectHKT, T[]>
values<T extends unknown[] = unknown[]>(query: SQL): any {
return this.prepareQuery<PreparedQueryConfig & { values: T[]; execute: T[] }>(
this.dialect.sqlToQuery(query),
undefined,
"all",
).values()
}
count(query: SQL): QueryEffectKind<TEffectHKT, number>
count(query: SQL): any {
return this.values<[number]>(query).pipe(Effect.map((result) => result[0]?.[0] ?? 0))
}
abstract transaction<A, E, R>(
transaction: (tx: SQLiteEffectTransaction<TEffectHKT, TRunResult, TRelations>) => Effect.Effect<A, E, R>,
config?: SQLiteTransactionConfig,
): Effect.Effect<A, E | SqlError, R>
}
export abstract class SQLiteEffectTransaction<
TEffectHKT extends QueryEffectHKTBase,
TRunResult,
TRelations extends AnyRelations = EmptyRelations,
> extends SQLiteEffectDatabase<TEffectHKT, TRunResult, TRelations> {
static override readonly [entityKind]: string = "SQLiteEffectTransaction"
constructor(
dialect: SQLiteAsyncDialect,
session: SQLiteEffectSession<TEffectHKT, TRunResult, TRelations>,
protected relations: TRelations,
) {
super(dialect, session, relations)
}
rollback() {
return new EffectTransactionRollbackError()
}
}
export const migrate = Effect.fn("migrate")(function* <TEffectHKT extends QueryEffectHKTBase>(
migrations: MigrationMeta[],
session: SQLiteEffectSession<TEffectHKT>,
config: string | MigrationConfigWithInit,
) {
const migrationsTable =
typeof config === "string" ? "__drizzle_migrations" : (config.migrationsTable ?? "__drizzle_migrations")
const { newDb } = yield* upgradeIfNeeded(migrationsTable, session, migrations)
if (newDb) {
yield* session.run(sql`
CREATE TABLE IF NOT EXISTS ${sql.identifier(migrationsTable)} (
id INTEGER PRIMARY KEY,
hash text NOT NULL,
created_at numeric,
name text,
applied_at TEXT
)
`)
}
const dbMigrations = yield* session.all<{ id: number; hash: string; created_at: string; name: string | null }>(
sql`SELECT id, hash, created_at, name FROM ${sql.identifier(migrationsTable)}`,
)
if (typeof config === "object" && config.init) {
if (dbMigrations.length) {
return yield* new MigratorInitError({ exitCode: "databaseMigrations" })
}
if (migrations.length > 1) {
return yield* new MigratorInitError({ exitCode: "localMigrations" })
}
const [migration] = migrations
if (!migration) return
yield* session.run(
sql`insert into ${sql.identifier(
migrationsTable,
)} ("hash", "created_at", "name", "applied_at") values(${migration.hash}, ${migration.folderMillis}, ${migration.name}, ${new Date().toISOString()})`,
)
return
}
const migrationsToRun = getMigrationsToRun({ localMigrations: migrations, dbMigrations })
if (migrationsToRun.length === 0) return
yield* session.transaction((tx) =>
Effect.gen(function* () {
for (const migration of migrationsToRun) {
for (const stmt of migration.sql) {
yield* tx.run(sql.raw(stmt))
}
yield* tx.run(
sql`insert into ${sql.identifier(
migrationsTable,
)} ("hash", "created_at", "name", "applied_at") values(${migration.hash}, ${migration.folderMillis}, ${migration.name}, ${new Date().toISOString()})`,
)
}
}),
)
})
@@ -0,0 +1,402 @@
/* oxlint-disable */
import type * as Effect from "effect/Effect"
import { applyEffectWrapper, type QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import { entityKind, is } from "drizzle-orm/entity"
import type { SelectResultFields } from "drizzle-orm/query-builders/select.types"
import type { RunnableQuery } from "drizzle-orm/runnable-query"
import { SelectionProxyHandler } from "drizzle-orm/selection-proxy"
import type { Placeholder, Query, SQL, SQLWrapper } from "drizzle-orm/sql/sql"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SelectedFields, SQLiteSelectJoinConfig } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { SQLiteUpdateConfig, SQLiteUpdateSetSource } from "drizzle-orm/sqlite-core/query-builders/update"
import type { PreparedQueryConfig } from "drizzle-orm/sqlite-core/session"
import { SQLiteTable } from "drizzle-orm/sqlite-core/table"
import { extractUsedTable } from "drizzle-orm/sqlite-core/utils"
import { SQLiteViewBase } from "drizzle-orm/sqlite-core/view-base"
import { Subquery } from "drizzle-orm/subquery"
import { type DrizzleTypeError, type UpdateSet, type ValueOrArray } from "drizzle-orm/utils"
import type { SQLiteColumn } from "drizzle-orm/sqlite-core/columns/common"
import {
getTableColumnsRuntime,
getTableLikeName,
getViewSelectedFieldsRuntime,
mapUpdateSet,
orderSelectedFields,
} from "../../internal/drizzle-utils"
import type { SQLiteEffectPreparedQuery, SQLiteEffectSession } from "./session"
export type SQLiteEffectUpdateWithout<
T extends AnySQLiteEffectUpdate,
TDynamic extends boolean,
K extends keyof T & string,
> = TDynamic extends true
? T
: Omit<
SQLiteEffectUpdateBase<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["from"],
T["_"]["returning"],
TDynamic,
T["_"]["excludedMethods"] | K,
T["_"]["effectHKT"]
>,
T["_"]["excludedMethods"] | K
>
export type SQLiteEffectUpdateWithJoins<
T extends AnySQLiteEffectUpdate,
TDynamic extends boolean,
TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL,
> = TDynamic extends true
? T
: Omit<
SQLiteEffectUpdateBase<
T["_"]["table"],
T["_"]["runResult"],
TFrom,
T["_"]["returning"],
TDynamic,
Exclude<T["_"]["excludedMethods"] | "from", "leftJoin" | "rightJoin" | "innerJoin" | "fullJoin">,
T["_"]["effectHKT"]
>,
Exclude<T["_"]["excludedMethods"] | "from", "leftJoin" | "rightJoin" | "innerJoin" | "fullJoin">
>
export type SQLiteEffectUpdateReturningAll<
T extends AnySQLiteEffectUpdate,
TDynamic extends boolean,
> = SQLiteEffectUpdateWithout<
SQLiteEffectUpdateBase<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["from"],
T["_"]["table"]["$inferSelect"],
TDynamic,
T["_"]["excludedMethods"],
T["_"]["effectHKT"]
>,
TDynamic,
"returning"
>
export type SQLiteEffectUpdateReturning<
T extends AnySQLiteEffectUpdate,
TDynamic extends boolean,
TSelectedFields extends SelectedFields,
> = SQLiteEffectUpdateWithout<
SQLiteEffectUpdateBase<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["from"],
SelectResultFields<TSelectedFields>,
TDynamic,
T["_"]["excludedMethods"],
T["_"]["effectHKT"]
>,
TDynamic,
"returning"
>
export type SQLiteEffectUpdateExecute<T extends AnySQLiteEffectUpdate> = T["_"]["returning"] extends undefined
? T["_"]["runResult"]
: T["_"]["returning"][]
export type SQLiteEffectUpdatePrepare<
T extends AnySQLiteEffectUpdate,
TEffectHKT extends QueryEffectHKTBase = T["_"]["effectHKT"],
> = SQLiteEffectPreparedQuery<
PreparedQueryConfig & {
run: T["_"]["runResult"]
all: T["_"]["returning"] extends undefined
? DrizzleTypeError<".all() cannot be used without .returning()">
: T["_"]["returning"][]
get: T["_"]["returning"] extends undefined
? DrizzleTypeError<".get() cannot be used without .returning()">
: T["_"]["returning"]
values: T["_"]["returning"] extends undefined
? DrizzleTypeError<".values() cannot be used without .returning()">
: any[][]
execute: SQLiteEffectUpdateExecute<T>
},
TEffectHKT
>
export type SQLiteEffectUpdateDynamic<T extends AnySQLiteEffectUpdate> = SQLiteEffectUpdate<
T["_"]["table"],
T["_"]["runResult"],
T["_"]["from"],
T["_"]["returning"],
T["_"]["effectHKT"]
>
export type SQLiteEffectUpdate<
TTable extends SQLiteTable = SQLiteTable,
TRunResult = unknown,
TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL | undefined = undefined,
TReturning extends Record<string, unknown> | undefined = Record<string, unknown> | undefined,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> = SQLiteEffectUpdateBase<TTable, TRunResult, TFrom, TReturning, true, never, TEffectHKT>
export type AnySQLiteEffectUpdate = SQLiteEffectUpdateBase<any, any, any, any, any, any, any>
export type SQLiteEffectUpdateJoinFn<T extends AnySQLiteEffectUpdate> = <
TJoinedTable extends SQLiteTable | Subquery | SQLiteViewBase | SQL,
>(
table: TJoinedTable,
on:
| ((
updateTable: T["_"]["table"]["_"]["columns"],
from: T["_"]["from"] extends SQLiteTable
? T["_"]["from"]["_"]["columns"]
: T["_"]["from"] extends Subquery | SQLiteViewBase
? T["_"]["from"]["_"]["selectedFields"]
: never,
) => SQL | undefined)
| SQL
| undefined,
) => T
export class SQLiteEffectUpdateBuilder<
TTable extends SQLiteTable,
TRunResult,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> {
static readonly [entityKind]: string = "SQLiteEffectUpdateBuilder"
declare readonly _: {
readonly table: TTable
}
constructor(
protected table: TTable,
protected session: SQLiteEffectSession<TEffectHKT, TRunResult, any>,
protected dialect: SQLiteDialect,
private withList?: Subquery[],
) {}
set(
values: SQLiteUpdateSetSource<TTable>,
): SQLiteEffectUpdateWithout<
SQLiteEffectUpdateBase<TTable, TRunResult, undefined, undefined, false, never, TEffectHKT>,
false,
"leftJoin" | "rightJoin" | "innerJoin" | "fullJoin"
> {
return new SQLiteEffectUpdateBase(
this.table,
mapUpdateSet(this.table, values),
this.session,
this.dialect,
this.withList,
) as any
}
}
export interface SQLiteEffectUpdateBase<
TTable extends SQLiteTable = SQLiteTable,
TRunResult = unknown,
TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL | undefined = undefined,
TReturning = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
> extends SQLWrapper,
RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">,
Effect.Effect<
TReturning extends undefined ? TRunResult : TReturning[],
TEffectHKT["error"],
TEffectHKT["context"]
> {
readonly _: {
readonly dialect: "sqlite"
readonly table: TTable
readonly resultType: "async"
readonly runResult: TRunResult
readonly from: TFrom
readonly returning: TReturning
readonly dynamic: TDynamic
readonly excludedMethods: _TExcludedMethods
readonly result: TReturning extends undefined ? TRunResult : TReturning[]
readonly effectHKT: TEffectHKT
}
}
export class SQLiteEffectUpdateBase<
TTable extends SQLiteTable = SQLiteTable,
TRunResult = unknown,
TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL | undefined = undefined,
TReturning = undefined,
TDynamic extends boolean = false,
_TExcludedMethods extends string = never,
TEffectHKT extends QueryEffectHKTBase = QueryEffectHKTBase,
>
implements RunnableQuery<TReturning extends undefined ? TRunResult : TReturning[], "sqlite">, SQLWrapper
{
static readonly [entityKind]: string = "SQLiteEffectUpdate"
/** @internal */
config: SQLiteUpdateConfig
constructor(
table: TTable,
set: UpdateSet,
private effectSession: SQLiteEffectSession<TEffectHKT, TRunResult, any>,
private effectDialect: SQLiteDialect,
withList?: Subquery[],
) {
this.config = { set, table, withList, joins: [] }
}
from<TFrom extends SQLiteTable | Subquery | SQLiteViewBase | SQL>(
source: TFrom,
): SQLiteEffectUpdateWithJoins<this, TDynamic, TFrom> {
this.config.from = source
return this as any
}
private createJoin<TJoinType extends SQLiteSelectJoinConfig["joinType"]>(
joinType: TJoinType,
): SQLiteEffectUpdateJoinFn<this> {
return ((
table: SQLiteTable | Subquery | SQLiteViewBase | SQL,
on: ((updateTable: TTable, from: TFrom) => SQL | undefined) | SQL | undefined,
) => {
const tableName = getTableLikeName(table)
if (typeof tableName === "string" && this.config.joins.some((join) => join.alias === tableName)) {
throw new Error(`Alias "${tableName}" is already used in this query`)
}
if (typeof on === "function") {
const from = this.config.from
? is(table, SQLiteTable)
? getTableColumnsRuntime(table)
: is(table, Subquery)
? table._.selectedFields
: is(table, SQLiteViewBase)
? getViewSelectedFieldsRuntime(table).selectedFields
: undefined
: undefined
on = on(
new Proxy(
this.config.table._.columns,
new SelectionProxyHandler({ sqlAliasedBehavior: "sql", sqlBehavior: "sql" }),
) as any,
from &&
(new Proxy(from, new SelectionProxyHandler({ sqlAliasedBehavior: "sql", sqlBehavior: "sql" })) as any),
)
}
this.config.joins.push({ on, table, joinType, alias: tableName })
return this as any
}) as any
}
leftJoin = this.createJoin("left")
rightJoin = this.createJoin("right")
innerJoin = this.createJoin("inner")
fullJoin = this.createJoin("full")
where(where: SQL | undefined): SQLiteEffectUpdateWithout<this, TDynamic, "where"> {
this.config.where = where
return this as any
}
orderBy(
builder: (updateTable: TTable) => ValueOrArray<SQLiteColumn | SQL | SQL.Aliased>,
): SQLiteEffectUpdateWithout<this, TDynamic, "orderBy">
orderBy(...columns: (SQLiteColumn | SQL | SQL.Aliased)[]): SQLiteEffectUpdateWithout<this, TDynamic, "orderBy">
orderBy(
...columns:
| [(updateTable: TTable) => ValueOrArray<SQLiteColumn | SQL | SQL.Aliased>]
| (SQLiteColumn | SQL | SQL.Aliased)[]
): SQLiteEffectUpdateWithout<this, TDynamic, "orderBy"> {
if (typeof columns[0] === "function") {
const orderBy = columns[0](
new Proxy(
getTableColumnsRuntime(this.config.table),
new SelectionProxyHandler({ sqlAliasedBehavior: "alias", sqlBehavior: "sql" }),
) as any,
)
this.config.orderBy = Array.isArray(orderBy) ? orderBy : [orderBy]
return this as any
}
this.config.orderBy = columns as (SQLiteColumn | SQL | SQL.Aliased)[]
return this as any
}
limit(limit: number | Placeholder): SQLiteEffectUpdateWithout<this, TDynamic, "limit"> {
this.config.limit = limit
return this as any
}
returning(): SQLiteEffectUpdateReturningAll<this, TDynamic>
returning<TSelectedFields extends SelectedFields>(
fields: TSelectedFields,
): SQLiteEffectUpdateReturning<this, TDynamic, TSelectedFields>
returning(
fields: SelectedFields = getTableColumnsRuntime(this.config.table),
): SQLiteEffectUpdateWithout<AnySQLiteEffectUpdate, TDynamic, "returning"> {
this.config.returning = orderSelectedFields<SQLiteColumn>(fields)
return this as any
}
/** @internal */
getSQL(): SQL {
return this.effectDialect.buildUpdateQuery(this.config)
}
toSQL(): Query {
return this.effectDialect.sqlToQuery(this.getSQL())
}
/** @internal */
_prepare(isOneTimeQuery = true): SQLiteEffectUpdatePrepare<this, TEffectHKT> {
return this.effectSession[isOneTimeQuery ? "prepareOneTimeQuery" : "prepareQuery"](
this.effectDialect.sqlToQuery(this.getSQL()),
this.config.returning,
this.config.returning ? "all" : "run",
undefined,
{
type: "update",
tables: extractUsedTable(this.config.table),
},
) as SQLiteEffectUpdatePrepare<this, TEffectHKT>
}
prepare(): SQLiteEffectUpdatePrepare<this, TEffectHKT> {
return this._prepare(false)
}
run: ReturnType<this["prepare"]>["run"] = (placeholderValues) => {
return this._prepare().run(placeholderValues)
}
all: ReturnType<this["prepare"]>["all"] = (placeholderValues) => {
return this._prepare().all(placeholderValues)
}
get: ReturnType<this["prepare"]>["get"] = (placeholderValues) => {
return this._prepare().get(placeholderValues)
}
values: ReturnType<this["prepare"]>["values"] = (placeholderValues) => {
return this._prepare().values(placeholderValues)
}
execute: ReturnType<this["prepare"]>["execute"] = (placeholderValues) => {
return this._prepare().execute(placeholderValues)
}
$dynamic(): SQLiteEffectUpdateDynamic<this> {
return this as any
}
}
applyEffectWrapper(SQLiteEffectUpdateBase)
@@ -0,0 +1,102 @@
/* oxlint-disable */
import * as Effect from "effect/Effect"
import type { SqlError } from "effect/unstable/sql/SqlError"
import { EffectDrizzleError } from "drizzle-orm/effect-core/errors"
import type { QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import type { MigrationMeta } from "drizzle-orm/migrator"
import { sql } from "drizzle-orm/sql/sql"
import type { SQLiteEffectSession } from "../sqlite-core/effect/session"
import {
buildSQLiteMigrationBackfillStatements,
prepareSQLiteMigrationBackfill,
type SQLiteMigrationTableRow,
} from "./sqlite"
import { GET_VERSION_FOR, MIGRATIONS_TABLE_VERSIONS, type UpgradeResult } from "./utils"
const migrationUpgradeError = (cause: unknown) =>
new EffectDrizzleError({
message:
typeof cause === "object" && cause !== null && "message" in cause && typeof cause.message === "string"
? cause.message
: String(cause),
cause,
})
export const upgradeIfNeeded: <TEffectHKT extends QueryEffectHKTBase>(
migrationsTable: string,
session: SQLiteEffectSession<TEffectHKT>,
localMigrations: MigrationMeta[],
) => Effect.Effect<UpgradeResult, EffectDrizzleError | TEffectHKT["error"] | SqlError, TEffectHKT["context"]> =
Effect.fn("upgradeIfNeeded")(function* <TEffectHKT extends QueryEffectHKTBase>(
migrationsTable: string,
session: SQLiteEffectSession<TEffectHKT>,
localMigrations: MigrationMeta[],
) {
const tableExists = yield* session.all(
sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`,
)
if (tableExists.length === 0) {
return { newDb: true }
}
const rows = yield* session.all<{ column_name: string }>(
sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`,
)
const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name))
for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) {
const upgradeFn = upgradeFunctions[v]
if (!upgradeFn) {
return yield* new EffectDrizzleError({
message: `No upgrade path from migration table version ${v} to ${v + 1}`,
cause: { version: v },
})
}
yield* upgradeFn(migrationsTable, session, localMigrations)
}
return { newDb: false }
})
const upgradeFunctions: Record<
number,
<TEffectHKT extends QueryEffectHKTBase>(
migrationsTable: string,
session: SQLiteEffectSession<TEffectHKT>,
localMigrations: MigrationMeta[],
) => Effect.Effect<void, EffectDrizzleError | TEffectHKT["error"] | SqlError, TEffectHKT["context"]>
> = {
0: upgradeFromV0,
}
function upgradeFromV0<TEffectHKT extends QueryEffectHKTBase>(
migrationsTable: string,
session: SQLiteEffectSession<TEffectHKT>,
localMigrations: MigrationMeta[],
): Effect.Effect<void, EffectDrizzleError | TEffectHKT["error"] | SqlError, TEffectHKT["context"]> {
return Effect.gen(function* () {
const table = sql`${sql.identifier(migrationsTable)}`
const dbRows = yield* session.all<SQLiteMigrationTableRow>(
sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`,
)
const statements = yield* Effect.try({
try: () =>
buildSQLiteMigrationBackfillStatements(
migrationsTable,
prepareSQLiteMigrationBackfill(dbRows, localMigrations),
),
catch: migrationUpgradeError,
})
yield* session.transaction((tx) =>
Effect.gen(function* () {
for (const statement of statements) {
yield* tx.run(statement)
}
}),
)
})
}
@@ -0,0 +1,253 @@
/* oxlint-disable */
import type { TablesRelationalConfig } from "drizzle-orm/_relations"
import type { MigrationMeta } from "drizzle-orm/migrator"
import type { AnyRelations } from "drizzle-orm/relations"
import { type SQL, sql } from "drizzle-orm/sql/sql"
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core"
import type { SQLiteSession } from "drizzle-orm/sqlite-core/session"
import { GET_VERSION_FOR, MIGRATIONS_TABLE_VERSIONS, type UpgradeResult } from "./utils"
/** @internal */
export type SQLiteMigrationTableRow = { id: number | null; hash: string; created_at: number }
type AsyncSQLiteDatabaseWithSession = BaseSQLiteDatabase<"async", unknown, Record<string, unknown>> & {
session: {
all<T>(query: SQL): Promise<T[]>
}
transaction<T>(transaction: (tx: { run(query: SQL): Promise<unknown> }) => Promise<T>): Promise<T>
}
type SQLiteMigrationBackfillEntry = {
name: string
selector:
| { column: "id"; value: number }
| { column: "created_at"; value: number }
| { column: "hash"; value: string }
}
function unmatchedMigrationError(unmatched: SQLiteMigrationTableRow[]) {
return new Error(
`While upgrading your database migrations table we found ${unmatched.length} (${unmatched
.map((it) => `[id: ${it.id}, created_at: ${it.created_at}]`)
.join(
", ",
)}) migrations in the database that do not match any local migration. This means that some migrations were applied to the database but are missing from the local environment`,
)
}
/** @internal */
export function prepareSQLiteMigrationBackfill(
dbRows: SQLiteMigrationTableRow[],
localMigrations: MigrationMeta[],
): SQLiteMigrationBackfillEntry[] {
const sortedLocalMigrations = [...localMigrations].sort((a, b) =>
a.folderMillis !== b.folderMillis ? a.folderMillis - b.folderMillis : (a.name ?? "").localeCompare(b.name ?? ""),
)
const byMillis = new Map<number, MigrationMeta[]>()
const byHash = new Map<string, MigrationMeta>()
for (const migration of sortedLocalMigrations) {
if (!byMillis.has(migration.folderMillis)) {
byMillis.set(migration.folderMillis, [])
}
byMillis.get(migration.folderMillis)!.push(migration)
byHash.set(migration.hash, migration)
}
const toApply: SQLiteMigrationBackfillEntry[] = []
const unmatched: SQLiteMigrationTableRow[] = []
for (const dbRow of dbRows) {
const stringified = String(dbRow.created_at)
const millis = Number(stringified.substring(0, stringified.length - 3) + "000")
const candidates = byMillis.get(millis)
const matchedByMillis = candidates?.length === 1 ? candidates[0] : undefined
const matchedByCandidateHash =
candidates && candidates.length > 1
? candidates.find((candidate) => candidate.hash && dbRow.hash && candidate.hash === dbRow.hash)
: undefined
const matchedByHash = matchedByMillis || matchedByCandidateHash ? undefined : byHash.get(dbRow.hash)
const matched = matchedByMillis ?? matchedByCandidateHash ?? matchedByHash
if (matched) {
toApply.push({
name: matched.name,
selector:
dbRow.id !== null
? { column: "id", value: dbRow.id }
: matchedByMillis
? { column: "created_at", value: dbRow.created_at }
: { column: "hash", value: dbRow.hash },
})
continue
}
unmatched.push(dbRow)
}
if (unmatched.length > 0) {
throw unmatchedMigrationError(unmatched)
}
return toApply
}
/** @internal */
export function buildSQLiteMigrationBackfillStatements(
migrationsTable: string,
backfillEntries: SQLiteMigrationBackfillEntry[],
) {
const table = sql`${sql.identifier(migrationsTable)}`
const statements: SQL[] = [
sql`ALTER TABLE ${table} ADD COLUMN ${sql.identifier("name")} text`,
sql`ALTER TABLE ${table} ADD COLUMN ${sql.identifier("applied_at")} TEXT`,
]
for (const backfillEntry of backfillEntries) {
const updateQuery = sql`UPDATE ${table} SET ${sql.identifier("name")} = ${backfillEntry.name}, ${sql.identifier(
"applied_at",
)} = NULL WHERE`
updateQuery.append(sql` ${sql.identifier(backfillEntry.selector.column)} = ${backfillEntry.selector.value}`)
statements.push(updateQuery)
}
return statements
}
/**
* Detects the current version of the migrations table schema and upgrades it if needed.
*
* Version 0: Original schema (id, hash, created_at)
* Version 1: Extended schema (id, hash, created_at, name, applied_at)
*/
export function upgradeSyncIfNeeded(
migrationsTable: string,
session: SQLiteSession<"sync", unknown, Record<string, unknown>, AnyRelations, TablesRelationalConfig>,
localMigrations: MigrationMeta[],
): UpgradeResult {
const tableExists = session.all(sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`)
if (tableExists.length === 0) {
return { newDb: true }
}
// Table exists, check table shape
const rows = session.all<{ column_name: string }>(
sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`,
)
const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name))
for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) {
const upgradeFn = upgradeSyncFunctions[v]
if (!upgradeFn) {
throw new Error(`No upgrade path from migration table version ${v} to ${v + 1}`)
}
upgradeFn(migrationsTable, session, localMigrations)
}
return { newDb: false }
}
const upgradeSyncFunctions: Record<
number,
(
migrationsTable: string,
session: SQLiteSession<"sync", unknown, Record<string, unknown>, AnyRelations, TablesRelationalConfig>,
localMigrations: MigrationMeta[],
) => void
> = {
/**
* Upgrade from version 0 to version 1:
* 1. Read all existing DB migrations
* 2. Sort localMigrations ASC by millis and if the same - sort by name
* 3. Match each DB row to a local migration
* If multiple migrations share the same second, use hash matching as a tiebreaker
* Not implemented for now -> If hash matching fails, fall back to serial id ordering
* 5. Create extra column and backfill names for matched migrations
*/
0: (migrationsTable, session, localMigrations) => {
const table = sql`${sql.identifier(migrationsTable)}`
const dbRows = session.all<SQLiteMigrationTableRow>(sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`)
const statements = buildSQLiteMigrationBackfillStatements(
migrationsTable,
prepareSQLiteMigrationBackfill(dbRows, localMigrations),
)
session.transaction((tx) => {
for (const statement of statements) {
tx.run(statement)
}
})
},
}
/**
* Detects the current version of the migrations table schema and upgrades it if needed.
*
* Version 0: Original schema (id, hash, created_at)
* Version 1: Extended schema (id, hash, created_at, name, applied_at)
*/
export async function upgradeAsyncIfNeeded(
migrationsTable: string,
db: AsyncSQLiteDatabaseWithSession,
localMigrations: MigrationMeta[],
): Promise<UpgradeResult> {
// Check if the table exists at all
const tableExists = await db.session.all(
sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`,
)
if (tableExists.length === 0) {
return { newDb: true }
}
const rows = await db.session.all<{ column_name: string }>(
sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`,
)
const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name))
for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) {
const upgradeFn = upgradeAsyncFunctions[v]
if (!upgradeFn) {
throw new Error(`No upgrade path from migration table version ${v} to ${v + 1}`)
}
await upgradeFn(migrationsTable, db, localMigrations)
}
return { newDb: false }
}
const upgradeAsyncFunctions: Record<
number,
(migrationsTable: string, db: AsyncSQLiteDatabaseWithSession, localMigrations: MigrationMeta[]) => Promise<void>
> = {
/**
* Upgrade from version 0 to version 1:
* 1. Read all existing DB migrations
* 2. Sort localMigrations ASC by millis and if the same - sort by name
* 3. Match each DB row to a local migration
* If multiple migrations share the same second, use hash matching as a tiebreaker
* Not implemented for now -> If hash matching fails, fall back to serial id ordering
* 5. Create extra column and backfill names for matched migrations
*/
0: async (migrationsTable, db, localMigrations) => {
const table = sql`${sql.identifier(migrationsTable)}`
const dbRows = await db.session.all<SQLiteMigrationTableRow>(
sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`,
)
const statements = buildSQLiteMigrationBackfillStatements(
migrationsTable,
prepareSQLiteMigrationBackfill(dbRows, localMigrations),
)
await db.transaction(async (tx) => {
for (const statement of statements) {
await tx.run(statement)
}
})
},
}
@@ -0,0 +1,45 @@
/* oxlint-disable */
export interface UpgradeResult {
newDb: boolean
}
export const MIGRATIONS_TABLE_VERSIONS = {
sqlite: 1,
pg: 1,
effect: 1,
mysql: 1,
mssql: 1,
cockroach: 1,
singlestore: 1,
} as const
export const GET_VERSION_FOR = {
mysql: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
pg: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
effect: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
mssql: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
cockroach: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
singlestore: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
sqlite: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
} as const
@@ -0,0 +1,139 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { expect, test } from "bun:test"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { eq, sql } from "drizzle-orm"
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { Effect } from "effect"
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
import { EffectDrizzleSqlite } from "../src"
const users = sqliteTable("users", {
id: integer().primaryKey({ autoIncrement: true }),
name: text().notNull(),
})
const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
Effect.runPromise(
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
)
const makeDb = Effect.gen(function* () {
const db = yield* EffectDrizzleSqlite.makeWithDefaults()
yield* db.run(sql`create table users (id integer primary key autoincrement, name text not null)`)
return db
})
const createMigrationsFolder = async () => {
const migrationsFolder = await mkdtemp(join(tmpdir(), "effect-drizzle-sqlite-"))
await mkdir(join(migrationsFolder, "20240101000000_create_migrated_users"), { recursive: true })
await Bun.write(
join(migrationsFolder, "20240101000000_create_migrated_users", "migration.sql"),
"create table migrated_users (id integer primary key autoincrement, name text not null);",
)
return migrationsFolder
}
test("selects rows through Effect-yieldable query builders", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.insert(users).values({ name: "Ada" })
expect(yield* db.select().from(users)).toEqual([{ id: 1, name: "Ada" }])
expect(yield* db.select({ id: users.id }).from(users).where(eq(users.name, "Ada")).get()).toEqual({ id: 1 })
}),
)
})
test("commits successful transactions", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.transaction((tx) => tx.insert(users).values({ name: "Grace" }), { behavior: "immediate" })
expect(yield* db.select().from(users)).toEqual([{ id: 1, name: "Grace" }])
}),
)
})
test("rolls back failed transactions", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db
.transaction((tx) =>
tx
.insert(users)
.values({ name: "Linus" })
.pipe(Effect.andThen(Effect.fail("boom"))),
)
.pipe(Effect.ignore)
expect(yield* db.select().from(users)).toEqual([])
}),
)
})
test("rolls back explicit transaction rollback", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db
.transaction((tx) =>
tx
.insert(users)
.values({ name: "Barbara" })
.pipe(Effect.andThen(Effect.fail(tx.rollback()))),
)
.pipe(Effect.ignore)
expect(yield* db.select().from(users)).toEqual([])
}),
)
})
test("supports returning and rejects empty update sets", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
const inserted = yield* db.insert(users).values({ name: "Ada" }).returning({ id: users.id, name: users.name })
expect(inserted).toEqual([{ id: 1, name: "Ada" }])
const updated = yield* db.update(users).set({ name: "Grace" }).where(eq(users.id, 1)).returning()
expect(updated).toEqual([{ id: 1, name: "Grace" }])
const deleted = yield* db.delete(users).where(eq(users.id, 1)).returning({ id: users.id })
expect(deleted).toEqual([{ id: 1 }])
expect(() => db.update(users).set({ name: undefined })).toThrow("No values to set")
}),
)
})
test("runs migrations once and records migration metadata", async () => {
const migrationsFolder = await createMigrationsFolder()
try {
await run(
Effect.gen(function* () {
const db = yield* EffectDrizzleSqlite.makeWithDefaults()
yield* EffectDrizzleSqlite.migrate(db, { migrationsFolder })
yield* EffectDrizzleSqlite.migrate(db, { migrationsFolder })
yield* db.run(sql`insert into migrated_users (name) values ('Margaret')`)
expect(yield* db.all<{ name: string }>(sql`select name from migrated_users`)).toEqual([{ name: "Margaret" }])
expect(yield* db.all<{ name: string | null }>(sql`select name from __drizzle_migrations`)).toEqual([
{ name: "20240101000000_create_migrated_users" },
])
}),
)
} finally {
await rm(migrationsFolder, { recursive: true, force: true })
}
})
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false,
"plugins": [
{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
}
]
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.15.5", "version": "1.15.6",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -323,6 +323,12 @@ export default function () {
current={activeMessage()} current={activeMessage()}
size="compact" size="compact"
onMessageSelect={setActiveMessage} onMessageSelect={setActiveMessage}
getLabel={(message) =>
data()
.part[message.id]?.find((part) => part.type === "text")
?.text.trim()
.split("\n")[0]
}
/> />
</Show> </Show>
<SessionTurn <SessionTurn
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.15.5" version = "1.15.6"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.6/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.6/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.6/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.6/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.5/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.15.6/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.15.5", "version": "1.15.6",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.15.5", "version": "1.15.6",
"name": "@opencode-ai/http-recorder", "name": "@opencode-ai/http-recorder",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+43 -37
View File
@@ -10,7 +10,7 @@
## Conventions ## Conventions
Per-type constructors live on the type's namespace, not as top-level re-exports. Use `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for the request-shaped call API: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.model`, `LLM.updateRequest`, `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.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.
## Tests ## Tests
@@ -21,13 +21,22 @@ Per-type constructors live on the type's namespace, not as top-level re-exports.
This package is an Effect Schema-first LLM core. The Schema classes in `src/schema/` are the canonical runtime data model. Convenience functions in `src/llm.ts` are thin constructors that return those same Schema class instances; they should improve callsites without creating a second model. This package is an Effect Schema-first LLM core. The Schema classes in `src/schema/` are the canonical runtime data model. Convenience functions in `src/llm.ts` are thin constructors that return those same Schema class instances; they should improve callsites without creating a second model.
Primary in-repo integration point:
- `packages/opencode/src/session/llm.ts` is the session-owned orchestration layer that decides whether a request uses AI SDK or this package's native route runtime.
- `packages/opencode/src/session/llm/native-request.ts` is the lowering adapter from opencode's session/AI SDK-shaped data into this package's `LLMRequest` model.
- `packages/opencode/src/session/llm/native-runtime.ts` is the execution adapter that calls `LLMClient.stream(...)` and bridges opencode tools into this package's tool runtime.
- `packages/opencode/src/session/llm/ai-sdk.ts` keeps the default AI SDK path compatible by converting AI SDK stream parts into this package's shared `LLMEvent`s.
Keep this package independent of session concerns. Session auth, permissions, plugins, telemetry headers, and runtime selection belong in `packages/opencode/src/session/llm.ts` and its local adapters.
### Request Flow ### Request Flow
The intended callsite is: The intended callsite is:
```ts ```ts
const request = LLM.request({ const request = LLM.request({
model: OpenAI.model("gpt-4o-mini", { apiKey }), model: OpenAI.configure({ apiKey }).responses("gpt-4o-mini"),
system: "You are concise.", system: "You are concise.",
prompt: "Say hello.", prompt: "Say hello.",
}) })
@@ -35,7 +44,7 @@ const request = LLM.request({
const response = yield * LLMClient.generate(request) const response = yield * LLMClient.generate(request)
``` ```
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` selects a registered route by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`. `LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`. Use `LLMClient.prepare<Body>(request)` to compile a request through the route pipeline without sending it — the optional `Body` type argument narrows `.body` to the route's native shape (e.g. `prepare<OpenAIChatBody>(...)` returns a `PreparedRequestOf<OpenAIChatBody>`). The runtime body is identical; the generic is a type-level assertion. Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`. Use `LLMClient.prepare<Body>(request)` to compile a request through the route pipeline without sending it — the optional `Body` type argument narrows `.body` to the route's native shape (e.g. `prepare<OpenAIChatBody>(...)` returns a `PreparedRequestOf<OpenAIChatBody>`). The runtime body is identical; the generic is a type-level assertion.
@@ -46,8 +55,8 @@ Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g.
A route is the registered, runnable composition of four orthogonal pieces: A route is the registered, runnable composition of four orthogonal pieces:
- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`. - **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`.
- **`Endpoint`** (`src/route/endpoint.ts`) — path construction. The host always lives on `model.baseURL`; the endpoint just supplies the path. `Endpoint.path("/chat/completions")` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`). - **`Endpoint`** (`src/route/endpoint.ts`) — URL construction. The host, path, and route query live on the endpoint. `Endpoint.path("/chat/completions", { baseURL })` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`).
- **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Routes read `model.apiKey` at request time via `Auth.bearer` (the default; sets `Authorization: Bearer <apiKey>`) or `Auth.apiKeyHeader(name)` for providers that use a custom header (Anthropic `x-api-key`, Gemini `x-goog-api-key`). Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result. - **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Provider facades configure credentials onto the route before model selection, usually via `Auth.bearer(apiKey)` or `Auth.header(name, apiKey)`. Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result.
- **`Framing`** (`src/route/framing.ts`) — bytes → frames. SSE (`Framing.sse`) is shared; Bedrock keeps its AWS event-stream framing as a typed `Framing<object>` value alongside its protocol. - **`Framing`** (`src/route/framing.ts`) — bytes → frames. SSE (`Framing.sse`) is shared; Bedrock keeps its AWS event-stream framing as a typed `Framing<object>` value alongside its protocol.
Compose them via `Route.make(...)`: Compose them via `Route.make(...)`:
@@ -57,55 +66,52 @@ export const route = Route.make({
id: "openai-chat", id: "openai-chat",
provider: "openai", provider: "openai",
protocol: OpenAIChat.protocol, protocol: OpenAIChat.protocol,
transport: HttpTransport.httpJson({ endpoint: Endpoint.path("/chat/completions", {
endpoint: Endpoint.path("/chat/completions"),
auth: Auth.bearer(),
framing: Framing.sse,
encodeBody,
}),
defaults: {
baseURL: "https://api.openai.com/v1", baseURL: "https://api.openai.com/v1",
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }), }),
}, auth: Auth.bearer(),
framing: Framing.sse,
}) })
``` ```
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.
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.
When a provider ships a non-HTTP transport (OpenAI's WebSocket Responses backend, hypothetical bidirectional streaming APIs), the seam is `Transport``WebSocketTransport.json(...)` constructs a transport whose `prepare` builds a WebSocket URL and message and whose `frames` yields decoded text from the socket. Same protocol, different transport. When a provider ships a non-HTTP transport (OpenAI's WebSocket Responses backend, hypothetical bidirectional streaming APIs), the seam is `Transport``WebSocketTransport.jsonTransport.with(...)` constructs an IO template whose `prepare` receives the route endpoint/auth at compile time, builds a WebSocket URL and message, and whose `frames` yields decoded text from the socket. Same protocol and endpoint source, different transport.
### URL Construction ### URL Construction
`model.baseURL` is required; `Endpoint` only carries the path. Each protocol's `Route.make` includes a canonical URL in `defaults.baseURL` (e.g. `https://api.openai.com/v1`); provider helpers can override by passing `baseURL` in their input. Routes that have no canonical URL (OpenAI-compatible Chat, GitHub Copilot) set `baseURL: string` (required) on their input type so TypeScript catches a missing host at the call site. `Endpoint` owns `{ baseURL, path, query }`. Each protocol route includes a canonical endpoint when the provider has one (e.g. `https://api.openai.com/v1`); provider helpers override endpoint fields by configuring the route before selecting a model. Routes that have no canonical URL (OpenAI-compatible Chat, GitHub Copilot) require configuration before execution.
For providers where the URL is derived from typed inputs (Azure resource name, Bedrock region), the provider helper computes `baseURL` at model construction time. Use `AtLeastOne<T>` from `route/auth-options.ts` for inputs that accept either of two derivation paths (Azure: `resourceName` or `baseURL`). For providers where the URL is derived from typed inputs (Azure resource name, Bedrock region), the provider helper configures the route endpoint before calling `.model(...)`. Use `AtLeastOne<T>` from `route/auth-options.ts` for inputs that accept either of two derivation paths (Azure: `resourceName` or `baseURL`).
### Provider Definitions ### Provider Facades
Provider-facing APIs are defined with `Provider.make(...)` from `src/provider.ts`: Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id:
```ts ```ts
export const provider = Provider.make({ const openai = OpenAI.configure({ apiKey, baseURL })
id: ProviderID.make("openai"), const model = openai.responses("gpt-4o-mini")
model: responses,
apis: { responses, chat },
})
export const model = provider.model const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" })
export const apis = provider.apis const deployment = azure.responses("my-deployment")
const gateway = CloudflareAIGateway.configure({ accountId, gatewayId, gatewayApiKey, apiKey })
const proxied = gateway.model("openai/gpt-4o-mini")
``` ```
Keep provider definitions small and explicit: Keep provider facades small and explicit:
- Use only `id`, `model`, and optional `apis` in `Provider.make(...)`.
- Use branded `ProviderID.make(...)` and `ModelID.make(...)` where ids are constructed directly. - Use branded `ProviderID.make(...)` and `ModelID.make(...)` where ids are constructed directly.
- Use `model` for the default API path and `apis` for named provider-native alternatives such as OpenAI `responses` versus `chat`. - Use `model` for the default API path and named methods for provider-native alternatives such as OpenAI `responses`, `responsesWebSocket`, and `chat`.
- Do not add author-facing `kind`, `version`, or `routes` fields. - Put provider-specific setup on `.configure(...)`; do not add `model(id, overrides)` as a duplicate construction path.
- Export lower-level `routes` arrays separately only when advanced internal wiring needs them. - Export lower-level `routes` arrays separately only when advanced internal wiring needs them.
- Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`. - Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`.
- Resolve `apiKey``Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash). - Resolve `apiKey``Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash).
- Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`.
Built-in providers are namespace modules from `src/providers/index.ts`, so aliases like `OpenAI.model(...)`, `OpenAI.responses(...)`, and `OpenAI.apis.chat(...)` are fine. External provider packages should default-export the `Provider.make(...)` result and may add named aliases if useful. `Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
### Folder layout ### Folder layout
@@ -113,7 +119,7 @@ Built-in providers are namespace modules from `src/providers/index.ts`, so alias
packages/llm/src/ packages/llm/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, Capabilities, Limits, ModelRef options.ts Generation/Provider/Http options, Limits, Model, cache policy
messages.ts content parts, Message, ToolDefinition, LLMRequest messages.ts content parts, Message, ToolDefinition, LLMRequest
events.ts Usage, individual events, LLMEvent, PreparedRequest, LLMResponse events.ts Usage, individual events, LLMEvent, PreparedRequest, LLMResponse
errors.ts error reasons, LLMError, ToolFailure errors.ts error reasons, LLMError, ToolFailure
@@ -145,12 +151,12 @@ packages/llm/src/
providers/ providers/
openai-compatible.ts generic compatible helper + family model helpers openai-compatible.ts generic compatible helper + family model helpers
openai-compatible-profile.ts family defaults (deepseek, togetherai, ...) openai-compatible-profile.ts family defaults (deepseek, togetherai, ...)
azure.ts / amazon-bedrock.ts / github-copilot.ts / google.ts / xai.ts / openai.ts / anthropic.ts / openrouter.ts azure.ts / amazon-bedrock.ts / cloudflare.ts / github-copilot.ts / google.ts / xai.ts / openai.ts / anthropic.ts / openrouter.ts
tool.ts typed tool() helper tool.ts typed tool() helper
tool-runtime.ts implementation helpers for LLMClient tool execution tool-runtime.ts implementation helpers for LLMClient tool execution
``` ```
The dependency arrow points down: `providers/*.ts` files import `protocols`, `endpoint`, `auth`, and `framing`; protocols do not import provider metadata. Lower-level modules know nothing about specific providers. The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata.
### Shared protocol helpers ### Shared protocol helpers
@@ -245,14 +251,14 @@ Use this order for every protocol module:
5. Request body construction (`fromRequest`) 5. Request body construction (`fromRequest`)
6. Stream parsing (`step` and per-event handlers) 6. Stream parsing (`step` and per-event handlers)
7. Protocol and route 7. Protocol and route
8. Model helper 8. Protocol route export
### Rules ### Rules
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`. - Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`. - Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `request-finish` (or `provider-error`) when a `terminal` event arrives. If a provider splits reason and usage across events, merge them in parser state before flushing. - Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing.
- Emit exactly one terminal `request-finish` event for a completed response. Use `stream.terminal` to signal the run is over and have `step` emit the final event. - Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
- Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly. - Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly.
- Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names. - Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names.
- Prefer dispatched per-event handlers (`onMessageStart`, `onContentBlockDelta`, ...) called from a small top-level `step` switch over a long if-chain. The dispatcher keeps the event surface visible at a glance. - Prefer dispatched per-event handlers (`onMessageStart`, `onContentBlockDelta`, ...) called from a small top-level `step` switch over a long if-chain. The dispatcher keeps the event surface visible at a glance.
+15 -13
View File
@@ -7,7 +7,7 @@ import { Effect } from "effect"
import { LLM, LLMClient } from "@opencode-ai/llm" import { LLM, LLMClient } from "@opencode-ai/llm"
import { OpenAI } from "@opencode-ai/llm/providers" import { OpenAI } from "@opencode-ai/llm/providers"
const model = OpenAI.model("gpt-4o-mini", { apiKey: process.env.OPENAI_API_KEY }) const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")
const request = LLM.request({ const request = LLM.request({
model, model,
@@ -28,10 +28,10 @@ Run `LLMClient.stream(request)` instead of `generate` when you want incremental
- **`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.
- **`LLM.user(...)` / `LLM.assistant(...)` / `LLM.toolMessage(...)`** — message constructors. - **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
- **`LLM.toolCall(...)` / `LLM.toolResult(...)` / `LLM.toolDefinition(...)`** — tool-related parts. - **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing. - **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
- **`LLMEvent.is.*`** — typed guards (`is.text`, `is.toolCall`, `is.requestFinish`, …) for filtering streams. - **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
## Caching ## Caching
@@ -92,17 +92,19 @@ Normalized cache usage is read back into `response.usage.cacheReadInputTokens` a
## Providers ## Providers
Each provider exports a `model(...)` helper that records identity, protocol, capabilities, auth, and defaults. Provider facades configure endpoint/auth/deployment details first, then expose model selectors that take only a model or deployment id. The selected model carries the executable route value used at runtime.
```ts ```ts
import { Anthropic } from "@opencode-ai/llm/providers" import { OpenAI, CloudflareAIGateway } from "@opencode-ai/llm/providers"
const model = Anthropic.model("claude-sonnet-4-6", { const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini")
apiKey: process.env.ANTHROPIC_API_KEY, const gateway = CloudflareAIGateway.configure({
}) accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
gatewayApiKey: process.env.CLOUDFLARE_API_TOKEN,
}).model("workers-ai/@cf/meta/llama-3.1-8b-instruct")
``` ```
Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc. Included providers: OpenAI, Anthropic, Google (Gemini), Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible helpers for DeepSeek, Cerebras, Groq, Fireworks, Together, etc.
## Provider options & HTTP overlays ## Provider options & HTTP overlays
@@ -112,15 +114,15 @@ Three escape hatches in order of stability:
2. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `promptCacheKey`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing). 2. **`providerOptions: { <provider>: {...} }`** — typed-at-the-facade provider-specific knobs (OpenAI `promptCacheKey`, Anthropic `thinking`, Gemini `thinkingConfig`, OpenRouter routing).
3. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist. 3. **`http: { body, headers, query }`** — last-resort serializable overlays merged into the final HTTP request. Reach for this only when a stable typed path doesn't yet exist.
Model-level defaults are overridden by request-level values for each axis. Route/provider defaults are overridden by request-level values for each axis.
## Routes ## Routes
Adding a new model or deployment is usually 515 lines using `Route.make({ protocol, transport, ... })`. The four orthogonal pieces are protocol (body construction + stream parsing), transport (endpoint + auth + framing + encoding), defaults, and capabilities. See `AGENTS.md` for the architectural detail. Adding a new model or deployment is usually 5-15 lines using `Route.make({ protocol, endpoint, auth, framing, ... })`. The route owns endpoint/auth/framing and the protocol owns body construction plus stream parsing. Transports are reusable IO templates that receive route endpoint/auth at compile time. Capability/catalog metadata lives outside this low-level package; unsupported request shapes fail during protocol lowering. See `AGENTS.md` for the architectural detail.
## Effect ## Effect
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` (the default registers every shipped route) for runtime dispatch. The example at `example/tutorial.ts` is a runnable walkthrough. This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for runtime dispatch and import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
## See also ## See also
+591
View File
@@ -0,0 +1,591 @@
# LLM Call Site Sketches
Scratchpad for examples first, abstractions second. Current direction: routes
execute, provider facades organize configured route sets, and models carry route
values directly.
## Conversation Summary
Kit and Aidan want provider-specific LLM behavior to move out of opencode's AI
SDK transform path and into `packages/llm` where possible. The goal is not a big
generic transform layer; the goal is small composable route definitions backed by
recorded golden tests.
Things to keep testing against:
- Cache placement: `cache: "auto"`, manual cache breakpoints, provider cache usage.
- Images: golden image tests for providers/protocols that claim image support.
- Reasoning: canonical reasoning parts/events versus provider-native knobs.
- Auth: bearer, custom headers, multiple credentials, query auth, SigV4, OAuth, no auth.
- OpenAI-compatible providers: DeepSeek, Together, Groq, Alibaba/DashScope, custom routers.
- Provider switching: stale signatures, encrypted reasoning, provider metadata, incompatible parts.
- Error quality: typed errors instead of generic SDK/server failures.
## Final Guide: Routes Execute, Providers Organize
Do not introduce a first-class `Deployment` abstraction unless it gains real
semantics. Provider facades are ergonomic configured route groups, not execution
registries. The executable/composable thing is still a route. Do not make route
construction publish to a global registry; models should carry their route value
directly.
Keep durable identity separate from runtime capability:
- Durable identity is small serializable data like `{ providerID, modelID }` for
config, sessions, logs, and catalogs.
- Runtime capability is a `Model` with a route value, protocol, transport, auth,
and defaults. It is allowed to contain functions and schemas.
- If persisted identity needs to become executable, resolve it through an app
boundary first. Do not make `LLMRequest` recover behavior from a global route
side table.
Keep unconfigured behavior values as values, not factories. A transport like
`HttpTransport.sseJson` should be a reusable immutable value. Use a function only
when the caller supplies options or when construction needs fresh state.
Use constants to remove repetition before inventing abstractions. Provider ids
are branded once per provider facade and reused across routes; a plain exported
object is enough for the provider-facing API unless a helper earns its keep by
removing repeated route projection.
Expose default configured provider instances, and put provider-specific setup on
`.configure(...)`. Model selectors stay pure: `model(id)`, `responses(id)`,
`chat(id)`, etc. Endpoint/auth/resource/api-version configuration happens before
model selection, not as a second argument to model selection.
Use provider/product facades consistently:
- One coherent provider/product config surface gets one top-level facade.
- APIs/model kinds that share that config are methods on the facade.
- Different products with different required config get separate top-level
facades, not a shared namespace with unrelated children.
- Default facades are exposed only when concrete defaults or lazy env/credential
defaults make the facade valid.
Examples:
```ts
OpenAI.responses("gpt-4o")
OpenAI.chat("gpt-4o")
OpenAI.responsesWebSocket("gpt-4o")
Azure.configure({ resourceName, apiKey }).responses("my-deployment")
AmazonBedrock.configure({ region, credentials }).model("anthropic.claude-3-5-sonnet-20241022-v2:0")
CloudflareAIGateway.configure({ accountId, gatewayId, gatewayApiKey, apiKey }).model("openai/gpt-4o")
CloudflareWorkersAI.configure({ accountId, apiKey }).model("@cf/meta/llama-3.1-8b-instruct")
OpenAICompatible.configure({
provider: "custom",
baseURL: "https://custom.example/v1",
auth: Auth.bearer(apiKey),
}).model("custom-model")
```
Standardize the provider facade contract before abstracting construction. A
plain object is enough at first; add a helper only if repeated route projection
starts hiding the real provider-specific config.
`Route.with(...)` patch semantics should be boring and explicit:
- Omitted fields inherit from the original route.
- `endpoint` patches merge with the existing endpoint, so overriding `baseURL`
keeps the existing `path`.
- `endpoint.query` merges by default; later values win.
- `auth` replaces.
- `headers` merge by default; undefined values are omitted.
- `id` is optional in patches. Route ids are diagnostic/provider API labels, not
global runtime registry keys.
1. **Route**
- route id
- provider id
- protocol
- body schema
- body builder
- stream event schema
- parser/state machine
- transport
- method / IO shape
- framing
- request preparation
- constants when unconfigured; functions only when configured
- endpoint
- base URL
- static path
- body/model-derived path
- query params
- auth
- bearer
- custom header
- multiple credentials
- SigV4
- none
- defaults
- headers
- generation defaults
- provider options
- limits
2. **Provider Facade**
- default configured provider instance
- provider-specific `.configure(...)`
- plain object/function facade over one or more routes
- top-level export only when it represents one coherent config surface
- no passive `Provider.make(...)` wrapper unless it gains runtime behavior
3. **Model Selector**
- route/provider-owned selector
- accepts model id only
- returns executable models
- does not accept endpoint/auth/deployment overrides
4. **Model**
- model id
- route value
- provider id
- configured route value at selection time
5. **LLM Request**
- model
- messages/tools
- generation/cache/reasoning/response-format options
- request-level HTTP overlays for per-request headers/query/body additions,
not provider endpoint/auth reconfiguration
6. **Compile**
- read route from model
- merge route defaults and request overrides
- build final URL from route endpoint
- apply auth from the configured route
- build body with protocol
- execute with transport and parse with protocol
## Provider Facade Shape
The provider abstraction is a facade over configured routes, not the runtime
execution mechanism:
```ts
type ProviderFacade<APIs, Config> = {
readonly id: ProviderID
readonly model: (id: string) => Model
readonly configure: (input?: Config) => ProviderFacade<APIs, Config>
} & APIs
```
Manual construction is fine and should be the default until duplication earns a
helper:
```ts
export const OpenAI = {
id: openAIProvider,
model: openAIResponses.model,
responses: openAIResponses.model,
chat: openAIChat.model,
configure: configureOpenAI,
} satisfies ProviderFacade<
{
responses: (id: string) => Model
chat: (id: string) => Model
},
OpenAIConfig
>
```
If several providers repeat the same projection from route values to model
methods, the helper can stay deliberately tiny:
```ts
const configureOpenAI = (input: OpenAIConfig = {}) =>
Provider.define({
id: openAIProvider,
routes: {
responses: openAIResponses.with(openAIConfig(input)),
chat: openAIChat.with(openAIConfig(input)),
},
default: "responses",
configure: configureOpenAI,
})
export const OpenAI = configureOpenAI()
```
`Provider.define(...)` would only project route methods and preserve types:
```ts
OpenAI.model("gpt-4o")
OpenAI.responses("gpt-4o")
OpenAI.chat("gpt-4o")
OpenAI.configure({ apiKey }).responses("gpt-4o")
```
It must not register routes, select routes dynamically, or participate in
execution. Execution still reads the route value carried by the model.
## Ideal Call Sites
Define concrete routes for a native provider, then project them through a
provider facade:
```ts
const openAIProvider = ProviderID.make("openai")
const openAIResponses = Route.make({
id: "openai-responses",
provider: openAIProvider,
protocol: OpenAIResponses.protocol,
transport: HttpTransport.sseJson,
endpoint: {
baseURL: "https://api.openai.com/v1",
path: "/responses",
},
auth: Auth.envBearer("OPENAI_API_KEY"),
})
const openAIChat = Route.make({
id: "openai-chat",
provider: openAIProvider,
protocol: OpenAIChat.protocol,
transport: HttpTransport.sseJson,
endpoint: {
baseURL: "https://api.openai.com/v1",
path: "/chat/completions",
},
auth: Auth.envBearer("OPENAI_API_KEY"),
})
const openAIResponsesWebSocket = openAIResponses.with({
id: "openai-responses-websocket",
transport: WebSocketTransport.json,
})
const openAIConfig = (input: OpenAIConfig) => ({
endpoint: input.endpoint,
auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined),
headers: {
"OpenAI-Organization": input.organization,
"OpenAI-Project": input.project,
},
})
const configureOpenAI = (input: OpenAIConfig = {}) => {
const responses = openAIResponses.with(openAIConfig(input))
const responsesWebSocket = openAIResponsesWebSocket.with(openAIConfig(input))
const chat = openAIChat.with(openAIConfig(input))
return {
id: openAIProvider,
responses: responses.model,
responsesWebSocket: responsesWebSocket.model,
chat: chat.model,
model: responses.model,
configure: configureOpenAI,
}
}
export const OpenAI = configureOpenAI()
```
Specialize it functionally for concrete providers:
```ts
const deepSeekProvider = ProviderID.make("deepseek")
const deepseekChat = openAIChat.with({
id: "deepseek-chat",
provider: deepSeekProvider,
endpoint: {
baseURL: "https://api.deepseek.com/v1",
},
auth: Auth.envBearer("DEEPSEEK_API_KEY"),
})
const configureDeepSeek = (input: OpenAICompatibleConfig = {}) => {
const route = deepseekChat.with({
endpoint: input.endpoint,
auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined),
})
return {
id: deepSeekProvider,
model: route.model,
configure: configureDeepSeek,
}
}
export const DeepSeek = {
id: deepSeekProvider,
model: deepseekChat.model,
configure: configureDeepSeek,
}
```
Provider-specific configuration happens before model selection:
```ts
const deepseek = DeepSeek.configure({
endpoint: {
baseURL: "https://proxy.example.com/v1",
},
auth: Auth.bearer(apiKey),
})
const model = deepseek.model("deepseek-chat")
```
Final request call site stays boring:
```ts
const response =
yield *
LLM.generate(
LLM.request({
model: DeepSeek.model("deepseek-chat"),
prompt: "Hello.",
}),
)
```
HTTP versus WebSocket is represented as named route selectors, not as model or
request overrides. Same protocol, different transport, different route:
```ts
OpenAI.responses("gpt-4o")
OpenAI.responsesWebSocket("gpt-4o")
```
The client should not require a different public layer just because a selected
route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime
capabilities available; routes that do not need WebSocket simply never touch it.
If a WebSocket route is selected in an environment without WebSocket support,
fail with a typed transport configuration error.
Azure is a route specialization with auth/path/default changes plus input
mapping. The public API configures the Azure resource once, then selects
deployment ids with pure model selectors:
```ts
const azureProvider = ProviderID.make("azure")
const azureResponses = openAIResponses.with({
id: "azure-openai-responses",
provider: azureProvider,
auth: Auth.envHeader("api-key", "AZURE_OPENAI_API_KEY"),
})
const configureAzure = (input: AzureConfig = {}) => {
const route = azureResponses.with({
endpoint: {
baseURL:
input.baseURL ??
Endpoint.envBaseURL(
"AZURE_RESOURCE_NAME",
(resourceName) => `https://${resourceName}.openai.azure.com/openai/v1`,
),
query: { "api-version": input.apiVersion ?? "v1" },
},
auth: input.apiKey ? Auth.header("api-key", input.apiKey) : Auth.envHeader("api-key", "AZURE_OPENAI_API_KEY"),
})
return {
id: azureProvider,
model: route.model,
responses: route.model,
configure: configureAzure,
}
}
export const Azure = configureAzure()
const azure = Azure.configure({
resourceName: "my-resource",
apiVersion: "v1",
})
const model = azure.responses("my-deployment")
```
Default provider facades are only valid when required configuration has a lazy
default source. `Azure.responses("my-deployment")` can be valid if endpoint
resolution reads `AZURE_RESOURCE_NAME` lazily and fails with a typed
configuration error when missing. If a provider has no sensible lazy default,
do not expose a default model selector; expose only a configured entrypoint.
Cloudflare AI Gateway and Workers AI are separate product facades because their
configuration surfaces differ. Do not make a root `Cloudflare.configure(...)`
pretend there is one coherent Cloudflare provider configuration:
```ts
const cloudflareProvider = ProviderID.make("cloudflare-ai-gateway")
const cloudflareOpenAIChat = openAIChat.with({
id: "cloudflare-ai-gateway-openai-chat",
provider: cloudflareProvider,
auth: Auth.bearerHeader("cf-aig-authorization").andThen(Auth.bearer()),
})
const configureCloudflareAIGateway = (input: CloudflareAIGatewayConfig) => {
const route = cloudflareOpenAIChat.with({
endpoint: {
baseURL: `https://gateway.ai.cloudflare.com/v1/${input.accountId}/${input.gatewayId}/openai`,
},
auth: Auth.bearerHeader("cf-aig-authorization", input.gatewayApiKey).andThen(Auth.bearer(input.apiKey)),
})
return {
id: cloudflareProvider,
model: (modelID: string) => route.model({ id: modelID }),
configure: configureCloudflareAIGateway,
}
}
export const CloudflareAIGateway = {
id: cloudflareProvider,
configure: configureCloudflareAIGateway,
}
const gateway = CloudflareAIGateway.configure({
accountId: "account",
gatewayId: "gateway",
gatewayApiKey,
apiKey,
})
const model = gateway.model("openai/gpt-4o")
```
If a Cloudflare product gains a full lazy env default, it can expose a direct
selector too. Until then, omitting `CloudflareAIGateway.model(...)` makes missing
account/gateway configuration unrepresentable.
opencode's dynamic runtime should construct executable models at its app
boundary instead of exposing a giant unstructured public model constructor or a
generic dynamic resolver:
```ts
const model =
providerID === "azure"
? Azure.configure(resolvedAzureConfig).responses(apiModelID)
: endpoint.websocket
? OpenAI.responsesWebSocket(apiModelID)
: OpenAI.responses(apiModelID)
```
That boundary can branch on durable config/catalog metadata and call typed
provider APIs directly. Transport selection belongs there too: map metadata like
`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`; otherwise use
the normal `OpenAI.responses(apiModelID)` route. The client runtime only executes
the route carried by the model.
## Competitive Shape
This follows the strongest parts of adjacent libraries:
- AI SDK: configured provider instances expose provider-specific model methods.
- Effect AI: executable models carry provider requirements and can be resolved by
an app boundary.
- LiteLLM/opencode config: dynamic `providerID/modelID` branching belongs at the
app boundary, not in the typed public provider API or a global runtime
resolver.
- LangChain/LlamaIndex: constructor-style config plus model id is convenient,
but we avoid making model selection also configure endpoint/auth.
The chosen split is:
```txt
Route = execution mechanics
Provider facade = configured route group
Model = selected executable model carrying route value
App boundary = explicit durable-config -> typed-provider call
```
## What This Removes
- No `Provider.make(...)` as a core abstraction.
- No `Provider.make(...)` wrapper just to bind an id to model functions. Use a
branded provider id constant and a plain exported provider facade.
- No `Deployment.define(...)` unless future examples force it.
- No global route registry as the normal execution path.
- No import side effects required before a model can execute.
- No duplicate `provider.id` object when selected models already carry provider
id.
- No `model(id, overrides)` escape hatch. Model selection takes the model id;
endpoint/auth/deployment customization happens by configuring the route first.
- No transport override on model/request. HTTP SSE versus WebSocket is a named
route selector such as `responses` versus `responsesWebSocket`.
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
client layer with the available transport capabilities.
- No executable `ModelRef`. The executable handle is `Model`; durable model
identity stays separate and cannot execute on its own.
## Implementation Todo
- [x] Replace the current executable `ModelRef` with `Model`.
- [x] Change `Model.route` to carry a route value, not a `RouteID` string.
- [ ] Keep a separate durable model identity type for persisted/session/catalog
data, likely `{ providerID, modelID }`, and make it clear that it cannot
execute without resolver context.
- [x] Change route model selectors so `route.model(id)` returns an executable
model with the route value attached, not a globally registered route id.
- [x] Remove the standalone `Route.model(route, defaults, mapInput)` helper;
configured route instances own model selection.
- [x] Remove endpoint/auth escape hatches from route model selection; callers must
configure endpoint/auth through `route.with(...)` or provider facades before
calling `.model(...)`.
- [x] Remove request-shaping defaults from `Model`; selected models now carry only
id, provider, and configured route while defaults live on routes or requests.
- [x] Rework `LLMClient.prepare` / `stream` / `generate` to read
`request.model.route` directly instead of calling `registeredRoute(...)`.
- [x] Remove `Route.make(...)` global registration from the normal execution
path; keep route ids only as diagnostics/provider API labels.
- [x] Model endpoint as `{ baseURL, path, query }` on routes, then remove the
current split where host/query live on the model and path lives in route
transport setup.
- [x] Define `Route.with(...)` with explicit patch semantics for endpoint merge,
query merge, header merge, auth replacement, and optional diagnostic id.
- [x] Make unconfigured transports reusable constants such as
`HttpTransport.sseJson`; keep transport functions only for configured/fresh
state construction.
- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer`
exposes available transport capabilities and selected routes fail with typed
transport config errors when a required capability is missing.
- [x] Convert OpenAI provider APIs to provider-facade shape:
`OpenAI.configure(config).responses(id)`, `.chat(id)`, and
`.responsesWebSocket(id)`.
- [x] Convert Azure to a configured facade where resource/base URL/api version
setup happens before selecting deployment ids.
- [x] Split Cloudflare products into separate facades such as
`CloudflareAIGateway` and `CloudflareWorkersAI`; do not expose a shared root
config surface unless one product actually exists.
- [x] Migrate remaining built-in provider facades one at a time so configuration
happens before model selection and selectors accept only ids:
xAI, GitHub Copilot, OpenRouter, OpenAI-compatible families, Anthropic,
Google/Gemini, and Amazon Bedrock now use configured facades such as
`Provider.configure(options).model(id)` with named selectors where needed.
- [ ] Decide whether a tiny `Provider.define(...)` helper is warranted after two
or three provider conversions; start with plain objects if duplication is not
yet painful.
- [x] Update `packages/opencode/src/session/llm/native-request.ts` to construct
executable models at the session boundary with explicit provider facade
calls, mapping catalog metadata such as `endpoint.websocket` to the correct
named route selector.
- [ ] Update tests so direct route/provider tests assert route values are carried
by executable models, and opencode/native tests assert boundary-based route
selection.
- [ ] Remove compatibility exports or stale docs only after internal call sites
are migrated; do not keep duplicate constructor paths without an external
compatibility need.
## Open Questions
- Default facades with required setup: should providers like Azure and Bedrock
expose default model selectors only when all required setup has lazy env or
credential-chain defaults? If not, omit the default selector so missing config
is impossible at the type/API level.
- Lazy endpoint/auth values: should `Endpoint.envBaseURL(...)` and env-backed
auth produce typed configuration/authentication errors at compile/prepare time
or only when executing the transport?
- `Route.with(...)` clearing semantics: endpoint/query/header patches merge by
default, but what is the explicit way to remove an inherited value?
- Provider facade helper: keep plain objects until duplication hurts, or add a
tiny `Provider.define(...)` immediately to enforce shape and method projection?
- Auth shape: should auth stay as today's composable `Auth`, or split into an
auth placement/strategy and credential sources?
- Naming: is `baseURL` still the right endpoint field name, or should it be
`origin` / `urlPrefix` to clarify that route `path` is appended?
+20 -16
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect" import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
import { LLM, LLMClient, Provider, ProviderID, Tool, type ProviderModelOptions } from "@opencode-ai/llm" import { LLM, LLMClient, ProviderID, Tool } from "@opencode-ai/llm"
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode-ai/llm/route" import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
import { OpenAI } from "@opencode-ai/llm/providers" import { OpenAI } from "@opencode-ai/llm/providers"
/** /**
@@ -18,18 +18,18 @@ const apiKey = Config.redacted("OPENAI_API_KEY")
// 1. Pick a model. The provider helper records provider identity, protocol // 1. Pick a model. The provider helper records provider identity, protocol
// choice, capabilities, deployment options, authentication, and defaults. // choice, capabilities, deployment options, authentication, and defaults.
const model = OpenAI.model("gpt-4o-mini", { const model = OpenAI.configure({
apiKey, apiKey,
generation: { maxTokens: 160 }, generation: { maxTokens: 160 },
providerOptions: { providerOptions: {
openai: { store: false }, openai: { store: false },
}, },
}) }).model("gpt-4o-mini")
// 2. Build a provider-neutral request. This is useful when reusing one request // 2. Build a provider-neutral request. This is useful when reusing one request
// across generate and stream examples. // across generate and stream examples.
// //
// Options can live on both the model and the request: // Options can live on both the configured route/provider facade and the request:
// //
// - `generation`: common controls such as max tokens, temperature, topP/topK, // - `generation`: common controls such as max tokens, temperature, topP/topK,
// penalties, seed, and stop sequences. // penalties, seed, and stop sequences.
@@ -39,7 +39,7 @@ const model = OpenAI.model("gpt-4o-mini", {
// - `http`: last-resort serializable overlays for final request body, headers, // - `http`: last-resort serializable overlays for final request body, headers,
// and query params. Prefer typed `providerOptions` when a field is stable. // and query params. Prefer typed `providerOptions` when a field is stable.
// //
// Model options are defaults. Request options override them for this call. // Route/provider options are defaults. Request options override them for this call.
const request = LLM.request({ const request = LLM.request({
model, model,
system: "You are concise and practical.", system: "You are concise and practical.",
@@ -193,19 +193,22 @@ const FakeProtocol = Protocol.make<FakeBody, string, string, void>({
// axes that the protocol deliberately does not know: URL, auth, and framing. // axes that the protocol deliberately does not know: URL, auth, and framing.
const FakeAdapter = Route.make({ const FakeAdapter = Route.make({
id: "fake-echo", id: "fake-echo",
provider: "fake-echo",
protocol: FakeProtocol, protocol: FakeProtocol,
endpoint: Endpoint.path("/v1/echo"), endpoint: Endpoint.path("/v1/echo", { baseURL: "https://fake.local" }),
auth: Auth.passthrough, auth: Auth.passthrough,
framing: Framing.sse, framing: Framing.sse,
}) })
// A provider module exports a Provider definition. The default `model` helper // A provider module exports a configured facade. Configuration happens before
// sets provider identity, protocol id, and the route id resolved by the registry. // model selection; model selectors accept ids only.
const fakeEchoModel = Route.model(FakeAdapter, { provider: "fake-echo", baseURL: "https://fake.local" }) const FakeEcho = {
const FakeEcho = Provider.make({
id: ProviderID.make("fake-echo"), id: ProviderID.make("fake-echo"),
model: (id: string, options: ProviderModelOptions = {}) => fakeEchoModel({ id, ...options }), configure: () => ({
}) id: ProviderID.make("fake-echo"),
model: (id: string) => FakeAdapter.model({ id }),
}),
}
// `LLMClient.prepare` is the lower-level inspection hook: it compiles through // `LLMClient.prepare` is the lower-level inspection hook: it compiles through
// body conversion, validation, endpoint, auth, and HTTP construction without // body conversion, validation, endpoint, auth, and HTTP construction without
@@ -213,7 +216,7 @@ const FakeEcho = Provider.make({
const inspectFakeProvider = Effect.gen(function* () { const inspectFakeProvider = Effect.gen(function* () {
const prepared = yield* LLMClient.prepare( const prepared = yield* LLMClient.prepare(
LLM.request({ LLM.request({
model: FakeEcho.model("tiny-echo"), model: FakeEcho.configure().model("tiny-echo"),
prompt: "Show me the provider pipeline.", prompt: "Show me the provider pipeline.",
}), }),
) )
@@ -227,7 +230,8 @@ const inspectFakeProvider = Effect.gen(function* () {
// enabled at a time so the tutorial can demonstrate generate, prepare, stream, // enabled at a time so the tutorial can demonstrate generate, prepare, stream,
// or tool-loop behavior without spending tokens on every example. // or tool-loop behavior without spending tokens on every example.
const requestExecutorLayer = RequestExecutor.defaultLayer const requestExecutorLayer = RequestExecutor.defaultLayer
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer)) const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
const program = Effect.gen(function* () { const program = Effect.gen(function* () {
// yield* generateOnce // yield* generateOnce
@@ -237,6 +241,6 @@ const program = Effect.gen(function* () {
// yield* generateStructuredObject // yield* generateStructuredObject
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object)))) // yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
yield* streamWithTools yield* streamWithTools
}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer))) }).pipe(Effect.provide(Layer.mergeAll(llmDeps, llmClientLayer)))
Effect.runPromise(program) Effect.runPromise(program)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.15.5", "version": "1.15.6",
"name": "@opencode-ai/llm", "name": "@opencode-ai/llm",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -97,7 +97,7 @@ const markMessages = (
} }
export const applyCachePolicy = (request: LLMRequest): LLMRequest => { export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route)) return request if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
const policy = resolve(request.cache) const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request if (!policy.tools && !policy.system && !policy.messages) return request
+1 -2
View File
@@ -1,4 +1,4 @@
export { LLMClient, modelLimits, modelRef } from "./route/client" export { LLMClient } from "./route/client"
export { Auth } from "./route/auth" export { Auth } from "./route/auth"
export { Provider } from "./provider" export { Provider } from "./provider"
export type { export type {
@@ -6,7 +6,6 @@ export type {
RouteRoutedModelInput, RouteRoutedModelInput,
Interface as LLMClientShape, Interface as LLMClientShape,
Service as LLMClientService, Service as LLMClientService,
ModelRefInput,
} from "./route/client" } from "./route/client"
export * from "./schema" export * from "./schema"
export { Tool, ToolFailure, toDefinitions, tool } from "./tool" export { Tool, ToolFailure, toDefinitions, tool } from "./tool"
+3 -6
View File
@@ -1,5 +1,5 @@
import { Effect, JsonSchema, Schema } from "effect" import { Effect, JsonSchema, Schema } from "effect"
import { LLMClient, modelLimits, modelRef, type ModelRefInput } from "./route/client" import { LLMClient } from "./route/client"
import { import {
GenerationOptions, GenerationOptions,
HttpOptions, HttpOptions,
@@ -9,6 +9,7 @@ import {
LLMRequest, LLMRequest,
LLMResponse, LLMResponse,
Message, Message,
type ModelInput as SchemaModelInput,
SystemPart, SystemPart,
ToolChoice, ToolChoice,
ToolDefinition, ToolDefinition,
@@ -18,7 +19,7 @@ import {
} from "./schema" } from "./schema"
import { make as makeTool, type ToolSchema } from "./tool" import { make as makeTool, type ToolSchema } from "./tool"
export type ModelInput = ModelRefInput export type ModelInput = SchemaModelInput
export type MessageInput = Message.Input export type MessageInput = Message.Input
@@ -42,10 +43,6 @@ export type RequestInput = Omit<
readonly http?: HttpOptions.Input readonly http?: HttpOptions.Input
} }
export const limits = modelLimits
export const model = modelRef
export const generate = LLMClient.generate export const generate = LLMClient.generate
export const stream = LLMClient.stream export const stream = LLMClient.stream
@@ -386,7 +386,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
tools, tools,
tool_choice: toolChoice, tool_choice: toolChoice,
stream: true as const, stream: true as const,
max_tokens: generation?.maxTokens ?? request.model.limits.output ?? 4096, max_tokens: generation?.maxTokens ?? request.model.route.defaults.limits?.output ?? 4096,
temperature: generation?.temperature, temperature: generation?.temperature,
top_p: generation?.topP, top_p: generation?.topP,
top_k: generation?.topK, top_k: generation?.topK,
@@ -452,8 +452,8 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined), totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
providerMetadata: { providerMetadata: {
anthropic: { anthropic: {
...(left.providerMetadata?.["anthropic"] ?? {}), ...left.providerMetadata?.["anthropic"],
...(right.providerMetadata?.["anthropic"] ?? {}), ...right.providerMetadata?.["anthropic"],
}, },
}, },
}) })
@@ -673,19 +673,12 @@ export const protocol = Protocol.make({
export const route = Route.make({ export const route = Route.make({
id: ADAPTER, id: ADAPTER,
provider: "anthropic",
protocol, protocol,
endpoint: Endpoint.path(PATH), endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
auth: Auth.apiKeyHeader("x-api-key"), auth: Auth.none,
framing: Framing.sse, framing: Framing.sse,
headers: () => ({ "anthropic-version": "2023-06-01" }), headers: () => ({ "anthropic-version": "2023-06-01" }),
}) })
// =============================================================================
// Model Helper
// =============================================================================
export const model = Route.model(route, {
provider: "anthropic",
baseURL: DEFAULT_BASE_URL,
})
export * as AnthropicMessages from "./anthropic-messages" export * as AnthropicMessages from "./anthropic-messages"
+36 -54
View File
@@ -1,5 +1,5 @@
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { Route, type RouteModelInput } from "../route/client" 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 {
@@ -14,7 +14,7 @@ import {
} from "../schema" } from "../schema"
import { BedrockEventStream } from "./bedrock-event-stream" import { BedrockEventStream } from "./bedrock-event-stream"
import { JsonObject, optionalArray, ProviderShared } from "./shared" import { JsonObject, optionalArray, ProviderShared } from "./shared"
import { BedrockAuth, type Credentials as BedrockCredentials } from "./utils/bedrock-auth" import { BedrockAuth } from "./utils/bedrock-auth"
import { BedrockCache } from "./utils/bedrock-cache" import { BedrockCache } from "./utils/bedrock-cache"
import { BedrockMedia } from "./utils/bedrock-media" import { BedrockMedia } from "./utils/bedrock-media"
import { Lifecycle } from "./utils/lifecycle" import { Lifecycle } from "./utils/lifecycle"
@@ -24,23 +24,6 @@ const ADAPTER = "bedrock-converse"
export type { Credentials as BedrockCredentials } from "./utils/bedrock-auth" export type { Credentials as BedrockCredentials } from "./utils/bedrock-auth"
// =============================================================================
// Public Model Input
// =============================================================================
export type BedrockConverseModelInput = RouteModelInput & {
/**
* Bearer API key (Bedrock's newer API key auth). Sets the `Authorization`
* header and bypasses SigV4 signing. Mutually exclusive with `credentials`.
*/
readonly apiKey?: string
/**
* AWS credentials for SigV4 signing. The route signs each request at
* `toHttp` time using `aws4fetch`. Mutually exclusive with `apiKey`.
*/
readonly credentials?: BedrockCredentials
readonly headers?: Record<string, string>
}
// ============================================================================= // =============================================================================
// Request Body Schema // Request Body Schema
// ============================================================================= // =============================================================================
@@ -61,6 +44,7 @@ type BedrockToolUseBlock = Schema.Schema.Type<typeof BedrockToolUseBlock>
const BedrockToolResultContentItem = Schema.Union([ const BedrockToolResultContentItem = Schema.Union([
Schema.Struct({ text: Schema.String }), Schema.Struct({ text: Schema.String }),
Schema.Struct({ json: Schema.Unknown }), Schema.Struct({ json: Schema.Unknown }),
BedrockMedia.ImageBlock,
]) ])
const BedrockToolResultBlock = Schema.Struct({ const BedrockToolResultBlock = Schema.Struct({
@@ -261,15 +245,33 @@ const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
}, },
}) })
const lowerToolResult = (part: ToolResultPart): BedrockToolResultBlock => ({ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (part: ToolResultPart) {
toolResult: { if (part.result.type === "text" || part.result.type === "error")
toolUseId: part.id, return [{ text: ProviderShared.toolResultText(part) }]
content: if (part.result.type === "json") return [{ json: part.result.value }]
part.result.type === "text" || part.result.type === "error"
? [{ text: ProviderShared.toolResultText(part) }] const content: Array<Schema.Schema.Type<typeof BedrockToolResultContentItem>> = []
: [{ json: part.result.value }], for (const item of part.result.value) {
status: part.result.type === "error" ? "error" : "success", if (item.type === "text") {
}, content.push({ text: item.text })
continue
}
const media = yield* BedrockMedia.lower(item)
if (!("image" in media))
return yield* ProviderShared.invalidRequest("Bedrock Converse only supports image media in tool results")
content.push(media)
}
return content
})
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (part: ToolResultPart) {
return {
toolResult: {
toolUseId: part.id,
content: yield* lowerToolResultContent(part),
status: part.result.type === "error" ? "error" : "success",
},
} satisfies BedrockToolResultBlock
}) })
const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* ( const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
@@ -331,7 +333,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
for (const part of message.content) { for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"])) if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"]) return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"])
content.push(lowerToolResult(part)) content.push(yield* lowerToolResult(part))
const cachePoint = BedrockCache.block(breakpoints, part.cache) const cachePoint = BedrockCache.block(breakpoints, part.cache)
if (cachePoint) content.push(cachePoint) if (cachePoint) content.push(cachePoint)
} }
@@ -597,11 +599,11 @@ export const protocol = Protocol.make({
export const route = Route.make({ export const route = Route.make({
id: ADAPTER, id: ADAPTER,
provider: "bedrock",
protocol, protocol,
// Bedrock's URL embeds the region in the host (set on `model.baseURL` by // Bedrock's URL embeds the region in the route endpoint host and the
// the provider helper from credentials) and the validated modelId in the // validated modelId in the path. We read the validated body so the URL
// path. We read the validated body so the URL matches the body that gets // matches the body that gets signed.
// signed.
endpoint: Endpoint.path<BedrockConverseBody>( endpoint: Endpoint.path<BedrockConverseBody>(
({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`, ({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`,
), ),
@@ -609,26 +611,6 @@ export const route = Route.make({
framing, framing,
}) })
export const nativeCredentials = BedrockAuth.nativeCredentials export const sigV4Auth = BedrockAuth.sigV4
const bedrockModel = Route.model(
route,
{
provider: "bedrock",
},
{
mapInput: (input: BedrockConverseModelInput) => {
const { credentials, ...rest } = input
const region = credentials?.region ?? "us-east-1"
return {
...rest,
baseURL: rest.baseURL ?? `https://bedrock-runtime.${region}.amazonaws.com`,
native: nativeCredentials(input.native, credentials),
}
},
},
)
export const model = bedrockModel
export * as BedrockConverse from "./bedrock-converse" export * as BedrockConverse from "./bedrock-converse"
+5 -10
View File
@@ -404,19 +404,14 @@ export const protocol = Protocol.make({
export const route = Route.make({ export const route = Route.make({
id: ADAPTER, id: ADAPTER,
provider: "google",
protocol, protocol,
// Gemini's path embeds the model id and pins SSE framing at the URL level. // Gemini's path embeds the model id and pins SSE framing at the URL level.
endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`), endpoint: Endpoint.path(({ request }) => `/models/${request.model.id}:streamGenerateContent?alt=sse`, {
auth: Auth.apiKeyHeader("x-goog-api-key"), baseURL: DEFAULT_BASE_URL,
}),
auth: Auth.none,
framing: Framing.sse, framing: Framing.sse,
}) })
// =============================================================================
// Model Helper
// =============================================================================
export const model = Route.model(route, {
provider: "google",
baseURL: DEFAULT_BASE_URL,
})
export * as Gemini from "./gemini" export * as Gemini from "./gemini"
+3 -17
View File
@@ -2,7 +2,6 @@ import { Array as Arr, Effect, Schema } from "effect"
import { Route } from "../route/client" import { Route } from "../route/client"
import { Auth } from "../route/auth" import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint" import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { HttpTransport } from "../route/transport" import { HttpTransport } from "../route/transport"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { import {
@@ -393,28 +392,15 @@ export const protocol = Protocol.make({
}, },
}) })
const encodeBody = Schema.encodeSync(Schema.fromJsonString(OpenAIChatBody)) export const httpTransport = HttpTransport.sseJson.with<OpenAIChatBody>()
export const httpTransport = HttpTransport.httpJson({
endpoint: Endpoint.path(PATH),
auth: Auth.bearer(),
framing: Framing.sse,
encodeBody,
})
export const route = Route.make({ export const route = Route.make({
id: ADAPTER, id: ADAPTER,
provider: "openai", provider: "openai",
protocol, protocol,
endpoint: Endpoint.path(PATH, { baseURL: DEFAULT_BASE_URL }),
auth: Auth.none,
transport: httpTransport, transport: httpTransport,
defaults: {
baseURL: DEFAULT_BASE_URL,
},
}) })
// =============================================================================
// Model Helper
// =============================================================================
export const model = route.model
export * as OpenAIChat from "./openai-chat" export * as OpenAIChat from "./openai-chat"
@@ -5,16 +5,14 @@ import * as OpenAIChat from "./openai-chat"
const ADAPTER = "openai-compatible-chat" const ADAPTER = "openai-compatible-chat"
export type OpenAICompatibleChatModelInput = Omit<RouteRoutedModelInput, "baseURL"> & { export type OpenAICompatibleChatModelInput = RouteRoutedModelInput
readonly baseURL: string
}
/** /**
* Route for non-OpenAI providers that expose an OpenAI Chat-compatible * Route for non-OpenAI providers that expose an OpenAI Chat-compatible
* `/chat/completions` endpoint. Reuses `OpenAIChat.protocol` end-to-end and * `/chat/completions` endpoint. Reuses `OpenAIChat.protocol` end-to-end and
* overrides only the route id so providers can be resolved per-family without * overrides only the route id so providers can be resolved per-family without
* colliding with native OpenAI. The model carries the host on `baseURL`, * colliding with native OpenAI. Provider helpers configure the route endpoint
* supplied by whichever profile/provider helper builds it. * before model selection.
*/ */
export const route = Route.make({ export const route = Route.make({
id: ADAPTER, id: ADAPTER,
@@ -23,6 +21,4 @@ export const route = Route.make({
framing: Framing.sse, framing: Framing.sse,
}) })
export const model = Route.model<OpenAICompatibleChatModelInput>(route)
export * as OpenAICompatibleChat from "./openai-compatible-chat" export * as OpenAICompatibleChat from "./openai-compatible-chat"
+45 -39
View File
@@ -2,11 +2,11 @@ import { Effect, Schema } from "effect"
import { Route } from "../route/client" import { Route } from "../route/client"
import { Auth } from "../route/auth" import { Auth } from "../route/auth"
import { Endpoint } from "../route/endpoint" import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { HttpTransport, WebSocketTransport } from "../route/transport" import { HttpTransport, WebSocketTransport } from "../route/transport"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { import {
LLMEvent, LLMEvent,
type MediaPart,
Usage, Usage,
type FinishReason, type FinishReason,
type LLMRequest, type LLMRequest,
@@ -31,6 +31,12 @@ const OpenAIResponsesInputText = Schema.Struct({
type: Schema.tag("input_text"), type: Schema.tag("input_text"),
text: Schema.String, text: Schema.String,
}) })
const OpenAIResponsesInputImage = Schema.Struct({
type: Schema.tag("input_image"),
image_url: Schema.String,
})
const OpenAIResponsesInputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
type OpenAIResponsesInputContent = Schema.Schema.Type<typeof OpenAIResponsesInputContent>
const OpenAIResponsesOutputText = Schema.Struct({ const OpenAIResponsesOutputText = Schema.Struct({
type: Schema.tag("output_text"), type: Schema.tag("output_text"),
@@ -39,7 +45,7 @@ const OpenAIResponsesOutputText = Schema.Struct({
const OpenAIResponsesInputItem = Schema.Union([ const OpenAIResponsesInputItem = Schema.Union([
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputText) }), Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }), Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
Schema.Struct({ Schema.Struct({
type: Schema.tag("function_call"), type: Schema.tag("function_call"),
@@ -151,12 +157,15 @@ const OpenAIResponsesEvent = Schema.Struct({
item_id: Schema.optional(Schema.String), item_id: Schema.optional(Schema.String),
item: Schema.optional(OpenAIResponsesStreamItem), item: Schema.optional(OpenAIResponsesStreamItem),
response: Schema.optional( response: Schema.optional(
Schema.Struct({ Schema.StructWithRest(
id: Schema.optional(Schema.String), Schema.Struct({
service_tier: Schema.optional(Schema.String), id: Schema.optional(Schema.String),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })), service_tier: optionalNull(Schema.String),
usage: optionalNull(OpenAIResponsesUsage), incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })),
}), usage: optionalNull(OpenAIResponsesUsage),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
), ),
code: Schema.optional(Schema.String), code: Schema.optional(Schema.String),
message: Schema.optional(Schema.String), message: Schema.optional(Schema.String),
@@ -196,6 +205,22 @@ const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
arguments: ProviderShared.encodeJson(part.input), arguments: ProviderShared.encodeJson(part.input),
}) })
const imageUrl = (part: MediaPart) =>
typeof part.data === "string" && part.data.startsWith("data:")
? part.data
: `data:${part.mediaType};base64,${ProviderShared.mediaBytes(part)}`
const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* (
part: LLMRequest["messages"][number]["content"][number],
) {
if (part.type === "text") return { type: "input_text" as const, text: part.text }
if (part.type === "media" && part.mediaType.startsWith("image/")) {
return { type: "input_image" as const, image_url: imageUrl(part) }
}
if (part.type === "media") return yield* invalid("OpenAI Responses user media content only supports images")
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"])
})
const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) { const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) {
const system: OpenAIResponsesInputItem[] = const system: OpenAIResponsesInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
@@ -203,13 +228,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
for (const message of request.messages) { for (const message of request.messages) {
if (message.role === "user") { if (message.role === "user") {
const content: TextPart[] = [] input.push({ role: "user", content: yield* Effect.forEach(message.content, lowerUserContent) })
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["text"]))
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text"])
content.push(part)
}
input.push({ role: "user", content: content.map((part) => ({ type: "input_text", text: part.text })) })
continue continue
} }
@@ -536,27 +555,18 @@ export const protocol = Protocol.make({
}, },
}) })
const encodeBody = Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesBody)) const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BASE_URL })
const transportBase = { const auth = Auth.none
endpoint: Endpoint.path<OpenAIResponsesBody>(PATH),
auth: Auth.bearer(),
encodeBody,
}
const routeDefaults = {
baseURL: DEFAULT_BASE_URL,
}
export const httpTransport = HttpTransport.httpJson({ export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
...transportBase,
framing: Framing.sse,
})
export const route = Route.make({ export const route = Route.make({
id: ADAPTER, id: ADAPTER,
provider: "openai", provider: "openai",
protocol, protocol,
endpoint,
auth,
transport: httpTransport, transport: httpTransport,
defaults: routeDefaults,
}) })
const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage)) const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
@@ -569,8 +579,10 @@ const webSocketMessage = (body: OpenAIResponsesBody | Record<string, unknown>) =
return yield* decodeWebSocketMessage({ ...message, type: "response.create" }) return yield* decodeWebSocketMessage({ ...message, type: "response.create" })
}) })
export const webSocketTransport = WebSocketTransport.json({ export const webSocketTransport = WebSocketTransport.jsonTransport.with<
...transportBase, OpenAIResponsesBody,
OpenAIResponsesWebSocketMessage
>({
toMessage: webSocketMessage, toMessage: webSocketMessage,
encodeMessage: encodeWebSocketMessage, encodeMessage: encodeWebSocketMessage,
}) })
@@ -579,15 +591,9 @@ export const webSocketRoute = Route.make({
id: `${ADAPTER}-websocket`, id: `${ADAPTER}-websocket`,
provider: "openai", provider: "openai",
protocol, protocol,
endpoint,
auth,
transport: webSocketTransport, transport: webSocketTransport,
defaults: routeDefaults,
}) })
// =============================================================================
// Model Helper
// =============================================================================
export const model = route.model
export const webSocketModel = webSocketRoute.model
export * as OpenAIResponses from "./openai-responses" export * as OpenAIResponses from "./openai-responses"
+2 -7
View File
@@ -11,6 +11,7 @@ import {
type MediaPart, type MediaPart,
type ToolResultPart, type ToolResultPart,
} from "../schema" } from "../schema"
export { isRecord } from "../utils/record"
export const Json = Schema.fromJsonString(Schema.Unknown) export const Json = Schema.fromJsonString(Schema.Unknown)
export const decodeJson = Schema.decodeUnknownSync(Json) export const decodeJson = Schema.decodeUnknownSync(Json)
@@ -19,13 +20,6 @@ export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema)) export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema)) export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
/**
* Plain-record narrowing. Excludes arrays so routes checking nested JSON
* Schema fragments don't accidentally treat a tuple as a key/value bag.
*/
export const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
/** /**
* Streaming tool-call accumulator. Adapters that build a tool call across * Streaming tool-call accumulator. Adapters that build a tool call across
* multiple `tool-input-delta` chunks store the partial JSON input string here * multiple `tool-input-delta` chunks store the partial JSON input string here
@@ -132,6 +126,7 @@ export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
export const toolResultText = (part: ToolResultPart) => { export const toolResultText = (part: ToolResultPart) => {
if (part.result.type === "text" || part.result.type === "error") return String(part.result.value) if (part.result.type === "text" || part.result.type === "error") return String(part.result.value)
if (part.result.type === "content") return encodeJson(part.result.value)
return encodeJson(part.result.value) return encodeJson(part.result.value)
} }
@@ -1,15 +1,14 @@
import { AwsV4Signer } from "aws4fetch" import { AwsV4Signer } from "aws4fetch"
import { Effect, Option, Schema } from "effect" import { Effect } from "effect"
import { Headers } from "effect/unstable/http" import { Headers } from "effect/unstable/http"
import { Auth, type AuthInput } from "../../route/auth" import { Auth, type AuthInput } from "../../route/auth"
import type { LLMRequest } from "../../schema"
import { ProviderShared } from "../shared" import { ProviderShared } from "../shared"
/** /**
* AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth * AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth,
* via `model.apiKey`, which bypasses SigV4 signing. STS-vended credentials * which provider facades configure as route auth instead of SigV4. STS-vended
* should be refreshed by the consumer (rebuild the model) before they expire; * credentials should be refreshed by the consumer (rebuild the model) before
* the route does not refresh. * they expire; the route does not refresh.
*/ */
export interface Credentials { export interface Credentials {
readonly region: string readonly region: string
@@ -18,32 +17,6 @@ export interface Credentials {
readonly sessionToken?: string readonly sessionToken?: string
} }
const NativeCredentials = Schema.Struct({
accessKeyId: Schema.String,
secretAccessKey: Schema.String,
region: Schema.optional(Schema.String),
sessionToken: Schema.optional(Schema.String),
})
const decodeNativeCredentials = Schema.decodeUnknownOption(NativeCredentials)
export const region = (request: LLMRequest) => {
const fromNative = request.model.native?.aws_region
if (typeof fromNative === "string" && fromNative !== "") return fromNative
return (
decodeNativeCredentials(request.model.native?.aws_credentials).pipe(
Option.map((credentials) => credentials.region),
Option.getOrUndefined,
) ?? "us-east-1"
)
}
const credentialsFromInput = (request: LLMRequest): Credentials | undefined =>
decodeNativeCredentials(request.model.native?.aws_credentials).pipe(
Option.map((creds) => ({ ...creds, region: creds.region ?? region(request) })),
Option.getOrUndefined,
)
const signRequest = (input: { const signRequest = (input: {
readonly url: string readonly url: string
readonly body: string readonly body: string
@@ -71,33 +44,27 @@ const signRequest = (input: {
), ),
}) })
/** /** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */
* Bedrock auth. `model.apiKey` (Bedrock's newer Bearer API key auth) wins if export const sigV4 = (credentials: Credentials | undefined) =>
* set; otherwise sign the exact JSON bytes with SigV4 using credentials from Auth.custom((input: AuthInput) => {
* `model.native.aws_credentials`. return Effect.gen(function* () {
*/ if (!credentials) {
export const auth = Auth.custom((input: AuthInput) => { return yield* ProviderShared.invalidRequest(
if (input.request.model.apiKey) return Auth.toEffect(Auth.bearer())(input) "Bedrock Converse requires either route bearer auth or AWS credentials configured on the route",
return Effect.gen(function* () { )
const credentials = credentialsFromInput(input.request)
if (!credentials) {
return yield* ProviderShared.invalidRequest(
"Bedrock Converse requires either model.apiKey or AWS credentials in model.native.aws_credentials",
)
}
const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
const signed = yield* signRequest({ url: input.url, body: input.body, headers: headersForSigning, credentials })
return Headers.setAll(headersForSigning, signed)
})
})
export const nativeCredentials = (native: Record<string, unknown> | undefined, credentials: Credentials | undefined) =>
credentials
? {
...native,
aws_credentials: credentials,
aws_region: credentials.region,
} }
: native const headersForSigning = Headers.set(input.headers, "content-type", "application/json")
const signed = yield* signRequest({
url: input.url,
body: input.body,
headers: headersForSigning,
credentials,
})
return Headers.setAll(headersForSigning, signed)
})
})
/** Bedrock route auth defaults to SigV4 and expects credentials from route configuration. */
export const auth = sigV4(undefined)
export * as BedrockAuth from "./bedrock-auth" export * as BedrockAuth from "./bedrock-auth"
+13 -7
View File
@@ -1,14 +1,20 @@
import type { RouteModelInput } from "./route/client" import type { RouteDefaultsInput } from "./route/client"
import type { ModelID, ModelRef, ProviderID } from "./schema" import type { Model, ModelID, ProviderID } from "./schema"
export type ModelOptions = Omit<RouteModelInput, "id"> export type ModelOptions = RouteDefaultsInput
/**
* Advanced structural provider definition helper. Built-in providers should
* prefer explicit `configure(options).model(id)` facades so deployment config is
* chosen before model selection. The optional `apis` map remains for external
* structural providers that expose multiple route selectors behind one provider.
*/
export type ModelFactory<Options extends ModelOptions = ModelOptions> = ( export type ModelFactory<Options extends ModelOptions = ModelOptions> = (
id: string | ModelID, id: string | ModelID,
options?: Options, options?: Options,
) => ModelRef ) => Model
type AnyModelFactory = (...args: never[]) => ModelRef type AnyModelFactory = (...args: never[]) => Model
export interface Definition<Factory extends AnyModelFactory = ModelFactory> { export interface Definition<Factory extends AnyModelFactory = ModelFactory> {
readonly id: ProviderID readonly id: ProviderID
@@ -18,8 +24,8 @@ export interface Definition<Factory extends AnyModelFactory = ModelFactory> {
type DefinitionShape = { type DefinitionShape = {
readonly id: ProviderID readonly id: ProviderID
readonly model: (...args: never[]) => ModelRef readonly model: (...args: never[]) => Model
readonly apis?: Record<string, (...args: never[]) => ModelRef> readonly apis?: Record<string, (...args: never[]) => Model>
} }
type NoExtraFields<Input, Shape> = Input & Record<Exclude<keyof Input, keyof Shape>, never> type NoExtraFields<Input, Shape> = Input & Record<Exclude<keyof Input, keyof Shape>, never>
+23 -28
View File
@@ -1,12 +1,12 @@
import { Route, type RouteModelInput } from "../route/client" import type { RouteDefaultsInput } from "../route/client"
import { Provider } from "../provider" import { Auth } from "../route/auth"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import * as BedrockConverse from "../protocols/bedrock-converse" import * as BedrockConverse from "../protocols/bedrock-converse"
import type { BedrockCredentials } from "../protocols/bedrock-converse" import type { BedrockCredentials } from "../protocols/bedrock-converse"
export const id = ProviderID.make("amazon-bedrock") export const id = ProviderID.make("amazon-bedrock")
export type ModelOptions = Omit<RouteModelInput, "id" | "baseURL"> & { export type Config = RouteDefaultsInput & {
readonly apiKey?: string readonly apiKey?: string
readonly headers?: Record<string, string> readonly headers?: Record<string, string>
readonly credentials?: BedrockCredentials readonly credentials?: BedrockCredentials
@@ -15,34 +15,29 @@ export type ModelOptions = Omit<RouteModelInput, "id" | "baseURL"> & {
/** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */ /** Override the computed `https://bedrock-runtime.<region>.amazonaws.com` URL. */
readonly baseURL?: string readonly baseURL?: string
} }
type ModelInput = ModelOptions & Pick<RouteModelInput, "id">
export const routes = [BedrockConverse.route] export const routes = [BedrockConverse.route]
const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com` const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com`
const converseModel = Route.model<ModelInput>( const configuredRoute = (input: Config) => {
BedrockConverse.route, const { apiKey, credentials, region, baseURL, ...rest } = input
{ const resolvedRegion = region ?? credentials?.region ?? "us-east-1"
provider: "amazon-bedrock", return BedrockConverse.route.with({
}, ...rest,
{ provider: id,
mapInput: (input) => { endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) },
const { credentials, region, baseURL, ...rest } = input auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey),
const resolvedRegion = region ?? credentials?.region ?? "us-east-1" })
return { }
...rest,
baseURL: baseURL ?? bedrockBaseURL(resolvedRegion),
native: BedrockConverse.nativeCredentials(input.native, credentials),
}
},
},
)
export const model = (modelID: string | ModelID, options: ModelOptions = {}) => export const configure = (input: Config = {}) => {
converseModel({ ...options, id: modelID }) const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
export const provider = Provider.make({ export const provider = configure()
id, export const model = provider.model
model,
})
+27 -10
View File
@@ -1,5 +1,6 @@
import type { RouteModelInput } from "../route/client" import type { RouteDefaultsInput } from "../route/client"
import { Provider } from "../provider" import { Auth } from "../route/auth"
import type { ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import * as AnthropicMessages from "../protocols/anthropic-messages" import * as AnthropicMessages from "../protocols/anthropic-messages"
@@ -7,12 +8,28 @@ export const id = ProviderID.make("anthropic")
export const routes = [AnthropicMessages.route] export const routes = [AnthropicMessages.route]
export const model = ( export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
id: string | ModelID,
options: Omit<RouteModelInput, "id" | "baseURL"> & { readonly baseURL?: string } = {},
) => AnthropicMessages.model({ ...options, id })
export const provider = Provider.make({ const auth = (options: ProviderAuthOption<"optional">) => {
id, if ("auth" in options && options.auth) return options.auth
model, return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
}) .orElse(Auth.config("ANTHROPIC_API_KEY"))
.pipe(Auth.header("x-api-key"))
}
const configuredRoute = (input: Config) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return AnthropicMessages.route.with({ ...rest, endpoint: { baseURL }, auth: auth(input) })
}
export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
export const provider = configure()
export const model = provider.model
+71 -44
View File
@@ -1,83 +1,110 @@
import { Auth } from "../route/auth" import { Auth } from "../route/auth"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options" import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options"
import { Route } from "../route/client" import type { Route as RouteDef, RouteDefaultsInput } from "../route/client"
import type { ModelInput } from "../llm"
import { Provider } from "../provider"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses" import * as OpenAIResponses from "../protocols/openai-responses"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options" import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
export const id = ProviderID.make("azure") export const id = ProviderID.make("azure")
const routeAuth = Auth.remove("authorization").andThen(Auth.apiKeyHeader("api-key")) const routeAuth = Auth.remove("authorization")
// Azure needs the customer's resource URL; supply either `resourceName` // Azure needs the customer's resource URL; supply either `resourceName`
// (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 ModelOptions = AzureURL &
Omit<ModelInput, "id" | "provider" | "route" | "apiKey" | "auth" | "baseURL"> & RouteDefaultsInput &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly apiVersion?: string readonly apiVersion?: string
readonly queryParams?: Record<string, string>
readonly useCompletionUrls?: boolean readonly useCompletionUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput readonly providerOptions?: OpenAIProviderOptionsInput
} }
type AzureModelInput = ModelOptions & Pick<ModelInput, "id"> export type Config = ModelOptions
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1` const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai/v1`
const responsesRoute = OpenAIResponses.route.with({ const responsesRoute = OpenAIResponses.route.with({
id: "azure-openai-responses", id: "azure-openai-responses",
provider: id, provider: id,
transport: OpenAIResponses.httpTransport.with({ auth: routeAuth }), auth: routeAuth,
endpoint: {
query: { "api-version": "v1" },
},
}) })
const chatRoute = OpenAIChat.route.with({ const chatRoute = OpenAIChat.route.with({
id: "azure-openai-chat", id: "azure-openai-chat",
provider: id, provider: id,
transport: OpenAIChat.httpTransport.with({ auth: routeAuth }), auth: routeAuth,
endpoint: {
query: { "api-version": "v1" },
},
}) })
export const routes = [responsesRoute, chatRoute] export const routes = [responsesRoute, chatRoute]
const mapInput = (input: AzureModelInput) => { const defaults = (input: Config) => {
const { apiKey: _, apiVersion, resourceName, useCompletionUrls, ...rest } = input const {
return { apiKey: _,
...withOpenAIOptions(input.id, rest), apiVersion: _apiVersion,
auth: resourceName: _resourceName,
"auth" in input && input.auth useCompletionUrls: _useCompletionUrls,
? input.auth baseURL: _baseURL,
: Auth.remove("authorization").andThen( queryParams: _queryParams,
Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey") ...rest
.orElse(Auth.config("AZURE_OPENAI_API_KEY")) } = input
.pipe(Auth.header("api-key")), if ("auth" in rest) {
), const { auth: _, ...withoutAuth } = rest
// AtLeastOne guarantees at least one is set; baseURL wins if both are. return withoutAuth
baseURL: rest.baseURL ?? resourceBaseURL(resourceName!), }
queryParams: { return rest
...rest.queryParams, }
"api-version": apiVersion ?? rest.queryParams?.["api-version"] ?? "v1",
const auth = (input: Config) => {
if ("auth" in input && input.auth) return input.auth
return Auth.remove("authorization").andThen(
Auth.optional("apiKey" in input ? input.apiKey : undefined, "apiKey")
.orElse(Auth.config("AZURE_OPENAI_API_KEY"))
.pipe(Auth.header("api-key")),
)
}
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config) =>
route.with({
auth: auth(input),
endpoint: {
// AtLeastOne guarantees at least one is set; baseURL wins if both are.
baseURL: input.baseURL ?? resourceBaseURL(input.resourceName!),
query: {
...(input.apiVersion ? { "api-version": input.apiVersion } : {}),
...input.queryParams,
},
}, },
})
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({ id: modelID })
const chat = (modelID: string | ModelID) =>
configuredChatRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
return {
id,
model: (modelID: string | ModelID) => (input.useCompletionUrls === true ? chat(modelID) : responses(modelID)),
responses,
chat,
configure,
} }
} }
const chatModel = Route.model<AzureModelInput>(chatRoute, {}, { mapInput }) export const provider = {
const responsesModel = Route.model<AzureModelInput>(responsesRoute, {}, { mapInput })
export const responses = (modelID: string | ModelID, options: ModelOptions) =>
responsesModel({ ...options, id: modelID })
export const chat = (modelID: string | ModelID, options: ModelOptions) => chatModel({ ...options, id: modelID })
export const model = (modelID: string | ModelID, options: ModelOptions) => {
if (options.useCompletionUrls === true) return chat(modelID, options)
return responses(modelID, options)
}
export const provider = Provider.make({
id, id,
model, configure,
apis: { responses, chat }, }
})
export const apis = provider.apis
+56 -68
View File
@@ -1,19 +1,16 @@
import type { Config, Redacted } from "effect" import type { Config, Redacted } from "effect"
import { type ModelInput } from "../llm"
import { Provider } from "../provider"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import { Auth } from "../route/auth" import { Auth } from "../route/auth"
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options" import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options"
import { Route } from "../route/client" import type { RouteDefaultsInput } from "../route/client"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway") export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway")
export const workersAIID = ProviderID.make("cloudflare-workers-ai") export const workersAIID = ProviderID.make("cloudflare-workers-ai")
export const id = aiGatewayID
export const aiGatewayAuthEnvVars = ["CLOUDFLARE_API_TOKEN", "CF_AIG_TOKEN"] as const export const aiGatewayAuthEnvVars = ["CLOUDFLARE_API_TOKEN", "CF_AIG_TOKEN"] as const
export const workersAIAuthEnvVars = ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN"] as const export const workersAIAuthEnvVars = ["CLOUDFLARE_API_KEY", "CLOUDFLARE_WORKERS_AI_TOKEN"] as const
type CloudflareSecret = string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> type CloudflareSecret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
type GatewayURL = AtLeastOne<{ type GatewayURL = AtLeastOne<{
readonly accountId: string readonly accountId: string
@@ -23,32 +20,26 @@ type GatewayURL = AtLeastOne<{
} }
export type AIGatewayOptions = GatewayURL & export type AIGatewayOptions = GatewayURL &
Omit<ModelInput, "id" | "provider" | "route" | "baseURL" | "apiKey" | "auth"> & RouteDefaultsInput &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
/** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */ /** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */
readonly gatewayApiKey?: CloudflareSecret readonly gatewayApiKey?: CloudflareSecret
} }
type AIGatewayInput = AIGatewayOptions & Pick<ModelInput, "id">
type WorkersAIURL = AtLeastOne<{ type WorkersAIURL = AtLeastOne<{
readonly accountId: string readonly accountId: string
readonly baseURL: string readonly baseURL: string
}> }>
export type WorkersAIOptions = WorkersAIURL & export type WorkersAIOptions = WorkersAIURL & RouteDefaultsInput & ProviderAuthOption<"optional">
Omit<ModelInput, "id" | "provider" | "route" | "baseURL" | "apiKey" | "auth"> &
ProviderAuthOption<"optional">
type WorkersAIInput = WorkersAIOptions & Pick<ModelInput, "id">
export const aiGatewayBaseURL = (input: GatewayURL) => { export const aiGatewayBaseURL = (input: GatewayURL) => {
if (input.baseURL) return input.baseURL if (input.baseURL) return input.baseURL
if (!input.accountId) throw new Error("Cloudflare.aiGateway requires accountId unless baseURL is supplied") if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat` return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
} }
const aiGatewayAuth = (input: AIGatewayInput) => { const aiGatewayAuth = (input: AIGatewayOptions) => {
if ("auth" in input && input.auth) return input.auth if ("auth" in input && input.auth) return input.auth
const gateway = Auth.optional(input.gatewayApiKey, "gatewayApiKey") const gateway = Auth.optional(input.gatewayApiKey, "gatewayApiKey")
.orElse(Auth.config("CLOUDFLARE_API_TOKEN")) .orElse(Auth.config("CLOUDFLARE_API_TOKEN"))
@@ -61,11 +52,11 @@ const aiGatewayAuth = (input: AIGatewayInput) => {
export const workersAIBaseURL = (input: WorkersAIURL) => { export const workersAIBaseURL = (input: WorkersAIURL) => {
if (input.baseURL) return input.baseURL if (input.baseURL) return input.baseURL
if (!input.accountId) throw new Error("Cloudflare.workersAI requires accountId unless baseURL is supplied") if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1` return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
} }
const workersAIAuth = (input: WorkersAIInput) => { const workersAIAuth = (input: WorkersAIOptions) => {
return AuthOptions.bearer(input, workersAIAuthEnvVars) return AuthOptions.bearer(input, workersAIAuthEnvVars)
} }
@@ -81,59 +72,56 @@ export const workersAIRoute = OpenAICompatibleChat.route.with({
export const routes = [aiGatewayRoute, workersAIRoute] export const routes = [aiGatewayRoute, workersAIRoute]
const aiGatewayModel = Route.model<AIGatewayInput>( const aiGatewayDefaults = (options: AIGatewayOptions) => {
aiGatewayRoute, const {
{ accountId: _accountId,
provider: id, gatewayId: _gatewayId,
}, apiKey: _apiKey,
{ gatewayApiKey: _gatewayApiKey,
mapInput: (input) => { baseURL: _baseURL,
const { auth: _auth,
accountId: _accountId, ...rest
gatewayId: _gatewayId, } = options
apiKey: _apiKey, return rest
gatewayApiKey: _gatewayApiKey, }
auth: _auth,
...rest
} = input
return {
...rest,
auth: aiGatewayAuth(input),
baseURL: aiGatewayBaseURL(input),
}
},
},
)
const workersAIModel = Route.model<WorkersAIInput>( const workersAIDefaults = (options: WorkersAIOptions) => {
workersAIRoute, const { accountId: _accountId, apiKey: _apiKey, auth: _auth, baseURL: _baseURL, ...rest } = options
{ return rest
provider: workersAIID, }
},
{
mapInput: (input) => {
const { accountId: _accountId, apiKey: _apiKey, auth: _auth, ...rest } = input
return {
...rest,
auth: workersAIAuth(input),
baseURL: workersAIBaseURL(input),
}
},
},
)
export const aiGateway = (modelID: string | ModelID, options: AIGatewayOptions) => const configureAIGateway = (options: AIGatewayOptions) => {
aiGatewayModel({ ...options, id: modelID }) const route = aiGatewayRoute.with({
...aiGatewayDefaults(options),
endpoint: { baseURL: aiGatewayBaseURL(options) },
auth: aiGatewayAuth(options),
})
return {
id: aiGatewayID,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure: configureAIGateway,
}
}
export const workersAI = (modelID: string | ModelID, options: WorkersAIOptions) => const configureWorkersAI = (options: WorkersAIOptions) => {
workersAIModel({ ...options, id: modelID }) const route = workersAIRoute.with({
...workersAIDefaults(options),
endpoint: { baseURL: workersAIBaseURL(options) },
auth: workersAIAuth(options),
})
return {
id: workersAIID,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure: configureWorkersAI,
}
}
export const model = aiGateway export const CloudflareAIGateway = {
id: aiGatewayID,
configure: configureAIGateway,
}
export const provider = Provider.make({ export const CloudflareWorkersAI = {
id, id: workersAIID,
model, configure: configureWorkersAI,
apis: { aiGateway, workersAI }, }
})
export const apis = provider.apis
+43 -25
View File
@@ -1,6 +1,5 @@
import { Route } from "../route/client" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { ModelInput } from "../llm" import type { RouteDefaultsInput } from "../route/client"
import { Provider } from "../provider"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses" import * as OpenAIResponses from "../protocols/openai-responses"
@@ -10,10 +9,11 @@ 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<ModelInput, "id" | "provider" | "route"> & { export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput ProviderAuthOption<"optional"> & {
} readonly baseURL: string
type CopilotModelInput = ModelOptions & Pick<ModelInput, "id"> readonly providerOptions?: OpenAIProviderOptionsInput
}
export const shouldUseResponsesApi = (modelID: string | ModelID) => { export const shouldUseResponsesApi = (modelID: string | ModelID) => {
const model = String(modelID) const model = String(modelID)
@@ -24,25 +24,43 @@ export const shouldUseResponsesApi = (modelID: string | ModelID) => {
export const routes = [OpenAIResponses.route, OpenAIChat.route] export const routes = [OpenAIResponses.route, OpenAIChat.route]
const mapInput = (input: CopilotModelInput) => withOpenAIOptions(input.id, input) const chatRoute = OpenAIChat.route.with({ provider: id })
const responsesRoute = OpenAIResponses.route.with({ provider: id })
const chatModel = Route.model<CopilotModelInput>(OpenAIChat.route, { provider: id }, { mapInput }) const defaults = (options: ModelOptions) => {
const responsesModel = Route.model<CopilotModelInput>(OpenAIResponses.route, { provider: id }, { mapInput }) const { apiKey: _, auth: _auth, baseURL: _baseURL, ...rest } = options
return rest
export const responses = (modelID: string | ModelID, options: ModelOptions) =>
responsesModel({ ...options, id: modelID })
export const chat = (modelID: string | ModelID, options: ModelOptions) => chatModel({ ...options, id: modelID })
export const model = (modelID: string | ModelID, options: ModelOptions) => {
const create = shouldUseResponsesApi(modelID) ? responsesModel : chatModel
return create({ ...options, id: modelID })
} }
export const provider = Provider.make({ const configuredResponsesRoute = (options: ModelOptions) =>
id, responsesRoute.with({
model, endpoint: { baseURL: options.baseURL },
apis: { responses, chat }, auth: AuthOptions.bearer(options, []),
}) })
export const apis = provider.apis const configuredChatRoute = (options: ModelOptions) =>
chatRoute.with({
endpoint: { baseURL: options.baseURL },
auth: AuthOptions.bearer(options, []),
})
export const configure = (options: ModelOptions) => {
const responsesRoute = configuredResponsesRoute(options)
const chatRoute = configuredChatRoute(options)
const responses = (modelID: string | ModelID) =>
responsesRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
const chat = (modelID: string | ModelID) =>
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
return {
id,
model: (modelID: string | ModelID) => (shouldUseResponsesApi(modelID) ? responses(modelID) : chat(modelID)),
responses,
chat,
configure,
}
}
export const provider = {
id,
configure,
}
+27 -10
View File
@@ -1,5 +1,6 @@
import type { RouteModelInput } from "../route/client" import type { RouteDefaultsInput } from "../route/client"
import { Provider } from "../provider" import { Auth } from "../route/auth"
import type { ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import * as Gemini from "../protocols/gemini" import * as Gemini from "../protocols/gemini"
@@ -7,12 +8,28 @@ export const id = ProviderID.make("google")
export const routes = [Gemini.route] export const routes = [Gemini.route]
export const model = ( export type Config = RouteDefaultsInput & ProviderAuthOption<"optional"> & { readonly baseURL?: string }
id: string | ModelID,
options: Omit<RouteModelInput, "id" | "baseURL"> & { readonly baseURL?: string } = {},
) => Gemini.model({ ...options, id })
export const provider = Provider.make({ const auth = (options: ProviderAuthOption<"optional">) => {
id, if ("auth" in options && options.auth) return options.auth
model, return Auth.optional("apiKey" in options ? options.apiKey : undefined, "apiKey")
}) .orElse(Auth.config("GOOGLE_GENERATIVE_AI_API_KEY"))
.pipe(Auth.header("x-goog-api-key"))
}
const configuredRoute = (input: Config) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return Gemini.route.with({ ...rest, endpoint: { baseURL }, auth: auth(input) })
}
export const configure = (input: Config = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
export const provider = configure()
export const model = provider.model
+1
View File
@@ -2,6 +2,7 @@ export * as Anthropic from "./anthropic"
export * as AmazonBedrock from "./amazon-bedrock" export * as AmazonBedrock from "./amazon-bedrock"
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 * as GitHubCopilot from "./github-copilot" export * as GitHubCopilot from "./github-copilot"
export * as Google from "./google" export * as Google from "./google"
export * as OpenAI from "./openai" export * as OpenAI from "./openai"
+42 -38
View File
@@ -1,56 +1,60 @@
import { Provider } from "../provider"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
import type { OpenAICompatibleChatModelInput } from "../protocols/openai-compatible-chat" import type { RouteDefaultsInput } from "../route/client"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile" import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile"
export const id = ProviderID.make("openai-compatible") export const id = ProviderID.make("openai-compatible")
export type ModelOptions = Omit<OpenAICompatibleChatModelInput, "id" | "provider"> & { type GenericModelOptions = RouteDefaultsInput &
readonly provider: string ProviderAuthOption<"optional"> & {
} readonly provider?: string
readonly baseURL: string
}
type GenericModelOptions = Omit<ModelOptions, "provider"> & { export type FamilyModelOptions = RouteDefaultsInput &
readonly provider?: string ProviderAuthOption<"optional"> & {
} readonly baseURL?: string
}
export type FamilyModelOptions = Omit<OpenAICompatibleChatModelInput, "id" | "provider" | "baseURL"> & {
readonly baseURL?: string
}
export const routes = [OpenAICompatibleChat.route] export const routes = [OpenAICompatibleChat.route]
export const model = (id: string | ModelID, options: ModelOptions) => { export const configure = (input: GenericModelOptions) => {
return OpenAICompatibleChat.model({ const provider = input.provider ?? "openai-compatible"
...options, const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
id, const route = OpenAICompatibleChat.route.with({
provider: ProviderID.make(options.provider), ...rest,
provider,
endpoint: { baseURL },
auth: AuthOptions.bearer(input, []),
}) })
return {
id: ProviderID.make(provider),
model: (modelID: string | ModelID) => route.model({ id: modelID, provider: ProviderID.make(provider) }),
configure,
}
} }
export const profileModel = ( const define = (profile: OpenAICompatibleProfile) => {
profile: OpenAICompatibleProfile, const configureProfile = (input: FamilyModelOptions = {}) => {
id: string | ModelID, const facade = configure({
options: FamilyModelOptions = {}, ...input,
) => baseURL: input.baseURL ?? profile.baseURL,
OpenAICompatibleChat.model({ provider: profile.provider,
...options, })
id, return {
provider: profile.provider, id: ProviderID.make(profile.provider),
baseURL: options.baseURL ?? profile.baseURL, model: facade.model,
}) configure: configureProfile,
}
}
return configureProfile()
}
const define = (profile: OpenAICompatibleProfile) => export const provider = {
Provider.make({
id: ProviderID.make(profile.provider),
model: (id: string | ModelID, options: FamilyModelOptions = {}) => profileModel(profile, id, options),
})
export const provider = Provider.make({
id, id,
model: (id: string | ModelID, options: GenericModelOptions) => configure,
model(id, { ...options, provider: options.provider ?? "openai-compatible" }), }
})
export const baseten = define(profiles.baseten) export const baseten = define(profiles.baseten)
export const cerebras = define(profiles.cerebras) export const cerebras = define(profiles.cerebras)
+1 -2
View File
@@ -59,10 +59,9 @@ export const withOpenAIOptions = <Options extends { readonly providerOptions?: O
modelID: string, modelID: string,
options: Options, options: Options,
defaults: { readonly textVerbosity?: boolean } = {}, defaults: { readonly textVerbosity?: boolean } = {},
): Options & { readonly id: string; readonly providerOptions?: ProviderOptions } => { ): Omit<Options, "providerOptions"> & { readonly providerOptions?: ProviderOptions } => {
return { return {
...options, ...options,
id: modelID,
providerOptions: mergeProviderOptions(openAIDefaultOptions(modelID, defaults), options.providerOptions), providerOptions: mergeProviderOptions(openAIDefaultOptions(modelID, defaults), options.providerOptions),
} }
} }
+35 -25
View File
@@ -1,6 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import type { RouteModelInput } from "../route/client" import type { Route, RouteDefaultsInput } from "../route/client"
import { Provider } from "../provider"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIChat from "../protocols/openai-chat"
import * as OpenAIResponses from "../protocols/openai-responses" import * as OpenAIResponses from "../protocols/openai-responses"
@@ -15,39 +14,50 @@ export const routes = [OpenAIResponses.route, OpenAIResponses.webSocketRoute, Op
// This provider facade wraps the lower-level Responses and Chat model factories // This provider facade wraps the lower-level Responses and Chat model factories
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback, // with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
// and default option normalization. // and default option normalization.
type OpenAIModelInput<ModelInput> = Omit<ModelInput, "apiKey" | "auth" | "baseURL"> & export type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly baseURL?: string readonly baseURL?: string
readonly queryParams?: Record<string, string>
readonly providerOptions?: OpenAIProviderOptionsInput readonly providerOptions?: OpenAIProviderOptionsInput
} }
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY") const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
export const responses = (id: string | ModelID, options: OpenAIModelInput<Omit<RouteModelInput, "id">> = {}) => { const defaults = (input: Config) => {
const { apiKey: _, ...rest } = options const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, ...rest } = input
return OpenAIResponses.model(withOpenAIOptions(id, { ...rest, auth: auth(options) }, { textVerbosity: true })) return rest
} }
export const responsesWebSocket = ( const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
id: string | ModelID, route.with({
options: OpenAIModelInput<Omit<RouteModelInput, "id">> = {}, auth: auth(input),
) => { endpoint: { baseURL: input.baseURL, query: input.queryParams },
const { apiKey: _, ...rest } = options })
return OpenAIResponses.webSocketModel(
withOpenAIOptions(id, { ...rest, auth: auth(options) }, { textVerbosity: true }), export const configure = (input: Config = {}) => {
) const responsesRoute = configuredRoute(OpenAIResponses.route, input)
const responsesWebSocketRoute = configuredRoute(OpenAIResponses.webSocketRoute, input)
const chatRoute = configuredRoute(OpenAIChat.route, input)
const modelDefaults = defaults(input)
const responses = (id: string | ModelID) =>
responsesRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
const responsesWebSocket = (id: string | ModelID) =>
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
return {
id,
model: responses,
responses,
responsesWebSocket,
chat,
configure,
}
} }
export const chat = (id: string | ModelID, options: OpenAIModelInput<Omit<RouteModelInput, "id">> = {}) => { export const provider = configure()
const { apiKey: _, ...rest } = options
return OpenAIChat.model(withOpenAIOptions(id, { ...rest, auth: auth(options) }))
}
export const provider = Provider.make({
id,
model: responses,
apis: { responses, responsesWebSocket, chat },
})
export const model = provider.model export const model = provider.model
export const apis = provider.apis export const responses = provider.responses
export const responsesWebSocket = provider.responsesWebSocket
export const chat = provider.chat
+27 -17
View File
@@ -1,9 +1,9 @@
import { Effect, Schema } from "effect" import { Effect, Schema } from "effect"
import { Route, type RouteModelInput } from "../route/client" import { Route, type RouteDefaultsInput } from "../route/client"
import { Endpoint } from "../route/endpoint" import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing" import { Framing } from "../route/framing"
import { Provider } from "../provider"
import { Protocol } from "../route/protocol" import { Protocol } from "../route/protocol"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema" import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile" import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAIChat from "../protocols/openai-chat" import * as OpenAIChat from "../protocols/openai-chat"
@@ -24,11 +24,11 @@ export type OpenRouterProviderOptionsInput = ProviderOptions & {
readonly openrouter?: OpenRouterOptions readonly openrouter?: OpenRouterOptions
} }
export type ModelOptions = Omit<RouteModelInput, "id" | "baseURL" | "providerOptions"> & { export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly baseURL?: string ProviderAuthOption<"optional"> & {
readonly providerOptions?: OpenRouterProviderOptionsInput readonly baseURL?: string
} readonly providerOptions?: OpenRouterProviderOptionsInput
type ModelInput = ModelOptions & Pick<RouteModelInput, "id"> }
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [ const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
Schema.Record(Schema.String, Schema.Any), Schema.Record(Schema.String, Schema.Any),
@@ -68,21 +68,31 @@ const bodyOptions = (input: unknown) => {
export const route = Route.make({ export const route = Route.make({
id: ADAPTER, id: ADAPTER,
provider: profile.provider,
protocol, protocol,
endpoint: Endpoint.path("/chat/completions"), endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }),
framing: Framing.sse, framing: Framing.sse,
}) })
export const routes = [route] export const routes = [route]
const modelRef = Route.model<ModelInput>(route, { const configuredRoute = (input: ModelOptions) => {
provider: profile.provider, const { apiKey: _, auth: _auth, baseURL, ...rest } = input
baseURL: profile.baseURL, return route.with({
}) ...rest,
endpoint: { baseURL: baseURL ?? profile.baseURL },
auth: AuthOptions.bearer(input, "OPENROUTER_API_KEY"),
})
}
export const model = (id: string | ModelID, options: ModelOptions = {}) => modelRef({ ...options, id }) export const configure = (input: ModelOptions = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
export const provider = Provider.make({ export const provider = configure()
id, export const model = provider.model
model,
})
+33 -29
View File
@@ -1,7 +1,5 @@
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { Route } from "../route/client" import type { RouteDefaultsInput } from "../route/client"
import type { RouteModelInput } from "../route/client"
import { Provider } from "../provider"
import { ProviderID, type ModelID } from "../schema" import { ProviderID, type ModelID } from "../schema"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile" import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat" import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
@@ -9,44 +7,50 @@ import * as OpenAIResponses from "../protocols/openai-responses"
export const id = ProviderID.make("xai") export const id = ProviderID.make("xai")
export type ModelOptions = Omit<RouteModelInput, "id" | "apiKey" | "auth" | "baseURL"> & export type ModelOptions = RouteDefaultsInput &
ProviderAuthOption<"optional"> & { ProviderAuthOption<"optional"> & {
readonly baseURL?: string readonly baseURL?: string
} }
export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route] export const routes = [OpenAIResponses.route, OpenAICompatibleChat.route]
const responsesModel = Route.model(OpenAIResponses.route, { provider: id })
const chatModel = OpenAICompatibleChat.model
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY") const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "XAI_API_KEY")
export const responses = (modelID: string | ModelID, options: ModelOptions = {}) => { const configuredResponsesRoute = (input: ModelOptions) => {
const { apiKey: _, ...rest } = options const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return responsesModel({ return OpenAIResponses.route.with({
...rest, ...rest,
auth: auth(options),
id: modelID,
baseURL: options.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL,
})
}
export const chat = (modelID: string | ModelID, options: ModelOptions = {}) => {
const { apiKey: _, ...rest } = options
return chatModel({
...rest,
auth: auth(options),
id: modelID,
provider: id, provider: id,
baseURL: options.baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL, endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
}) })
} }
export const provider = Provider.make({ const configuredChatRoute = (input: ModelOptions) => {
id, const { apiKey: _, auth: _auth, baseURL, ...rest } = input
model: responses, return OpenAICompatibleChat.route.with({
apis: { responses, chat }, ...rest,
}) provider: id,
endpoint: { baseURL: baseURL ?? OpenAICompatibleProfiles.profiles.xai.baseURL },
auth: auth(input),
})
}
export const configure = (input: ModelOptions = {}) => {
const responsesRoute = configuredResponsesRoute(input)
const chatRoute = configuredChatRoute(input)
const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID })
const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID })
return {
id,
model: responses,
responses,
chat,
configure,
}
}
export const provider = configure()
export const model = provider.model export const model = provider.model
export const apis = provider.apis export const responses = provider.responses
export const chat = provider.chat
+19 -60
View File
@@ -12,6 +12,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 | LLMError
type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
export interface AuthInput { export interface AuthInput {
readonly request: LLMRequest readonly request: LLMRequest
@@ -22,7 +23,7 @@ export interface AuthInput {
} }
export interface Credential { export interface Credential {
readonly load: Effect.Effect<Redacted.Redacted<string>, CredentialError> readonly load: Effect.Effect<Redacted.Redacted, CredentialError>
readonly orElse: (that: Credential) => Credential readonly orElse: (that: Credential) => Credential
readonly bearer: () => Auth readonly bearer: () => Auth
readonly header: (name: string) => Auth readonly header: (name: string) => Auth
@@ -39,7 +40,7 @@ export interface Auth {
export const isAuth = (input: unknown): input is Auth => export const isAuth = (input: unknown): input is Auth =>
typeof input === "object" && input !== null && "apply" in input && typeof input.apply === "function" typeof input === "object" && input !== null && "apply" in input && typeof input.apply === "function"
const credential = (load: Effect.Effect<Redacted.Redacted<string>, CredentialError>): Credential => { const credential = (load: Effect.Effect<Redacted.Redacted, CredentialError>): Credential => {
const self: Credential = { const self: Credential = {
load, load,
orElse: (that) => credential(load.pipe(Effect.catch(() => that.load))), orElse: (that) => credential(load.pipe(Effect.catch(() => that.load))),
@@ -66,16 +67,13 @@ const fromCredential = (source: Credential, render: (secret: string) => Headers.
source.load.pipe(Effect.map((secret) => Headers.setAll(input.headers, render(Redacted.value(secret))))), source.load.pipe(Effect.map((secret) => Headers.setAll(input.headers, render(Redacted.value(secret))))),
) )
const secretEffect = (secret: string | Redacted.Redacted<string>, source: string) => { const secretEffect = (secret: string | Redacted.Redacted, source: string) => {
const redacted = typeof secret === "string" ? Redacted.make(secret) : secret const redacted = typeof secret === "string" ? Redacted.make(secret) : secret
if (Redacted.value(redacted) === "") return Effect.fail(new MissingCredentialError(source)) if (Redacted.value(redacted) === "") return Effect.fail(new MissingCredentialError(source))
return Effect.succeed(redacted) return Effect.succeed(redacted)
} }
const credentialFromSecret = ( const credentialFromSecret = (secret: Secret, source: string) => {
secret: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>>,
source: string,
) => {
if (typeof secret === "string" || Redacted.isRedacted(secret)) return credential(secretEffect(secret, source)) if (typeof secret === "string" || Redacted.isRedacted(secret)) return credential(secretEffect(secret, source))
return credential( return credential(
Effect.gen(function* () { Effect.gen(function* () {
@@ -86,17 +84,14 @@ const credentialFromSecret = (
export const value = (secret: string, source = "value") => credentialFromSecret(secret, source) export const value = (secret: string, source = "value") => credentialFromSecret(secret, source)
export const optional = ( export const optional = (secret: Secret | undefined, source = "optional value") =>
secret: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | undefined,
source = "optional value",
) =>
secret === undefined secret === undefined
? credential(Effect.fail(new MissingCredentialError(source))) ? credential(Effect.fail(new MissingCredentialError(source)))
: credentialFromSecret(secret, source) : credentialFromSecret(secret, source)
export const config = (name: string) => credentialFromSecret(Config.redacted(name), name) export const config = (name: string) => credentialFromSecret(Config.redacted(name), name)
export const effect = (load: Effect.Effect<Redacted.Redacted<string>, CredentialError>) => credential(load) export const effect = (load: Effect.Effect<Redacted.Redacted, CredentialError>) => credential(load)
export const none = auth((input) => Effect.succeed(input.headers)) export const none = auth((input) => Effect.succeed(input.headers))
@@ -109,68 +104,32 @@ export const custom = (apply: (input: AuthInput) => Effect.Effect<Headers.Header
export const passthrough = none export const passthrough = none
const fromModelApiKey = (from: (apiKey: string) => Headers.Input) => const credentialInput = (source: Secret | Credential) =>
auth(({ request, headers }) => {
const key = request.model.apiKey
if (!key) return Effect.succeed(headers)
return Effect.succeed(Headers.setAll(headers, from(key)))
})
const credentialInput = (
source: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
) =>
typeof source === "string" || Redacted.isRedacted(source) || Config.isConfig(source) typeof source === "string" || Redacted.isRedacted(source) || Config.isConfig(source)
? credentialFromSecret(source, "value") ? credentialFromSecret(source, "value")
: source : source
export function bearer(): Auth export function bearer(source: Secret | Credential): Auth
export function bearer( export function bearer(source: Secret | Credential) {
source: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
): Auth
export function bearer(
source?: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
) {
if (source === undefined) return fromModelApiKey((key) => ({ authorization: `Bearer ${key}` }))
return credentialInput(source).bearer() return credentialInput(source).bearer()
} }
export const apiKey = bearer export const apiKey = bearer
export const apiKeyHeader = (name: string) => fromModelApiKey((key) => ({ [name]: key })) export function header(name: string): (source: Secret | Credential) => Auth
export function header(name: string, source: Secret | Credential): Auth
export function header( export function header(name: string, source?: Secret | Credential) {
name: string,
): (source: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential) => Auth
export function header(
name: string,
source: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
): Auth
export function header(
name: string,
source?: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
) {
if (source === undefined) { if (source === undefined) {
return ( return (next: Secret | Credential) => credentialInput(next).header(name)
next: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
) => credentialInput(next).header(name)
} }
return credentialInput(source).header(name) return credentialInput(source).header(name)
} }
export function bearerHeader( export function bearerHeader(name: string): (source: Secret | Credential) => Auth
name: string, export function bearerHeader(name: string, source: Secret | Credential): Auth
): (source: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential) => Auth export function bearerHeader(name: string, source?: Secret | Credential) {
export function bearerHeader( const render = (input: Secret | Credential) =>
name: string, fromCredential(credentialInput(input), (secret) => ({ [name]: `Bearer ${secret}` }))
source: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
): Auth
export function bearerHeader(
name: string,
source?: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
) {
const render = (
input: string | Redacted.Redacted<string> | Config.Config<string | Redacted.Redacted<string>> | Credential,
) => fromCredential(credentialInput(input), (secret) => ({ [name]: `Bearer ${secret}` }))
if (source === undefined) return render if (source === undefined) return render
return render(source) return render(source)
} }
+117 -190
View File
@@ -1,31 +1,28 @@
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect" import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
import type { Auth as AuthDef } from "./auth" import * as Option from "effect/Option"
import type { Endpoint } from "./endpoint" import { Auth, type Auth as AuthDef } from "./auth"
import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor" import { RequestExecutor } from "./executor"
import type { Framing } from "./framing" import type { Framing } from "./framing"
import { HttpTransport } from "./transport" import { HttpTransport } from "./transport"
import type { Transport, TransportRuntime } from "./transport" import type { Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport" import { WebSocketExecutor } from "./transport"
import type { Service as WebSocketExecutorService } from "./transport/websocket"
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 * as ToolRuntime from "../tool-runtime" import * as ToolRuntime from "../tool-runtime"
import type { Tools } from "../tool" import type { Tools } from "../tool"
import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID } from "../schema" import type { LLMError, LLMEvent, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
import { import {
GenerationOptions, GenerationOptions,
HttpOptions, HttpOptions,
LLMRequest, LLMRequest,
LLMResponse, LLMResponse,
ModelID, Model,
ModelLimits, ModelLimits,
ModelRef,
LLMError as LLMErrorClass, LLMError as LLMErrorClass,
NoRouteReason,
PreparedRequest, PreparedRequest,
ProviderID, ProviderID,
RouteID,
mergeGenerationOptions, mergeGenerationOptions,
mergeHttpOptions, mergeHttpOptions,
mergeProviderOptions, mergeProviderOptions,
@@ -42,11 +39,13 @@ export interface Route<Body, Prepared = unknown> {
readonly id: string readonly id: string
readonly provider?: ProviderID readonly provider?: ProviderID
readonly protocol: ProtocolID readonly protocol: ProtocolID
readonly endpoint: Endpoint<Body>
readonly auth: AuthDef
readonly transport: Transport<Body, Prepared, unknown> readonly transport: Transport<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: <Input extends RouteModelInput = RouteModelInput>(input: Input) => ModelRef readonly model: (input: RouteMappedModelInput) => Model
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError> readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
readonly streamPrepared: ( readonly streamPrepared: (
prepared: Prepared, prepared: Prepared,
@@ -61,116 +60,77 @@ export interface Route<Body, Prepared = unknown> {
// oxlint-disable-next-line typescript-eslint/no-explicit-any // oxlint-disable-next-line typescript-eslint/no-explicit-any
export type AnyRoute = Route<any, any> export type AnyRoute = Route<any, any>
const routeRegistry = new Map<string, AnyRoute>()
// Route lookup is intentionally global: model refs name a route id, and
// importing the provider/protocol/custom-route module registers the runnable
// implementation. Duplicate ids are bugs because model refs cannot disambiguate
// them.
const register = <R extends AnyRoute>(route: R): R => {
const existing = routeRegistry.get(route.id)
if (existing && existing !== route) throw new Error(`Duplicate LLM route id "${route.id}"`)
routeRegistry.set(route.id, route)
return route
}
const registeredRoute = (id: string) => routeRegistry.get(id)
export type HttpOptionsInput = HttpOptions.Input export type HttpOptionsInput = HttpOptions.Input
export type ModelRefInput = Omit< export type RouteModelInput = Omit<Model.Input, "provider" | "route">
ConstructorParameters<typeof ModelRef>[0],
"id" | "provider" | "route" | "limits" | "generation" | "http" | "auth" export type RouteRoutedModelInput = Omit<Model.Input, "route">
> & {
readonly id: string | ModelID export interface RouteDefaults {
readonly provider: string | ProviderID readonly headers?: Record<string, string>
readonly route: string | RouteID readonly limits?: ModelLimits
readonly auth?: AuthDef readonly generation?: GenerationOptions
readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions
}
export interface RouteDefaultsInput {
readonly headers?: Record<string, string>
readonly limits?: ModelLimits.Input readonly limits?: ModelLimits.Input
readonly generation?: GenerationOptions.Input readonly generation?: GenerationOptions.Input
readonly http?: HttpOptionsInput readonly providerOptions?: ProviderOptions
readonly http?: HttpOptions.Input
} }
// `baseURL` is required on `ModelRefInput` (every materialized `ModelRef` has export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
// a host) but optional at the route-input layers below. The route's `defaults` readonly id?: string
// can supply a canonical URL (e.g. OpenAI/Anthropic) so the user's input may
// omit it. Routes without a canonical URL (OpenAI-compatible, GitHub Copilot)
// re-tighten this in their own input type.
export type RouteModelInput = Omit<ModelRefInput, "provider" | "route" | "baseURL"> & {
readonly baseURL?: string
}
export type RouteModelDefaults = Omit<ModelRefInput, "id" | "route" | "baseURL"> & {
readonly baseURL?: string
}
export type RouteRoutedModelInput = Omit<ModelRefInput, "route" | "baseURL"> & {
readonly baseURL?: string
}
export type RouteRoutedModelDefaults = Partial<Omit<ModelRefInput, "id" | "provider" | "route">>
export type RouteDefaults = Partial<Omit<ModelRefInput, "id" | "provider" | "route">>
export interface RoutePatch<Body, Prepared> extends RouteDefaults {
readonly id: string
readonly provider?: string | ProviderID readonly provider?: string | ProviderID
readonly auth?: AuthDef
readonly transport?: Transport<Body, Prepared, unknown> readonly transport?: Transport<Body, Prepared, unknown>
readonly endpoint?: EndpointPatch<Body>
} }
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
export interface RouteModelOptions< const makeRouteModel = (route: AnyRoute, mapped: RouteMappedModelInput) => {
Input extends RouteMappedModelInput, const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
Output extends RouteMappedModelInput = RouteMappedModelInput, if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
> { if (!endpointBaseURL(route.endpoint))
readonly mapInput?: (input: Input) => Output throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
return Model.make({
...mapped,
provider,
route,
})
} }
export interface RouteMappedModelOptions<Input, Output extends RouteMappedModelInput = RouteMappedModelInput> { const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefaultsInput): RouteDefaults => {
readonly mapInput: (input: Input) => Output const headers = mergeHeaders(base?.headers, patch.headers)
} return {
...base,
const modelWithDefaults = ...patch,
<Input>( headers,
route: AnyRoute, limits: patch.limits === undefined ? base?.limits : ModelLimits.make(patch.limits),
defaults: Partial<Omit<ModelRefInput, "id" | "route">>, generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
options: { readonly mapInput?: (input: Input) => RouteMappedModelInput }, providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
) => http: mergeHttpOptions(
(input: Input) => { base?.http,
const mapped = options.mapInput === undefined ? (input as RouteMappedModelInput) : options.mapInput(input) httpOptions(patch.http),
const provider = defaults.provider ?? route.provider ?? ("provider" in mapped ? mapped.provider : undefined) headers === undefined ? undefined : new HttpOptions({ headers }),
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`) ),
const baseURL = mapped.baseURL ?? defaults.baseURL ?? route.defaults.baseURL
if (!baseURL)
throw new Error(`Route.model(${route.id}) requires a baseURL — supply it via input, defaults, or route defaults`)
const generation = mergeGenerationOptions(route.defaults.generation, defaults.generation)
const providerOptions = mergeProviderOptions(route.defaults.providerOptions, defaults.providerOptions)
const http = mergeHttpOptions(httpOptions(route.defaults.http), httpOptions(defaults.http))
return modelRef({
...route.defaults,
...defaults,
...mapped,
baseURL,
provider,
route: route.id,
limits: mapped.limits ?? defaults.limits ?? route.defaults.limits,
generation: mergeGenerationOptions(generation, mapped.generation),
providerOptions: mergeProviderOptions(providerOptions, mapped.providerOptions),
http: mergeHttpOptions(http, httpOptions(mapped.http)),
})
} }
}
const mergeRouteDefaults = (base: RouteDefaults | undefined, patch: RouteDefaults): RouteDefaults => ({ const endpointBaseURL = <Body>(endpoint: Endpoint<Body>) =>
...base, typeof endpoint.baseURL === "string" ? endpoint.baseURL : undefined
...patch,
limits: patch.limits ?? base?.limits,
generation: mergeGenerationOptions(generationOptions(base?.generation), generationOptions(patch.generation)),
providerOptions: mergeProviderOptions(base?.providerOptions, patch.providerOptions),
http: mergeHttpOptions(httpOptions(base?.http), httpOptions(patch.http)),
})
export const modelLimits = ModelLimits.make const mergeHeaders = (...items: ReadonlyArray<Record<string, string> | undefined>) => {
const entries = items.flatMap((item) =>
item === undefined ? [] : Object.entries(item).filter((entry): entry is [string, string] => entry[1] !== undefined),
)
if (entries.length === 0) return undefined
return Object.fromEntries(entries)
}
export const generationOptions = (input: GenerationOptions.Input | undefined) => export const generationOptions = (input: GenerationOptions.Input | undefined) =>
input === undefined ? undefined : GenerationOptions.make(input) input === undefined ? undefined : GenerationOptions.make(input)
@@ -180,40 +140,6 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
return HttpOptions.make(input) return HttpOptions.make(input)
} }
export const modelRef = (input: ModelRefInput) =>
new ModelRef({
...input,
id: ModelID.make(input.id),
provider: ProviderID.make(input.provider),
route: RouteID.make(input.route),
limits: modelLimits(input.limits),
generation: generationOptions(input.generation),
http: httpOptions(input.http),
})
function model<Input extends RouteModelInput = RouteModelInput>(
route: AnyRoute,
defaults: RouteModelDefaults,
options?: RouteModelOptions<Input, RouteModelInput>,
): (input: Input) => ModelRef
function model<Input extends RouteRoutedModelInput = RouteRoutedModelInput>(
route: AnyRoute,
defaults?: RouteRoutedModelDefaults,
options?: RouteModelOptions<Input, RouteRoutedModelInput>,
): (input: Input) => ModelRef
function model<Input, Output extends RouteMappedModelInput = RouteMappedModelInput>(
route: AnyRoute,
defaults: Partial<Omit<ModelRefInput, "id" | "route">>,
options: RouteMappedModelOptions<Input, Output>,
): (input: Input) => ModelRef
function model<Input>(
route: AnyRoute,
defaults: Partial<Omit<ModelRefInput, "id" | "route">> = {},
options: { readonly mapInput?: (input: Input) => RouteMappedModelInput } = {},
) {
return modelWithDefaults(route, defaults, options)
}
export interface Interface { export interface Interface {
/** /**
* Compile a request through protocol body construction, validation, and HTTP * Compile a request through protocol body construction, validation, and HTTP
@@ -242,22 +168,16 @@ export interface GenerateMethod {
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {} export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
const noRoute = (model: ModelRef) =>
new LLMErrorClass({
module: "LLMClient",
method: "resolveRoute",
reason: new NoRouteReason({ route: model.route, provider: model.provider, model: model.id }),
})
const resolveRequestOptions = (request: LLMRequest) => const resolveRequestOptions = (request: LLMRequest) =>
LLMRequest.update(request, { LLMRequest.update(request, {
generation: mergeGenerationOptions(request.model.generation, request.generation) ?? new GenerationOptions({}), generation:
providerOptions: mergeProviderOptions(request.model.providerOptions, request.providerOptions), mergeGenerationOptions(request.model.route.defaults.generation, request.generation) ?? new GenerationOptions({}),
http: mergeHttpOptions(request.model.http, request.http), providerOptions: mergeProviderOptions(request.model.route.defaults.providerOptions, request.providerOptions),
http: mergeHttpOptions(request.model.route.defaults.http, request.http),
}) })
export interface MakeInput<Body, Frame, Event, State> { export interface MakeInput<Body, Frame, Event, State> {
/** Route id used in registry lookup and error messages. */ /** Route id used in diagnostics and prepared request metadata. */
readonly id: string readonly id: string
/** Provider identity for route-owned model construction. */ /** Provider identity for route-owned model construction. */
readonly provider?: string | ProviderID readonly provider?: string | ProviderID
@@ -265,27 +185,33 @@ export interface MakeInput<Body, Frame, Event, State> {
readonly protocol: Protocol<Body, Frame, Event, State> readonly protocol: Protocol<Body, Frame, Event, State>
/** Where the request is sent. */ /** Where the request is sent. */
readonly endpoint: Endpoint<Body> readonly endpoint: Endpoint<Body>
/** Per-request transport auth. Model-level `Auth` overrides this. */ /** Per-request transport auth. Provider facades override this via `route.with(...)`. */
readonly auth?: AuthDef readonly auth?: AuthDef
/** Stream framing — bytes -> frames before `protocol.stream.event` decoding. */ /** Stream framing — bytes -> frames before `protocol.stream.event` decoding. */
readonly framing: Framing<Frame> readonly framing: Framing<Frame>
/** Static / per-request headers added before `auth` runs. */ /** Static / per-request headers added before `auth` runs. */
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string> readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
/** Model defaults used by the route's `.model(...)` helper. */ /** Route/request defaults used when compiling requests for this route. */
readonly defaults?: RouteDefaults readonly defaults?: RouteDefaultsInput
} }
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> { export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
/** Route id used in registry lookup and error messages. */ /** Route id used in diagnostics and prepared request metadata. */
readonly id: string readonly id: string
/** Provider identity for route-owned model construction. */ /** Provider identity for route-owned model construction. */
readonly provider?: string | ProviderID readonly provider?: string | ProviderID
/** Semantic API contract — owns body construction, body schema, and parsing. */ /** Semantic API contract — owns body construction, body schema, and parsing. */
readonly protocol: Protocol<Body, Frame, Event, State> readonly protocol: Protocol<Body, Frame, Event, State>
/** Where the request is sent. */
readonly endpoint: Endpoint<Body>
/** Per-request transport auth. Provider facades override this via `route.with(...)`. */
readonly auth?: AuthDef
/** Static / per-request headers added before `auth` runs. */
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
/** Runnable transport route. */ /** Runnable transport route. */
readonly transport: Transport<Body, Prepared, Frame> readonly transport: Transport<Body, Prepared, Frame>
/** Provider/model defaults used by the route's `.model(...)` helper. */ /** Route/request defaults used when compiling requests for this route. */
readonly defaults?: RouteDefaults readonly defaults?: RouteDefaultsInput
} }
const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => { const streamError = (route: string, message: string, cause: Cause.Cause<unknown>) => {
@@ -298,6 +224,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
input: MakeTransportInput<Body, Prepared, Frame, Event, State>, input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
): Route<Body, Prepared> { ): Route<Body, Prepared> {
const protocol = input.protocol const protocol = input.protocol
const encodeBody = Schema.encodeSync(Schema.fromJsonString(protocol.body.schema))
const decodeEventEffect = Schema.decodeUnknownEffect(protocol.stream.event) const decodeEventEffect = Schema.decodeUnknownEffect(protocol.stream.event)
const decodeEvent = (route: string) => (frame: Frame) => const decodeEvent = (route: string) => (frame: Frame) =>
decodeEventEffect(frame).pipe( decodeEventEffect(frame).pipe(
@@ -310,29 +237,44 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
), ),
) )
const build = (routeInput: MakeTransportInput<Body, Prepared, Frame, Event, State>): Route<Body, Prepared> => { type BuiltRouteInput = Omit<MakeTransportInput<Body, Prepared, Frame, Event, State>, "defaults"> & {
readonly defaults?: RouteDefaults
}
const build = (routeInput: BuiltRouteInput): Route<Body, Prepared> => {
const route: Route<Body, Prepared> = { const route: Route<Body, Prepared> = {
id: routeInput.id, id: routeInput.id,
provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider), provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider),
protocol: protocol.id, protocol: protocol.id,
endpoint: routeInput.endpoint,
auth: routeInput.auth ?? Auth.none,
transport: routeInput.transport, transport: routeInput.transport,
defaults: routeInput.defaults ?? {}, defaults: routeInput.defaults ?? {},
body: protocol.body, body: protocol.body,
with: (patch: RoutePatch<Body, Prepared>) => { with: (patch: RoutePatch<Body, Prepared>) => {
const { id, provider, transport, ...defaults } = patch const { id, provider, auth, transport, endpoint, ...defaults } = patch
if (!id || id === routeInput.id) throw new Error(`Route.with(${routeInput.id}) requires a new route id`)
return build({ return build({
...routeInput, ...routeInput,
id, id: id ?? routeInput.id,
provider: provider ?? routeInput.provider, provider: provider ?? routeInput.provider,
auth: auth ?? routeInput.auth,
endpoint: endpoint ? Endpoint.merge(routeInput.endpoint, endpoint) : routeInput.endpoint,
transport: (transport as Transport<Body, Prepared, Frame> | undefined) ?? routeInput.transport, transport: (transport as Transport<Body, Prepared, Frame> | undefined) ?? routeInput.transport,
defaults: mergeRouteDefaults(routeInput.defaults, defaults), defaults: mergeRouteDefaults(route.defaults, defaults),
}) })
}, },
model: (input: RouteModelInput): ModelRef => modelWithDefaults<RouteModelInput>(route, {}, {})(input), model: (input) => makeRouteModel(route, input),
prepareTransport: routeInput.transport.prepare, prepareTransport: (body, request) =>
routeInput.transport.prepare({
body,
request,
endpoint: routeInput.endpoint,
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
}),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => { streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route}` const route = `${request.model.provider}/${request.model.route.id}`
const events = routeInput.transport const events = routeInput.transport
.frames(prepared, request, runtime) .frames(prepared, request, runtime)
.pipe( .pipe(
@@ -349,10 +291,10 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
) )
}, },
} satisfies Route<Body, Prepared> } satisfies Route<Body, Prepared>
return register(route) return route
} }
return build(input) return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
} }
export function make<Body, Prepared, Frame, Event, State>( export function make<Body, Prepared, Frame, Event, State>(
@@ -381,18 +323,14 @@ export function make<Body, Prepared, Frame, Event, State>(
): Route<Body, Prepared> | Route<Body, HttpTransport.HttpPrepared<Frame>> { ): Route<Body, Prepared> | Route<Body, HttpTransport.HttpPrepared<Frame>> {
if ("transport" in input) return makeFromTransport(input) if ("transport" in input) return makeFromTransport(input)
const protocol = input.protocol const protocol = input.protocol
const encodeBody = Schema.encodeSync(Schema.fromJsonString(protocol.body.schema))
return makeFromTransport({ return makeFromTransport({
id: input.id, id: input.id,
provider: input.provider, provider: input.provider,
protocol, protocol,
transport: HttpTransport.httpJson({ endpoint: input.endpoint,
endpoint: input.endpoint, auth: input.auth,
auth: input.auth, headers: input.headers,
framing: input.framing, transport: HttpTransport.httpJson({ framing: input.framing }),
encodeBody,
headers: input.headers,
}),
defaults: input.defaults, defaults: input.defaults,
}) })
} }
@@ -402,8 +340,7 @@ export function make<Body, Prepared, Frame, Event, State>(
// execute transport. // execute transport.
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) { const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
const resolved = applyCachePolicy(resolveRequestOptions(request)) const resolved = applyCachePolicy(resolveRequestOptions(request))
const route = registeredRoute(resolved.model.route) const route = resolved.model.route
if (!route) return yield* noRoute(resolved.model)
const body = yield* route.body const body = yield* route.body
.from(resolved) .from(resolved)
@@ -495,31 +432,21 @@ export const streamRequest = (request: LLMRequest) =>
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect( export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const stream = streamWith(streamRequestWith({ http: yield* RequestExecutor.Service })) const stream = streamWith(
streamRequestWith({
http: yield* RequestExecutor.Service,
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
}),
)
return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) }) return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
}), }),
) )
export const layerWithWebSocket: Layer.Layer<Service, never, RequestExecutor.Service | WebSocketExecutorService> = export const Route = { make } as const
Layer.effect(
Service,
Effect.gen(function* () {
const stream = streamWith(
streamRequestWith({
http: yield* RequestExecutor.Service,
webSocket: yield* WebSocketExecutor.Service,
}),
)
return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
}),
)
export const Route = { make, model } as const
export const LLMClient = { export const LLMClient = {
Service, Service,
layer, layer,
layerWithWebSocket,
prepare, prepare,
stream, stream,
generate, generate,
+21 -7
View File
@@ -11,28 +11,42 @@ export type EndpointPart<Body> = string | ((input: EndpointInput<Body>) => strin
/** /**
* Declarative URL construction for one route. * Declarative URL construction for one route.
* *
* `Endpoint` carries only the path. The host always lives on `model.baseURL`, * `Endpoint` carries URL construction for one route. Routes with a canonical
* supplied by the provider helper that constructs the model. `render(...)` * host put `baseURL` here; provider helpers can override it by configuring the
* just appends the path (and any `model.queryParams`) to that host. * route before selecting a model.
* *
* `path` may be a string or a function of `EndpointInput`, for routes whose * `path` may be a string or a function of `EndpointInput`, for routes whose
* URL embeds the model id, region, or another body field (e.g. Bedrock, * URL embeds the model id, region, or another body field (e.g. Bedrock,
* Gemini). * Gemini).
*/ */
export interface Endpoint<Body> { export interface Endpoint<Body> {
readonly baseURL?: string
readonly path: EndpointPart<Body> readonly path: EndpointPart<Body>
readonly query?: Record<string, string>
} }
export type EndpointPatch<Body> = Partial<Endpoint<Body>>
/** Construct an `Endpoint` from a path string or path function. */ /** Construct an `Endpoint` from a path string or path function. */
export const path = <Body>(value: EndpointPart<Body>): Endpoint<Body> => ({ path: value }) export const path = <Body>(value: EndpointPart<Body>, options: Omit<Endpoint<Body>, "path"> = {}): Endpoint<Body> => ({
...options,
path: value,
})
export const merge = <Body>(base: Endpoint<Body>, patch: EndpointPatch<Body>): Endpoint<Body> => ({
...base,
...patch,
baseURL: patch.baseURL ?? base.baseURL,
path: patch.path ?? base.path,
query: patch.query === undefined ? base.query : { ...base.query, ...patch.query },
})
const renderPart = <Body>(part: EndpointPart<Body>, input: EndpointInput<Body>) => const renderPart = <Body>(part: EndpointPart<Body>, input: EndpointInput<Body>) =>
typeof part === "function" ? part(input) : part typeof part === "function" ? part(input) : part
export const render = <Body>(endpoint: Endpoint<Body>, input: EndpointInput<Body>) => { export const render = <Body>(endpoint: Endpoint<Body>, input: EndpointInput<Body>) => {
const url = new URL(`${ProviderShared.trimBaseUrl(input.request.model.baseURL)}${renderPart(endpoint.path, input)}`) const url = new URL(`${ProviderShared.trimBaseUrl(endpoint.baseURL ?? "")}${renderPart(endpoint.path, input)}`)
const params = input.request.model.queryParams for (const [key, value] of Object.entries(endpoint.query ?? {})) url.searchParams.set(key, value)
if (params) for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value)
return url return url
} }
+3 -4
View File
@@ -1,14 +1,13 @@
export { Route, LLMClient, modelLimits, modelRef } from "./client" export { Route, LLMClient } from "./client"
export type { export type {
Route as RouteShape, Route as RouteShape,
RouteModelDefaults,
RouteModelInput, RouteModelInput,
RouteRoutedModelDefaults,
RouteRoutedModelInput, RouteRoutedModelInput,
RouteDefaults,
RouteDefaultsInput,
AnyRoute, AnyRoute,
Interface as LLMClientShape, Interface as LLMClientShape,
Service as LLMClientService, Service as LLMClientService,
ModelRefInput,
} from "./client" } from "./client"
export * from "./executor" export * from "./executor"
export { Auth } from "./auth" export { Auth } from "./auth"

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