Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07b357de04 | ||
|
|
ddbd119dcb | ||
|
|
bd41dac88f | ||
|
|
41f6daf96a | ||
|
|
5381795844 | ||
|
|
7b9d7a7b7d | ||
|
|
7b0fd9fed2 | ||
|
|
b4a01cc3cd | ||
|
|
a6e1aa085f | ||
|
|
6a9cbe7de0 | ||
|
|
0e17c4f299 | ||
|
|
7c121d48b9 | ||
|
|
f5a8202b41 | ||
|
|
43c24d8d0f | ||
|
|
09603ed52f | ||
|
|
38e3b4087b | ||
|
|
eb52362e96 | ||
|
|
2969a513ce | ||
|
|
de672fd214 | ||
|
|
d0779d2aca | ||
|
|
650594e801 | ||
|
|
58143c4b07 | ||
|
|
4cbeacbc76 | ||
|
|
b0ca0419be | ||
|
|
4308dd75fb | ||
|
|
7690481fc1 | ||
|
|
ec6d42d41d | ||
|
|
3553754083 | ||
|
|
ed839846d1 | ||
|
|
ba803dd89a | ||
|
|
0e118d1961 | ||
|
|
40e73c4910 | ||
|
|
9559e2425b | ||
|
|
ac76af8c56 | ||
|
|
b67b03e0ae | ||
|
|
ef82426e28 | ||
|
|
14e9e5d9d6 | ||
|
|
9324ef0d08 | ||
|
|
8940572098 | ||
|
|
17d66ee4fe | ||
|
|
13006d6d7c | ||
|
|
539b118690 | ||
|
|
11f7e5a1b0 | ||
|
|
38b406fb35 | ||
|
|
4702cddb3e | ||
|
|
82c5d45601 |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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,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",
|
||||||
|
|||||||
@@ -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,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,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,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,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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"]>
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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 }>
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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)
|
||||||
+4
@@ -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,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
|
||||||
|
|||||||
@@ -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,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,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
@@ -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
@@ -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 5–15 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
|
||||||
|
|
||||||
|
|||||||
@@ -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?
|
||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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,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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user