chore: merge dev into database wrapper branch

This commit is contained in:
Dax Raad
2026-05-25 19:35:44 -04:00
127 changed files with 14067 additions and 539 deletions
+12
View File
@@ -0,0 +1,12 @@
.git
.opencode
.sst
.turbo
.wrangler
node_modules
**/node_modules
**/.output
**/dist
**/.turbo
**/.vite
**/coverage
+12
View File
@@ -9,9 +9,15 @@ on:
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: read
id-token: write
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'production')
runs-on: ubuntu-latest
environment: ${{ github.ref_name }}
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
@@ -21,6 +27,12 @@ jobs:
with:
node-version: "24"
- uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.3.1
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
role-session-name: opencode-${{ github.run_id }}
aws-region: us-east-1
- run: bun sst deploy --stage=${{ github.ref_name }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+115 -1
View File
@@ -23,7 +23,7 @@
"oxlint-tsgolint": "0.21.0",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "4.13.1",
"sst": "catalog:",
"turbo": "2.8.13",
},
},
@@ -102,6 +102,7 @@
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"@stripe/stripe-js": "8.6.1",
"@upstash/redis": "1.38.0",
"chart.js": "4.5.1",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
@@ -618,6 +619,66 @@
"typescript": "catalog:",
},
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.14.50",
"dependencies": {
"@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"d3-scale": "4.0.2",
"effect": "catalog:",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
"vite": "catalog:",
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@types/bun": "catalog:",
"@types/d3-scale": "4.0.9",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.14.50",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"sst": "catalog:",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
"typescript": "catalog:",
},
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.14.50",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
"@opencode-ai/stats-core": "workspace:*",
"effect": "catalog:",
"sst": "catalog:",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/storybook": {
"name": "@opencode-ai/storybook",
"devDependencies": {
@@ -798,6 +859,7 @@
"shiki": "3.20.0",
"solid-js": "1.9.10",
"solid-list": "0.3.0",
"sst": "4.13.1",
"tailwindcss": "4.1.11",
"typescript": "5.8.2",
"ulid": "3.0.1",
@@ -923,8 +985,12 @@
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-athena": ["@aws-sdk/client-athena@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/credential-provider-node": "3.933.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-9eMUCu1Ay3C9ojo+dJcynSdpbxuwDVtZUt/Xhce+c2+mgDsmvRzjww+wfLpZwRNWxBWmeauQQAZk52tCwQgXsQ=="],
"@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.993.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.11", "@aws-sdk/credential-provider-node": "^3.972.10", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", "@aws-sdk/middleware-user-agent": "^3.972.11", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", "@aws-sdk/util-endpoints": "3.993.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.9", "@smithy/config-resolver": "^4.4.6", "@smithy/core": "^3.23.2", "@smithy/fetch-http-handler": "^5.3.9", "@smithy/hash-node": "^4.2.8", "@smithy/invalid-dependency": "^4.2.8", "@smithy/middleware-content-length": "^4.2.8", "@smithy/middleware-endpoint": "^4.4.16", "@smithy/middleware-retry": "^4.4.33", "@smithy/middleware-serde": "^4.2.9", "@smithy/middleware-stack": "^4.2.8", "@smithy/node-config-provider": "^4.3.8", "@smithy/node-http-handler": "^4.4.10", "@smithy/protocol-http": "^5.3.8", "@smithy/smithy-client": "^4.11.5", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.32", "@smithy/util-defaults-mode-node": "^4.2.35", "@smithy/util-endpoints": "^3.2.8", "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-7Ne3Yk/bgQPVebAkv7W+RfhiwTRSbfER9BtbhOa2w/+dIr902LrJf6vrZlxiqaJbGj2ALx8M+ZK1YIHVxSwu9A=="],
"@aws-sdk/client-firehose": ["@aws-sdk/client-firehose@3.933.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/credential-provider-node": "3.933.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-tDrtgczN2lQsflLDPYu/wdOoyCZLVYtgzmWnYzSEOBWd/cp2AbuQ7D+FemSwUTzyoMTuhhIevyEJKzqsF+QYxA=="],
"@aws-sdk/client-lambda": ["@aws-sdk/client-lambda@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-ryEYNVdilyWkKsOs/7Xy/l7+qjtSz4sll8NpcWD6AtONxjG/5OMaAhxxDkQb4iBoNMKnISxsARzQAp/Wa8pXIg=="],
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.933.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.932.0", "@aws-sdk/credential-provider-node": "3.933.0", "@aws-sdk/middleware-bucket-endpoint": "3.930.0", "@aws-sdk/middleware-expect-continue": "3.930.0", "@aws-sdk/middleware-flexible-checksums": "3.932.0", "@aws-sdk/middleware-host-header": "3.930.0", "@aws-sdk/middleware-location-constraint": "3.930.0", "@aws-sdk/middleware-logger": "3.930.0", "@aws-sdk/middleware-recursion-detection": "3.933.0", "@aws-sdk/middleware-sdk-s3": "3.932.0", "@aws-sdk/middleware-ssec": "3.930.0", "@aws-sdk/middleware-user-agent": "3.932.0", "@aws-sdk/region-config-resolver": "3.930.0", "@aws-sdk/signature-v4-multi-region": "3.932.0", "@aws-sdk/types": "3.930.0", "@aws-sdk/util-endpoints": "3.930.0", "@aws-sdk/util-user-agent-browser": "3.930.0", "@aws-sdk/util-user-agent-node": "3.932.0", "@smithy/config-resolver": "^4.4.3", "@smithy/core": "^3.18.2", "@smithy/eventstream-serde-browser": "^4.2.5", "@smithy/eventstream-serde-config-resolver": "^4.3.5", "@smithy/eventstream-serde-node": "^4.2.5", "@smithy/fetch-http-handler": "^5.3.6", "@smithy/hash-blob-browser": "^4.2.6", "@smithy/hash-node": "^4.2.5", "@smithy/hash-stream-node": "^4.2.5", "@smithy/invalid-dependency": "^4.2.5", "@smithy/md5-js": "^4.2.5", "@smithy/middleware-content-length": "^4.2.5", "@smithy/middleware-endpoint": "^4.3.9", "@smithy/middleware-retry": "^4.4.9", "@smithy/middleware-serde": "^4.2.5", "@smithy/middleware-stack": "^4.2.5", "@smithy/node-config-provider": "^4.3.5", "@smithy/node-http-handler": "^4.4.5", "@smithy/protocol-http": "^5.3.5", "@smithy/smithy-client": "^4.9.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.8", "@smithy/util-defaults-mode-node": "^4.2.11", "@smithy/util-endpoints": "^3.2.5", "@smithy/util-middleware": "^4.2.5", "@smithy/util-retry": "^4.2.5", "@smithy/util-stream": "^4.5.6", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-KxwZvdxdCeWK6o8mpnb+kk7Kgb8V+8AjTwSXUWH1UAD85B0tjdo1cSfE5zoR5fWGol4Ml5RLez12a6LPhsoTqA=="],
@@ -1605,6 +1671,12 @@
"@opencode-ai/slack": ["@opencode-ai/slack@workspace:packages/slack"],
"@opencode-ai/stats-app": ["@opencode-ai/stats-app@workspace:packages/stats/app"],
"@opencode-ai/stats-core": ["@opencode-ai/stats-core@workspace:packages/stats/core"],
"@opencode-ai/stats-server": ["@opencode-ai/stats-server@workspace:packages/stats/server"],
"@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"],
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
@@ -2343,6 +2415,10 @@
"@types/cross-spawn": ["@types/cross-spawn@6.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
@@ -2487,6 +2563,8 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="],
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
@@ -2913,6 +2991,20 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
@@ -3487,6 +3579,8 @@
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
@@ -5195,6 +5289,16 @@
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-sdk/client-athena/@smithy/core": ["@smithy/core@3.24.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg=="],
"@aws-sdk/client-athena/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A=="],
"@aws-sdk/client-athena/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="],
"@aws-sdk/client-athena/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="],
"@aws-sdk/client-athena/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@aws-sdk/client-cognito-identity/@aws-sdk/core": ["@aws-sdk/core@3.973.27", "", { "dependencies": { "@aws-sdk/types": "^3.973.7", "@aws-sdk/xml-builder": "^3.972.17", "@smithy/core": "^3.23.14", "@smithy/node-config-provider": "^4.3.13", "@smithy/property-provider": "^4.2.13", "@smithy/protocol-http": "^5.3.13", "@smithy/signature-v4": "^5.3.13", "@smithy/smithy-client": "^4.12.9", "@smithy/types": "^4.14.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.13", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A=="],
"@aws-sdk/client-cognito-identity/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.30", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.25", "@aws-sdk/credential-provider-http": "^3.972.27", "@aws-sdk/credential-provider-ini": "^3.972.29", "@aws-sdk/credential-provider-process": "^3.972.25", "@aws-sdk/credential-provider-sso": "^3.972.29", "@aws-sdk/credential-provider-web-identity": "^3.972.29", "@aws-sdk/types": "^3.973.7", "@smithy/credential-provider-imds": "^4.2.13", "@smithy/property-provider": "^4.2.13", "@smithy/shared-ini-file-loader": "^4.4.8", "@smithy/types": "^4.14.0", "tslib": "^2.6.2" } }, "sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw=="],
@@ -5219,6 +5323,16 @@
"@aws-sdk/client-cognito-identity/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@aws-sdk/client-firehose/@smithy/core": ["@smithy/core@3.24.3", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg=="],
"@aws-sdk/client-firehose/@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.4.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A=="],
"@aws-sdk/client-firehose/@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="],
"@aws-sdk/client-firehose/@smithy/types": ["@smithy/types@4.14.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw=="],
"@aws-sdk/client-firehose/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@aws-sdk/client-lambda/@aws-sdk/core": ["@aws-sdk/core@3.974.11", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/xml-builder": "^3.972.24", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.2", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug=="],
"@aws-sdk/client-lambda/@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.42", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.37", "@aws-sdk/credential-provider-http": "^3.972.39", "@aws-sdk/credential-provider-ini": "^3.972.41", "@aws-sdk/credential-provider-process": "^3.972.37", "@aws-sdk/credential-provider-sso": "^3.972.41", "@aws-sdk/credential-provider-web-identity": "^3.972.41", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/credential-provider-imds": "^4.3.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ=="],
+1 -1
View File
@@ -30,7 +30,7 @@ export const api = new sst.cloudflare.Worker("Api", {
transform: {
worker: (args) => {
args.logpush = true
if ($app.stage === "vimtor") return
if ($app.stage === "vimtor" || $app.stage === "adam") return
args.bindings = $resolve(args.bindings).apply((bindings) => [
...bindings,
{
+7 -3
View File
@@ -1,7 +1,9 @@
import { domain } from "./stage"
import { deployAws, domain } from "./stage"
import { EMAILOCTOPUS_API_KEY } from "./app"
import { SECRET } from "./secret"
const lake = deployAws ? await import("./lake") : undefined
////////////////
// DATABASE
////////////////
@@ -240,7 +242,7 @@ const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts",
link: [new sst.Secret("HONEYCOMB_API_KEY")],
link: [SECRET.HoneycombApiKey, ...(lake?.lakeIngest ? [lake.lakeIngest] : [])],
})
new sst.cloudflare.x.SolidStart("Console", {
@@ -250,6 +252,8 @@ new sst.cloudflare.x.SolidStart("Console", {
bucket,
bucketNew,
database,
SECRET.UpstashRedisRestUrl,
SECRET.UpstashRedisRestToken,
AUTH_API_URL,
STRIPE_WEBHOOK_SECRET,
DISCORD_INCIDENT_WEBHOOK_URL,
@@ -281,7 +285,7 @@ new sst.cloudflare.x.SolidStart("Console", {
},
transform: {
server: {
placement: { region: "aws:us-east-1" },
placement: { region: "aws:us-east-2" },
transform: {
worker: {
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
+322
View File
@@ -0,0 +1,322 @@
import { domain } from "./stage"
const current = aws.getCallerIdentityOutput({})
const partition = aws.getPartitionOutput({})
const region = aws.getRegionOutput({})
const tableBucketName = `opencode-${$app.stage}-lake`
const glueCatalogName = "s3tablescatalog"
const glueCatalogArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:catalog`
const glueS3TablesCatalogArn = $interpolate`${glueCatalogArn}/${glueCatalogName}`
const glueS3TablesChildCatalogArn = $interpolate`${glueS3TablesCatalogArn}/${tableBucketName}`
const glueS3TablesDatabaseWildcardArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/${glueCatalogName}/${tableBucketName}/*`
const glueS3TablesTableWildcardArn = $interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/${tableBucketName}/*/*`
const s3TablesBucketWildcardArn = $interpolate`arn:${partition.partition}:s3tables:${region.region}:${current.accountId}:bucket/*`
export const tableBucket = new aws.s3tables.TableBucket("LakeTableBucket", {
name: tableBucketName,
forceDestroy: $app.stage !== "production",
})
const s3TablesCatalog = new aws.cloudcontrol.Resource(
"LakeS3TablesCatalog",
{
typeName: "AWS::Glue::Catalog",
desiredState: $jsonStringify({
Name: glueCatalogName,
Description: "Federated catalog for S3 Tables",
FederatedCatalog: {
Identifier: s3TablesBucketWildcardArn,
ConnectionName: "aws:s3tables",
},
CreateDatabaseDefaultPermissions: [
{
Principal: {
DataLakePrincipalIdentifier: "IAM_ALLOWED_PRINCIPALS",
},
Permissions: ["ALL"],
},
],
CreateTableDefaultPermissions: [
{
Principal: {
DataLakePrincipalIdentifier: "IAM_ALLOWED_PRINCIPALS",
},
Permissions: ["ALL"],
},
],
AllowFullTableExternalDataAccess: "True",
}),
},
{ dependsOn: [tableBucket] },
)
const athenaResultsBucket = new aws.s3.Bucket("LakeAthenaResults", {
bucket: `opencode-${$app.stage}-lake-athena-results`,
forceDestroy: $app.stage !== "production",
})
const firehoseErrorBucket = new aws.s3.Bucket("LakeFirehoseErrors", {
bucket: `opencode-${$app.stage}-lake-firehose-errors`,
forceDestroy: $app.stage !== "production",
})
const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", {
name: `opencode-${$app.stage}-lake-workgroup`,
forceDestroy: $app.stage !== "production",
configuration: {
enforceWorkgroupConfiguration: true,
publishCloudwatchMetricsEnabled: true,
resultConfiguration: {
outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`,
},
},
})
const firehoseRole = new aws.iam.Role("LakeFirehoseRole", {
assumeRolePolicy: aws.iam.getPolicyDocumentOutput({
statements: [
{
effect: "Allow",
actions: ["sts:AssumeRole"],
principals: [
{
type: "Service",
identifiers: ["firehose.amazonaws.com"],
},
],
},
],
}).json,
})
const firehosePolicy = new aws.iam.RolePolicy("LakeFirehosePolicy", {
role: firehoseRole.id,
policy: aws.iam.getPolicyDocumentOutput({
statements: [
{
effect: "Allow",
actions: [
"s3tables:ListTableBuckets",
"s3tables:GetTableBucket",
"s3tables:GetNamespace",
"s3tables:GetTable",
"s3tables:GetTableData",
"s3tables:GetTableMetadataLocation",
"s3tables:ListNamespaces",
"s3tables:ListTables",
"s3tables:PutTableData",
"s3tables:UpdateTableMetadataLocation",
],
resources: ["*"],
},
{
effect: "Allow",
actions: [
"glue:GetCatalog",
"glue:GetCatalogs",
"glue:GetDatabase",
"glue:GetDatabases",
"glue:GetTable",
"glue:GetTables",
"glue:UpdateTable",
],
resources: [
glueCatalogArn,
glueS3TablesCatalogArn,
$interpolate`${glueS3TablesCatalogArn}/*`,
glueS3TablesDatabaseWildcardArn,
glueS3TablesTableWildcardArn,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/*`,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/*/*`,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/*`,
],
},
{
effect: "Allow",
actions: [
"s3:AbortMultipartUpload",
"s3:GetBucketLocation",
"s3:GetObject",
"s3:ListBucket",
"s3:ListBucketMultipartUploads",
"s3:PutObject",
],
resources: [firehoseErrorBucket.arn, $interpolate`${firehoseErrorBucket.arn}/*`],
},
{
effect: "Allow",
actions: ["lakeformation:GetDataAccess"],
resources: ["*"],
},
],
}).json,
})
const firehose = new aws.kinesis.FirehoseDeliveryStream(
"LakeFirehose",
{
name: `opencode-${$app.stage}-lake-ingest`,
destination: "iceberg",
icebergConfiguration: {
appendOnly: true,
bufferingInterval: 60,
bufferingSize: 1,
catalogArn: glueS3TablesChildCatalogArn,
processingConfiguration: {
enabled: true,
processors: [
{
type: "MetadataExtraction",
parameters: [
{ parameterName: "JsonParsingEngine", parameterValue: "JQ-1.6" },
{
parameterName: "MetadataExtractionQuery",
parameterValue:
'{destinationDatabaseName:._lake_database,destinationTableName:._lake_table,operation:(._lake_operation // "insert")}',
},
],
},
],
},
roleArn: firehoseRole.arn,
s3BackupMode: "FailedDataOnly",
s3Configuration: {
roleArn: firehoseRole.arn,
bucketArn: firehoseErrorBucket.arn,
errorOutputPrefix: "errors/!{firehose:error-output-type}/",
},
},
},
{ dependsOn: [s3TablesCatalog, firehosePolicy] },
)
export const lakeVpc = new sst.aws.Vpc("LakeVpc")
export const lakeCluster = new sst.aws.Cluster("LakeCluster", { vpc: lakeVpc })
export const lakeRegion = region.region
export const lakeCatalog = $interpolate`${glueCatalogName}/${tableBucket.name}`
export const lakeAthenaWorkgroup = athenaWorkgroup
const ingestSecret = new random.RandomPassword("LakeIngestSecret", { length: 32 })
const ingestConfig = new sst.Linkable("LakeIngestConfig", {
properties: {
streamName: firehose.name,
secret: ingestSecret.result,
},
})
const ingestService = new sst.aws.Service("LakeIngestService", {
cluster: lakeCluster,
architecture: "arm64",
cpu: "0.5 vCPU",
memory: "1 GB",
image: {
context: ".",
dockerfile: "packages/stats/server/Dockerfile",
},
link: [ingestConfig],
permissions: [
{
actions: ["firehose:PutRecord", "firehose:PutRecordBatch"],
resources: [firehose.arn],
},
],
scaling: {
min: $app.stage === "production" ? 2 : 1,
max: $app.stage === "production" ? 32 : 4,
cpuUtilization: 60,
memoryUtilization: 70,
},
loadBalancer: {
domain: {
name: `lake.${domain}`,
dns: sst.cloudflare.dns(),
},
rules: [
{ listen: "80/http", redirect: "443/https" },
{ listen: "443/https", forward: "3000/http" },
],
health: {
"3000/http": {
path: "/ready",
successCodes: "200-299",
},
},
},
health: {
command: [
"CMD-SHELL",
"bun --eval \"fetch('http://localhost:3000/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))\"",
],
interval: "30 seconds",
retries: 3,
startPeriod: "30 seconds",
timeout: "5 seconds",
},
dev: {
command: "bun run start",
directory: "packages/stats/server",
url: "http://localhost:3000",
},
wait: $app.stage === "production",
})
export const lakeIngest = new sst.Linkable("LakeIngest", {
properties: {
url: ingestService.url,
secret: ingestSecret.result,
},
})
export const lakeQueryPermissions = [
{
actions: ["athena:StartQueryExecution", "athena:GetQueryExecution", "athena:GetQueryResults"],
resources: [athenaWorkgroup.arn],
},
{
actions: [
"glue:GetCatalog",
"glue:GetCatalogs",
"glue:GetDatabase",
"glue:GetDatabases",
"glue:GetTable",
"glue:GetTables",
"glue:GetPartitions",
],
resources: [
glueCatalogArn,
glueS3TablesCatalogArn,
$interpolate`${glueS3TablesCatalogArn}/*`,
glueS3TablesDatabaseWildcardArn,
glueS3TablesTableWildcardArn,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:database/*`,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/*/*`,
$interpolate`arn:${partition.partition}:glue:${region.region}:${current.accountId}:table/${glueCatalogName}/*`,
],
},
{
actions: ["s3:GetBucketLocation", "s3:ListBucket"],
resources: [athenaResultsBucket.arn],
},
{
actions: ["s3:GetObject", "s3:PutObject", "s3:AbortMultipartUpload", "s3:ListBucketMultipartUploads"],
resources: [$interpolate`${athenaResultsBucket.arn}/*`],
},
{
actions: [
"s3tables:GetTableBucket",
"s3tables:GetNamespace",
"s3tables:GetTable",
"s3tables:GetTableData",
"s3tables:GetTableMetadataLocation",
"s3tables:ListNamespaces",
"s3tables:ListTables",
],
resources: ["*"],
},
{
actions: ["lakeformation:GetDataAccess"],
resources: ["*"],
},
]
+3
View File
@@ -7,5 +7,8 @@ sst.Linkable.wrap(random.RandomPassword, (resource) => ({
export const SECRET = {
R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
}
+46
View File
@@ -5,6 +5,52 @@ export const domain = (() => {
})()
export const zoneID = "430ba34c138cfb5360826c4909f99be8"
// Dev owns the shared AWS lake/stats infra for all non-production stages.
export const awsStage = $app.stage === "production" ? "production" : "dev"
export const deployAws = $app.stage === awsStage
const githubActionsDeployRole = (() => {
if ($app.stage !== "dev" && $app.stage !== "production") return
const provider = new aws.iam.OpenIdConnectProvider("GithubActionsOidcProvider", {
url: "https://token.actions.githubusercontent.com",
clientIdLists: ["sts.amazonaws.com"],
})
const role = new aws.iam.Role("GithubActionsDeployRole", {
name: `opencode-${$app.stage}-github-actions-deploy`,
maxSessionDuration: 3600,
assumeRolePolicy: aws.iam.getPolicyDocumentOutput({
statements: [
{
effect: "Allow",
actions: ["sts:AssumeRoleWithWebIdentity"],
principals: [{ type: "Federated", identifiers: [provider.arn] }],
conditions: [
{
test: "StringEquals",
variable: "token.actions.githubusercontent.com:aud",
values: ["sts.amazonaws.com"],
},
{
test: "StringEquals",
variable: "token.actions.githubusercontent.com:sub",
values: [`repo:anomalyco/opencode:environment:${$app.stage}`],
},
],
},
],
}).json,
})
new aws.iam.RolePolicyAttachment("GithubActionsDeployRoleAdmin", {
role: role.name,
policyArn: "arn:aws:iam::aws:policy/AdministratorAccess",
})
return role
})()
export const githubActionsDeployRoleArn = githubActionsDeployRole?.arn
new cloudflare.RegionalHostname("RegionalHostname", {
hostname: domain,
+207
View File
@@ -0,0 +1,207 @@
import { lakeAthenaWorkgroup, lakeCatalog, lakeCluster, lakeQueryPermissions, lakeRegion, tableBucket } from "./lake"
const domain = (() => {
if ($app.stage === "production") return "stats.opencode.ai"
if ($app.stage === "dev") return "stats.dev.opencode.ai"
return `stats.${$app.stage}.dev.opencode.ai`
})()
////////////////
// LAKE
////////////////
const inferenceNamespace = new aws.s3tables.Namespace("LakeInferenceNamespace", {
namespace: "inference",
tableBucketArn: tableBucket.arn,
})
const inferenceEventTable = new aws.s3tables.Table(
"LakeInferenceEventTable",
{
name: "event",
namespace: inferenceNamespace.namespace,
tableBucketArn: inferenceNamespace.tableBucketArn,
format: "ICEBERG",
metadata: {
iceberg: {
schema: {
fields: [
{ name: "event_timestamp", type: "string", required: false },
{ name: "event_date", type: "string", required: false },
{ name: "event_type", type: "string", required: false },
{ name: "dataset", type: "string", required: false },
{ name: "cf_continent", type: "string", required: false },
{ name: "cf_country", type: "string", required: false },
{ name: "cf_city", type: "string", required: false },
{ name: "cf_region", type: "string", required: false },
{ name: "cf_latitude", type: "double", required: false },
{ name: "cf_longitude", type: "double", required: false },
{ name: "cf_timezone", type: "string", required: false },
{ name: "duration", type: "double", required: false },
{ name: "request_length", type: "long", required: false },
{ name: "status", type: "int", required: false },
{ name: "ip", type: "string", required: false },
{ name: "is_stream", type: "boolean", required: false },
{ name: "session", type: "string", required: false },
{ name: "request", type: "string", required: false },
{ name: "client", type: "string", required: false },
{ name: "user_agent", type: "string", required: false },
{ name: "model_variant", type: "string", required: false },
{ name: "source", type: "string", required: false },
{ name: "provider", type: "string", required: false },
{ name: "provider_model", type: "string", required: false },
{ name: "model", type: "string", required: false },
{ name: "llm_error_code", type: "int", required: false },
{ name: "llm_error_message", type: "string", required: false },
{ name: "error_response", type: "string", required: false },
{ name: "error_type", type: "string", required: false },
{ name: "error_message", type: "string", required: false },
{ name: "error_cause", type: "string", required: false },
{ name: "error_cause2", type: "string", required: false },
{ name: "api_key", type: "string", required: false },
{ name: "workspace", type: "string", required: false },
{ name: "is_subscription", type: "boolean", required: false },
{ name: "subscription", type: "string", required: false },
{ name: "response_length", type: "long", required: false },
{ name: "time_to_first_byte", type: "long", required: false },
{ name: "timestamp_first_byte", type: "long", required: false },
{ name: "timestamp_last_byte", type: "long", required: false },
{ name: "tokens_input", type: "long", required: false },
{ name: "tokens_output", type: "long", required: false },
{ name: "tokens_reasoning", type: "long", required: false },
{ name: "tokens_cache_read", type: "long", required: false },
{ name: "tokens_cache_write_5m", type: "long", required: false },
{ name: "tokens_cache_write_1h", type: "long", required: false },
{ name: "cost_input_microcents", type: "long", required: false },
{ name: "cost_output_microcents", type: "long", required: false },
{ name: "cost_cache_read_microcents", type: "long", required: false },
{ name: "cost_cache_write_microcents", type: "long", required: false },
{ name: "cost_total_microcents", type: "long", required: false },
{ name: "cost_input", type: "long", required: false },
{ name: "cost_output", type: "long", required: false },
{ name: "cost_cache_read", type: "long", required: false },
{ name: "cost_cache_write_5m", type: "long", required: false },
{ name: "cost_cache_write_1h", type: "long", required: false },
{ name: "cost_total", type: "long", required: false },
],
},
},
},
},
{ deleteBeforeReplace: $app.stage !== "production" },
)
export const inferenceEvent = new sst.Linkable("InferenceEvent", {
properties: {
region: lakeRegion,
catalog: lakeCatalog,
database: inferenceNamespace.namespace,
table: inferenceEventTable.name,
tableBucket: tableBucket.name,
workgroup: lakeAthenaWorkgroup.name,
},
})
////////////////
// DATABASE
////////////////
const cluster = planetscale.getDatabaseOutput({
name: "opencode-stats",
organization: "anomalyco",
})
const branch =
$app.stage === "production"
? planetscale.getBranchOutput({
name: "production",
organization: cluster.organization,
database: cluster.name,
})
: new planetscale.Branch("StatsDatabaseBranch", {
database: cluster.name,
organization: cluster.organization,
name: $app.stage,
parentBranch: "production",
})
const password = new planetscale.Password("StatsDatabasePassword", {
name: $app.stage,
database: cluster.name,
organization: cluster.organization,
branch: branch.name,
})
const databaseUrl = $interpolate`mysql://${password.username.apply(encodeURIComponent)}:${password.plaintext.apply(
encodeURIComponent,
)}@${password.accessHostUrl}/${cluster.name}`
export const database = new sst.Linkable("StatsDatabase", {
properties: {
host: password.accessHostUrl,
database: cluster.name,
username: password.username,
password: password.plaintext,
port: 3306,
url: databaseUrl,
},
})
new sst.x.DevCommand("StatsStudio", {
link: [database],
environment: {
DATABASE_URL: databaseUrl,
},
dev: {
command: "bun db:studio",
directory: "packages/stats/core",
autostart: false,
},
})
////////////////
// APP
////////////////
// export const app = new sst.cloudflare.x.SolidStart("Stats", {
// path: "packages/stats/app",
// buildCommand: "bun run build",
// domain,
// link: [database],
// environment: {
// PUBLIC_URL: `https://${domain}`,
// },
// })
////////////////
// SERVICES
////////////////
const statsSyncConfig = new sst.Linkable("StatsSyncConfig", {
properties: {
dataset: "zen",
},
})
export const statSync = new sst.aws.Service("StatsSyncService", {
cluster: lakeCluster,
architecture: "arm64",
cpu: "0.25 vCPU",
memory: "0.5 GB",
image: {
context: ".",
dockerfile: "packages/stats/server/Dockerfile",
},
command: ["bun", "src/stat-sync.ts"],
link: [database, inferenceEvent, statsSyncConfig],
permissions: lakeQueryPermissions,
scaling: {
min: 1,
max: 1,
},
dev: {
command: "bun src/stat-sync.ts",
directory: "packages/stats/server",
autostart: false,
},
})
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Nol27sqG6za6oR32MqZrmRxch0/XFeQ61C9Yqtxo7Hk=",
"aarch64-linux": "sha256-+jfkGr4tuv/s/v890XaFTRqUxwA8H8KVMiys6sEXi/4=",
"aarch64-darwin": "sha256-FYtKluNazzuQcrr2iHm7B2pJfYrAHkbcj4kWs2FtBgc=",
"x86_64-darwin": "sha256-PpyWDkzb0p50US7Bs3m3A4PZ0JM3dccGgTKYN9JFYR0="
"x86_64-linux": "sha256-6s5msV+dYMHHt9Gc/CqvCrUj8K7ELjxoAe6ejHSJo4I=",
"aarch64-linux": "sha256-SP94UPy5LePd7ZdC3eENIXiozc67blpg1SN9Ug5yiv8=",
"aarch64-darwin": "sha256-jv3lffiAQ5kxDAbXNavsHD+tjjdgT0dT0JxN0bWMYTE=",
"x86_64-darwin": "sha256-aYNtcarg516ZmqaO62mnUZYiSyWt8rJjUHQslhrhGHM="
}
}
+5 -2
View File
@@ -10,6 +10,7 @@
"dev:desktop": "bun --cwd packages/desktop dev",
"dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:stats": "bun run --cwd packages/stats/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"typecheck": "bun turbo typecheck",
@@ -17,13 +18,14 @@
"postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"test": "echo 'do not run tests from root' && exit 1"
},
"workspaces": {
"packages": [
"packages/*",
"packages/console/*",
"packages/stats/*",
"packages/sdk/js",
"packages/slack"
],
@@ -72,6 +74,7 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"sst": "4.13.1",
"shiki": "3.20.0",
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
@@ -98,7 +101,7 @@
"oxlint-tsgolint": "0.21.0",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "4.13.1",
"sst": "catalog:",
"turbo": "2.8.13"
},
"dependencies": {
+1
View File
@@ -26,6 +26,7 @@
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"@stripe/stripe-js": "8.6.1",
"@upstash/redis": "1.38.0",
"chart.js": "4.5.1",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
@@ -249,8 +249,9 @@ export async function handler(
if (!isStream || [400, 404, 429].includes(res.status)) {
const json = await res.json()
await rateLimiter?.track()
if (json.usage) {
const usageInfo = providerInfo.normalizeUsage(json.usage)
const usage = providerInfo.extractUsage(json)
if (usage) {
const usageInfo = providerInfo.normalizeUsage(usage)
const costInfo = calculateCost(modelInfo, usageInfo)
await trialLimiter?.track(usageInfo)
await modelTpmLimiter?.track(providerInfo.id, providerInfo.model, usageInfo)
@@ -2,6 +2,7 @@ import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizz
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { FreeUsageLimitError } from "./error"
import { logger } from "./logger"
import { buildRateLimitKey, getRedis } from "./redis"
import { i18n } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
import { Subscription } from "@opencode-ai/console-core/subscription.js"
@@ -23,43 +24,62 @@ export function createRateLimiter(modelId: string, rateLimit: number | undefined
const now = Date.now()
const lifetimeInterval = ""
const dailyInterval = rateLimit ? `${buildYYYYMMDD(now)}${modelId.substring(0, 2)}` : buildYYYYMMDD(now)
let _isNew: boolean
const retryAfter = getRetryAfterDay(now)
const redis = getRedis()
const lifetimeKey = buildRateLimitKey("ip", ip)
const dailyKey = buildRateLimitKey("ip", ip, dailyInterval)
let isNew = false
return {
check: async () => {
const rows = await Database.use((tx) =>
tx
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
.from(IpRateLimitTable)
.where(
and(
eq(IpRateLimitTable.ip, ip),
isDefaultModel
? inArray(IpRateLimitTable.interval, [lifetimeInterval, dailyInterval])
: inArray(IpRateLimitTable.interval, [dailyInterval]),
const [counts, rows] = await Promise.all([
redis.mget<(string | number | null)[]>(isDefaultModel ? [lifetimeKey, dailyKey] : [dailyKey]).catch(() => []),
Database.use((tx) =>
tx
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
.from(IpRateLimitTable)
.where(
and(
eq(IpRateLimitTable.ip, ip),
isDefaultModel
? inArray(IpRateLimitTable.interval, [lifetimeInterval, dailyInterval])
: inArray(IpRateLimitTable.interval, [dailyInterval]),
),
),
),
)
const lifetimeCount = rows.find((r) => r.interval === lifetimeInterval)?.count ?? 0
const dailyCount = rows.find((r) => r.interval === dailyInterval)?.count ?? 0
),
])
const redisLifetimeCount = isDefaultModel ? Number(counts[0] ?? 0) : 0
const redisDailyCount = Number(counts[isDefaultModel ? 1 : 0] ?? 0)
const databaseLifetimeCount = rows.find((r) => r.interval === lifetimeInterval)?.count ?? 0
const databaseDailyCount = rows.find((r) => r.interval === dailyInterval)?.count ?? 0
const lifetimeCount = Math.max(redisLifetimeCount, databaseLifetimeCount)
const dailyCount = Math.max(redisDailyCount, databaseDailyCount)
logger.debug(`rate limit lifetime: ${lifetimeCount}, daily: ${dailyCount}`)
_isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
isNew = isDefaultModel && lifetimeCount < dailyLimit * 7
if (isDefaultModel && databaseLifetimeCount > redisLifetimeCount)
await redis.set(lifetimeKey, databaseLifetimeCount).catch(() => {})
if ((_isNew && dailyCount >= dailyLimit * 2) || (!_isNew && dailyCount >= dailyLimit))
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], getRetryAfterDay(now))
if ((isNew && dailyCount >= dailyLimit * 2) || (!isNew && dailyCount >= dailyLimit))
throw new FreeUsageLimitError(dict["zen.api.error.rateLimitExceeded"], retryAfter)
},
track: async () => {
await Database.use((tx) =>
tx
.insert(IpRateLimitTable)
.values([
{ ip, interval: dailyInterval, count: 1 },
...(_isNew ? [{ ip, interval: lifetimeInterval, count: 1 }] : []),
])
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
)
const pipeline = redis.pipeline()
pipeline.incr(dailyKey)
pipeline.expire(dailyKey, retryAfter)
if (isNew) pipeline.incr(lifetimeKey)
await Promise.all([
pipeline.exec().catch(() => {}),
Database.use((tx) =>
tx
.insert(IpRateLimitTable)
.values([
{ ip, interval: dailyInterval, count: 1 },
...(isNew ? [{ ip, interval: lifetimeInterval, count: 1 }] : []),
])
.onDuplicateKeyUpdate({ set: { count: sql`${IpRateLimitTable.count} + 1` } }),
),
])
},
}
}
@@ -175,6 +175,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
retrieve: () => usage,
}
},
extractUsage: (response: any) => response.usage,
normalizeUsage: (usage: Usage) => ({
inputTokens: usage.input_tokens ?? 0,
outputTokens: usage.output_tokens ?? 0,
@@ -58,6 +58,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
retrieve: () => usage,
}
},
extractUsage: (response: any) => response.usageMetadata,
normalizeUsage: (usage: Usage) => {
const inputTokens = usage.promptTokenCount ?? 0
const outputTokens = usage.candidatesTokenCount ?? 0
@@ -58,6 +58,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
retrieve: () => usage,
}
},
extractUsage: (response: any) => response.usage,
normalizeUsage: (usage: Usage) => {
let inputTokens = usage.prompt_tokens ?? 0
const outputTokens = usage.completion_tokens ?? 0
@@ -43,6 +43,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
retrieve: () => usage,
}
},
extractUsage: (response: any) => response.usage ?? response.response?.usage,
normalizeUsage: (usage: Usage) => {
const inputTokens = usage.input_tokens ?? 0
const outputTokens = usage.output_tokens ?? 0
@@ -49,6 +49,7 @@ export type ProviderHelper = (input: {
parse: (chunk: string) => void
retrieve: () => any
}
extractUsage: (response: any) => any
normalizeUsage: (usage: any) => UsageInfo
}
@@ -0,0 +1,18 @@
import { Resource } from "@opencode-ai/console-resource"
import { Redis } from "@upstash/redis/cloudflare"
let redis: Redis | undefined
export function getRedis() {
if (redis) return redis
redis = new Redis({
url: Resource.UpstashRedisRestUrl.value,
token: Resource.UpstashRedisRestToken.value,
enableTelemetry: false,
})
return redis
}
export function buildRateLimitKey(kind: string, identifier: string, interval?: string) {
return `${Resource.App.stage}:ratelimit:${kind}:${identifier}${interval ? `:${interval}` : ""}`
}
@@ -0,0 +1,68 @@
import { describe, expect, test } from "bun:test"
import type { ZenData } from "@opencode-ai/console-core/model.js"
import type { ProviderHelper } from "../src/routes/zen/util/provider/provider"
import { anthropicHelper } from "../src/routes/zen/util/provider/anthropic"
import { googleHelper } from "../src/routes/zen/util/provider/google"
import { oaCompatHelper } from "../src/routes/zen/util/provider/openai-compatible"
import { openaiHelper } from "../src/routes/zen/util/provider/openai"
const providers = {
anthropic: anthropicHelper({ reqModel: "claude-haiku-4-5", providerModel: "claude-haiku-4-5" }),
google: googleHelper({ reqModel: "gemini-3-flash", providerModel: "gemini-3-flash" }),
openai: openaiHelper({ reqModel: "gpt-5", providerModel: "gpt-5" }),
"oa-compat": oaCompatHelper({ reqModel: "gpt-5-nano", providerModel: "gpt-5-nano" }),
} satisfies Record<ZenData.Format, ReturnType<ProviderHelper>>
describe("provider usage extraction", () => {
test("extracts Google non-stream usage metadata", () => {
const usage = providers.google.extractUsage({
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 3,
thoughtsTokenCount: 2,
cachedContentTokenCount: 4,
},
})
expect(providers.google.normalizeUsage(usage)).toEqual({
inputTokens: 6,
outputTokens: 3,
reasoningTokens: 2,
cacheReadTokens: 4,
cacheWrite5mTokens: undefined,
cacheWrite1hTokens: undefined,
})
})
test("parses Google stream usage metadata", () => {
const usageParser = providers.google.createUsageParser()
usageParser.parse(
'data: {"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":3,"thoughtsTokenCount":2,"cachedContentTokenCount":4}}',
)
expect(providers.google.normalizeUsage(usageParser.retrieve())).toEqual({
inputTokens: 6,
outputTokens: 3,
reasoningTokens: 2,
cacheReadTokens: 4,
cacheWrite5mTokens: undefined,
cacheWrite1hTokens: undefined,
})
})
test("extracts nested OpenAI Responses usage", () => {
expect(
providers.openai.extractUsage({
response: {
usage: {
input_tokens: 5,
output_tokens: 7,
},
},
}),
).toEqual({
input_tokens: 5,
output_tokens: 7,
})
})
})
+1 -1
View File
@@ -9,7 +9,7 @@ export default defineConfig({
}) as PluginOption,
nitro({
compatibilityDate: "2024-09-19",
preset: "cloudflare_module",
preset: "cloudflare-module",
cloudflare: {
nodeCompat: true,
},
@@ -0,0 +1,146 @@
import { Resource } from "@opencode-ai/console-resource"
import { and, Database, eq, isNull } from "../src/drizzle/index.js"
import { Identifier } from "../src/identifier.js"
import { AccountTable } from "../src/schema/account.sql.js"
import { AuthTable } from "../src/schema/auth.sql.js"
import { BillingTable } from "../src/schema/billing.sql.js"
import { KeyTable } from "../src/schema/key.sql.js"
import { UserTable } from "../src/schema/user.sql.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
import { centsToMicroCents } from "../src/util/price.js"
const args = parseArgs(process.argv.slice(2))
if (!args.email) {
console.error(
"Usage: bun script/create-api-key.ts --email <email> [--workspace-id <wrk_...>] [--workspace-name <name>] [--key-name <name>] [--balance-dollars <amount>] [--allow-production]",
)
process.exit(1)
}
if (Resource.App.stage === "production" && !args.allowProduction) {
throw new Error("Refusing to create a production API key without --allow-production")
}
const result = await Database.transaction(async (tx) => {
const auth = await tx
.select()
.from(AuthTable)
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, args.email)))
.then((rows) => rows[0])
const accountID = auth?.accountID ?? Identifier.create("account")
if (!auth) {
await tx.insert(AccountTable).values({ id: accountID })
await tx.insert(AuthTable).values({
id: Identifier.create("auth"),
provider: "email",
subject: args.email,
accountID,
})
}
const workspace = args.workspaceID
? await tx
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, args.workspaceID))
.then((rows) => rows[0])
: await tx
.select({ workspace: WorkspaceTable })
.from(UserTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
.where(and(eq(UserTable.accountID, accountID), isNull(UserTable.timeDeleted)))
.then((rows) => rows[0]?.workspace)
if (args.workspaceID && !workspace) throw new Error(`Workspace not found: ${args.workspaceID}`)
const workspaceID = workspace?.id ?? Identifier.create("workspace")
if (!workspace) {
await tx.insert(WorkspaceTable).values({
id: workspaceID,
slug: null,
name: args.workspaceName ?? `${args.email} manual`,
})
}
const user = await tx
.select()
.from(UserTable)
.where(
and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.accountID, accountID), isNull(UserTable.timeDeleted)),
)
.then((rows) => rows[0])
const userID = user?.id ?? Identifier.create("user")
if (!user) {
await tx.insert(UserTable).values({
id: userID,
workspaceID,
accountID,
email: args.email,
name: args.email,
role: "admin",
})
}
const balance = centsToMicroCents(args.balanceDollars * 100)
const billing = await tx
.select()
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0])
if (!billing) {
await tx.insert(BillingTable).values({
id: Identifier.create("billing"),
workspaceID,
balance,
})
} else if (billing.balance < balance) {
await tx.update(BillingTable).set({ balance }).where(eq(BillingTable.workspaceID, workspaceID))
}
const secretKey = createSecretKey()
const keyID = Identifier.create("key")
await tx.insert(KeyTable).values({
id: keyID,
workspaceID,
userID,
name: args.keyName ?? "Manual API Key",
key: secretKey,
timeUsed: null,
})
return { accountID, workspaceID, userID, keyID, secretKey }
})
console.log(JSON.stringify({ stage: Resource.App.stage, ...result }, null, 2))
function createSecretKey() {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
const values = new Uint32Array(64)
crypto.getRandomValues(values)
return `sk-${Array.from(values, (value) => chars[value % chars.length]).join("")}`
}
function parseArgs(argv: string[]) {
const parsed = {
email: "",
workspaceID: "",
workspaceName: "",
keyName: "",
balanceDollars: 100,
allowProduction: false,
}
for (let index = 0; index < argv.length; index++) {
const arg = argv[index]
if (arg === "--email") parsed.email = requiredValue(argv, ++index, arg)
if (arg === "--workspace-id") parsed.workspaceID = requiredValue(argv, ++index, arg)
if (arg === "--workspace-name") parsed.workspaceName = requiredValue(argv, ++index, arg)
if (arg === "--key-name") parsed.keyName = requiredValue(argv, ++index, arg)
if (arg === "--balance-dollars") parsed.balanceDollars = Number(requiredValue(argv, ++index, arg))
if (arg === "--allow-production") parsed.allowProduction = true
}
if (!Number.isFinite(parsed.balanceDollars) || parsed.balanceDollars < 0) throw new Error("Invalid --balance-dollars")
return parsed
}
function requiredValue(argv: string[], index: number, arg: string) {
const value = argv[index]
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${arg}`)
return value
}
+145 -23
View File
@@ -21,7 +21,7 @@ export default {
)
continue
let data = {
let data: Record<string, unknown> = {
"cf.continent": event.event.request.cf?.continent,
"cf.country": event.event.request.cf?.country,
"cf.city": event.event.request.cf?.city,
@@ -35,30 +35,152 @@ export default {
ip: event.event.request.headers["x-real-ip"],
}
const time = new Date(event.eventTimestamp ?? Date.now()).toISOString()
const events = []
for (const log of event.logs) {
for (const message of log.message) {
if (!message.startsWith("_metric:")) continue
const json = JSON.parse(message.slice(8))
data = { ...data, ...json }
if ("llm.error.code" in json) {
events.push({ time, data: { ...data, event_type: "llm.error" } })
}
}
}
events.push({ time, data: { ...data, event_type: "completions" } })
const events = [
...event.logs.flatMap((log) =>
log.message.flatMap((message: string) => {
if (!message.startsWith("_metric:")) return []
const json = JSON.parse(message.slice(8)) as Record<string, unknown>
data = { ...data, ...json }
if ("llm.error.code" in json) {
return [{ time, data: { ...data, event_type: "llm.error" } }]
}
return []
}),
),
{ time, data: { ...data, event_type: "completions" } },
]
console.log(JSON.stringify(data, null, 2))
const ret = await fetch("https://api.honeycomb.io/1/batch/zen", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Honeycomb-Team": Resource.HONEYCOMB_API_KEY.value,
},
body: JSON.stringify(events),
})
console.log(ret.status)
console.log(await ret.text())
const lakeIngest = getLakeIngest()
const [honeycomb, lake] = await Promise.all([
fetch("https://api.honeycomb.io/1/batch/zen", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Honeycomb-Team": Resource.HONEYCOMB_API_KEY.value,
},
body: JSON.stringify(events),
}),
...(lakeIngest
? [
fetch(lakeIngest.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${lakeIngest.secret}`,
},
body: JSON.stringify({ events: events.map((event) => toLakeEvent(event.time, event.data)) }),
}),
]
: []),
])
console.log(honeycomb.status)
console.log(await honeycomb.text())
if (lake) {
console.log(lake.status)
console.log(await lake.text())
}
}
},
}
function getLakeIngest(): { url: string; secret: string } | undefined {
try {
return Resource.LakeIngest
} catch {
return undefined
}
}
function toLakeEvent(time: string, data: Record<string, unknown>) {
return {
_datalake_key: "inference.event",
event_timestamp: time,
event_date: time.slice(0, 10),
event_type: string(data, "event_type"),
dataset: "zen",
cf_continent: string(data, "cf.continent"),
cf_country: string(data, "cf.country"),
cf_city: string(data, "cf.city"),
cf_region: string(data, "cf.region"),
cf_latitude: number(data, "cf.latitude"),
cf_longitude: number(data, "cf.longitude"),
cf_timezone: string(data, "cf.timezone"),
duration: number(data, "duration"),
request_length: integer(data, "request_length"),
status: integer(data, "status"),
ip: string(data, "ip"),
is_stream: boolean(data, "is_stream"),
session: string(data, "session"),
request: string(data, "request"),
client: string(data, "client"),
user_agent: string(data, "user_agent"),
model_variant: string(data, "model.variant"),
source: string(data, "source"),
provider: string(data, "provider"),
provider_model: string(data, "provider.model"),
model: string(data, "model"),
llm_error_code: integer(data, "llm.error.code"),
llm_error_message: string(data, "llm.error.message"),
error_response: string(data, "error.response"),
error_type: string(data, "error.type"),
error_message: string(data, "error.message"),
error_cause: string(data, "error.cause"),
error_cause2: string(data, "error.cause2"),
api_key: string(data, "api_key"),
workspace: string(data, "workspace"),
is_subscription: boolean(data, "isSubscription"),
subscription: string(data, "subscription"),
response_length: integer(data, "response_length"),
time_to_first_byte: integer(data, "time_to_first_byte"),
timestamp_first_byte: integer(data, "timestamp.first_byte"),
timestamp_last_byte: integer(data, "timestamp.last_byte"),
tokens_input: integer(data, "tokens.input"),
tokens_output: integer(data, "tokens.output"),
tokens_reasoning: integer(data, "tokens.reasoning"),
tokens_cache_read: integer(data, "tokens.cache_read"),
tokens_cache_write_5m: integer(data, "tokens.cache_write_5m"),
tokens_cache_write_1h: integer(data, "tokens.cache_write_1h"),
cost_input_microcents: integer(data, "cost.input.microcents"),
cost_output_microcents: integer(data, "cost.output.microcents"),
cost_cache_read_microcents: integer(data, "cost.cache_read.microcents"),
cost_cache_write_microcents: integer(data, "cost.cache_write.microcents"),
cost_total_microcents: integer(data, "cost.total.microcents"),
cost_input: integer(data, "cost.input"),
cost_output: integer(data, "cost.output"),
cost_cache_read: integer(data, "cost.cache_read"),
cost_cache_write_5m: integer(data, "cost.cache_write_5m"),
cost_cache_write_1h: integer(data, "cost.cache_write_1h"),
cost_total: integer(data, "cost.total"),
}
}
function string(data: Record<string, unknown>, key: string) {
const value = data[key]
if (typeof value === "string") return value
if (typeof value === "number" || typeof value === "boolean") return String(value)
return undefined
}
function boolean(data: Record<string, unknown>, key: string) {
const value = data[key]
if (typeof value === "boolean") return value
if (typeof value === "string") return value === "true" ? true : value === "false" ? false : undefined
return undefined
}
function integer(data: Record<string, unknown>, key: string) {
const value = number(data, key)
if (value === undefined) return undefined
return Math.round(value)
}
function number(data: Record<string, unknown>, key: string) {
const value = data[key]
if (typeof value === "number") return Number.isFinite(value) ? value : undefined
if (typeof value === "string") {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : undefined
}
return undefined
}
+3 -2
View File
@@ -11,6 +11,7 @@ export const Resource = new Proxy(
{
get(_target, prop: keyof typeof ResourceBase) {
const value = ResourceBase[prop]
const secrets = ResourceBase as unknown as Record<string, { value: string }>
if ("type" in value) {
// @ts-ignore
if (value.type === "sst.cloudflare.Bucket") {
@@ -21,11 +22,11 @@ export const Resource = new Proxy(
// @ts-ignore
if (value.type === "sst.cloudflare.Kv") {
const client = new Cloudflare({
apiToken: ResourceBase.CLOUDFLARE_API_TOKEN.value,
apiToken: secrets.CLOUDFLARE_API_TOKEN.value,
})
// @ts-ignore
const namespaceId = value.namespaceId
const accountId = ResourceBase.CLOUDFLARE_DEFAULT_ACCOUNT_ID.value
const accountId = secrets.CLOUDFLARE_DEFAULT_ACCOUNT_ID.value
return {
get: (k: string | string[]) => {
const isMulti = Array.isArray(k)
+5 -1
View File
@@ -8,6 +8,10 @@ function truthy(key: string) {
const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"]
function enabledByExperimental(key: string) {
return process.env[key] === undefined ? OPENCODE_EXPERIMENTAL : truthy(key)
}
export const Flag = {
OTEL_EXPORTER_OTLP_ENDPOINT: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"],
OTEL_EXPORTER_OTLP_HEADERS: process.env["OTEL_EXPORTER_OTLP_HEADERS"],
@@ -42,7 +46,7 @@ export const Flag = {
OPENCODE_DB: process.env["OPENCODE_DB"],
OPENCODE_WORKSPACE_ID: process.env["OPENCODE_WORKSPACE_ID"],
OPENCODE_EXPERIMENTAL_WORKSPACES: OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES"),
OPENCODE_EXPERIMENTAL_WORKSPACES: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
// Evaluated at access time (not module load) because tests, the CLI, and
// external tooling set these env vars at runtime.
+1 -1
View File
@@ -8,7 +8,7 @@ const nitroConfig: any = (() => {
if (target === "cloudflare") {
return {
compatibilityDate: "2024-09-19",
preset: "cloudflare_module",
preset: "cloudflare-module",
cloudflare: {
nodeCompat: true,
},
+75
View File
@@ -0,0 +1,75 @@
import {
RequestError,
type Agent as ACPAgent,
type AgentSideConnection,
type AuthenticateRequest,
type CancelNotification,
type InitializeRequest,
type LoadSessionRequest,
type NewSessionRequest,
type PromptRequest,
type SetSessionConfigOptionRequest,
type SetSessionModelRequest,
type SetSessionModeRequest,
} from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import * as ACPNextError from "./error"
import * as ACPNextService from "./service"
export function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
return {
create: (connection: AgentSideConnection) => {
return new Agent(ACPNextService.make({ sdk: _sdk, connection }))
},
}
}
export class Agent implements ACPAgent {
constructor(private readonly service: ACPNextService.Interface) {}
initialize(params: InitializeRequest) {
return run(this.service.initialize(params))
}
authenticate(params: AuthenticateRequest) {
return run(this.service.authenticate(params))
}
newSession(params: NewSessionRequest) {
return run(this.service.newSession(params))
}
loadSession(params: LoadSessionRequest) {
return run(this.service.loadSession(params))
}
setSessionConfigOption(params: SetSessionConfigOptionRequest) {
return run(this.service.setSessionConfigOption(params))
}
setSessionMode(params: SetSessionModeRequest) {
return run(this.service.setSessionMode(params))
}
unstable_setSessionModel(params: SetSessionModelRequest) {
return run(this.service.setSessionModel(params))
}
prompt(params: PromptRequest) {
return run(this.service.prompt(params))
}
cancel(params: CancelNotification) {
return run(this.service.cancel(params))
}
}
function run<A>(effect: Effect.Effect<A, ACPNextService.Error>) {
return Effect.runPromise(effect.pipe(Effect.mapError(ACPNextError.toRequestError))).catch((defect: unknown) => {
if (defect instanceof RequestError) throw defect
throw ACPNextError.toRequestError(ACPNextError.fromUnknownDefect(defect))
})
}
export * as ACPNext from "./agent"
@@ -0,0 +1,203 @@
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
export const DEFAULT_VARIANT_VALUE = "default"
export type ConfigOptionModel = {
id: string
name: string
variants?: Record<string, Record<string, unknown>>
}
export type ConfigOptionProvider = {
id: string
name: string
models: Record<string, ConfigOptionModel>
}
export type ConfigOptionMode = {
id: string
name: string
description?: string
}
export type ModelSelection = {
model: {
providerID: string
modelID: string
}
variant?: string
}
export function buildModelSelectOption(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
currentVariant?: string
includeVariants?: boolean
}): SessionConfigOption {
return {
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: formatCurrentModelId({
model: input.currentModel,
variant: input.currentVariant,
variants: variantsForModel(input.providers, input.currentModel),
includeVariant: input.includeVariants ?? false,
}),
options: buildModelSelectOptions(input.providers, { includeVariants: input.includeVariants ?? false }),
}
}
export function buildEffortSelectOption(input: {
variants: readonly string[]
currentVariant?: string
}): SessionConfigOption | undefined {
if (input.variants.length === 0) return undefined
return {
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: selectVariant(input.currentVariant, input.variants),
options: input.variants.map((variant) => ({
value: variant,
name: formatVariantName(variant),
})),
}
}
export function buildModeSelectOption(input: {
modes: readonly ConfigOptionMode[]
currentModeId: string
}): SessionConfigOption {
return {
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: input.currentModeId,
options: input.modes.map((mode) => ({
value: mode.id,
name: mode.name,
...(mode.description ? { description: mode.description } : {}),
})),
}
}
export function buildConfigOptions(input: {
providers: readonly ConfigOptionProvider[]
currentModel: ModelSelection["model"]
currentVariant?: string
includeModelVariants?: boolean
modes?: readonly ConfigOptionMode[]
currentModeId?: string
}): SessionConfigOption[] {
const variants = variantsForModel(input.providers, input.currentModel)
const effort = buildEffortSelectOption({ variants, currentVariant: input.currentVariant })
return [
buildModelSelectOption({
providers: input.providers,
currentModel: input.currentModel,
currentVariant: input.currentVariant,
includeVariants: input.includeModelVariants ?? false,
}),
...(effort ? [effort] : []),
...(input.modes && input.currentModeId
? [buildModeSelectOption({ modes: input.modes, currentModeId: input.currentModeId })]
: []),
]
}
export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection {
const provider = providers.find((item) => modelId.startsWith(`${item.id}/`))
if (provider) {
const modelID = modelId.slice(provider.id.length + 1)
if (provider.models[modelID]) {
return { model: { providerID: provider.id, modelID } }
}
const separator = modelID.lastIndexOf("/")
if (separator > -1) {
const baseModelID = modelID.slice(0, separator)
const variant = modelID.slice(separator + 1)
if (provider.models[baseModelID]?.variants?.[variant]) {
return { model: { providerID: provider.id, modelID: baseModelID }, variant }
}
}
return { model: { providerID: provider.id, modelID } }
}
const separator = modelId.indexOf("/")
if (separator === -1) {
return { model: { providerID: modelId, modelID: "" } }
}
return {
model: {
providerID: modelId.slice(0, separator),
modelID: modelId.slice(separator + 1),
},
}
}
export function formatCurrentModelId(input: {
model: ModelSelection["model"]
variant?: string
variants?: readonly string[]
includeVariant?: boolean
}) {
const base = `${input.model.providerID}/${input.model.modelID}`
if (!input.includeVariant || !input.variants?.length) return base
return `${base}/${selectVariant(input.variant, input.variants)}`
}
export function formatVariantName(variant: string) {
return variant
.split(/[_-]/)
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
.join(" ")
}
function buildModelSelectOptions(
providers: readonly ConfigOptionProvider[],
options: { includeVariants: boolean },
): Array<{ value: string; name: string }> {
return providers.flatMap((provider) =>
Object.values(provider.models)
.sort((a, b) => a.name.localeCompare(b.name))
.flatMap((model) => {
const base = {
value: `${provider.id}/${model.id}`,
name: `${provider.name}/${model.name}`,
}
if (!options.includeVariants || !model.variants) return [base]
return [
base,
...Object.keys(model.variants)
.filter((variant) => variant !== DEFAULT_VARIANT_VALUE)
.map((variant) => ({
value: `${provider.id}/${model.id}/${variant}`,
name: `${provider.name}/${model.name} (${formatVariantName(variant)})`,
})),
]
}),
)
}
function variantsForModel(providers: readonly ConfigOptionProvider[], model: ModelSelection["model"]) {
return Object.keys(
providers.find((provider) => provider.id === model.providerID)?.models[model.modelID]?.variants ?? {},
)
}
function selectVariant(variant: string | undefined, variants: readonly string[]) {
if (variant && variants.includes(variant)) return variant
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
return variants[0]
}
+250
View File
@@ -0,0 +1,250 @@
import type { ContentBlock, ContentChunk, ResourceLink, Role } from "@agentclientprotocol/sdk"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
export type PromptPart = SessionLegacy.TextPartInput | SessionLegacy.FilePartInput
export type ReplayPart =
| {
type: "text"
text: string
synthetic?: boolean
ignored?: boolean
}
| {
type: "file"
url: string
mime: string
filename?: string
}
| {
type: "reasoning"
text: string
}
export function promptContentToParts(content: readonly ContentBlock[]): PromptPart[] {
return content.flatMap(contentBlockToParts)
}
export function contentBlockToParts(block: ContentBlock): PromptPart[] {
switch (block.type) {
case "text":
return [
{
type: "text",
text: block.text,
...audienceFlags(block.annotations?.audience ?? undefined),
},
]
case "image":
if (block.data) {
return [
{
type: "file",
url: `data:${block.mimeType};base64,${block.data}`,
filename: filenameFromUri(block.uri ?? undefined) ?? "image",
mime: block.mimeType,
},
]
}
if (block.uri?.startsWith("data:")) {
return [
{
type: "file",
url: block.uri,
filename: filenameFromUri(block.uri) ?? "image",
mime: block.mimeType,
},
]
}
if (block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) {
return [
{
type: "file",
url: block.uri,
filename: filenameFromUri(block.uri) ?? "image",
mime: block.mimeType,
},
]
}
return []
case "resource_link":
return [resourceLinkToPart(block)]
case "resource":
if ("text" in block.resource) {
return [{ type: "text", text: block.resource.text }]
}
if (block.resource.mimeType) {
return [
{
type: "file",
url: block.resource.uri.startsWith("data:")
? block.resource.uri
: `data:${block.resource.mimeType};base64,${block.resource.blob}`,
filename: filenameFromUri(block.resource.uri) ?? "file",
mime: block.resource.mimeType,
},
]
}
return []
default:
return []
}
}
export function partsToContentChunks(parts: readonly ReplayPart[]): ContentChunk[] {
return parts.flatMap(partToContentChunks)
}
export function partToContentChunks(part: ReplayPart): ContentChunk[] {
switch (part.type) {
case "text":
if (!part.text) return []
return [
{
content: {
type: "text",
text: part.text,
...partAudience(part),
},
},
]
case "file":
return filePartToContentChunks(part)
case "reasoning":
if (!part.text) return []
return [
{
content: {
type: "text",
text: part.text,
},
},
]
}
}
function resourceLinkToPart(link: ResourceLink): PromptPart {
const parsed = uriToFilePart(link.uri, link.mimeType ?? "text/plain", link.name)
if (parsed.type === "file") return parsed
return { type: "text", text: parsed.text }
}
function uriToFilePart(
uri: string,
mime: string,
filename?: string,
): SessionLegacy.FilePartInput | SessionLegacy.TextPartInput {
try {
if (uri.startsWith("file://")) {
return {
type: "file",
url: uri,
filename: filename ?? filenameFromUri(uri) ?? "file",
mime,
}
}
if (uri.startsWith("zed://")) {
const pathname = new URL(uri).searchParams.get("path")
if (pathname) {
return {
type: "file",
url: pathToFileURL(pathname).href,
filename: filename ?? (path.basename(pathname) || "file"),
mime,
}
}
}
return { type: "text", text: uri }
} catch {
return { type: "text", text: uri }
}
}
function filePartToContentChunks(part: Extract<ReplayPart, { type: "file" }>): ContentChunk[] {
if (part.url.startsWith("file://")) {
return [
{
content: {
type: "resource_link",
uri: part.url,
name: part.filename ?? "file",
mimeType: part.mime,
},
},
]
}
if (!part.url.startsWith("data:")) return []
const data = decodeDataUrl(part.url)
if (!data) return []
if (data.mime.startsWith("image/")) {
return [
{
content: {
type: "image",
mimeType: data.mime,
data: data.base64,
uri: pathToFileURL(part.filename ?? "image").href,
},
},
]
}
return [
{
content: {
type: "resource",
resource:
data.mime.startsWith("text/") || data.mime === "application/json"
? {
uri: pathToFileURL(part.filename ?? "file").href,
mimeType: data.mime,
text: Buffer.from(data.base64, "base64").toString("utf8"),
}
: {
uri: pathToFileURL(part.filename ?? "file").href,
mimeType: data.mime,
blob: data.base64,
},
},
},
]
}
function decodeDataUrl(url: string) {
const match = /^data:([^;]+);base64,(.*)$/.exec(url)
if (!match) return
return { mime: match[1], base64: match[2] }
}
function audienceFlags(audience: readonly Role[] | null | undefined) {
if (audience?.length === 1 && audience[0] === "assistant") return { synthetic: true }
if (audience?.length === 1 && audience[0] === "user") return { ignored: true }
return {}
}
function partAudience(part: Extract<ReplayPart, { type: "text" }>) {
const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined
if (!audience) return {}
return { annotations: { audience } }
}
function filenameFromUri(uri: string | undefined) {
if (!uri) return
if (uri.startsWith("data:")) return
try {
const parsed = new URL(uri)
const name = path.basename(parsed.pathname)
return name || undefined
} catch {
return path.basename(uri) || undefined
}
}
+209
View File
@@ -0,0 +1,209 @@
import { Agent } from "@/agent/agent"
import { Command } from "@/command"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@/provider/provider"
import { Context, Effect, Layer, SynchronizedRef } from "effect"
import type * as ACPNextError from "./error"
export type ModelOption = {
readonly providerID: ProviderV2.ID
readonly providerName: string
readonly modelID: ProviderV2.ModelID
readonly modelName: string
}
export type ModeOption = {
readonly id: string
readonly name: string
readonly description?: string
}
export type ModelVariants = NonNullable<Provider.Model["variants"]>
export type DefaultModel = {
readonly providerID: ProviderV2.ID
readonly modelID: ProviderV2.ModelID
}
export type Snapshot = {
readonly directory: string
readonly providers: Record<ProviderV2.ID, Provider.Info>
readonly modelOptions: readonly ModelOption[]
readonly variantsByModel: Readonly<Record<string, ModelVariants>>
readonly availableModes: readonly ModeOption[]
readonly defaultModeID: string
readonly availableCommands: readonly Command.Info[]
readonly defaultModel?: DefaultModel
}
export interface LoaderInterface {
readonly load: (directory: string) => Effect.Effect<Snapshot, ACPNextError.Error>
}
export interface Interface {
readonly get: (directory: string) => Effect.Effect<Snapshot, ACPNextError.Error>
readonly refresh: (directory: string) => Effect.Effect<Snapshot, ACPNextError.Error>
readonly variants: (snapshot: Snapshot, model: DefaultModel) => ModelVariants | undefined
}
export class Loader extends Context.Service<Loader, LoaderInterface>()("@opencode/ACPNextDirectoryLoader") {}
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNextDirectory") {}
export const modelKey = (model: DefaultModel) => `${model.providerID}/${model.modelID}`
export const variants = (snapshot: Snapshot, model: DefaultModel) => snapshot.variantsByModel[modelKey(model)]
export const build = (input: {
readonly directory: string
readonly providers: Record<ProviderV2.ID, Provider.Info>
readonly modes: readonly ModeOption[]
readonly defaultModeID: string
readonly commands: readonly Command.Info[]
readonly defaultModel?: DefaultModel
}): Snapshot => {
const modelOptions = Provider.sort(
Object.values(input.providers).flatMap((provider) =>
Object.values(provider.models).map((model) => ({
id: model.id,
providerID: provider.id,
providerName: provider.name,
modelID: model.id,
modelName: model.name,
})),
),
).map((model) => ({
providerID: model.providerID,
providerName: model.providerName,
modelID: model.modelID,
modelName: model.modelName,
}))
return {
directory: input.directory,
providers: input.providers,
modelOptions,
variantsByModel: Object.fromEntries(
Object.values(input.providers).flatMap((provider) =>
Object.values(provider.models).flatMap((model) =>
model.variants ? [[modelKey({ providerID: provider.id, modelID: model.id }), model.variants]] : [],
),
),
),
availableModes: input.modes,
defaultModeID: input.modes.some((mode) => mode.id === input.defaultModeID)
? input.defaultModeID
: (input.modes[0]?.id ?? input.defaultModeID),
availableCommands: input.commands,
...(input.defaultModel ? { defaultModel: input.defaultModel } : {}),
}
}
export const loaderLayer = Layer.effect(
Loader,
Effect.gen(function* () {
const store = yield* InstanceStore.Service
const provider = yield* Provider.Service
const agent = yield* Agent.Service
const command = yield* Command.Service
return Loader.of({
load: Effect.fn("ACPNextDirectoryLoader.load")(function* (directory) {
const ctx = yield* store.load({ directory })
return yield* Effect.gen(function* () {
const providers = yield* provider.list()
const [agents, defaultAgent, commands, defaultModel] = yield* Effect.all(
[agent.list(), agent.defaultInfo(), command.list(), provider.defaultModel().pipe(Effect.option)],
{ concurrency: "unbounded" },
)
return build({
directory,
providers,
modes: agents
.filter((item) => item.mode !== "subagent" && item.hidden !== true)
.map((item) => ({
id: item.name,
name: item.name,
...(item.description ? { description: item.description } : {}),
})),
defaultModeID: defaultAgent.name,
commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
...(defaultModel._tag === "Some" ? { defaultModel: defaultModel.value } : {}),
})
}).pipe(Effect.provideService(InstanceRef, ctx))
}),
})
}),
)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const loader = yield* Loader
const snapshots = yield* SynchronizedRef.make(new Map<string, Effect.Effect<Snapshot, ACPNextError.Error>>())
const cached = Effect.fnUntraced(function* (directory: string) {
return yield* SynchronizedRef.modifyEffect(
snapshots,
Effect.fnUntraced(function* (items) {
const current = items.get(directory)
if (current) return [current, items] as const
const next = yield* Effect.cached(
loader.load(directory).pipe(
Effect.tapError(() =>
SynchronizedRef.update(snapshots, (state) => {
const next = new Map(state)
next.delete(directory)
return next
}),
),
),
)
return [next, new Map(items).set(directory, next)] as const
}),
)
})
const get = Effect.fn("ACPNextDirectory.get")(function* (directory: string) {
return yield* yield* cached(directory)
})
const refresh = Effect.fn("ACPNextDirectory.refresh")(function* (directory: string) {
return yield* SynchronizedRef.modifyEffect(
snapshots,
Effect.fnUntraced(function* (items) {
const next = yield* Effect.cached(
loader.load(directory).pipe(
Effect.tapError(() =>
SynchronizedRef.update(snapshots, (state) => {
const next = new Map(state)
next.delete(directory)
return next
}),
),
),
)
return [next, new Map(items).set(directory, next)] as const
}),
).pipe(Effect.flatten)
})
return Service.of({
get,
refresh,
variants,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(loaderLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Command.defaultLayer),
Layer.provide(InstanceStore.defaultLayer),
)
export * as Directory from "./directory"
+93
View File
@@ -0,0 +1,93 @@
import { RequestError } from "@agentclientprotocol/sdk"
import { Schema } from "effect"
export class SessionNotFoundError extends Schema.TaggedErrorClass<SessionNotFoundError>()(
"ACPNextSessionNotFoundError",
{
sessionId: Schema.String,
},
) {}
export class InvalidConfigOptionError extends Schema.TaggedErrorClass<InvalidConfigOptionError>()(
"ACPNextInvalidConfigOptionError",
{
configId: Schema.String,
},
) {}
export class InvalidModelError extends Schema.TaggedErrorClass<InvalidModelError>()("ACPNextInvalidModelError", {
modelId: Schema.String,
providerId: Schema.optional(Schema.String),
}) {}
export class InvalidEffortError extends Schema.TaggedErrorClass<InvalidEffortError>()("ACPNextInvalidEffortError", {
effort: Schema.String,
}) {}
export class InvalidModeError extends Schema.TaggedErrorClass<InvalidModeError>()("ACPNextInvalidModeError", {
mode: Schema.String,
}) {}
export class AuthRequiredError extends Schema.TaggedErrorClass<AuthRequiredError>()("ACPNextAuthRequiredError", {
providerId: Schema.optional(Schema.String),
}) {}
export class UnknownAuthMethodError extends Schema.TaggedErrorClass<UnknownAuthMethodError>()(
"ACPNextUnknownAuthMethodError",
{
methodId: Schema.String,
},
) {}
export class UnsupportedOperationError extends Schema.TaggedErrorClass<UnsupportedOperationError>()(
"ACPNextUnsupportedOperationError",
{
method: Schema.String,
},
) {}
export class ServiceFailureError extends Schema.TaggedErrorClass<ServiceFailureError>()("ACPNextServiceFailureError", {
safeMessage: Schema.String,
service: Schema.optional(Schema.String),
}) {}
export type Error =
| SessionNotFoundError
| InvalidConfigOptionError
| InvalidModelError
| InvalidEffortError
| InvalidModeError
| AuthRequiredError
| UnknownAuthMethodError
| UnsupportedOperationError
| ServiceFailureError
export function toRequestError(error: Error) {
switch (error._tag) {
case "ACPNextSessionNotFoundError":
return RequestError.invalidParams({ sessionId: error.sessionId }, `session not found: ${error.sessionId}`)
case "ACPNextInvalidConfigOptionError":
return RequestError.invalidParams({ configId: error.configId }, `unknown config option: ${error.configId}`)
case "ACPNextInvalidModelError":
return RequestError.invalidParams(
{ providerId: error.providerId, modelId: error.modelId },
`model not found: ${error.modelId}`,
)
case "ACPNextInvalidEffortError":
return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`)
case "ACPNextInvalidModeError":
return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
case "ACPNextAuthRequiredError":
return RequestError.authRequired({ providerId: error.providerId }, "provider authentication required")
case "ACPNextUnknownAuthMethodError":
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
case "ACPNextUnsupportedOperationError":
return RequestError.methodNotFound(error.method)
case "ACPNextServiceFailureError":
return RequestError.internalError({ service: error.service }, error.safeMessage)
}
}
export function fromUnknownDefect(_defect: unknown, safeMessage = "Internal service failure") {
return new ServiceFailureError({ safeMessage })
}
+654
View File
@@ -0,0 +1,654 @@
import {
type AgentSideConnection,
type AuthenticateRequest,
type AuthenticateResponse,
type AuthMethod,
type CancelNotification,
type InitializeRequest,
type InitializeResponse,
type LoadSessionRequest,
type LoadSessionResponse,
type McpServer,
type NewSessionRequest,
type NewSessionResponse,
type PromptRequest,
type PromptResponse,
type SetSessionConfigOptionRequest,
type SetSessionConfigOptionResponse,
type SetSessionModelRequest,
type SetSessionModelResponse,
type SetSessionModeRequest,
type SetSessionModeResponse,
} from "@agentclientprotocol/sdk"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import * as ACPNextError from "./error"
import { buildConfigOptions, parseModelSelection } from "./config-option"
import { Directory } from "./directory"
import { ACPNextSession } from "./session"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@/provider/provider"
import type { Command } from "@/command"
export const AuthMethodID = "opencode-login"
export type Error = ACPNextError.Error
export type Interface = {
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse, Error>
readonly authenticate: (input: AuthenticateRequest) => Effect.Effect<AuthenticateResponse, Error>
readonly newSession: (input: NewSessionRequest) => Effect.Effect<NewSessionResponse, Error>
readonly loadSession: (input: LoadSessionRequest) => Effect.Effect<LoadSessionResponse, Error>
readonly setSessionConfigOption: (
input: SetSessionConfigOptionRequest,
) => Effect.Effect<SetSessionConfigOptionResponse, Error>
readonly setSessionMode: (input: SetSessionModeRequest) => Effect.Effect<SetSessionModeResponse, Error>
readonly setSessionModel: (input: SetSessionModelRequest) => Effect.Effect<SetSessionModelResponse, Error>
readonly prompt: (input: PromptRequest) => Effect.Effect<PromptResponse, Error>
readonly cancel: (input: CancelNotification) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNext/Service") {}
export function make(input: {
sdk: OpencodeClient
connection?: Pick<AgentSideConnection, "sessionUpdate">
directory?: Directory.Interface
session?: ACPNextSession.Interface
}): Interface {
const session = input.session ?? makeSessionService()
const directoryService = input.directory ?? makeDirectoryService(input.sdk)
const registeredMcp = new Map<string, Set<string>>()
const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
id: AuthMethodID,
}
if (params.clientCapabilities?._meta?.["terminal-auth"] === true) {
authMethod._meta = {
"terminal-auth": {
command: "opencode",
args: ["auth", "login"],
label: "OpenCode Login",
},
}
}
return {
protocolVersion: 1,
agentCapabilities: {
loadSession: true,
mcpCapabilities: {
http: true,
sse: true,
},
promptCapabilities: {
embeddedContext: true,
image: true,
},
},
authMethods: [authMethod],
agentInfo: {
name: "OpenCode",
version: InstallationVersion,
},
}
})
const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) {
if (params.methodId !== AuthMethodID) {
return yield* new ACPNextError.UnknownAuthMethodError({ methodId: params.methodId })
}
return {}
})
const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (cwd: string) {
return yield* directoryService.get(cwd)
})
const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) {
const snapshot = yield* directorySnapshot(params.cwd)
const selected = selectDefaultModel(snapshot)
const variant = selectVariant(snapshot, selected)
const modeId = snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined
const created = yield* request(
() =>
input.sdk.session.create(
{
directory: params.cwd,
...(modeId ? { agent: modeId } : {}),
model: {
providerID: selected.providerID,
id: selected.modelID,
...(variant ? { variant } : {}),
},
},
{ throwOnError: true },
),
"session",
)
const state = yield* session.create({
id: created.id,
cwd: params.cwd,
mcpServers: params.mcpServers,
model: selected,
variant,
modeId,
})
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
yield* sendAvailableCommands(input.connection, state.id, snapshot)
return {
sessionId: state.id,
configOptions: configOptions(snapshot, {
model: state.model ?? selected,
variant: state.variant,
modeId: state.modeId,
}),
}
})
const loadSession = Effect.fn("ACPNext.loadSession")(function* (params: LoadSessionRequest) {
const snapshot = yield* directorySnapshot(params.cwd)
yield* request(
() => input.sdk.session.get({ directory: params.cwd, sessionID: params.sessionId }, { throwOnError: true }),
"session",
)
const messages = yield* request(
() =>
input.sdk.session.messages(
{ directory: params.cwd, sessionID: params.sessionId, limit: 100 },
{ throwOnError: true },
),
"session",
)
const restored = restoreFromMessages(messages.map((item) => item.info))
const model = restored.model ?? selectDefaultModel(snapshot)
const state = yield* session.load({
id: params.sessionId,
cwd: params.cwd,
mcpServers: params.mcpServers,
model,
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
yield* sendAvailableCommands(input.connection, state.id, snapshot)
return {
sessionId: state.id,
configOptions: configOptions(snapshot, {
model: state.model ?? model,
variant: state.variant,
modeId: state.modeId,
}),
}
})
const setSessionConfigOption = Effect.fn("ACPNext.setSessionConfigOption")(function* (
params: SetSessionConfigOptionRequest,
) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd)
if (typeof params.value !== "string") {
return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
}
if (params.configId === "model") {
const selected = yield* parseSelectedModel(snapshot, params.value)
const variant = selected.variant ?? selectVariant(snapshot, selected.model)
const state = yield* session
.setVariant(params.sessionId, Directory.variants(snapshot, selected.model) ? variant : undefined)
.pipe(Effect.andThen(session.setModel(params.sessionId, selected.model)))
return {
configOptions: configOptions(snapshot, {
model: state.model ?? selected.model,
variant: state.variant,
modeId: state.modeId,
}),
}
}
if (params.configId === "effort") {
const model = current.model ?? selectDefaultModel(snapshot)
const variants = Directory.variants(snapshot, model)
if (!variants || !Object.keys(variants).includes(params.value)) {
return yield* new ACPNextError.InvalidEffortError({ effort: params.value })
}
const state = yield* session.setVariant(params.sessionId, params.value)
return {
configOptions: configOptions(snapshot, {
model: state.model ?? model,
variant: state.variant,
modeId: state.modeId,
}),
}
}
if (params.configId === "mode") {
if (!snapshot.availableModes.some((mode) => mode.id === params.value)) {
return yield* new ACPNextError.InvalidModeError({ mode: params.value })
}
const state = yield* session.setMode(params.sessionId, params.value)
return {
configOptions: configOptions(snapshot, {
model: state.model ?? selectDefaultModel(snapshot),
variant: state.variant,
modeId: state.modeId,
}),
}
}
return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
})
const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd)
if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) {
return yield* new ACPNextError.InvalidModeError({ mode: params.modeId })
}
yield* session.setMode(params.sessionId, params.modeId)
return {}
})
const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd)
const selected = yield* parseSelectedModel(snapshot, params.modelId)
yield* session
.setVariant(
params.sessionId,
Directory.variants(snapshot, selected.model)
? (selected.variant ?? selectVariant(snapshot, selected.model))
: undefined,
)
.pipe(Effect.andThen(session.setModel(params.sessionId, selected.model)))
return {}
})
return {
initialize,
authenticate,
newSession,
loadSession,
setSessionConfigOption,
setSessionMode,
setSessionModel,
prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) {
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" })
}),
cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) {
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" })
}),
}
}
function makeSessionService() {
return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync(
ACPNextSession.Service.use((service) => Effect.succeed(service)),
)
}
function makeDirectoryService(sdk: OpencodeClient) {
return ManagedRuntime.make(
Directory.layer.pipe(
Layer.provide(
Layer.succeed(
Directory.Loader,
Directory.Loader.of({
load: (directory) => request(() => loadDirectorySnapshot(sdk, directory), "directory"),
}),
),
),
),
).runSync(Directory.Service.use((service) => Effect.succeed(service)))
}
type ConfigState = {
readonly model: Directory.DefaultModel
readonly variant?: string
readonly modeId?: string
}
type SdkResponse<T> = {
readonly data?: T
readonly error?: unknown
}
type MessageInfo = {
readonly role?: string
readonly model?: {
readonly providerID?: string
readonly modelID?: string
readonly variant?: string
}
readonly providerID?: string
readonly modelID?: string
readonly variant?: string
readonly mode?: string
readonly agent?: string
}
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
return Effect.tryPromise({
try: async () => {
const result = await fn()
if (isSdkResponse<T>(result)) {
if (result.error) throw result.error
if (result.data !== undefined) return result.data
}
return result as T
},
catch: (error) => fromUnknownError(error, service),
})
}
async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
const [providersResponse, agentsResponse, commandsResponse, skillsResponse] = await Promise.all([
sdk.config.providers({ directory }, { throwOnError: true }),
sdk.app.agents({ directory }, { throwOnError: true }),
sdk.command.list({ directory }, { throwOnError: true }),
sdk.app.skills({ directory }, { throwOnError: true }),
])
const providersData = providersResponse.data!
const agents = agentsResponse.data!
const commandsData = commandsResponse.data!
const skills = skillsResponse.data!
const providers = Object.fromEntries(providersData.providers.map((provider) => [provider.id, provider])) as Record<
ProviderV2.ID,
Provider.Info
>
const defaultModel = await defaultModelFromSdk(sdk, directory, providers)
const modes = agents
.filter((agent) => agent.mode !== "subagent" && agent.hidden !== true)
.map((agent) => ({
id: agent.name,
name: agent.name,
...(agent.description ? { description: agent.description } : {}),
}))
const commands = [
...commandsData,
...skills
.filter((skill) => !commandsData.some((command) => command.name === skill.name))
.map((skill) => ({
name: skill.name,
description: skill.description,
source: "skill" as const,
template: skill.content,
hints: [],
})),
] as Command.Info[]
return Directory.build({
directory,
providers,
modes,
defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build",
commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
...(defaultModel ? { defaultModel } : {}),
})
}
async function defaultModelFromSdk(
sdk: OpencodeClient,
directory: string,
providers: Record<ProviderV2.ID, Provider.Info>,
): Promise<Directory.DefaultModel | undefined> {
const configured = await sdk.config
.get({ directory }, { throwOnError: true })
.then((response) => (response.data?.model ? Provider.parseModel(response.data.model) : undefined))
.catch(() => undefined)
if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured
const lastUsed = await lastUsedModel(sdk, directory, providers)
if (lastUsed) return lastUsed
const opencodeProvider = providers[ProviderV2.ID.make("opencode")]
const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined
if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id }
const best = Provider.sort(Object.values(providers).flatMap((provider) => Object.values(provider.models)))[0]
if (best) return { providerID: best.providerID, modelID: best.id }
if (configured) return configured
}
async function lastUsedModel(
sdk: OpencodeClient,
directory: string,
providers: Record<ProviderV2.ID, Provider.Info>,
): Promise<Directory.DefaultModel | undefined> {
const session = await sdk.session
.list({ directory, roots: true, limit: 1 }, { throwOnError: true })
.then((response) => response.data?.[0])
.catch(() => undefined)
if (!session) return
const lastUser = await sdk.session
.messages({ directory, sessionID: session.id, limit: 20 }, { throwOnError: true })
.then((response) => response.data?.findLast((message) => message.info.role === "user")?.info)
.catch(() => undefined)
if (lastUser?.role !== "user") return
if (!providers[ProviderV2.ID.make(lastUser.model.providerID)]?.models[ProviderV2.ModelID.make(lastUser.model.modelID)]) return
return {
providerID: ProviderV2.ID.make(lastUser.model.providerID),
modelID: ProviderV2.ModelID.make(lastUser.model.modelID),
}
}
function selectDefaultModel(snapshot: Directory.Snapshot) {
if (snapshot.defaultModel) return snapshot.defaultModel
const model = snapshot.modelOptions[0]
if (model) return { providerID: model.providerID, modelID: model.modelID }
return { providerID: "unknown" as ProviderV2.ID, modelID: "unknown" as ProviderV2.ModelID }
}
function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultModel) {
const variants = Directory.variants(snapshot, model)
if (!variants) return
if (variants.default) return "default"
return Object.keys(variants)[0]
}
function configOptions(snapshot: Directory.Snapshot, session: ConfigState) {
return buildConfigOptions({
providers: Object.values(snapshot.providers),
currentModel: session.model,
currentVariant: session.variant,
modes: snapshot.availableModes,
currentModeId: session.modeId,
})
}
function parseSelectedModel(snapshot: Directory.Snapshot, modelId: string) {
const selected = parseModelSelection(modelId, Object.values(snapshot.providers))
const provider = snapshot.providers[ProviderV2.ID.make(selected.model.providerID)]
const model = provider?.models[ProviderV2.ModelID.make(selected.model.modelID)]
if (!model) {
return Effect.fail(
new ACPNextError.InvalidModelError({
providerId: selected.model.providerID,
modelId,
}),
)
}
if (selected.variant && !model.variants?.[selected.variant]) {
return Effect.fail(new ACPNextError.InvalidEffortError({ effort: selected.variant }))
}
return Effect.succeed({
model: {
providerID: provider.id,
modelID: model.id,
},
variant: selected.variant,
})
}
function sendAvailableCommands(
connection: Pick<AgentSideConnection, "sessionUpdate"> | undefined,
sessionId: string,
snapshot: Directory.Snapshot,
) {
if (!connection) return Effect.void
return Effect.sync(() => {
setTimeout(() => {
void connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands: snapshot.availableCommands.map((command) => ({
name: command.name,
description: command.description ?? "",
})),
},
})
}, 0)
})
}
function registerMcpServers(
sdk: OpencodeClient,
registered: Map<string, Set<string>>,
directory: string,
sessionId: string,
servers: readonly McpServer[],
) {
const current = registered.get(sessionId) ?? new Set<string>()
registered.set(sessionId, current)
const pending = new Set<string>()
return Effect.all(
servers
.map((server) => ({ server, config: mcpConfig(server) }))
.filter((entry) => {
const key = mcpRegistrationKey(entry.server.name, entry.config)
if (current.has(key) || pending.has(key)) return false
pending.add(key)
return true
})
.map((entry) =>
request(
() =>
sdk.mcp.add(
{
directory,
name: entry.server.name,
config: entry.config,
},
{ throwOnError: true },
),
"mcp",
).pipe(
Effect.tap(() => Effect.sync(() => current.add(mcpRegistrationKey(entry.server.name, entry.config)))),
Effect.ignore,
),
),
{ concurrency: "unbounded" },
).pipe(Effect.asVoid)
}
function mcpRegistrationKey(name: string, config: ReturnType<typeof mcpConfig>) {
return `${name}:${stableStringify(config)}`
}
function mcpConfig(server: McpServer) {
if ("type" in server) {
return {
type: "remote" as const,
url: server.url,
headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])),
}
}
return {
type: "local" as const,
command: [server.command, ...server.args],
environment: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])),
}
}
function stableStringify(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
if (!value || typeof value !== "object") return JSON.stringify(value)
return `{${Object.entries(value)
.toSorted(([a], [b]) => a.localeCompare(b))
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
.join(",")}}`
}
function restoreFromMessages(messages: readonly MessageInfo[]) {
const user = messages.findLast(
(message) => message.role === "user" && message.model?.providerID && message.model.modelID,
)
if (user?.model?.providerID && user.model.modelID) {
return {
model: { providerID: user.model.providerID as ProviderV2.ID, modelID: user.model.modelID as ProviderV2.ModelID },
variant: user.model.variant,
modeId: user.agent,
}
}
const assistant = messages.findLast((message) => message.providerID && message.modelID)
if (assistant?.providerID && assistant.modelID) {
return {
model: { providerID: assistant.providerID as ProviderV2.ID, modelID: assistant.modelID as ProviderV2.ModelID },
variant: assistant.variant,
modeId: assistant.mode ?? assistant.agent,
}
}
return {}
}
function isSdkResponse<T>(value: T | SdkResponse<T>): value is SdkResponse<T> {
return typeof value === "object" && value !== null && ("data" in value || "error" in value)
}
function fromUnknownError(error: unknown, service?: string): Error {
if (isACPNextError(error)) return error
if (isAuthRequired(error)) {
return new ACPNextError.AuthRequiredError({ providerId: findProviderID(error) })
}
return new ACPNextError.ServiceFailureError({ safeMessage: "OpenCode service failure", service })
}
function isACPNextError(error: unknown): error is Error {
return (
typeof error === "object" &&
error !== null &&
"_tag" in error &&
typeof error._tag === "string" &&
error._tag.startsWith("ACPNext")
)
}
function isAuthRequired(value: unknown): boolean {
if (typeof value !== "object" || value === null) return false
if (value instanceof Error && (value.name === "ProviderAuthError" || value.name === "LoadAPIKeyError")) return true
if (
value instanceof Error &&
(value.message.includes("ProviderAuthError") || value.message.includes("LoadAPIKeyError"))
) {
return true
}
if ("name" in value && (value.name === "ProviderAuthError" || value.name === "LoadAPIKeyError")) return true
if ("_tag" in value && (value._tag === "ProviderAuthError" || value._tag === "LoadAPIKeyError")) return true
if ("error" in value && isAuthRequired(value.error)) return true
if ("data" in value && isAuthRequired(value.data)) return true
return false
}
function findProviderID(value: unknown): string | undefined {
if (typeof value !== "object" || value === null) return
if ("providerID" in value && typeof value.providerID === "string") return value.providerID
if ("providerId" in value && typeof value.providerId === "string") return value.providerId
if ("data" in value) return findProviderID(value.data)
if ("error" in value) return findProviderID(value.error)
}
+216
View File
@@ -0,0 +1,216 @@
import type { McpServer } from "@agentclientprotocol/sdk"
import { Context, Effect, Layer, Ref } from "effect"
import type { ProviderV2 } from "@opencode-ai/core/provider"
import * as ACPNextError from "./error"
export type SelectedModel = {
providerID: ProviderV2.ID
modelID: ProviderV2.ModelID
}
export type KnownMessagePartMetadata = {
messageId: string
partId: string
toolCallId?: string
metadata?: unknown
}
export type Info = {
id: string
cwd: string
mcpServers: readonly McpServer[]
createdAt: Date
model?: SelectedModel
variant?: string
modeId?: string
knownParts: ReadonlyMap<string, KnownMessagePartMetadata>
}
export type StoreInput = {
id: string
cwd: string
mcpServers?: readonly McpServer[]
createdAt?: Date
model?: SelectedModel
variant?: string
modeId?: string
}
export type RecordPartMetadataInput = {
sessionId: string
messageId: string
partId: string
toolCallId?: string
metadata?: unknown
}
export type PartMetadataLookupInput = {
sessionId: string
messageId: string
partId: string
}
export type Interface = {
readonly create: (input: StoreInput) => Effect.Effect<Info>
readonly load: (input: StoreInput) => Effect.Effect<Info>
readonly get: (sessionId: string) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
readonly tryGet: (sessionId: string) => Effect.Effect<Info | undefined>
readonly remove: (sessionId: string) => Effect.Effect<Info | undefined>
readonly setModel: (
sessionId: string,
model: SelectedModel | undefined,
) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
readonly getModel: (sessionId: string) => Effect.Effect<SelectedModel | undefined, ACPNextError.SessionNotFoundError>
readonly setVariant: (
sessionId: string,
variant: string | undefined,
) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
readonly getVariant: (sessionId: string) => Effect.Effect<string | undefined, ACPNextError.SessionNotFoundError>
readonly setMode: (
sessionId: string,
modeId: string | undefined,
) => Effect.Effect<Info, ACPNextError.SessionNotFoundError>
readonly getMode: (sessionId: string) => Effect.Effect<string | undefined, ACPNextError.SessionNotFoundError>
readonly recordPartMetadata: (
input: RecordPartMetadataInput,
) => Effect.Effect<KnownMessagePartMetadata, ACPNextError.SessionNotFoundError>
readonly getPartMetadata: (
input: PartMetadataLookupInput,
) => Effect.Effect<KnownMessagePartMetadata | undefined, ACPNextError.SessionNotFoundError>
readonly tryGetPartMetadata: (input: PartMetadataLookupInput) => Effect.Effect<KnownMessagePartMetadata | undefined>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNext/Session") {}
type State = Map<string, Info>
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const sessions = yield* Ref.make<State>(new Map())
const store = Effect.fn("ACPNext.Session.store")(function* (input: StoreInput) {
const session = makeSession(input)
yield* Ref.update(sessions, (state) => new Map(state).set(session.id, session))
return snapshot(session)
})
const tryGet = Effect.fn("ACPNext.Session.tryGet")(function* (sessionId: string) {
const session = (yield* Ref.get(sessions)).get(sessionId)
if (!session) return
return snapshot(session)
})
const get = Effect.fn("ACPNext.Session.get")(function* (sessionId: string) {
const session = yield* tryGet(sessionId)
if (session) return session
return yield* new ACPNextError.SessionNotFoundError({ sessionId })
})
const update = Effect.fn("ACPNext.Session.update")(function* (sessionId: string, fn: (session: Info) => Info) {
const result = yield* Ref.modify(sessions, (state) => {
const session = state.get(sessionId)
if (!session) return [undefined, state] as const
const next = fn(session)
return [snapshot(next), new Map(state).set(sessionId, next)] as const
})
if (result) return result
return yield* new ACPNextError.SessionNotFoundError({ sessionId })
})
const remove = Effect.fn("ACPNext.Session.remove")(function* (sessionId: string) {
return yield* Ref.modify(sessions, (state) => {
const session = state.get(sessionId)
if (!session) return [undefined, state] as const
const next = new Map(state)
next.delete(sessionId)
return [snapshot(session), next] as const
})
})
const setModel: Interface["setModel"] = Effect.fn("ACPNext.Session.setModel")((sessionId, model) =>
update(sessionId, (session) => ({ ...session, model })),
)
const setVariant: Interface["setVariant"] = Effect.fn("ACPNext.Session.setVariant")((sessionId, variant) =>
update(sessionId, (session) => ({ ...session, variant })),
)
const setMode: Interface["setMode"] = Effect.fn("ACPNext.Session.setMode")((sessionId, modeId) =>
update(sessionId, (session) => ({ ...session, modeId })),
)
const recordPartMetadata: Interface["recordPartMetadata"] = Effect.fn("ACPNext.Session.recordPartMetadata")((
input,
) => {
const metadata = {
messageId: input.messageId,
partId: input.partId,
toolCallId: input.toolCallId,
metadata: input.metadata,
}
return update(input.sessionId, (session) => ({
...session,
knownParts: new Map(session.knownParts).set(partMetadataKey(input), metadata),
})).pipe(Effect.as(metadata))
})
return Service.of({
create: store,
load: store,
get,
tryGet,
remove,
setModel,
getModel: Effect.fn("ACPNext.Session.getModel")(function* (sessionId) {
return (yield* get(sessionId)).model
}),
setVariant,
getVariant: Effect.fn("ACPNext.Session.getVariant")(function* (sessionId) {
return (yield* get(sessionId)).variant
}),
setMode,
getMode: Effect.fn("ACPNext.Session.getMode")(function* (sessionId) {
return (yield* get(sessionId)).modeId
}),
recordPartMetadata,
getPartMetadata: Effect.fn("ACPNext.Session.getPartMetadata")(function* (input) {
return (yield* get(input.sessionId)).knownParts.get(partMetadataKey(input))
}),
tryGetPartMetadata: Effect.fn("ACPNext.Session.tryGetPartMetadata")(function* (input) {
return (yield* tryGet(input.sessionId))?.knownParts.get(partMetadataKey(input))
}),
})
}),
)
export const defaultLayer = layer
function makeSession(input: StoreInput): Info {
return {
id: input.id,
cwd: input.cwd,
mcpServers: [...(input.mcpServers ?? [])],
createdAt: input.createdAt ? new Date(input.createdAt) : new Date(),
model: input.model,
variant: input.variant,
modeId: input.modeId,
knownParts: new Map(),
}
}
function snapshot(session: Info): Info {
return {
...session,
mcpServers: [...session.mcpServers],
createdAt: new Date(session.createdAt),
knownParts: new Map(session.knownParts),
}
}
function partMetadataKey(input: { messageId: string; partId: string }) {
return `${input.messageId}:${input.partId}`
}
export * as ACPNextSession from "./session"
+174
View File
@@ -0,0 +1,174 @@
import type { ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk"
export type ToolInput = Record<string, unknown>
export type ToolAttachment = {
readonly mime?: string
readonly url?: string
readonly [key: string]: unknown
}
export type CompletedToolState = {
readonly status: "completed"
readonly input: ToolInput
readonly output: string
readonly metadata?: unknown
readonly attachments?: ReadonlyArray<ToolAttachment>
}
export type ImageAttachment = {
readonly mimeType: string
readonly data: string
}
export function toToolKind(toolName: string): ToolKind {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
case "bash":
case "shell":
return "execute"
case "webfetch":
return "fetch"
case "edit":
case "patch":
case "write":
return "edit"
case "grep":
case "glob":
case "repo_clone":
case "repo_overview":
case "context":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return "search"
case "read":
return "read"
default:
return "other"
}
}
export function toLocations(toolName: string, input: ToolInput): ToolCallLocation[] {
const tool = toolName.toLocaleLowerCase()
switch (tool) {
case "read":
case "edit":
case "write":
return locationFrom(input.filePath)
case "grep":
case "glob":
case "repo_clone":
case "repo_overview":
case "context":
case "context7_resolve_library_id":
case "context7_get_library_docs":
return locationFrom(input.path)
case "bash":
case "shell":
return []
default:
return []
}
}
export function completedToolContent(toolName: string, state: CompletedToolState): ToolCallContent[] {
const content: ToolCallContent[] = [
{
type: "content",
content: {
type: "text",
text: state.output,
},
},
]
if (toToolKind(toolName) === "edit") {
content.push(...diffContent(state.input))
}
content.push(...imageContents(state.attachments ?? []))
return content
}
export function completedToolRawOutput(state: CompletedToolState) {
return {
output: state.output,
...(state.metadata !== undefined ? { metadata: state.metadata } : {}),
...(state.attachments?.length ? { attachments: state.attachments } : {}),
}
}
export function imageContents(attachments: ReadonlyArray<ToolAttachment>): ToolCallContent[] {
return extractImageAttachments(attachments).map((attachment): ToolCallContent => {
return {
type: "content",
content: {
type: "image",
mimeType: attachment.mimeType,
data: attachment.data,
},
}
})
}
export function extractImageAttachments(attachments: ReadonlyArray<ToolAttachment>): ImageAttachment[] {
return attachments.flatMap((attachment): ImageAttachment[] => {
const data = dataUrlImage(attachment)
return data ? [data] : []
})
}
export function shellOutputSnapshot(state: { readonly metadata?: unknown }) {
if (!state.metadata || typeof state.metadata !== "object") return undefined
return stringValue((state.metadata as Record<string, unknown>).output)
}
export const mapToolKind = toToolKind
export const extractLocations = toLocations
export const buildCompletedToolContent = completedToolContent
export const buildCompletedRawOutput = completedToolRawOutput
export const extractShellOutputSnapshot = shellOutputSnapshot
function locationFrom(value: unknown): ToolCallLocation[] {
const path = stringValue(value)
return path ? [{ path }] : []
}
function diffContent(input: ToolInput): ToolCallContent[] {
const oldText = stringValue(input.oldString)
const newText = stringValue(input.newString) ?? stringValue(input.content)
if (oldText === undefined || newText === undefined) return []
return [
{
type: "diff",
path: stringValue(input.filePath) ?? "",
oldText,
newText,
},
]
}
function dataUrlImage(attachment: ToolAttachment) {
const match = stringValue(attachment.url)?.match(/^data:([^;,]+)(?:;[^,]*)*;base64,(.*)$/)
const mime = match?.[1] ?? stringValue(attachment.mime)
if (!mime?.startsWith("image/")) return undefined
const data = match?.[2]
if (data === undefined) return undefined
return { mimeType: mime, data }
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
+250
View File
@@ -0,0 +1,250 @@
import type { AgentSideConnection, Usage } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import { InstanceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@/provider/provider"
import { Context, Effect, Layer, SynchronizedRef } from "effect"
const log = Log.create({ service: "acp-next-usage" })
export type AssistantTokenCost = {
readonly cost: number
readonly tokens: {
readonly input: number
readonly output: number
readonly reasoning: number
readonly cache: {
readonly read: number
readonly write: number
}
}
}
export type AssistantMessage = AssistantTokenCost & {
readonly role: "assistant"
readonly providerID?: string
readonly modelID?: string
}
export type SessionMessage = {
readonly info: { readonly role: string } | AssistantMessage
}
export type MessagesInput = {
readonly sessionID: string
readonly directory: string
}
export type SDK = {
readonly session: {
readonly messages: (
parameters: { readonly sessionID: string; readonly directory: string },
options: { readonly throwOnError: true },
) => Promise<{ readonly data?: readonly SessionMessage[] | null }>
}
}
export interface MessageLoaderInterface {
readonly messages: (input: MessagesInput) => Effect.Effect<readonly SessionMessage[], unknown>
}
export interface ContextLimitLoaderInterface {
readonly providers: (directory: string) => Effect.Effect<Record<ProviderV2.ID, Provider.Info>, unknown>
}
export type UsageConnection = Pick<AgentSideConnection, "sessionUpdate">
export interface Interface {
readonly buildUsage: (message: AssistantTokenCost) => Usage
readonly latestAssistantMessage: (messages: readonly SessionMessage[]) => AssistantMessage | undefined
readonly totalSessionCost: (messages: readonly SessionMessage[]) => number
readonly contextLimit: (input: {
readonly directory: string
readonly providerID: ProviderV2.ID
readonly modelID: ProviderV2.ModelID
}) => Effect.Effect<number | undefined>
readonly sendUpdate: (input: {
readonly connection: UsageConnection
readonly sessionID: string
readonly directory: string
}) => Effect.Effect<void>
}
export class MessageLoader extends Context.Service<MessageLoader, MessageLoaderInterface>()(
"@opencode/ACPNextUsageMessageLoader",
) {}
export class ContextLimitLoader extends Context.Service<ContextLimitLoader, ContextLimitLoaderInterface>()(
"@opencode/ACPNextUsageContextLimitLoader",
) {}
export class Service extends Context.Service<Service, Interface>()("@opencode/ACPNextUsage") {}
export function messageLoaderFromSDK(sdk: SDK): MessageLoaderInterface {
return MessageLoader.of({
messages: (input) =>
Effect.promise(() =>
sdk.session
.messages({ sessionID: input.sessionID, directory: input.directory }, { throwOnError: true })
.then((response) => response.data ?? []),
),
})
}
export const messageLoaderLayer = (sdk: SDK) => Layer.succeed(MessageLoader, messageLoaderFromSDK(sdk))
export function buildUsage(message: AssistantTokenCost): Usage {
const cachedReadTokens = message.tokens.cache.read
const cachedWriteTokens = message.tokens.cache.write
const thoughtTokens = message.tokens.reasoning
return {
inputTokens: message.tokens.input,
outputTokens: message.tokens.output,
totalTokens: message.tokens.input + message.tokens.output + thoughtTokens + cachedReadTokens + cachedWriteTokens,
...(thoughtTokens > 0 ? { thoughtTokens } : {}),
...(cachedReadTokens > 0 ? { cachedReadTokens } : {}),
...(cachedWriteTokens > 0 ? { cachedWriteTokens } : {}),
}
}
export function latestAssistantMessage(messages: readonly SessionMessage[]): AssistantMessage | undefined {
return messages
.filter((message): message is { readonly info: AssistantMessage } => message.info.role === "assistant")
.at(-1)?.info
}
export function totalSessionCost(messages: readonly SessionMessage[]): number {
return messages
.filter((message): message is { readonly info: AssistantMessage } => message.info.role === "assistant")
.reduce((sum, message) => sum + message.info.cost, 0)
}
export function findContextLimit(
providers: Record<ProviderV2.ID, Provider.Info>,
providerID: ProviderV2.ID,
modelID: ProviderV2.ModelID,
): number | undefined {
return providers[providerID]?.models[modelID]?.limit.context
}
export const contextLimitLoaderLayer = Layer.effect(
ContextLimitLoader,
Effect.gen(function* () {
const store = yield* InstanceStore.Service
const provider = yield* Provider.Service
return ContextLimitLoader.of({
providers: Effect.fn("ACPNextUsageContextLimitLoader.providers")(function* (directory) {
const ctx = yield* store.load({ directory })
return yield* Effect.gen(function* () {
return yield* provider.list()
}).pipe(Effect.provideService(InstanceRef, ctx))
}),
})
}),
)
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const messageLoader = yield* MessageLoader
const contextLimitLoader = yield* ContextLimitLoader
const limits = yield* SynchronizedRef.make(new Map<string, Effect.Effect<number | undefined>>())
const cachedLimit = Effect.fnUntraced(function* (input: {
readonly directory: string
readonly providerID: ProviderV2.ID
readonly modelID: ProviderV2.ModelID
}) {
return yield* SynchronizedRef.modifyEffect(
limits,
Effect.fnUntraced(function* (items) {
const key = `${input.directory}\u0000${input.providerID}\u0000${input.modelID}`
const current = items.get(key)
if (current) return [current, items] as const
const next = yield* Effect.cached(
contextLimitLoader.providers(input.directory).pipe(
Effect.map((providers) => findContextLimit(providers, input.providerID, input.modelID)),
Effect.catch((error) =>
Effect.sync(() => {
log.error("failed to get providers for usage context limit", { error })
return undefined
}),
),
),
)
return [next, new Map(items).set(key, next)] as const
}),
)
})
const contextLimit = Effect.fn("ACPNextUsage.contextLimit")(function* (input: {
readonly directory: string
readonly providerID: ProviderV2.ID
readonly modelID: ProviderV2.ModelID
}) {
return yield* yield* cachedLimit(input)
})
const sendUpdate = Effect.fn("ACPNextUsage.sendUpdate")(function* (input: {
readonly connection: UsageConnection
readonly sessionID: string
readonly directory: string
}) {
const messages = yield* messageLoader.messages({ sessionID: input.sessionID, directory: input.directory }).pipe(
Effect.catch((error) =>
Effect.sync(() => {
log.error("failed to fetch messages for usage update", { error })
return undefined
}),
),
)
if (!messages) return
const message = latestAssistantMessage(messages)
if (!message) return
if (!message.providerID || !message.modelID) return
const size = yield* contextLimit({
directory: input.directory,
providerID: ProviderV2.ID.make(message.providerID),
modelID: ProviderV2.ModelID.make(message.modelID),
})
if (!size) return
yield* Effect.promise(() =>
input.connection
.sessionUpdate({
sessionId: input.sessionID,
update: {
sessionUpdate: "usage_update",
used: message.tokens.input + message.tokens.cache.read,
size,
cost: { amount: totalSessionCost(messages), currency: "USD" },
},
})
.catch((error) => {
log.error("failed to send usage update", { error })
}),
)
})
return Service.of({
buildUsage,
latestAssistantMessage,
totalSessionCost,
contextLimit,
sendUpdate,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(contextLimitLoaderLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(InstanceStore.defaultLayer),
)
export * as UsageService from "./usage"
+4 -1
View File
@@ -3,10 +3,12 @@ import { Effect } from "effect"
import { effectCmd } from "../effect-cmd"
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { ACP } from "@/acp/agent"
import { ACPNext } from "@/acp-next/agent"
import { Server } from "@/server/server"
import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { RuntimeFlags } from "@/effect/runtime-flags"
const log = Log.create({ service: "acp-command" })
@@ -22,6 +24,7 @@ export const AcpCommand = effectCmd({
},
handler: Effect.fn("Cli.acp")(function* (args) {
process.env.OPENCODE_CLIENT = "acp"
const flags = yield* RuntimeFlags.Service
const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() => Server.listen(opts))
@@ -54,7 +57,7 @@ export const AcpCommand = effectCmd({
})
const stream = ndJsonStream(input, output)
const agent = ACP.init({ sdk })
const agent = flags.acpNext ? ACPNext.init({ sdk }) : ACP.init({ sdk })
new AgentSideConnection((conn) => {
return agent.create(conn, { sdk })
@@ -1464,11 +1464,13 @@ export function Prompt(props: PromptProps) {
}),
}
})
const maxHeight = createMemo(() => tuiConfig.prompt?.max_height ?? Math.max(6, Math.floor(dimensions().height / 3)))
return (
<>
<box ref={(r: BoxRenderable) => (anchor = r)} visible={props.visible !== false}>
<box ref={(r: BoxRenderable) => (anchor = r)} visible={props.visible !== false} width="100%">
<box
width="100%"
border={["left"]}
borderColor={borderHighlight()}
customBorderChars={{
@@ -1483,14 +1485,16 @@ export function Prompt(props: PromptProps) {
flexShrink={0}
backgroundColor={theme.backgroundElement}
flexGrow={1}
width="100%"
>
<textarea
width="100%"
placeholder={placeholderText()}
placeholderColor={theme.textMuted}
textColor={leader() ? theme.textMuted : theme.text}
focusedTextColor={leader() ? theme.textMuted : theme.text}
minHeight={1}
maxHeight={6}
maxHeight={maxHeight()}
onContentChange={() => {
const value = input.plainText
setStore("prompt", "input", value)
@@ -61,6 +61,15 @@ export const Attention = Schema.Struct({
sounds: Schema.optional(TuiAttentionSounds),
}).annotate({ description: "Attention notification and sound settings" })
const PromptSize = Schema.Int.check(Schema.isGreaterThan(0))
export const Prompt = Schema.Struct({
max_height: Schema.optional(PromptSize).annotate({ description: "Prompt textarea max height" }),
max_width: Schema.optional(Schema.Union([PromptSize, Schema.Literal("auto")])).annotate({
description: "Home prompt max width: a positive integer for a fixed cap, or 'auto' to scale with terminal width",
}),
}).annotate({ description: "Prompt size settings" })
export const TuiInfo = Schema.Struct({
$schema: Schema.optional(Schema.String),
theme: Schema.optional(Schema.String),
@@ -69,6 +78,7 @@ export const TuiInfo = Schema.Struct({
plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
leader_timeout: Schema.optional(KeymapLeaderTimeout),
attention: Schema.optional(Attention),
prompt: Schema.optional(Prompt),
scroll_speed: Schema.optional(ScrollSpeed).annotate({
description: "TUI scroll speed",
}),
@@ -1,5 +1,5 @@
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createSignal, onMount } from "solid-js"
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
import { Logo } from "../component/logo"
import { useSync } from "../context/sync"
import { Toast } from "../ui/toast"
@@ -9,6 +9,8 @@ import { usePromptRef } from "../context/prompt"
import { useLocal } from "../context/local"
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
import { useEditorContext } from "@tui/context/editor"
import { useTerminalDimensions } from "@opentui/solid"
import { useTuiConfig } from "../context/tui-config"
let once = false
const placeholder = {
@@ -24,6 +26,13 @@ export function Home() {
const args = useArgs()
const local = useLocal()
const editor = useEditorContext()
const dimensions = useTerminalDimensions()
const tuiConfig = useTuiConfig()
const promptMaxWidth = createMemo(() => {
const configured = tuiConfig.prompt?.max_width
if (configured === "auto") return Math.max(75, Math.floor(dimensions().width * 0.7))
return configured ?? 75
})
let sent = false
onMount(() => {
@@ -67,7 +76,7 @@ export function Home() {
</TuiPluginRuntime.Slot>
</box>
<box height={1} minHeight={0} flexShrink={1} />
<box width="100%" maxWidth={75} zIndex={1000} paddingTop={1} flexShrink={0}>
<box width="100%" maxWidth={promptMaxWidth()} zIndex={1000} paddingTop={1} flexShrink={0}>
<TuiPluginRuntime.Slot name="home_prompt" mode="replace" ref={bind}>
<Prompt ref={bind} right={<TuiPluginRuntime.Slot name="home_prompt_right" />} placeholders={placeholder} />
</TuiPluginRuntime.Slot>
+4 -2
View File
@@ -44,8 +44,10 @@ export const Model = Schema.Struct({
),
modalities: Schema.optional(
Schema.Struct({
input: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
output: Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
input: Schema.optional(Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])))),
output: Schema.optional(
Schema.mutable(Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"]))),
),
}),
),
experimental: Schema.optional(Schema.Boolean),
@@ -1,4 +1,4 @@
import { Config, ConfigProvider, Context, Effect, Layer } from "effect"
import { Config, ConfigProvider, Context, Effect, Layer, Option } from "effect"
import { ConfigService } from "@/effect/config-service"
const bool = (name: string) => Config.boolean(name).pipe(Config.withDefault(false))
@@ -9,7 +9,9 @@ const positiveInteger = (name: string) =>
)
const experimental = bool("OPENCODE_EXPERIMENTAL")
const enabledByExperimental = (name: string) =>
Config.all({ experimental, enabled: bool(name) }).pipe(Config.map((flags) => flags.experimental || flags.enabled))
Config.all({ experimental, enabled: Config.boolean(name).pipe(Config.option) }).pipe(
Config.map((flags) => Option.getOrElse(flags.enabled, () => flags.experimental)),
)
export class Service extends ConfigService.Service<Service>()("@opencode/RuntimeFlags", {
autoShare: bool("OPENCODE_AUTO_SHARE"),
@@ -46,6 +48,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"),
experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"),
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
acpNext: bool("OPENCODE_ACP_NEXT"),
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
@@ -5,7 +5,7 @@ import { Session } from "@/session/session"
import { Worktree } from "@/worktree"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import {
@@ -169,7 +169,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
HttpApiEndpoint.post("worktreeCreate", ExperimentalPaths.worktree, {
disableCodecs: true,
query: WorkspaceRoutingQuery,
payload: Schema.UndefinedOr(Worktree.CreateInput),
payload: [HttpApiSchema.NoContent, Worktree.CreateInput],
success: described(Worktree.Info, "Worktree created"),
error: WorktreeApiError,
}).annotateMerge(
@@ -2,7 +2,7 @@ import { Config } from "@/config/config"
import { BusEvent } from "@/bus/bus-event"
import "@/server/event"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { described } from "./metadata"
const GlobalHealth = Schema.Struct({
@@ -91,7 +91,7 @@ export const GlobalApi = HttpApi.make("global").add(
}),
),
HttpApiEndpoint.post("upgrade", GlobalPaths.upgrade, {
payload: GlobalUpgradeInput,
payload: [HttpApiSchema.NoContent, GlobalUpgradeInput],
success: described(GlobalUpgradeResult, "Upgrade result"),
error: HttpApiError.BadRequest,
}).annotateMerge(
@@ -238,7 +238,7 @@ export const SessionApi = HttpApi.make("session")
HttpApiEndpoint.post("fork", SessionPaths.fork, {
params: { sessionID: SessionID },
query: WorkspaceRoutingQuery,
payload: Schema.optional(ForkPayload),
payload: [HttpApiSchema.NoContent, ForkPayload],
success: described(Session.Info, "200"),
error: [HttpApiError.BadRequest, ApiNotFoundError],
}).annotateMerge(
@@ -104,9 +104,9 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
})
const worktreeCreate = Effect.fn("ExperimentalHttpApi.worktreeCreate")(function* (ctx: {
payload: Worktree.CreateInput | undefined
payload: typeof Worktree.CreateInput.Type | void
}) {
return yield* mapWorktreeError(worktreeSvc.create(ctx.payload))
return yield* mapWorktreeError(worktreeSvc.create(ctx.payload ?? undefined))
})
const worktreeRemove = Effect.fn("ExperimentalHttpApi.worktreeRemove")(function* (input: {
+22 -6
View File
@@ -82,6 +82,12 @@ const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested struc
const log = Log.create({ service: "session.prompt" })
const elog = EffectLogger.create({ service: "session.prompt" })
function isOrphanedInterruptedTool(part: SessionLegacy.ToolPart) {
// cleanup() marks abandoned tool_use blocks this way after retries/aborts.
// They are not pending work and must not trigger an assistant-prefill request.
return part.state.status === "error" && part.state.metadata?.interrupted === true
}
export interface Interface {
readonly cancel: (sessionID: SessionID) => Effect.Effect<void>
readonly prompt: (input: PromptInput) => Effect.Effect<SessionLegacy.WithParts, Image.Error>
@@ -131,7 +137,6 @@ export const layer = Layer.effect(
cancel: (sessionID: SessionID) => cancel(sessionID),
resolvePromptParts: (template: string) => resolvePromptParts(template),
prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)),
loop: (input: LoopInput) => loop(input),
} satisfies TaskPromptOps
})
@@ -1264,12 +1269,13 @@ export const layer = Layer.effect(
const lastAssistantMsg = msgs.findLast(
(msg) => msg.info.role === "assistant" && msg.info.id === lastAssistant?.id,
)
// Some providers return "stop" even when the assistant message contains tool calls.
// Keep the loop running so tool results can be sent back to the model.
// Skip provider-executed tool parts — those were fully handled within the
// provider's stream (e.g. DWS Agent Platform) and don't need a re-loop.
// Some providers return "stop" even when the assistant message contains
// tool calls. Keep the loop running so tool results can be sent back to
// the model, but ignore cleanup-marked interrupted orphans.
const hasToolCalls =
lastAssistantMsg?.parts.some((part) => part.type === "tool" && !part.metadata?.providerExecuted) ?? false
lastAssistantMsg?.parts.some(
(part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
) ?? false
if (
lastAssistant?.finish &&
@@ -1277,6 +1283,16 @@ export const layer = Layer.effect(
!hasToolCalls &&
lastUser.id < lastAssistant.id
) {
const orphan = lastAssistantMsg?.parts.find(
(part): part is SessionLegacy.ToolPart => part.type === "tool" && isOrphanedInterruptedTool(part),
)
if (orphan) {
yield* slog.warn("loop exit with orphaned interrupted tool", {
messageID: lastAssistant.id,
tool: orphan.tool,
callID: orphan.callID,
})
}
yield* slog.info("exiting loop")
break
}
+1 -7
View File
@@ -8,7 +8,6 @@ import { GrepTool } from "./grep"
import { ReadTool } from "./read"
import { TaskTool } from "./task"
import { Database } from "@opencode-ai/core/database/database"
import { TaskStatusTool } from "./task_status"
import { TodoWriteTool } from "./todo"
import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
@@ -54,7 +53,6 @@ import { Skill } from "../skill"
import { Permission } from "@/permission"
import { Reference } from "@/reference/reference"
import { BackgroundJob } from "@/background/job"
import { SessionStatus } from "@/session/status"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ProviderV2 } from "@opencode-ai/core/provider"
@@ -93,7 +91,6 @@ export const layer: Layer.Layer<
| Agent.Service
| Skill.Service
| Session.Service
| SessionStatus.Service
| BackgroundJob.Service
| Provider.Service
| Git.Service
@@ -122,7 +119,6 @@ export const layer: Layer.Layer<
const invalid = yield* InvalidTool
const task = yield* TaskTool
const taskStatus = yield* TaskStatusTool
const read = yield* ReadTool
const question = yield* QuestionTool
const todo = yield* TodoWriteTool
@@ -238,7 +234,6 @@ export const layer: Layer.Layer<
edit: Tool.init(edit),
write: Tool.init(writetool),
task: Tool.init(task),
task_status: Tool.init(taskStatus),
fetch: Tool.init(webfetch),
todo: Tool.init(todo),
search: Tool.init(websearch),
@@ -263,7 +258,6 @@ export const layer: Layer.Layer<
tool.edit,
tool.write,
tool.task,
...(flags.experimentalBackgroundSubagents ? [tool.task_status] : []),
tool.fetch,
tool.todo,
tool.search,
@@ -388,7 +382,7 @@ export const defaultLayer = Layer.suspend(() =>
Layer.provide(Skill.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(Session.defaultLayer),
Layer.provide(Layer.mergeAll(SessionStatus.defaultLayer, BackgroundJob.defaultLayer)),
Layer.provide(BackgroundJob.defaultLayer),
Layer.provide(Provider.defaultLayer),
Layer.provide(Layer.mergeAll(Git.defaultLayer, RepositoryCache.defaultLayer)),
Layer.provide(Reference.defaultLayer),
+48 -92
View File
@@ -1,19 +1,16 @@
import * as Tool from "./tool"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import DESCRIPTION from "./task.txt"
import { ToolJsonSchema } from "./json-schema"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import { BackgroundJob } from "@/background/job"
import { Bus } from "@/bus"
import { Session } from "@/session/session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Agent } from "../agent/agent"
import { deriveSubagentSessionPermission } from "../agent/subagent-permissions"
import type { SessionPrompt } from "../session/prompt"
import { SessionStatus } from "@/session/status"
import { Config } from "@/config/config"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { Cause, Effect, Exit, Option, Schema, Scope } from "effect"
import { Cause, Effect, Exit, Schema, Scope } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Database } from "@opencode-ai/core/database/database"
@@ -22,7 +19,6 @@ export interface TaskPromptOps {
cancel(sessionID: SessionID): Effect.Effect<void>
resolvePromptParts(template: string): Effect.Effect<SessionPrompt.PromptInput["parts"]>
prompt(input: SessionPrompt.PromptInput): Effect.Effect<SessionLegacy.WithParts>
loop(input: SessionPrompt.LoopInput): Effect.Effect<SessionLegacy.WithParts>
}
const id = "task"
@@ -30,12 +26,14 @@ const BACKGROUND_DESCRIPTION = [
"",
"",
[
"Background mode: background=true launches the subagent asynchronously.",
"Use task_status(task_id=..., wait=false) to poll, or wait=true to block until done.",
"Background mode: background=true launches the subagent asynchronously and returns immediately.",
"Foreground is the default; use it when you need the result before continuing.",
"Use background only for independent work that can run while you continue elsewhere.",
"You will be notified automatically when it finishes.",
].join(" "),
].join("\n")
const BaseParameters = Schema.Struct({
const BaseParameterFields = {
description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }),
prompt: Schema.String.annotate({ description: "The task for the agent to perform" }),
subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
@@ -44,40 +42,30 @@ const BaseParameters = Schema.Struct({
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
}),
command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }),
})
}
const BaseParameters = Schema.Struct(BaseParameterFields)
export const Parameters = Schema.Struct({
description: Schema.String.annotate({ description: "A short (3-5 words) description of the task" }),
prompt: Schema.String.annotate({ description: "The task for the agent to perform" }),
subagent_type: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
task_id: Schema.optional(Schema.String).annotate({
description:
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
}),
command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }),
...BaseParameterFields,
background: Schema.optional(Schema.Boolean).annotate({
description: "When true, launch the subagent in the background and return immediately",
description: "Run the agent in the background. You will be notified when it completes.",
}),
})
function output(sessionID: SessionID, text: string) {
return [
`task_id: ${sessionID} (for resuming to continue this task if needed)`,
"",
"<task_result>",
text,
"</task_result>",
].join("\n")
return [`<task id="${sessionID}" state="completed">`, "<task_result>", text, "</task_result>", "</task>"].join("\n")
}
function backgroundOutput(sessionID: SessionID) {
return [
`task_id: ${sessionID} (for polling this task with task_status)`,
"state: running",
"",
`<task id="${sessionID}" state="running">`,
"<summary>Background task started</summary>",
"<task_result>",
"Background task started. Continue your current work and call task_status when you need the result.",
"Background task started. You will be notified automatically when it finishes; do not poll for progress.",
"Do not duplicate its work. Continue only with non-overlapping work, or stop if there is nothing else useful to do.",
"</task_result>",
"</task>",
].join("\n")
}
@@ -92,9 +80,14 @@ function backgroundMessage(input: {
input.state === "completed"
? `Background task completed: ${input.description}`
: `Background task failed: ${input.description}`
return [title, `task_id: ${input.sessionID}`, `state: ${input.state}`, "", `<${tag}>`, input.text, `</${tag}>`].join(
"\n",
)
return [
`<task id="${input.sessionID}" state="${input.state}">`,
`<summary>${title}</summary>`,
`<${tag}>`,
input.text,
`</${tag}>`,
"</task>",
].join("\n")
}
function errorText(error: unknown) {
@@ -107,11 +100,9 @@ export const TaskTool = Tool.define(
Effect.gen(function* () {
const agent = yield* Agent.Service
const background = yield* BackgroundJob.Service
const bus = yield* Bus.Service
const config = yield* Config.Service
const sessions = yield* Session.Service
const scope = yield* Scope.Scope
const status = yield* SessionStatus.Service
const flags = yield* RuntimeFlags.Service
const database = yield* Database.Service
@@ -144,9 +135,8 @@ export const TaskTool = Tool.define(
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
}
const taskID = params.task_id
const session = taskID
? yield* sessions.get(SessionID.make(taskID)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
const session = params.task_id
? yield* sessions.get(SessionID.make(params.task_id)).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
: undefined
const parent = yield* sessions.get(ctx.sessionID)
const parentAgent = parent.agent
@@ -195,7 +185,6 @@ export const TaskTool = Tool.define(
const ops = ctx.extra?.promptOps as TaskPromptOps
if (!ops) return yield* Effect.fail(new Error("TaskTool requires promptOps in ctx.extra"))
const runCancel = yield* EffectBridge.make()
const runTask = Effect.fn("TaskTool.runTask")(function* () {
const parts = yield* ops.resolvePromptParts(params.prompt)
@@ -217,68 +206,34 @@ export const TaskTool = Tool.define(
return result.parts.findLast((item) => item.type === "text")?.text ?? ""
})
const resumeWhenIdle: (input: { userID: MessageID; state: "completed" | "error" }) => Effect.Effect<void> =
Effect.fn("TaskTool.resumeWhenIdle")(function* (input: { userID: MessageID; state: "completed" | "error" }) {
const latest = yield* sessions
.findMessage(ctx.sessionID, (item) => item.info.role === "user")
.pipe(Effect.orDie)
if (Option.isNone(latest)) return
if (latest.value.info.id !== input.userID) return
if ((yield* status.get(ctx.sessionID)).type !== "idle") {
yield* Effect.sleep("300 millis")
return yield* resumeWhenIdle(input)
}
yield* bus.publish(TuiEvent.ToastShow, {
title: input.state === "completed" ? "Background task complete" : "Background task failed",
message:
input.state === "completed"
? `Background task "${params.description}" finished. Resuming the main thread.`
: `Background task "${params.description}" failed. Resuming the main thread.`,
variant: input.state === "completed" ? "success" : "error",
duration: 5000,
})
yield* ops
.loop({ sessionID: ctx.sessionID })
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }))
})
const continueIfIdle = Effect.fn("TaskTool.continueIfIdle")(function* (input: {
userID: MessageID
state: "completed" | "error"
}) {
yield* resumeWhenIdle(input).pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }))
})
const inject = Effect.fn("TaskTool.injectBackgroundResult")(function* (
state: "completed" | "error",
text: string,
) {
const currentParent = yield* sessions.get(ctx.sessionID)
const message = yield* ops.prompt({
sessionID: ctx.sessionID,
noReply: true,
agent: currentParent.agent ?? ctx.agent,
parts: [
{
type: "text",
synthetic: true,
text: backgroundMessage({
sessionID: nextSession.id,
description: params.description,
state,
text,
}),
},
],
})
yield* continueIfIdle({ userID: message.info.id, state })
yield* ops
.prompt({
sessionID: ctx.sessionID,
agent: currentParent.agent ?? ctx.agent,
parts: [
{
type: "text",
synthetic: true,
text: backgroundMessage({
sessionID: nextSession.id,
description: params.description,
state,
text,
}),
},
],
})
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }))
})
const existing = yield* background.get(nextSession.id)
if (existing?.status === "running") {
return yield* Effect.fail(
new Error(`Task ${nextSession.id} is already running. Use task_status to check progress.`),
)
return yield* Effect.fail(new Error(`Task ${nextSession.id} is already running.`))
}
if (runInBackground) {
@@ -308,6 +263,7 @@ export const TaskTool = Tool.define(
}
}
const runCancel = yield* EffectBridge.make()
const cancel = ops.cancel(nextSession.id)
function onAbort() {
-180
View File
@@ -1,180 +0,0 @@
import * as Tool from "./tool"
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
import DESCRIPTION from "./task_status.txt"
import { BackgroundJob } from "@/background/job"
import { Session } from "@/session/session"
import { MessageV2 } from "@/session/message-v2"
import { SessionID } from "@/session/schema"
import { SessionStatus } from "@/session/status"
import { PositiveInt } from "@opencode-ai/core/schema"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Effect, Option, Schema } from "effect"
const DEFAULT_TIMEOUT = 60_000
const POLL_MS = 300
const Parameters = Schema.Struct({
task_id: SessionID.annotate({ description: "The task_id returned by the task tool" }),
wait: Schema.optional(Schema.Boolean).annotate({
description: "When true, wait until the task reaches a terminal state or timeout",
}),
timeout_ms: Schema.optional(PositiveInt).annotate({
description: "Maximum milliseconds to wait when wait=true (default: 60000)",
}),
})
type State = BackgroundJob.Status
type InspectResult = { state: State; text: string }
function format(input: { taskID: SessionID; state: State; text: string }) {
const tag = input.state === "completed" || input.state === "running" ? "task_result" : "task_error"
return [`task_id: ${input.taskID}`, `state: ${input.state}`, "", `<${tag}>`, input.text, `</${tag}>`].join("\n")
}
function errorText(error: NonNullable<SessionLegacy.Assistant["error"]>) {
const data = Reflect.get(error, "data")
const message = data && typeof data === "object" ? Reflect.get(data, "message") : undefined
if (typeof message === "string" && message) return message
return error.name
}
function inspectMessage(message: SessionLegacy.WithParts): InspectResult | undefined {
if (message.info.role !== "assistant") return
const text = message.parts.findLast((part) => part.type === "text")?.text ?? ""
if (message.info.error) return { state: "error", text: text || errorText(message.info.error) }
if (message.info.finish && !["tool-calls", "unknown"].includes(message.info.finish))
return { state: "completed", text }
return { state: "running", text: text || "Task is still running." }
}
export const TaskStatusTool = Tool.define(
"task_status",
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const sessions = yield* Session.Service
const status = yield* SessionStatus.Service
const flags = yield* RuntimeFlags.Service
const inspect: (taskID: SessionID) => Effect.Effect<InspectResult> = Effect.fn("TaskStatusTool.inspect")(function* (
taskID: SessionID,
) {
const job = yield* jobs.get(taskID)
if (job) {
return {
state: job.status,
text:
job.output ??
job.error ??
(job.status === "running"
? "Task is still running."
: job.status === "cancelled"
? "Task was cancelled."
: ""),
}
}
const current = yield* status.get(taskID)
if (current.type === "busy" || current.type === "retry") {
return {
state: "running",
text: current.type === "retry" ? `Task is retrying: ${current.message}` : "Task is still running.",
}
}
const latestAssistant = yield* sessions
.findMessage(taskID, (item) => item.info.role === "assistant")
.pipe(Effect.orDie)
if (Option.isSome(latestAssistant)) {
const latest = inspectMessage(latestAssistant.value)
if (!latest) return { state: "error", text: "Task is not running in this process." }
if (latest.state === "running")
return { state: "error", text: "Task is not running in this process and has no final output." }
return latest
}
return { state: "error", text: "Task is not running in this process and has not produced output." }
})
const waitForTerminal: (
taskID: SessionID,
timeout: number,
) => Effect.Effect<{ result: InspectResult; timedOut: boolean }> = Effect.fn("TaskStatusTool.waitForTerminal")(
function* (taskID: SessionID, timeout: number) {
const result = yield* inspect(taskID)
if (result.state !== "running") return { result, timedOut: false }
if (timeout <= 0) return { result, timedOut: true }
const sleep = Math.min(POLL_MS, timeout)
yield* Effect.sleep(`${sleep} millis`)
return yield* waitForTerminal(taskID, timeout - sleep)
},
)
const run = Effect.fn("TaskStatusTool.execute")(function* (
params: Schema.Schema.Type<typeof Parameters>,
_ctx: Tool.Context,
) {
if (!flags.experimentalBackgroundSubagents) {
return yield* Effect.fail(new Error("task_status requires OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true"))
}
const session = yield* sessions.get(params.task_id).pipe(Effect.catchCause(() => Effect.succeed(undefined)))
if (!session) {
return {
title: "Task status",
metadata: {
task_id: params.task_id,
state: "error" as const,
timed_out: false,
},
output: format({
taskID: params.task_id,
state: "error",
text: `Task not found: ${params.task_id}`,
}),
}
}
const waited =
params.wait === true
? yield* jobs.wait({ id: params.task_id, timeout: params.timeout_ms ?? DEFAULT_TIMEOUT })
: { info: yield* jobs.get(params.task_id), timedOut: false }
const inspected = waited.info
? {
result: {
state: waited.info.status,
text:
waited.info.output ??
waited.info.error ??
(waited.info.status === "running" ? "Task is still running." : ""),
},
timedOut: waited.timedOut,
}
: params.wait === true
? yield* waitForTerminal(params.task_id, params.timeout_ms ?? DEFAULT_TIMEOUT)
: { result: yield* inspect(params.task_id), timedOut: false }
const text = inspected.timedOut
? `Timed out after ${params.timeout_ms ?? DEFAULT_TIMEOUT}ms while waiting for task completion.`
: inspected.result.text
return {
title: "Task status",
metadata: {
task_id: params.task_id,
state: inspected.result.state,
timed_out: inspected.timedOut,
},
output: format({
taskID: params.task_id,
state: inspected.result.state,
text,
}),
}
})
return {
description: DESCRIPTION,
parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
run(params, ctx).pipe(Effect.orDie),
}
}),
)
@@ -1,13 +0,0 @@
Poll the status of a background subagent task launched with the task tool.
Use this for tasks started with `task(background=true)`.
Parameters:
- `task_id` (required): the task session id returned by the task tool
- `wait` (optional): when true, wait for completion
- `timeout_ms` (optional): max wait duration in milliseconds when `wait=true`
Returns compact, parseable output:
- `task_id`
- `state` (`running`, `completed`, `error`, or `cancelled`)
- `<task_result>...</task_result>` or `<task_error>...</task_error>` containing final output, error summary, or current progress text
@@ -0,0 +1,229 @@
import { describe, expect, test } from "bun:test"
import {
buildConfigOptions,
buildEffortSelectOption,
buildModeSelectOption,
buildModelSelectOption,
formatCurrentModelId,
formatVariantName,
parseModelSelection,
type ConfigOptionProvider,
} from "@/acp-next/config-option"
const providers: ConfigOptionProvider[] = [
{
id: "anthropic",
name: "Anthropic",
models: {
"claude/sonnet-4": {
id: "claude/sonnet-4",
name: "Claude Sonnet 4",
variants: {
default: {},
high: {},
"very-high": {},
},
},
"claude-haiku": {
id: "claude-haiku",
name: "Claude Haiku",
},
},
},
{
id: "openai",
name: "OpenAI",
models: {
"gpt-5": {
id: "gpt-5",
name: "GPT-5",
variants: {
minimal: {},
low: {},
},
},
},
},
]
describe("acp-next config options", () => {
test("builds the model select option with ACP verifier category", () => {
expect(
buildModelSelectOption({
providers,
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
currentVariant: "high",
}),
).toEqual({
id: "model",
name: "Model",
category: "model",
type: "select",
currentValue: "anthropic/claude/sonnet-4",
options: [
{ value: "anthropic/claude-haiku", name: "Anthropic/Claude Haiku" },
{ value: "anthropic/claude/sonnet-4", name: "Anthropic/Claude Sonnet 4" },
{ value: "openai/gpt-5", name: "OpenAI/GPT-5" },
],
})
})
test("includes variant ids in the model option only when requested", () => {
const option = buildModelSelectOption({
providers,
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
currentVariant: "high",
includeVariants: true,
})
expect(option.currentValue).toBe("anthropic/claude/sonnet-4/high")
if (option.type !== "select") throw new Error("expected select option")
expect(option.options).toContainEqual({
value: "anthropic/claude/sonnet-4/high",
name: "Anthropic/Claude Sonnet 4 (High)",
})
expect(option.options).not.toContainEqual({
value: "anthropic/claude/sonnet-4/default",
name: "Anthropic/Claude Sonnet 4 (Default)",
})
})
test("builds effort option from variants and falls back to default when current variant is invalid", () => {
expect(buildEffortSelectOption({ variants: ["low", "default", "high"], currentVariant: "missing" })).toEqual({
id: "effort",
name: "Effort",
description: "Available effort levels for this model",
category: "thought_level",
type: "select",
currentValue: "default",
options: [
{ value: "low", name: "Low" },
{ value: "default", name: "Default" },
{ value: "high", name: "High" },
],
})
})
test("effort fallback uses the first variant when default is absent", () => {
expect(buildEffortSelectOption({ variants: ["minimal", "low"], currentVariant: "missing" })?.currentValue).toBe(
"minimal",
)
})
test("omits effort option when there are no variants", () => {
expect(buildEffortSelectOption({ variants: [] })).toBeUndefined()
})
test("builds the mode select option with descriptions when present", () => {
expect(
buildModeSelectOption({
currentModeId: "build",
modes: [
{ id: "build", name: "Build", description: "Make code changes" },
{ id: "plan", name: "Plan" },
],
}),
).toEqual({
id: "mode",
name: "Session Mode",
category: "mode",
type: "select",
currentValue: "build",
options: [
{ value: "build", name: "Build", description: "Make code changes" },
{ value: "plan", name: "Plan" },
],
})
})
test("builds full config options with model, effort, and mode in stable order", () => {
const options = buildConfigOptions({
providers,
currentModel: { providerID: "anthropic", modelID: "claude/sonnet-4" },
currentVariant: "very-high",
modes: [
{ id: "build", name: "Build" },
{ id: "plan", name: "Plan" },
],
currentModeId: "plan",
})
expect(options.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(options.map((option) => option.category)).toEqual(["model", "thought_level", "mode"])
expect(options[1]?.currentValue).toBe("very-high")
})
test("full config options omit effort for models without variants", () => {
expect(
buildConfigOptions({
providers,
currentModel: { providerID: "anthropic", modelID: "claude-haiku" },
}).map((option) => option.id),
).toEqual(["model"])
})
test("parses provider/model selections", () => {
expect(parseModelSelection("openai/gpt-5", providers)).toEqual({
model: { providerID: "openai", modelID: "gpt-5" },
})
})
test("parses provider/model/variant selections when the base model exposes that variant", () => {
expect(parseModelSelection("openai/gpt-5/low", providers)).toEqual({
model: { providerID: "openai", modelID: "gpt-5" },
variant: "low",
})
})
test("prefers exact slash-containing model ids before treating the tail as a variant", () => {
expect(parseModelSelection("anthropic/claude/sonnet-4", providers)).toEqual({
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
})
})
test("parses trailing variants for slash-containing model ids", () => {
expect(parseModelSelection("anthropic/claude/sonnet-4/high", providers)).toEqual({
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
variant: "high",
})
})
test("keeps unknown trailing segments in the model id when they are not valid variants", () => {
expect(parseModelSelection("anthropic/claude/sonnet-4/missing", providers)).toEqual({
model: { providerID: "anthropic", modelID: "claude/sonnet-4/missing" },
})
})
test("formats current model ids with and without selected variants", () => {
expect(
formatCurrentModelId({
model: { providerID: "openai", modelID: "gpt-5" },
variant: "low",
variants: ["minimal", "low"],
}),
).toBe("openai/gpt-5")
expect(
formatCurrentModelId({
model: { providerID: "openai", modelID: "gpt-5" },
variant: "low",
variants: ["minimal", "low"],
includeVariant: true,
}),
).toBe("openai/gpt-5/low")
})
test("formats current model ids with variant fallback", () => {
expect(
formatCurrentModelId({
model: { providerID: "anthropic", modelID: "claude/sonnet-4" },
variant: "missing",
variants: ["default", "high"],
includeVariant: true,
}),
).toBe("anthropic/claude/sonnet-4/default")
})
test("formats variant names for display", () => {
expect(formatVariantName("very_high-effort")).toBe("Very High Effort")
})
})
@@ -0,0 +1,201 @@
import { describe, expect, test } from "bun:test"
import type { ContentBlock } from "@agentclientprotocol/sdk"
import { pathToFileURL } from "node:url"
import { contentBlockToParts, partsToContentChunks, promptContentToParts } from "../../src/acp-next/content"
describe("acp-next content conversion", () => {
test("plain text block becomes a text part", () => {
expect(contentBlockToParts({ type: "text", text: "hello" })).toEqual([{ type: "text", text: "hello" }])
})
test("assistant-only text audience becomes synthetic", () => {
expect(
contentBlockToParts({
type: "text",
text: "internal",
annotations: { audience: ["assistant"] },
}),
).toEqual([{ type: "text", text: "internal", synthetic: true }])
})
test("user-only text audience becomes ignored", () => {
expect(
contentBlockToParts({
type: "text",
text: "visible to user",
annotations: { audience: ["user"] },
}),
).toEqual([{ type: "text", text: "visible to user", ignored: true }])
})
test("image block with base64 data becomes a data URL file part", () => {
expect(
contentBlockToParts({
type: "image",
data: "AAAA",
mimeType: "image/png",
uri: "file:///tmp/screenshot.png",
}),
).toEqual([
{
type: "file",
url: "data:image/png;base64,AAAA",
filename: "screenshot.png",
mime: "image/png",
},
])
})
test("image block with http URI becomes a file part", () => {
expect(
contentBlockToParts({
type: "image",
data: "",
mimeType: "image/jpeg",
uri: "http://example.com/assets/photo.jpg",
}),
).toEqual([
{
type: "file",
url: "http://example.com/assets/photo.jpg",
filename: "photo.jpg",
mime: "image/jpeg",
},
])
})
test("resource_link file URL becomes a file part with name and fallback mime", () => {
expect(
contentBlockToParts({
type: "resource_link",
uri: "file:///tmp/notes.txt",
name: "client-notes.txt",
}),
).toEqual([
{
type: "file",
url: "file:///tmp/notes.txt",
filename: "client-notes.txt",
mime: "text/plain",
},
])
})
test("resource_link zed path becomes a file URL part", () => {
expect(
contentBlockToParts({
type: "resource_link",
uri: "zed://workspace?path=/tmp/project/src/app.ts",
name: "app.ts",
mimeType: "text/typescript",
}),
).toEqual([
{
type: "file",
url: pathToFileURL("/tmp/project/src/app.ts").href,
filename: "app.ts",
mime: "text/typescript",
},
])
})
test("resource with text becomes a text part", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "file:///tmp/context.txt",
mimeType: "text/plain",
text: "context",
},
}),
).toEqual([{ type: "text", text: "context" }])
})
test("resource with blob and mimeType becomes a data URL file part", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "file:///tmp/report.pdf",
mimeType: "application/pdf",
blob: "JVBERg==",
},
}),
).toEqual([
{
type: "file",
url: "data:application/pdf;base64,JVBERg==",
filename: "report.pdf",
mime: "application/pdf",
},
])
})
test("data URL resource is preserved as a file part", () => {
expect(
contentBlockToParts({
type: "resource",
resource: {
uri: "data:text/plain;base64,aGVsbG8=",
mimeType: "text/plain",
blob: "ignored",
},
}),
).toEqual([
{
type: "file",
url: "data:text/plain;base64,aGVsbG8=",
filename: "file",
mime: "text/plain",
},
])
})
test("unsupported blocks are ignored", () => {
expect(promptContentToParts([{ type: "audio", data: "AAAA", mimeType: "audio/wav" }])).toEqual([])
expect(promptContentToParts([{ type: "unknown", text: "skip" } as unknown as ContentBlock])).toEqual([])
})
})
describe("acp-next replay conversion", () => {
test("replays text audience annotations", () => {
expect(partsToContentChunks([{ type: "text", text: "cached", synthetic: true }])).toEqual([
{
content: {
type: "text",
text: "cached",
annotations: { audience: ["assistant"] },
},
},
])
})
test("replays file and data URL parts as ACP content", () => {
expect(
partsToContentChunks([
{ type: "file", url: "file:///tmp/readme.md", filename: "readme.md", mime: "text/markdown" },
{ type: "file", url: "data:text/plain;base64,aGVsbG8=", filename: "note.txt", mime: "text/plain" },
]),
).toEqual([
{
content: {
type: "resource_link",
uri: "file:///tmp/readme.md",
name: "readme.md",
mimeType: "text/markdown",
},
},
{
content: {
type: "resource",
resource: {
uri: pathToFileURL("note.txt").href,
mimeType: "text/plain",
text: "hello",
},
},
},
])
})
})
@@ -0,0 +1,185 @@
import { describe, expect } from "bun:test"
import { Directory } from "@/acp-next/directory"
import { Command } from "@/command"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@/provider/provider"
import { Effect, Layer } from "effect"
import { it } from "../lib/effect"
const command = (name: string): Command.Info => ({
name,
source: "command",
template: `run ${name}`,
hints: [],
})
const model = (providerID: ProviderV2.ID, id: string, variants?: Directory.ModelVariants): Provider.Model => ({
id: ProviderV2.ModelID.make(id),
providerID,
api: {
id,
url: "https://example.com",
npm: "@ai-sdk/openai-compatible",
},
name: id,
family: "test",
capabilities: {
temperature: true,
reasoning: Boolean(variants),
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: {
context: 128000,
output: 4096,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
...(variants ? { variants } : {}),
})
const snapshot = (directory: string) => {
const providerID = ProviderV2.ID.make(`provider-${directory}`)
const modelID = ProviderV2.ModelID.make(`model-${directory}`)
const providers = {
[providerID]: {
id: providerID,
name: `Provider ${directory}`,
source: "config",
env: [],
options: {},
models: {
[modelID]: model(providerID, modelID, {
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
}),
[ProviderV2.ModelID.make(`plain-${directory}`)]: model(providerID, `plain-${directory}`),
},
},
} satisfies Record<ProviderV2.ID, Provider.Info>
return Directory.build({
directory,
providers,
modes: [
{ id: "build", name: `build-${directory}` },
{ id: "plan", name: `plan-${directory}`, description: "plan first" },
],
defaultModeID: "build",
commands: [command(`init-${directory}`), command(`review-${directory}`)],
defaultModel: { providerID, modelID },
})
}
const fakeLayer = (calls: string[]) =>
Directory.layer.pipe(
Layer.provide(
Layer.succeed(
Directory.Loader,
Directory.Loader.of({
load: (directory) =>
Effect.sync(() => {
calls.push(directory)
return snapshot(directory)
}),
}),
),
),
)
describe("ACP next directory snapshot", () => {
it.effect("two concurrent callers share one load", () => {
const calls: string[] = []
return Effect.gen(function* () {
const directory = yield* Directory.Service
const [first, second] = yield* Effect.all([directory.get("alpha"), directory.get("alpha")], {
concurrency: "unbounded",
})
expect(calls).toEqual(["alpha"])
expect(first).toBe(second)
}).pipe(Effect.provide(fakeLayer(calls)))
})
it.effect("warm calls use cached data", () => {
const calls: string[] = []
return Effect.gen(function* () {
const directory = yield* Directory.Service
const first = yield* directory.get("alpha")
const second = yield* directory.get("alpha")
expect(calls).toEqual(["alpha"])
expect(first).toBe(second)
}).pipe(Effect.provide(fakeLayer(calls)))
})
it.effect("different directories get different snapshots", () => {
const calls: string[] = []
return Effect.gen(function* () {
const directory = yield* Directory.Service
const [alpha, beta] = yield* Effect.all([directory.get("alpha"), directory.get("beta")], {
concurrency: "unbounded",
})
expect(calls.toSorted()).toEqual(["alpha", "beta"])
expect(alpha.directory).toBe("alpha")
expect(beta.directory).toBe("beta")
expect(alpha.defaultModel?.providerID).not.toBe(beta.defaultModel?.providerID)
}).pipe(Effect.provide(fakeLayer(calls)))
})
it.effect("model variant lookup works", () =>
Effect.gen(function* () {
const directory = yield* Directory.Service
const alpha = yield* directory.get("alpha")
const model = alpha.defaultModel!
expect(directory.variants(alpha, model)).toEqual({
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
})
expect(directory.variants(alpha, { ...model, modelID: ProviderV2.ModelID.make("missing") })).toBeUndefined()
}).pipe(Effect.provide(fakeLayer([]))),
)
it.effect("commands and modes are included", () =>
Effect.gen(function* () {
const directory = yield* Directory.Service
const alpha = yield* directory.get("alpha")
expect(alpha.availableCommands.map((item) => item.name)).toEqual(["init-alpha", "review-alpha"])
expect(alpha.availableModes).toEqual([
{ id: "build", name: "build-alpha" },
{ id: "plan", name: "plan-alpha", description: "plan first" },
])
expect(alpha.defaultModeID).toBe("build")
}).pipe(Effect.provide(fakeLayer([]))),
)
it.effect("falls back when the default mode is not available", () =>
Effect.sync(() => {
expect(
Directory.build({
directory: "alpha",
providers: {},
modes: [
{ id: "build", name: "Build" },
{ id: "plan", name: "Plan" },
],
defaultModeID: "hidden",
commands: [],
}).defaultModeID,
).toBe("build")
}),
)
})
@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test"
import { RequestError } from "@agentclientprotocol/sdk"
import * as ACPNextError from "../../src/acp-next/error"
describe("acp-next.error", () => {
test("maps validation failures to invalid params", () => {
const cases: ACPNextError.Error[] = [
new ACPNextError.SessionNotFoundError({ sessionId: "ses_missing" }),
new ACPNextError.InvalidConfigOptionError({ configId: "temperature" }),
new ACPNextError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }),
new ACPNextError.InvalidEffortError({ effort: "extreme" }),
new ACPNextError.InvalidModeError({ mode: "turbo" }),
]
expect(cases.map((error) => ACPNextError.toRequestError(error).code)).toEqual([
-32602, -32602, -32602, -32602, -32602,
])
})
test("includes safe validation details", () => {
expect(ACPNextError.toRequestError(new ACPNextError.SessionNotFoundError({ sessionId: "ses_123" }))).toMatchObject({
code: -32602,
data: { sessionId: "ses_123" },
})
expect(ACPNextError.toRequestError(new ACPNextError.InvalidModelError({ modelId: "gpt-missing" }))).toMatchObject({
code: -32602,
data: { modelId: "gpt-missing" },
})
})
test("maps auth required to the SDK auth error", () => {
const requestError = ACPNextError.toRequestError(new ACPNextError.AuthRequiredError({ providerId: "anthropic" }))
expect(requestError).toBeInstanceOf(RequestError)
expect(requestError.code).toBe(-32000)
expect(requestError.message).toBe("Authentication required: provider authentication required")
expect(requestError.data).toEqual({ providerId: "anthropic" })
})
test("maps unsupported operations to method not found", () => {
const requestError = ACPNextError.toRequestError(
new ACPNextError.UnsupportedOperationError({ method: "session/new" }),
)
expect(requestError.code).toBe(-32601)
expect(requestError.data).toEqual({ method: "session/new" })
})
test("maps service failures to safe internal errors", () => {
const requestError = ACPNextError.toRequestError(
new ACPNextError.ServiceFailureError({ service: "provider", safeMessage: "Provider request failed" }),
)
expect(requestError.code).toBe(-32603)
expect(requestError.message).toBe("Internal error: Provider request failed")
expect(requestError.data).toEqual({ service: "provider" })
})
test("wraps unknown defects without leaking raw details", () => {
const requestError = ACPNextError.toRequestError(
ACPNextError.fromUnknownDefect(new Error("stack has sk-ant-secret and oauth refresh token")),
)
const serialized = JSON.stringify(requestError.toErrorResponse())
expect(requestError.code).toBe(-32603)
expect(requestError.message).toBe("Internal error: Internal service failure")
expect(serialized).not.toContain("sk-ant-secret")
expect(serialized).not.toContain("oauth refresh token")
expect(serialized).not.toContain("stack")
})
})
@@ -0,0 +1,534 @@
import { describe, expect, it } from "bun:test"
import type {
AgentSideConnection,
LoadSessionResponse,
NewSessionResponse,
SessionConfigOption,
SessionConfigSelectOption,
SetSessionConfigOptionResponse,
} from "@agentclientprotocol/sdk"
import type { OpencodeClient } from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import * as ACPNextService from "@/acp-next/service"
import * as ACPNextError from "@/acp-next/error"
import { ProviderV2 } from "@opencode-ai/core/provider"
import type { Provider } from "@/provider/provider"
const providerID = ProviderV2.ID.make("test")
const modelID = ProviderV2.ModelID.make("test-model")
const configuredModelID = ProviderV2.ModelID.make("configured-model")
const secondModelID = ProviderV2.ModelID.make("second-model")
const provider: Provider.Info = {
id: providerID,
name: "Test",
source: "config",
env: [],
options: {},
models: {
[modelID]: {
id: modelID,
providerID,
api: {
id: modelID,
url: "https://example.com",
npm: "@ai-sdk/openai-compatible",
},
name: "Test Model",
family: "test",
capabilities: {
temperature: true,
reasoning: true,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: {
context: 128000,
output: 4096,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
variants: {
default: {},
high: { reasoningEffort: "high" },
},
},
[configuredModelID]: {
id: configuredModelID,
providerID,
api: {
id: configuredModelID,
url: "https://example.com",
npm: "@ai-sdk/openai-compatible",
},
name: "Configured Model",
family: "test",
capabilities: {
temperature: true,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: {
context: 128000,
output: 4096,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
},
[secondModelID]: {
id: secondModelID,
providerID,
api: {
id: secondModelID,
url: "https://example.com",
npm: "@ai-sdk/openai-compatible",
},
name: "Second Model",
family: "test",
capabilities: {
temperature: true,
reasoning: true,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: {
context: 128000,
output: 4096,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
variants: {
low: { reasoningEffort: "low" },
medium: { reasoningEffort: "medium" },
},
},
},
}
describe("ACP next service sessions", () => {
const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
const updates: unknown[] = []
const mcpAdds: string[] = []
const sdk = {
config: {
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () =>
Promise.resolve({
data: [
{ name: "build", mode: "primary", permission: [], options: {} },
{ name: "plan", mode: "primary", description: "Plan first", permission: [], options: {} },
{ name: "hidden", mode: "primary", hidden: true, permission: [], options: {} },
],
}),
skills: () =>
Promise.resolve({
data: [{ name: "review-skill", description: "Review", location: "/skills/review", content: "review" }],
}),
},
command: {
list: () =>
Promise.resolve({
data: [{ name: "init", description: "Initialize", source: "command", template: "init", hints: [] }],
}),
},
session: {
create: () => Promise.resolve({ data: { id: "ses_new" } }),
get: () => Promise.resolve({ data: { id: "ses_loaded" } }),
list: () => Promise.resolve({ data: [] }),
messages: () => Promise.resolve({ data: messages }),
},
mcp: {
add: (input: { name?: string }) => {
if (input.name) mcpAdds.push(input.name)
return Promise.resolve({ data: {} })
},
},
} as unknown as OpencodeClient
const connection = {
sessionUpdate: (update: unknown) => {
updates.push(update)
return Promise.resolve()
},
} as Pick<AgentSideConnection, "sessionUpdate">
return { service: ACPNextService.make({ sdk, connection }), updates, mcpAdds }
}
it("creates a backed session with config options and command update", async () => {
const { service, updates, mcpAdds } = makeService()
const result = await Effect.runPromise(
service.newSession({
cwd: "/workspace",
mcpServers: [
{ name: "tools", command: "node", args: ["server.js"], env: [] },
{ name: "tools", command: "node", args: ["server.js"], env: [] },
],
}),
)
await new Promise((resolve) => setTimeout(resolve, 5))
expect(result.sessionId).toBe("ses_new")
expect(categories(result)).toContain("model")
expect(categories(result)).toContain("thought_level")
expect(categories(result)).toContain("mode")
expect(updates).toHaveLength(1)
expect(JSON.stringify(updates[0])).toContain("available_commands_update")
expect(JSON.stringify(updates[0])).toContain("review-skill")
expect(mcpAdds).toEqual(["tools"])
})
it("loads a session and restores model variant and mode from messages", async () => {
const { service } = makeService([
{
info: {
role: "assistant",
providerID: "test",
modelID: "test-model",
variant: "high",
mode: "plan",
},
parts: [],
},
])
const result = await Effect.runPromise(
service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }),
)
expect(result.configOptions?.find((option) => option.id === "effort")?.currentValue).toBe("high")
expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan")
})
it("restores model variant and mode from the latest user message", async () => {
const { service } = makeService([
{
info: {
role: "user",
model: { providerID: "test", modelID: "test-model", variant: "default" },
agent: "build",
},
parts: [],
},
{
info: {
role: "user",
model: { providerID: "test", modelID: "test-model", variant: "high" },
agent: "plan",
},
parts: [],
},
])
const result = await Effect.runPromise(
service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }),
)
expect(result.configOptions?.find((option) => option.id === "effort")?.currentValue).toBe("high")
expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan")
})
it("maps provider auth failures to auth-required request errors", async () => {
const service = ACPNextService.make({
sdk: {
config: {
providers: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }),
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => Promise.resolve({ data: [] }),
skills: () => Promise.resolve({ data: [] }),
},
command: {
list: () => Promise.resolve({ data: [] }),
},
} as unknown as OpencodeClient,
})
const error = await Effect.runPromise(
service
.newSession({ cwd: "/workspace", mcpServers: [] })
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
)
expect(error.code).toBe(-32000)
})
it("does not cache failed directory snapshots", async () => {
let providersCalls = 0
const sdk = {
config: {
providers: () => {
providersCalls++
if (providersCalls === 1) {
return Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } })
}
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
},
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
skills: () => Promise.resolve({ data: [] }),
},
command: {
list: () => Promise.resolve({ data: [] }),
},
session: {
create: () => Promise.resolve({ data: { id: "ses_retry" } }),
list: () => Promise.resolve({ data: [] }),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
const first = await Effect.runPromise(
service
.newSession({ cwd: "/workspace", mcpServers: [] })
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
)
const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(first.code).toBe(-32000)
expect(second.sessionId).toBe("ses_retry")
expect(providersCalls).toBe(2)
})
it("registers same-name MCP servers again for different sessions or configs", async () => {
const adds: unknown[] = []
let nextSession = 0
const sdk = {
config: {
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
skills: () => Promise.resolve({ data: [] }),
},
command: {
list: () => Promise.resolve({ data: [] }),
},
session: {
create: () => {
nextSession++
return Promise.resolve({ data: { id: `ses_${nextSession}` } })
},
list: () => Promise.resolve({ data: [] }),
},
mcp: {
add: (input: unknown) => {
adds.push(input)
return Promise.resolve({ data: {} })
},
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
await Effect.runPromise(
service.newSession({
cwd: "/workspace",
mcpServers: [{ name: "tools", command: "node", args: ["one.js"], env: [] }],
}),
)
await Effect.runPromise(
service.newSession({
cwd: "/workspace",
mcpServers: [{ name: "tools", command: "node", args: ["two.js"], env: [] }],
}),
)
expect(adds).toHaveLength(2)
expect(JSON.stringify(adds[0])).toContain("one.js")
expect(JSON.stringify(adds[1])).toContain("two.js")
})
it("uses the configured model as the new session default", async () => {
const sdk = {
config: {
providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
get: () => Promise.resolve({ data: { model: "test/configured-model" } }),
},
app: {
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
skills: () => Promise.resolve({ data: [] }),
},
command: {
list: () => Promise.resolve({ data: [] }),
},
session: {
create: (input: { model?: { id?: string } }) => Promise.resolve({ data: { id: input.model?.id } }),
list: () => Promise.resolve({ data: [] }),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(result.sessionId).toBe("configured-model")
expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model")
})
it("switches model and returns updated model and effort options", async () => {
const { service } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const updated = await Effect.runPromise(
service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
}),
)
expect(select(updated, "model")?.currentValue).toBe("test/second-model")
expect(select(updated, "effort")?.currentValue).toBe("low")
expect(flattenSelectOptions(select(updated, "effort")).map((option) => option.value)).toEqual(["low", "medium"])
})
it("switches effort and returns the updated effort current value", async () => {
const { service } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const updated = await Effect.runPromise(
service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "high",
}),
)
expect(select(updated, "effort")?.currentValue).toBe("high")
})
it("switches mode and returns the updated mode current value", async () => {
const { service } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const updated = await Effect.runPromise(
service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "mode",
value: "plan",
}),
)
expect(select(updated, "mode")?.currentValue).toBe("plan")
})
it("maps invalid model effort mode and config id to invalid params", async () => {
const { service } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const results = await Promise.all(
[
{ configId: "model", value: "test/missing-model" },
{ configId: "effort", value: "max" },
{ configId: "mode", value: "missing-mode" },
{ configId: "missing", value: "value" },
].map((input) =>
Effect.runPromise(
service
.setSessionConfigOption({ sessionId: session.sessionId, ...input })
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
),
),
)
expect(results.map((error) => error.code)).toEqual([-32602, -32602, -32602, -32602])
})
it("does not reload providers or commands when switching effort from a warm snapshot", async () => {
let providersCalls = 0
let commandCalls = 0
const sdk = {
config: {
providers: () => {
providersCalls++
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
},
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
skills: () => Promise.resolve({ data: [] }),
},
command: {
list: () => {
commandCalls++
return Promise.resolve({ data: [] })
},
},
session: {
create: () => Promise.resolve({ data: { id: "ses_fast" } }),
list: () => Promise.resolve({ data: [] }),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient
const service = ACPNextService.make({ sdk })
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(providersCalls).toBe(1)
expect(commandCalls).toBe(1)
await Effect.runPromise(
service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "high",
}),
)
expect(providersCalls).toBe(1)
expect(commandCalls).toBe(1)
})
})
function categories(result: NewSessionResponse | LoadSessionResponse) {
return result.configOptions?.map((option) => option.category) ?? []
}
function select(result: SetSessionConfigOptionResponse, id: string) {
return result.configOptions.find(
(option): option is Extract<SessionConfigOption, { type: "select" }> =>
option.id === id && option.type === "select",
)
}
function flattenSelectOptions(option: Extract<SessionConfigOption, { type: "select" }> | undefined) {
return option?.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options)) ?? []
}
@@ -0,0 +1,199 @@
import { describe, expect } from "bun:test"
import type { McpServer } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import * as ACPNextError from "@/acp-next/error"
import * as ACPNextSession from "@/acp-next/session"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
const sessionTest = testEffect(ACPNextSession.defaultLayer)
const model = (providerID: string, modelID: string): ACPNextSession.SelectedModel => ({
providerID: ProviderV2.ID.make(providerID),
modelID: ProviderV2.ModelID.make(modelID),
})
const mcpServer: McpServer = {
name: "local-tools",
command: "node",
args: ["server.js"],
env: [],
}
describe("acp-next session state", () => {
sessionTest.effect("creates and retrieves session state", () =>
Effect.gen(function* () {
const createdAt = new Date("2026-05-25T00:00:00.000Z")
const created = yield* ACPNextSession.Service.use((session) =>
session.create({
id: "ses_1",
cwd: "/workspace",
mcpServers: [mcpServer],
createdAt,
model: model("anthropic", "claude-sonnet"),
variant: "high",
modeId: "build",
}),
)
const loaded = yield* ACPNextSession.Service.use((session) => session.get("ses_1"))
expect(created).toMatchObject({
id: "ses_1",
cwd: "/workspace",
mcpServers: [mcpServer],
model: model("anthropic", "claude-sonnet"),
variant: "high",
modeId: "build",
})
expect(loaded.createdAt).toEqual(createdAt)
expect(loaded.knownParts.size).toBe(0)
}),
)
sessionTest.effect("fails required lookups with typed SessionNotFound", () =>
Effect.gen(function* () {
const error = yield* ACPNextSession.Service.use((session) => session.get("ses_missing")).pipe(Effect.flip)
expect(error).toBeInstanceOf(ACPNextError.SessionNotFoundError)
expect(error.sessionId).toBe("ses_missing")
}),
)
sessionTest.effect("tryGet lets event routing ignore unknown sessions", () =>
Effect.gen(function* () {
const missing = yield* ACPNextSession.Service.use((session) => session.tryGet("ses_missing"))
const missingPart = yield* ACPNextSession.Service.use((session) =>
session.tryGetPartMetadata({ sessionId: "ses_missing", messageId: "msg_1", partId: "part_1" }),
)
expect(missing).toBeUndefined()
expect(missingPart).toBeUndefined()
}),
)
sessionTest.effect("updates selected model while preserving session identity and inputs", () =>
Effect.gen(function* () {
yield* ACPNextSession.Service.use((session) =>
session.create({
id: "ses_model",
cwd: "/workspace",
mcpServers: [mcpServer],
model: model("anthropic", "claude-sonnet"),
variant: "high",
modeId: "build",
}),
)
const updated = yield* ACPNextSession.Service.use((session) =>
session.setModel("ses_model", model("openai", "gpt-5")),
)
expect(updated.id).toBe("ses_model")
expect(updated.cwd).toBe("/workspace")
expect(updated.mcpServers).toEqual([mcpServer])
expect(updated.model).toEqual(model("openai", "gpt-5"))
expect(updated.variant).toBe("high")
expect(updated.modeId).toBe("build")
}),
)
sessionTest.effect("updates selected variant and mode independently", () =>
Effect.gen(function* () {
yield* ACPNextSession.Service.use((session) =>
session.load({
id: "ses_config",
cwd: "/workspace",
model: model("anthropic", "claude-sonnet"),
variant: "low",
modeId: "plan",
}),
)
yield* ACPNextSession.Service.use((session) => session.setVariant("ses_config", "high"))
expect(yield* ACPNextSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
expect(yield* ACPNextSession.Service.use((session) => session.getMode("ses_config"))).toBe("plan")
yield* ACPNextSession.Service.use((session) => session.setMode("ses_config", "build"))
expect(yield* ACPNextSession.Service.use((session) => session.getVariant("ses_config"))).toBe("high")
expect(yield* ACPNextSession.Service.use((session) => session.getMode("ses_config"))).toBe("build")
}),
)
sessionTest.effect("records known message part metadata for delta routing", () =>
Effect.gen(function* () {
yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_parts", cwd: "/workspace" }))
const metadata = yield* ACPNextSession.Service.use((session) =>
session.recordPartMetadata({
sessionId: "ses_parts",
messageId: "msg_1",
partId: "part_1",
toolCallId: "tool_1",
metadata: { output: "first chunk" },
}),
)
const routed = yield* ACPNextSession.Service.use((session) =>
session.getPartMetadata({ sessionId: "ses_parts", messageId: "msg_1", partId: "part_1" }),
)
expect(metadata).toEqual({
messageId: "msg_1",
partId: "part_1",
toolCallId: "tool_1",
metadata: { output: "first chunk" },
})
expect(routed).toEqual(metadata)
}),
)
sessionTest.effect("keeps repeated part ids distinct across messages", () =>
Effect.gen(function* () {
yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_duplicate_parts", cwd: "/workspace" }))
yield* ACPNextSession.Service.use((session) =>
session.recordPartMetadata({
sessionId: "ses_duplicate_parts",
messageId: "msg_1",
partId: "part_1",
metadata: { output: "from first message" },
}),
)
yield* ACPNextSession.Service.use((session) =>
session.recordPartMetadata({
sessionId: "ses_duplicate_parts",
messageId: "msg_2",
partId: "part_1",
metadata: { output: "from second message" },
}),
)
const first = yield* ACPNextSession.Service.use((session) =>
session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_1", partId: "part_1" }),
)
const second = yield* ACPNextSession.Service.use((session) =>
session.getPartMetadata({ sessionId: "ses_duplicate_parts", messageId: "msg_2", partId: "part_1" }),
)
expect(first?.metadata).toEqual({ output: "from first message" })
expect(second?.metadata).toEqual({ output: "from second message" })
}),
)
sessionTest.effect("removing a session clears its known part metadata", () =>
Effect.gen(function* () {
yield* ACPNextSession.Service.use((session) => session.create({ id: "ses_remove", cwd: "/workspace" }))
yield* ACPNextSession.Service.use((session) =>
session.recordPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }),
)
const removed = yield* ACPNextSession.Service.use((session) => session.remove("ses_remove"))
const missing = yield* ACPNextSession.Service.use((session) => session.tryGet("ses_remove"))
const missingPart = yield* ACPNextSession.Service.use((session) =>
session.tryGetPartMetadata({ sessionId: "ses_remove", messageId: "msg_1", partId: "part_1" }),
)
expect(removed?.knownParts.size).toBe(1)
expect(missing).toBeUndefined()
expect(missingPart).toBeUndefined()
}),
)
})
@@ -0,0 +1,169 @@
import { describe, expect, test } from "bun:test"
import {
completedToolContent,
completedToolRawOutput,
extractImageAttachments,
imageContents,
shellOutputSnapshot,
toLocations,
toToolKind,
} from "../../src/acp-next/tool"
describe("acp-next tool conversion", () => {
test("maps OpenCode tool ids to ACP tool kinds", () => {
expect(toToolKind("bash")).toBe("execute")
expect(toToolKind("shell")).toBe("execute")
expect(toToolKind("webfetch")).toBe("fetch")
expect(toToolKind("edit")).toBe("edit")
expect(toToolKind("patch")).toBe("edit")
expect(toToolKind("write")).toBe("edit")
expect(toToolKind("grep")).toBe("search")
expect(toToolKind("glob")).toBe("search")
expect(toToolKind("repo_clone")).toBe("search")
expect(toToolKind("repo_overview")).toBe("search")
expect(toToolKind("context7_resolve_library_id")).toBe("search")
expect(toToolKind("context7_get_library_docs")).toBe("search")
expect(toToolKind("read")).toBe("read")
expect(toToolKind("custom_tool")).toBe("other")
})
test("extracts file locations from tool input", () => {
expect(toLocations("read", { filePath: "/tmp/a.ts" })).toEqual([{ path: "/tmp/a.ts" }])
expect(toLocations("edit", { filePath: "/tmp/b.ts" })).toEqual([{ path: "/tmp/b.ts" }])
expect(toLocations("write", { filePath: "/tmp/c.ts" })).toEqual([{ path: "/tmp/c.ts" }])
expect(toLocations("grep", { path: "/repo/src" })).toEqual([{ path: "/repo/src" }])
expect(toLocations("glob", { path: "/repo/test" })).toEqual([{ path: "/repo/test" }])
expect(toLocations("repo_clone", { path: "/repo" })).toEqual([{ path: "/repo" }])
expect(toLocations("repo_overview", { path: "/repo" })).toEqual([{ path: "/repo" }])
expect(toLocations("context7_get_library_docs", { path: "/docs" })).toEqual([{ path: "/docs" }])
expect(toLocations("bash", { filePath: "/tmp/nope.ts", path: "/tmp" })).toEqual([])
expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([])
})
test("builds completed content with text, edit diffs, and image attachments", () => {
const image = Buffer.from("image-data").toString("base64")
expect(
completedToolContent("edit", {
status: "completed",
input: {
filePath: "/tmp/file.ts",
oldString: "before",
newString: "after",
},
output: "edited /tmp/file.ts",
attachments: [
{
type: "file",
mime: "image/png",
filename: "image.png",
url: `data:image/png;base64,${image}`,
},
{
type: "file",
mime: "text/plain",
filename: "note.txt",
url: "data:text/plain;base64,bm90ZQ==",
},
],
}),
).toEqual([
{
type: "content",
content: { type: "text", text: "edited /tmp/file.ts" },
},
{
type: "diff",
path: "/tmp/file.ts",
oldText: "before",
newText: "after",
},
{
type: "content",
content: { type: "image", mimeType: "image/png", data: image },
},
])
})
test("omits edit diffs until old and new text fields exist", () => {
expect(
completedToolContent("write", {
status: "completed",
input: {
filePath: "/tmp/file.ts",
content: "created",
},
output: "wrote /tmp/file.ts",
}),
).toEqual([
{
type: "content",
content: { type: "text", text: "wrote /tmp/file.ts" },
},
])
})
test("builds completed raw output with optional metadata and attachments", () => {
const attachments = [
{
type: "file",
mime: "image/jpeg",
filename: "photo.jpg",
url: "data:image/jpeg;base64,AAAA",
},
]
expect(
completedToolRawOutput({
status: "completed",
input: {},
output: "done",
metadata: { exit: 0 },
attachments,
}),
).toEqual({
output: "done",
metadata: { exit: 0 },
attachments,
})
expect(
completedToolRawOutput({
status: "completed",
input: {},
output: "done",
}),
).toEqual({ output: "done" })
})
test("extracts image attachments only from data URLs", () => {
const attachments = [
{
mime: "image/webp",
url: "data:image/webp;charset=utf-8;base64,AAAA",
},
{
mime: "image/png",
url: "https://example.com/image.png",
},
{
mime: "text/plain",
url: "data:text/plain;base64,BBBB",
},
]
expect(extractImageAttachments(attachments)).toEqual([{ mimeType: "image/webp", data: "AAAA" }])
expect(imageContents(attachments)).toEqual([
{
type: "content",
content: { type: "image", mimeType: "image/webp", data: "AAAA" },
},
])
})
test("reads shell output snapshot from string metadata output", () => {
expect(shellOutputSnapshot({ metadata: { output: "line 1\nline 2" } })).toBe("line 1\nline 2")
expect(shellOutputSnapshot({ metadata: { output: 42 } })).toBeUndefined()
expect(shellOutputSnapshot({ metadata: undefined })).toBeUndefined()
})
})
@@ -0,0 +1,314 @@
import { describe, expect, test } from "bun:test"
import type { SessionNotification } from "@agentclientprotocol/sdk"
import { UsageService } from "@/acp-next/usage"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "@/provider/provider"
import { Effect, Layer } from "effect"
import { it } from "../lib/effect"
const assistant = (
input: Partial<UsageService.AssistantMessage> & Pick<UsageService.AssistantMessage, "cost">,
): UsageService.SessionMessage => ({
info: {
role: "assistant",
providerID: "anthropic",
modelID: "claude-sonnet",
tokens: {
input: 10,
output: 20,
reasoning: 0,
cache: { read: 0, write: 0 },
},
...input,
},
})
const user = (): UsageService.SessionMessage => ({
info: { role: "user" },
})
const assistantWithoutProvider = (): UsageService.SessionMessage => ({
info: {
role: "assistant",
modelID: "claude-sonnet",
cost: 1,
tokens: {
input: 10,
output: 20,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
})
const model = (providerID: ProviderV2.ID, modelID: ProviderV2.ModelID, context: number): Provider.Model => ({
id: modelID,
providerID,
api: {
id: modelID,
url: "https://example.com",
npm: "@ai-sdk/openai-compatible",
},
name: modelID,
family: "test",
capabilities: {
temperature: true,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
limit: {
context,
output: 4096,
},
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
})
const providers = (context = 128_000): Record<ProviderV2.ID, Provider.Info> => {
const providerID = ProviderV2.ID.make("anthropic")
const modelID = ProviderV2.ModelID.make("claude-sonnet")
return {
[providerID]: {
id: providerID,
name: "Anthropic",
source: "config",
env: [],
options: {},
models: {
[modelID]: model(providerID, modelID, context),
},
},
}
}
const fakeLayer = (input: {
readonly messages?: Effect.Effect<readonly UsageService.SessionMessage[], unknown>
readonly providers?: (directory: string) => Effect.Effect<Record<ProviderV2.ID, Provider.Info>, unknown>
}) =>
UsageService.layer.pipe(
Layer.provide(
Layer.mergeAll(
Layer.succeed(
UsageService.MessageLoader,
UsageService.MessageLoader.of({
messages: () => input.messages ?? Effect.succeed([]),
}),
),
Layer.succeed(
UsageService.ContextLimitLoader,
UsageService.ContextLimitLoader.of({
providers: input.providers ?? (() => Effect.succeed(providers())),
}),
),
),
),
)
const connection = (updates: SessionNotification[]) => ({
sessionUpdate(params: SessionNotification) {
updates.push(params)
return Promise.resolve()
},
})
describe("acp-next usage", () => {
test("builds ACP Usage from assistant token shape", () => {
expect(
UsageService.buildUsage({
cost: 0.02,
tokens: {
input: 100,
output: 40,
reasoning: 7,
cache: { read: 11, write: 13 },
},
}),
).toEqual({
inputTokens: 100,
outputTokens: 40,
thoughtTokens: 7,
cachedReadTokens: 11,
cachedWriteTokens: 13,
totalTokens: 171,
})
})
test("omits optional token fields when they are zero", () => {
expect(
UsageService.buildUsage({
cost: 0,
tokens: {
input: 3,
output: 4,
reasoning: 0,
cache: { read: 0, write: 0 },
},
}),
).toEqual({
inputTokens: 3,
outputTokens: 4,
totalTokens: 7,
})
})
test("finds the latest assistant message", () => {
expect(
UsageService.latestAssistantMessage([assistant({ cost: 1, modelID: "older" }), user(), assistant({ cost: 2 })]),
).toMatchObject({ cost: 2 })
})
test("calculates total session cost from assistant messages", () => {
expect(UsageService.totalSessionCost([assistant({ cost: 1.25 }), user(), assistant({ cost: 2.5 })])).toBe(3.75)
})
it.effect("loads context limits from providers and caches by directory/provider/model", () => {
const calls: string[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
const first = yield* usage.contextLimit({
directory: "/workspace",
providerID: ProviderV2.ID.make("anthropic"),
modelID: ProviderV2.ModelID.make("claude-sonnet"),
})
const second = yield* usage.contextLimit({
directory: "/workspace",
providerID: ProviderV2.ID.make("anthropic"),
modelID: ProviderV2.ModelID.make("claude-sonnet"),
})
expect(first).toBe(200_000)
expect(second).toBe(200_000)
expect(calls).toEqual(["/workspace"])
}).pipe(
Effect.provide(
fakeLayer({
providers: (directory) =>
Effect.sync(() => {
calls.push(directory)
return providers(200_000)
}),
}),
),
)
})
it.effect("sends ACP usage_update with context size and cumulative assistant cost", () => {
const updates: SessionNotification[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
yield* usage.sendUpdate({
connection: connection(updates),
sessionID: "ses_1",
directory: "/workspace",
})
expect(updates).toEqual([
{
sessionId: "ses_1",
update: {
sessionUpdate: "usage_update",
used: 15,
size: 128_000,
cost: { amount: 3, currency: "USD" },
},
},
])
}).pipe(
Effect.provide(
fakeLayer({
messages: Effect.succeed([
assistant({ cost: 1 }),
assistant({
cost: 2,
tokens: {
input: 10,
output: 20,
reasoning: 0,
cache: { read: 5, write: 0 },
},
}),
]),
}),
),
)
})
it.effect("skips usage update when messages cannot be fetched", () => {
const updates: SessionNotification[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
yield* usage.sendUpdate({
connection: connection(updates),
sessionID: "ses_1",
directory: "/workspace",
})
expect(updates).toEqual([])
}).pipe(Effect.provide(fakeLayer({ messages: Effect.fail(new Error("boom")) })))
})
it.effect("skips usage update when no assistant message exists", () => {
const updates: SessionNotification[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
yield* usage.sendUpdate({
connection: connection(updates),
sessionID: "ses_1",
directory: "/workspace",
})
expect(updates).toEqual([])
}).pipe(Effect.provide(fakeLayer({ messages: Effect.succeed([user()]) })))
})
it.effect("skips usage update when assistant message has no provider or model", () => {
const updates: SessionNotification[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
yield* usage.sendUpdate({
connection: connection(updates),
sessionID: "ses_1",
directory: "/workspace",
})
expect(updates).toEqual([])
}).pipe(
Effect.provide(
fakeLayer({
messages: Effect.succeed([assistantWithoutProvider()]),
}),
),
)
})
it.effect("skips usage update when context size is unknown", () => {
const updates: SessionNotification[] = []
return Effect.gen(function* () {
const usage = yield* UsageService.Service
yield* usage.sendUpdate({
connection: connection(updates),
sessionID: "ses_1",
directory: "/workspace",
})
expect(updates).toEqual([])
}).pipe(
Effect.provide(
fakeLayer({
messages: Effect.succeed([assistant({ cost: 1, providerID: "missing" })]),
}),
),
)
})
})
@@ -0,0 +1,228 @@
import { describe, expect } from "bun:test"
import type {
AuthenticateResponse,
InitializeResponse,
LoadSessionResponse,
NewSessionResponse,
SessionNotification,
SetSessionConfigOptionResponse,
} from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { testProviderConfig } from "../../lib/test-provider"
import { createAcpClient, expectOk, firstAlternateValue, selectConfigOption } from "../acp/acp-test-client"
describe("opencode acp-next (subprocess)", () => {
cliIt.live(
"responds to initialize behind OPENCODE_ACP_NEXT",
({ opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }))
const initialized = expectOk(
yield* acp.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: { _meta: { "terminal-auth": true } },
}),
)
expect(initialized.protocolVersion).toBe(1)
expect(initialized.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(true)
expect(initialized.agentCapabilities?.promptCapabilities?.image).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.http).toBe(true)
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(true)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
expect(initialized.agentCapabilities?.sessionCapabilities).toBeUndefined()
expect(initialized.agentInfo?.name).toBe("OpenCode")
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined()
}),
60_000,
)
cliIt.live(
"authenticate succeeds for the advertised auth method and rejects unknown methods safely",
({ opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } }))
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
const methodId = initialized.authMethods?.[0]?.id
expect(methodId).toBe("opencode-login")
expectOk(yield* acp.request<AuthenticateResponse>("authenticate", { methodId }))
const rejected = yield* acp.request<AuthenticateResponse>("authenticate", { methodId: "missing-auth-method" })
expect(errorCode(rejected.error)).toBe(-32602)
}),
60_000,
)
cliIt.live(
"creates and loads sessions behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(testProviderConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
expect(typeof session.sessionId).toBe("string")
expect(selectConfigOption(session.configOptions, "model")?.category).toBe("model")
const update = yield* acp.waitForNotification<SessionNotification>(
"session/update",
(params) =>
params.sessionId === session.sessionId && params.update.sessionUpdate === "available_commands_update",
)
expect(update.params?.sessionId).toBe(session.sessionId)
const loaded = expectOk(
yield* acp.request<LoadSessionResponse>("session/load", {
cwd: home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
const prompt = yield* acp.request("session/prompt", {
sessionId: "ses_missing",
prompt: [{ type: "text", text: "hello" }],
})
expect(errorCode(prompt.error)).toBe(-32601)
}),
60_000,
)
cliIt.live(
"switches model through config options behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: "test/second-model",
}),
)
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe("test/second-model")
}),
60_000,
)
cliIt.live(
"switches effort through config options behind OPENCODE_ACP_NEXT",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const effort = selectConfigOption(session.configOptions, "effort")
expect(effort?.category).toBe("thought_level")
const nextEffort = effort ? firstAlternateValue(effort) : undefined
expect(nextEffort).toBe("high")
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: nextEffort,
}),
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
}),
60_000,
)
cliIt.live(
"exits cleanly when flagged stdin is closed",
({ opencode }) =>
Effect.gen(function* () {
const exitedPromise = yield* Effect.scoped(
Effect.gen(function* () {
const acp = yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } })
return acp.exited
}),
)
const code = yield* Effect.promise(() => exitedPromise)
expect(typeof code === "number" || code === null).toBe(true)
}),
60_000,
)
cliIt.live(
"default unflagged path still uses production ACP",
({ opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp())
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}),
60_000,
)
})
function errorCode(error: unknown) {
if (!error || typeof error !== "object") return undefined
if (!("code" in error)) return undefined
return typeof error.code === "number" ? error.code : undefined
}
function verifierConfig(llmUrl: string) {
const config = testProviderConfig(llmUrl)
return {
...config,
model: "test/test-model",
provider: {
test: {
...config.provider.test,
models: {
"test-model": {
...config.provider.test.models["test-model"],
variants: {
low: {},
high: {},
},
},
"second-model": {
...config.provider.test.models["test-model"],
id: "second-model",
name: "Second Test Model",
variants: {
medium: {},
max: {},
},
},
},
},
},
}
}
@@ -0,0 +1,322 @@
import { describe, expect } from "bun:test"
import type {
CloseSessionResponse,
InitializeResponse,
NewSessionResponse,
ResumeSessionResponse,
SessionNotification,
SetSessionConfigOptionResponse,
} from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { cliIt } from "../../lib/cli-process"
import { testProviderConfig } from "../../lib/test-provider"
import {
createAcpClient,
expectOk,
firstAlternateValue,
flattenSelectOptions,
selectConfigOption,
} from "./acp-test-client"
describe("opencode acp verifier compatibility baseline", () => {
cliIt.live(
"initialize advertises close and resume capabilities",
({ opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp())
const initialized = expectOk(
yield* acp.request<InitializeResponse>("initialize", {
protocolVersion: 1,
}),
)
expect(initialized.protocolVersion).toBe(1)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}),
60_000,
)
cliIt.live(
"first session timing diagnostic stays bounded and returns model options",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
const started = Date.now()
yield* acp.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: {},
clientInfo: { name: "opencode-local-acp-baseline", version: "0.1.0" },
})
const session = expectOk(
yield* acp.request<NewSessionResponse>("session/new", {
cwd: home,
mcpServers: [],
}),
)
const durationMs = Date.now() - started
expect(durationMs).toBeLessThan(15_000)
const model = selectConfigOption(session.configOptions, "model")
expect(model?.category).toBe("model")
expect(model?.currentValue).toBe("test/test-model")
expect(model ? flattenSelectOptions(model).length : 0).toBeGreaterThanOrEqual(2)
}),
60_000,
)
cliIt.live(
"warm newSession timing diagnostic stays bounded",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] })
const started = Date.now()
const session = expectOk(
yield* acp.request<NewSessionResponse>("session/new", {
cwd: home,
mcpServers: [],
}),
)
const durationMs = Date.now() - started
expect(durationMs).toBeLessThan(15_000)
expect(session.sessionId).toBeTruthy()
}),
60_000,
)
cliIt.live(
"model switch timing diagnostic updates currentValue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const model = selectConfigOption(session.configOptions, "model")
expect(model).toBeDefined()
const nextModel = model
? flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value
: undefined
expect(nextModel).toBe("test/second-model")
const started = Date.now()
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: nextModel,
}),
)
const durationMs = Date.now() - started
expect(durationMs).toBeLessThan(15_000)
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
}),
60_000,
)
cliIt.live(
"effort option is listed for variant-capable models and can switch",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const effort = selectConfigOption(session.configOptions, "effort")
expect(effort?.category).toBe("thought_level")
const nextEffort = effort ? firstAlternateValue(effort) : undefined
expect(nextEffort).toBe("high")
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: nextEffort,
}),
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
}),
60_000,
)
cliIt.live(
"default test provider documents missing effort option when the model has no variants",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_CONFIG_CONTENT: JSON.stringify(noVariantConfig(llm.url)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
expect(selectConfigOption(session.configOptions, "model")?.currentValue).toBe("test/test-model")
expect(selectConfigOption(session.configOptions, "effort")).toBeUndefined()
}),
60_000,
)
cliIt.live(
"skill slash command timing diagnostic appears through available_commands_update",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const skills = path.join(home, "skills")
yield* Effect.promise(() => mkdir(path.join(skills, "verifier-skill"), { recursive: true }))
yield* Effect.promise(() => Bun.write(path.join(skills, "verifier-skill", "SKILL.md"), verifierSkill))
const acp = createAcpClient(
yield* opencode.acp({
env: {
OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)),
},
}),
)
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const update = yield* acp.waitForNotification<SessionNotification>(
"session/update",
(params) =>
params.sessionId === session.sessionId &&
params.update.sessionUpdate === "available_commands_update" &&
params.update.availableCommands.some((command) => command.name === "verifier-skill"),
)
expect(update.params?.sessionId).toBe(session.sessionId)
const secondSession = expectOk(
yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }),
)
const started = Date.now()
yield* acp.waitForNotification<SessionNotification>(
"session/update",
(params) =>
params.sessionId === secondSession.sessionId &&
params.update.sessionUpdate === "available_commands_update" &&
params.update.availableCommands.some((command) => command.name === "verifier-skill"),
)
const durationMs = Date.now() - started
expect(durationMs).toBeLessThan(15_000)
}),
60_000,
)
cliIt.live(
"close request succeeds for a live session",
({ home, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp())
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
expectOk(yield* acp.request<CloseSessionResponse>("session/close", { sessionId: session.sessionId }))
}),
60_000,
)
cliIt.live(
"resume request succeeds for a created session",
({ home, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(yield* opencode.acp())
yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const resumed = expectOk(
yield* acp.request<ResumeSessionResponse>("session/resume", {
sessionId: session.sessionId,
cwd: home,
mcpServers: [],
}),
)
expect(resumed.configOptions?.length).toBeGreaterThan(0)
}),
60_000,
)
})
function verifierConfig(llmUrl: string, skills?: string) {
const config = testProviderConfig(llmUrl)
return {
...config,
model: "test/test-model",
...(skills ? { skills: { paths: [skills] } } : {}),
provider: {
test: {
...config.provider.test,
models: {
"test-model": {
...config.provider.test.models["test-model"],
variants: {
low: {},
high: {},
},
},
"second-model": {
...config.provider.test.models["test-model"],
id: "second-model",
name: "Second Test Model",
},
},
},
},
}
}
function noVariantConfig(llmUrl: string) {
const config = verifierConfig(llmUrl)
return {
...config,
provider: {
test: {
...config.provider.test,
models: {
"test-model": {
...config.provider.test.models["test-model"],
variants: undefined,
},
"second-model": config.provider.test.models["second-model"],
},
},
},
}
}
const verifierSkill = `---
name: verifier-skill
description: Verifier compatibility skill.
---
# Verifier Skill
`
@@ -0,0 +1,97 @@
import { expect } from "bun:test"
import type { SessionConfigOption, SessionConfigSelectOption } from "@agentclientprotocol/sdk"
import { Duration, Effect } from "effect"
import type { AcpHandle } from "../../lib/cli-process"
type JsonRpcRequest = {
readonly jsonrpc: "2.0"
readonly id: number
readonly method: string
readonly params?: unknown
}
type JsonRpcResponse<T = unknown> = {
readonly jsonrpc: "2.0"
readonly id: number
readonly result?: T
readonly error?: unknown
}
type JsonRpcNotification<T = unknown> = {
readonly jsonrpc: "2.0"
readonly method: string
readonly params?: T
}
export type AcpClient = {
readonly request: <T>(method: string, params?: unknown) => Effect.Effect<JsonRpcResponse<T>, unknown>
readonly receive: Effect.Effect<unknown>
readonly waitForNotification: <T>(
method: string,
predicate: (params: T) => boolean,
timeoutMs?: number,
) => Effect.Effect<JsonRpcNotification<T>, unknown>
}
export function createAcpClient(acp: AcpHandle): AcpClient {
const state = { nextId: 1 }
const request = <T>(method: string, params?: unknown) =>
Effect.gen(function* () {
const id = state.nextId++
const message: JsonRpcRequest =
params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params }
yield* acp.send(message)
while (true) {
const received = yield* acp.receive.pipe(Effect.timeout(Duration.seconds(15)))
if (isJsonRpcResponse<T>(received) && received.id === id) return received
}
})
const waitForNotification = <T>(method: string, predicate: (params: T) => boolean, timeoutMs = 15_000) =>
Effect.gen(function* () {
while (true) {
const received = yield* acp.receive.pipe(Effect.timeout(Duration.millis(timeoutMs)))
if (!isJsonRpcNotification<T>(received)) continue
if (received.method === method && predicate(received.params as T)) return received
}
})
return {
request,
receive: acp.receive,
waitForNotification,
}
}
export function expectOk<T>(response: JsonRpcResponse<T>) {
expect(response.error).toBeUndefined()
expect(response.result).toBeDefined()
return response.result as T
}
export function selectConfigOption(options: SessionConfigOption[] | null | undefined, id: string) {
return options?.find(
(option): option is Extract<SessionConfigOption, { type: "select" }> =>
option.id === id && option.type === "select",
)
}
export function firstAlternateValue(option: Extract<SessionConfigOption, { type: "select" }>) {
return flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
}
export function flattenSelectOptions(option: Extract<SessionConfigOption, { type: "select" }>) {
return option.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options))
}
function isJsonRpcResponse<T>(input: unknown): input is JsonRpcResponse<T> {
if (!input || typeof input !== "object") return false
return "id" in input && "jsonrpc" in input
}
function isJsonRpcNotification<T>(input: unknown): input is JsonRpcNotification<T> {
if (!input || typeof input !== "object") return false
return "method" in input && !("id" in input) && "jsonrpc" in input
}
@@ -235,11 +235,11 @@ describe("run entry body", () => {
},
title: "",
output: [
"task_id: child-1 (for resuming to continue this task if needed)",
"",
'<task id="child-1" state="completed">',
"<task_result>",
"# Findings\n\n- Footer stays live",
"</task_result>",
"</task>",
].join("\n"),
metadata: {
sessionId: "child-1",
@@ -264,13 +264,9 @@ describe("run entry body", () => {
subagent_type: "explore",
},
title: "",
output: [
"task_id: child-1 (for resuming to continue this task if needed)",
"",
"<task_result>",
"",
"</task_result>",
].join("\n"),
output: ['<task id="child-1" state="completed">', "<task_result>", "", "</task_result>", "</task>"].join(
"\n",
),
metadata: {
sessionId: "child-1",
},
@@ -938,8 +938,7 @@ test("renders promoted task markdown without a leading blank row", async () => {
subagent_type: "explore",
},
output: [
"task_id: child-1 (for resuming to continue this task if needed)",
"",
'<task id="child-1" state="completed">',
"<task_result>",
"Location: `/tmp/run.ts`",
"",
@@ -947,6 +946,7 @@ test("renders promoted task markdown without a leading blank row", async () => {
"- Local interactive mode",
"- Attach mode",
"</task_result>",
"</task>",
].join("\n"),
metadata: {
sessionId: "child-1",
@@ -191,6 +191,21 @@ describe("RuntimeFlags", () => {
}),
)
it.effect("specific experimental flags override OPENCODE_EXPERIMENTAL", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(
Effect.provide(
fromConfig({
OPENCODE_EXPERIMENTAL: "true",
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "false",
}),
),
)
expect(flags.experimentalIconDiscovery).toBe(false)
}),
)
it.effect("experimentalOxfmt defaults to false", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(Effect.provide(fromConfig({})))
@@ -0,0 +1,63 @@
import { NodeHttpServer } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Context, Effect, Layer, Option } from "effect"
import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Auth } from "../../src/auth"
import { Config } from "../../src/config/config"
import { Installation } from "../../src/installation"
import { ServerAuth } from "../../src/server/auth"
import { RootHttpApi } from "../../src/server/routes/instance/httpapi/api"
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
import { controlHandlers } from "../../src/server/routes/instance/httpapi/handlers/control"
import { globalHandlers } from "../../src/server/routes/instance/httpapi/handlers/global"
import { authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { schemaErrorLayer } from "../../src/server/routes/instance/httpapi/middleware/schema-error"
import { testEffect } from "../lib/effect"
const apiLayer = HttpRouter.serve(
HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, globalHandlers]),
Layer.provide([authorizationLayer, schemaErrorLayer]),
),
{ disableListenLog: true, disableLogger: true },
).pipe(
Layer.provideMerge(NodeHttpServer.layerTest),
Layer.provide(Layer.mock(Auth.Service)({})),
Layer.provide(Layer.mock(Config.Service)({})),
Layer.provide(
Layer.mock(Installation.Service)({
method: () => Effect.succeed("npm"),
latest: () => Effect.succeed("9.9.9"),
upgrade: () => Effect.void,
}),
),
Layer.provide(ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })),
// Raw HttpApi routes expose an opaque handler context at the web boundary.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.provide(Layer.succeedContext(Context.empty() as Context.Context<unknown>)),
)
const it = testEffect(apiLayer)
describe("global HttpApi", () => {
it.live("upgrades to latest when the request body is omitted", () =>
Effect.gen(function* () {
const response = yield* HttpClient.post(GlobalPaths.upgrade)
expect(response.status).toBe(200)
expect(yield* response.json).toEqual({ success: true, version: "9.9.9" })
}),
)
it.live("rejects malformed upgrade payloads", () =>
Effect.gen(function* () {
const response = yield* HttpClientRequest.post(GlobalPaths.upgrade).pipe(
HttpClientRequest.setBody(HttpBody.text("{", "application/json")),
HttpClient.execute,
)
expect(response.status).toBe(400)
expect(yield* response.json).toEqual({ success: false, error: "Invalid request body" })
}),
)
})
@@ -601,6 +601,32 @@ describe("session HttpApi", () => {
})
expect(forked.id).not.toBe(created.id)
const forkedWithoutContentType = yield* requestJson<Session.Info>(
pathFor(SessionPaths.fork, { sessionID: created.id }),
{
method: "POST",
headers: { "x-opencode-directory": test.directory },
},
)
expect(forkedWithoutContentType.id).not.toBe(created.id)
const invalidFork = yield* request(pathFor(SessionPaths.fork, { sessionID: created.id }), {
method: "POST",
headers,
body: "{",
})
expect(invalidFork.status).toBe(400)
const forkedWhitespace = yield* requestJson<Session.Info>(
pathFor(SessionPaths.fork, { sessionID: created.id }),
{
method: "POST",
headers,
body: " \n",
},
)
expect(forkedWhitespace.id).not.toBe(created.id)
expect(
yield* requestJson<boolean>(pathFor(SessionPaths.abort, { sessionID: created.id }), {
method: "POST",
@@ -228,6 +228,28 @@ describe("worktree endpoint reproduction", () => {
{ git: true },
)
worktreeTest(
"direct HttpApi worktree create rejects explicit null payload",
() =>
Effect.gen(function* () {
const test = yield* TestInstance
const server = yield* serverScoped()
const response = yield* request(
server,
`${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: "null",
},
)
expect(response.status).toBe(400)
}),
{ git: true },
)
worktreeTest(
"workspace worktree create does not hang",
() =>
@@ -459,6 +459,35 @@ noLLMServer.instance(
{ config: cfg },
)
it.instance("loop exits without an LLM request for interrupted orphan tool calls", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
const seeded = yield* seed(chat.id, { finish: "stop" })
yield* sessions.updatePart({
id: PartID.ascending(),
messageID: seeded.assistant.id,
sessionID: chat.id,
type: "tool",
callID: "interrupted-call",
tool: "edit",
state: {
status: "error",
input: {},
error: "Tool execution aborted",
metadata: { interrupted: true },
time: { start: 1, end: 2 },
},
})
const result = yield* prompt.loop({ sessionID: chat.id })
expect(result.info.id).toBe(seeded.assistant.id)
expect(yield* llm.hits).toHaveLength(0)
}),
)
it.instance("loop calls LLM and returns assistant message", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
@@ -320,7 +320,7 @@ exports[`tool parameters JSON Schema (wire shape) task 1`] = `
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"background": {
"description": "When true, launch the subagent in the background and return immediately",
"description": "Run the agent in the background. You will be notified when it completes.",
"type": "boolean",
},
"command": {
+1 -13
View File
@@ -101,9 +101,6 @@ const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer))
const scout = testEffect(
Layer.mergeAll(registryLayer({ flags: { experimentalScout: true } }), node, Agent.defaultLayer),
)
const background = testEffect(
Layer.mergeAll(registryLayer({ flags: { experimentalBackgroundSubagents: true } }), node, Agent.defaultLayer),
)
const withBrokenPlugin = testEffect(
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer),
)
@@ -133,7 +130,7 @@ describe("tool.registry", () => {
}),
)
it.instance("hides task_status unless experimental background subagents are enabled", () =>
it.instance("does not expose task_status", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
@@ -159,15 +156,6 @@ describe("tool.registry", () => {
}),
)
background.instance("shows task_status when experimental background subagents are enabled", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("task_status")
}),
)
it.instance("loads tools from .opencode/tool (singular)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
+5 -28
View File
@@ -96,7 +96,6 @@ function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void;
opts?.onPrompt?.(input)
return reply(input, opts?.text ?? "done")
}),
loop: (input) => Effect.succeed(reply({ sessionID: input.sessionID, parts: [] }, opts?.text ?? "done")),
}
}
@@ -241,7 +240,7 @@ describe("tool.task", () => {
expect(kids).toHaveLength(1)
expect(kids[0]?.id).toBe(child.id)
expect(result.metadata.sessionId).toBe(child.id)
expect(result.output).toContain(`task_id: ${child.id}`)
expect(result.output).toContain(`<task id="${child.id}" state="completed">`)
expect(seen?.sessionID).toBe(child.id)
}),
)
@@ -311,7 +310,6 @@ describe("tool.task", () => {
ready.resolve(input)
return cancelled.promise
}).pipe(Effect.as(reply(input, "cancelled"))),
loop: (input) => Effect.succeed(reply({ sessionID: input.sessionID, parts: [] }, "done")),
}
const fiber = yield* def
@@ -375,7 +373,7 @@ describe("tool.task", () => {
expect(kids).toHaveLength(1)
expect(kids[0]?.id).toBe(result.metadata.sessionId)
expect(result.metadata.sessionId).not.toBe("ses_missing")
expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`)
expect(result.output).toContain(`<task id="${result.metadata.sessionId}" state="completed">`)
expect(seen?.sessionID).toBe(result.metadata.sessionId)
}),
)
@@ -515,7 +513,7 @@ describe("tool.task", () => {
const job = yield* jobs.get(result.metadata.sessionId)
expect(result.metadata.background).toBe(true)
expect(result.output).toContain("state: running")
expect(result.output).toContain(`state="running"`)
expect(job?.status).toBe("running")
}),
)
@@ -553,10 +551,9 @@ describe("tool.task", () => {
}),
)
background.instance("background task completion does not wait for the parent resume loop", () =>
background.instance("background task completion does not wait for the parent async prompt", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
@@ -577,27 +574,7 @@ describe("tool.task", () => {
promptOps: {
...stubOps({ text: "background done" }),
prompt: (input) =>
input.noReply
? Effect.gen(function* () {
const user = yield* sessions.updateMessage({
id: input.messageID ?? MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
agent: input.agent ?? "build",
model: input.model ?? ref,
time: { created: Date.now() },
})
const parts = input.parts.map((part) => ({
...part,
id: part.id ?? PartID.ascending(),
messageID: user.id,
sessionID: input.sessionID,
}))
yield* Effect.forEach(parts, (part) => sessions.updatePart(part), { discard: true })
return { info: user, parts }
})
: Effect.succeed(reply(input, "background done")),
loop: () => Effect.never,
input.sessionID === chat.id ? Effect.never : Effect.succeed(reply(input, "background done")),
} satisfies TaskPromptOps,
},
messages: [],
@@ -1,92 +0,0 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Agent } from "@/agent/agent"
import { BackgroundJob } from "@/background/job"
import { Bus } from "@/bus"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Session } from "@/session/session"
import { MessageID } from "@/session/schema"
import { SessionStatus } from "@/session/status"
import { TaskStatusTool } from "@/tool/task_status"
import { Truncate } from "@/tool/truncate"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { disposeAllInstances } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await disposeAllInstances()
})
const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.mergeAll(
Agent.defaultLayer,
BackgroundJob.defaultLayer,
Bus.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Session.defaultLayer,
SessionStatus.defaultLayer,
Truncate.defaultLayer,
RuntimeFlags.layer(flags),
)
const it = testEffect(layer({ experimentalBackgroundSubagents: true }))
describe("tool.task_status", () => {
it.instance("returns completed background job output", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const sessions = yield* Session.Service
const tool = yield* TaskStatusTool
const def = yield* tool.init()
const chat = yield* sessions.create({})
yield* jobs.start({ id: chat.id, type: "task", run: Effect.succeed("all done") })
const result = yield* def.execute(
{ task_id: chat.id, wait: true, timeout_ms: 1_000 },
{
sessionID: chat.id,
messageID: MessageID.ascending(),
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
expect(result.output).toContain("state: completed")
expect(result.output).toContain("all done")
expect(result.metadata.timed_out).toBe(false)
}),
)
it.instance("wait=true times out while the background job is running", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const sessions = yield* Session.Service
const tool = yield* TaskStatusTool
const def = yield* tool.init()
const chat = yield* sessions.create({})
yield* jobs.start({ id: chat.id, type: "task", run: Effect.never })
const result = yield* def.execute(
{ task_id: chat.id, wait: true, timeout_ms: 50 },
{
sessionID: chat.id,
messageID: MessageID.ascending(),
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
expect(result.output).toContain("state: running")
expect(result.output).toContain("Timed out after 50ms")
expect(result.metadata.timed_out).toBe(true)
}),
)
})
+2 -2
View File
@@ -1088,8 +1088,8 @@ export type ProviderConfig = {
output: number
}
modalities?: {
input: Array<"text" | "audio" | "image" | "video" | "pdf">
output: Array<"text" | "audio" | "image" | "video" | "pdf">
input?: Array<"text" | "audio" | "image" | "video" | "pdf">
output?: Array<"text" | "audio" | "image" | "video" | "pdf">
}
experimental?: boolean
status?: "alpha" | "beta" | "deprecated" | "active"
-1
View File
@@ -13717,7 +13717,6 @@
}
}
},
"required": ["input", "output"],
"additionalProperties": false
},
"experimental": {
+16
View File
@@ -0,0 +1,16 @@
# OpenCode Stats
Stats is a separate site from the console. Runtime, database, and domain services live in `core`; the SolidStart website lives in `app`; deployable Lambda entrypoints live in `function`.
## Packages
- `app`: SolidStart frontend/site.
- `core`: Effect services, app config, Drizzle schema/migrations, and stats domains.
- `function`: Lambda handlers that call into `core` services.
## Commands
- `bun run dev:stats` from the repo root starts the SolidStart app.
- `bun run --cwd packages/stats/app typecheck` typechecks the site.
- `bun run --cwd packages/stats/core typecheck` typechecks the Effect/database package.
- `bun run --cwd packages/stats/function typecheck` typechecks Lambda entrypoints.
+17
View File
@@ -0,0 +1,17 @@
dist
.wrangler
.output
.vercel
.netlify
app.config.timestamp_*.js
# Environment
.env
.env*.local
# dependencies
/node_modules
# System Files
.DS_Store
Thumbs.db
+5
View File
@@ -0,0 +1,5 @@
export default {
server: {
preset: "cloudflare-module",
},
}
+36
View File
@@ -0,0 +1,36 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-app",
"version": "1.14.50",
"private": true,
"type": "module",
"license": "MIT",
"scripts": {
"typecheck": "tsgo --noEmit",
"dev": "vite dev --host 0.0.0.0",
"build": "vite build",
"start": "vite start"
},
"dependencies": {
"@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"d3-scale": "4.0.2",
"effect": "catalog:",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
"vite": "catalog:"
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@types/bun": "catalog:",
"@types/d3-scale": "4.0.9",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:"
},
"engines": {
"node": ">=22"
}
}
+123
View File
@@ -0,0 +1,123 @@
:root {
color-scheme: light dark;
--stats-bg: #f8f5ee;
--stats-ink: #16110d;
--stats-muted: #6d6257;
--stats-line: #ded5c9;
--stats-panel: #fffaf1;
--stats-accent: #2357ff;
}
@media (prefers-color-scheme: dark) {
:root {
--stats-bg: #11100e;
--stats-ink: #f7efe4;
--stats-muted: #b8aa99;
--stats-line: #322d27;
--stats-panel: #1a1714;
--stats-accent: #86a2ff;
}
}
html {
line-height: 1;
background: var(--stats-bg);
}
body {
margin: 0;
min-width: 320px;
background:
radial-gradient(circle at top left, color-mix(in srgb, var(--stats-accent) 16%, transparent), transparent 32rem),
var(--stats-bg);
color: var(--stats-ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
}
.shell {
box-sizing: border-box;
min-height: 100vh;
padding: 2rem clamp(1rem, 4vw, 4rem);
}
.panel {
display: grid;
gap: clamp(2rem, 8vw, 5rem);
box-sizing: border-box;
width: min(100%, 72rem);
margin: 0 auto;
padding: clamp(1.25rem, 4vw, 3rem);
border: 1px solid var(--stats-line);
border-radius: 1.5rem;
background: color-mix(in srgb, var(--stats-panel) 88%, transparent);
}
.eyebrow {
margin: 0 0 1rem;
color: var(--stats-muted);
font-size: 0.75rem;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1 {
max-width: 11ch;
margin: 0;
font-size: clamp(3rem, 14vw, 9rem);
line-height: 0.85;
letter-spacing: -0.08em;
}
.summary {
max-width: 42rem;
margin: 1.5rem 0 0;
color: var(--stats-muted);
font-size: clamp(1rem, 2vw, 1.25rem);
line-height: 1.6;
}
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid var(--stats-line);
border-radius: 1rem;
background: var(--stats-line);
}
.metric {
padding: 1rem;
background: var(--stats-panel);
}
.metric b {
display: block;
margin-bottom: 0.5rem;
font-size: clamp(1.5rem, 4vw, 3rem);
letter-spacing: -0.05em;
}
.metric span {
color: var(--stats-muted);
font-size: 0.8125rem;
}
.link {
display: inline-flex;
width: fit-content;
margin-top: 1.5rem;
color: var(--stats-accent);
text-decoration: none;
}
@media (max-width: 720px) {
.grid {
grid-template-columns: 1fr;
}
}
+30
View File
@@ -0,0 +1,30 @@
import { MetaProvider, Meta, Title } from "@solidjs/meta"
import { Router } from "@solidjs/router"
import { FileRoutes } from "@solidjs/start/router"
import { Suspense } from "solid-js"
import "./app.css"
function AppMeta() {
return (
<>
<Title>opencode stats</Title>
<Meta name="description" content="OpenCode usage and stats." />
</>
)
}
export default function App() {
return (
<Router
explicitLinks={true}
root={(props) => (
<MetaProvider>
<AppMeta />
<Suspense>{props.children}</Suspense>
</MetaProvider>
)}
>
<FileRoutes />
</Router>
)
}
@@ -0,0 +1,18 @@
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/>
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/>
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/>
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/>
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/>
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/>
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/>
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/>
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,18 @@
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 30H6V18H18V30Z" fill="#CFCECD"/>
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#656363"/>
<path d="M48 30H36V18H48V30Z" fill="#CFCECD"/>
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#656363"/>
<path d="M84 24V30H66V24H84Z" fill="#CFCECD"/>
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#656363"/>
<path d="M108 36H96V18H108V36Z" fill="#CFCECD"/>
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#656363"/>
<path d="M144 30H126V18H144V30Z" fill="#CFCECD"/>
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#211E1E"/>
<path d="M168 30H156V18H168V30Z" fill="#CFCECD"/>
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#211E1E"/>
<path d="M198 30H186V18H198V30Z" fill="#CFCECD"/>
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#211E1E"/>
<path d="M234 24V30H216V24H234Z" fill="#CFCECD"/>
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#211E1E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+7
View File
@@ -0,0 +1,7 @@
// @refresh reload
import { mount, StartClient } from "@solidjs/start/client"
const root = document.getElementById("app")
if (!root) throw new Error("Root element #app not found")
mount(() => <StartClient />, root)
+25
View File
@@ -0,0 +1,25 @@
// @refresh reload
import { createHandler, StartServer } from "@solidjs/start/server"
export default createHandler(
() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{assets}
</head>
<body>
<div id="app">{children}</div>
{scripts}
</body>
</html>
)}
/>
),
{
mode: "async",
},
)
+1
View File
@@ -0,0 +1 @@
/// <reference types="@solidjs/start/env" />
@@ -0,0 +1,19 @@
import { AppConfig } from "@opencode-ai/stats-core/config"
import { runtime } from "@opencode-ai/stats-core/runtime"
import { Effect } from "effect"
export async function GET() {
return Response.json(
await runtime.runPromise(
Effect.gen(function* () {
const config = yield* AppConfig
return {
ok: true,
app: "stats",
stage: config.stage,
publicUrl: config.publicUrl,
}
}),
),
)
}
File diff suppressed because it is too large Load Diff
+971
View File
@@ -0,0 +1,971 @@
import "./index.css"
import { Meta, Title } from "@solidjs/meta"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import {
type CountryEntry,
getStatsHomeData,
type LeaderboardEntry,
type MarketDay,
type StatsHomeData,
type SessionCostEntry,
type TokenCostEntry,
type UsagePoint,
} from "@opencode-ai/stats-core/domain/home"
import { runtime } from "@opencode-ai/stats-core/runtime"
import { createAsync, query } from "@solidjs/router"
import { scaleBand, scaleLinear } from "d3-scale"
import { createMemo, createSignal, For, Show, type JSX } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import logoDark from "../asset/logo-ornate-dark.svg"
import logoLight from "../asset/logo-ornate-light.svg"
const products = ["All Users", "Zen", "Go", "Enterprise"] as const
const tokenProducts = ["Zen", "Go", "Enterprise"] as const
const ranges = ["1D", "1W", "1M", "3M", "YTD", "ALL"] as const
const usageColors = ["#ff5d64", "#ff8a00", "#8bef00", "#12c8b3", "#18c7dc", "#6c7dff", "#9d73f7"]
const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"]
const countryPositions = [
{ x: 112, y: 96 },
{ x: 284, y: 144 },
{ x: 472, y: 92 },
{ x: 642, y: 154 },
{ x: 800, y: 96 },
{ x: 172, y: 234 },
{ x: 362, y: 250 },
{ x: 552, y: 236 },
{ x: 744, y: 252 },
{ x: 48, y: 184 },
{ x: 892, y: 198 },
{ x: 456, y: 176 },
] as const
type UsageProduct = (typeof products)[number]
type TokenProduct = (typeof tokenProducts)[number]
type UsageRange = (typeof ranges)[number]
const getData = query(async () => {
"use server"
return runtime.runPromise(getStatsHomeData())
}, "getStatsHomeData")
export default function StatsHome() {
getRequestEvent()?.response.headers.set(
"Cache-Control",
"public, max-age=60, s-maxage=300, stale-while-revalidate=86400",
)
const data = createAsync(() => getData())
return (
<main data-page="stats">
<Title>OpenCode Stats</Title>
<Meta name="description" content="OpenCode usage, market share, token cost, and session cost stats." />
<div data-component="container">
<Header />
<div data-component="content">
<Show when={data()} fallback={<StatsLoading />}>
{(stats) => (
<>
<Hero updatedAt={stats().updatedAt} />
<UsageSection data={stats().usage} />
<LeaderboardSection data={stats().leaderboard} />
<MarketShareSection data={stats().market} />
<TokenCostSection data={stats().tokenCost} />
<SessionCostSection data={stats().sessionCost} />
<CountrySection data={stats().country} />
<Newsletter />
</>
)}
</Show>
</div>
<Footer />
</div>
<Legal />
</main>
)
}
function Hero(props: { updatedAt: string | null }) {
return (
<section data-section="hero">
<div>
<h1>OpenCode Stats</h1>
<p data-slot="meta">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
<rect x="3" y="3" width="10" height="10" fill="currentColor" />
<rect x="7" y="6.5" width="2" height="4.5" fill="var(--stats-layer-2)" />
<rect x="7" y="5" width="2" height="1" fill="var(--stats-layer-2)" />
</svg>
<span>OpenCode data</span> <b>·</b>{" "}
<em>{props.updatedAt ? `Updated ${formatUpdatedAt(props.updatedAt)}` : "No rows yet"}</em>
</p>
</div>
<p>See how model usage, provider share, cost, and geography move across OpenCode traffic.</p>
</section>
)
}
function StatsLoading() {
return (
<>
<Hero updatedAt={null} />
<ChartSection title="Usage">
<EmptyState title="Loading stats" description="Reading model aggregates from model_stat." />
</ChartSection>
</>
)
}
function ChartSection(props: { title: string; description?: string; controls?: JSX.Element; children: JSX.Element }) {
return (
<section data-section="chart">
<div data-slot="section-header">
<div>
<h2>{props.title}</h2>
{props.description && <p>{props.description}</p>}
</div>
{props.controls}
</div>
{props.children}
</section>
)
}
function EmptyState(props: { title: string; description: string }) {
return (
<div data-component="empty-state">
<strong>{props.title}</strong>
<p>{props.description}</p>
</div>
)
}
function formatUpdatedAt(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return "just now"
return new Intl.DateTimeFormat("en", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZone: "UTC",
timeZoneName: "short",
}).format(date)
}
function UsageSection(props: { data: StatsHomeData["usage"] }) {
const [product, setProduct] = createSignal<UsageProduct>("All Users")
const [range, setRange] = createSignal<UsageRange>("1W")
const data = createMemo(() => props.data[product()][range()])
return (
<ChartSection title="Usage">
<Show
when={data().some((item) => usageTotal(item) > 0)}
fallback={<EmptyState title="No usage data" description="No model_stat rows matched this product and range." />}
>
<UsageChart data={data()} />
</Show>
<div data-slot="chart-footer">
<StatsFilters product={product()} range={range()} onProductSelect={setProduct} onRangeSelect={setRange} />
</div>
</ChartSection>
)
}
function StatsFilters(props: {
product: UsageProduct
range: UsageRange
onProductSelect: (product: UsageProduct) => void
onRangeSelect: (range: UsageRange) => void
}) {
return (
<>
<FilterPills
items={products}
selected={props.product}
label="Product filter"
variant="product"
onSelect={props.onProductSelect}
/>
<FilterPills
items={ranges}
selected={props.range}
label="Date range"
variant="range"
onSelect={props.onRangeSelect}
/>
</>
)
}
function FilterPills<T extends string>(props: {
items: readonly T[]
selected: T
label: string
variant: "product" | "range"
onSelect: (item: T) => void
}) {
return (
<div data-component="usage-filter" data-variant={props.variant} role="radiogroup" aria-label={props.label}>
<For each={props.items}>
{(item) => (
<button
type="button"
role="radio"
aria-checked={props.selected === item}
data-active={props.selected === item ? "true" : undefined}
onClick={() => props.onSelect(item)}
>
{item}
</button>
)}
</For>
</div>
)
}
function UsageChart(props: { data: UsagePoint[] }) {
const [activeIndex, setActiveIndex] = createSignal<number>()
const [activeSegment, setActiveSegment] = createSignal<number>()
const height = 434
const width = 920
const headerOffset = 46
const segmentGap = 2
const maxTotal = createMemo(() => Math.max(1, Math.max(...props.data.map((item) => usageTotal(item))) * 1.02))
const activePoint = createMemo(() => props.data[activeIndex() ?? -1])
const y = createMemo(() => scaleLinear([0, maxTotal()], [height, 0]))
const x = createMemo(() =>
scaleBand(
props.data.map((_, index) => String(index)),
[0, width],
).paddingInner(0.08),
)
const activeBar = createMemo(() => {
const index = activeIndex()
const point = activePoint()
if (index === undefined) return
if (!point) return
return {
point,
x: x()(String(index)) ?? 0,
width: x().bandwidth(),
}
})
return (
<div data-component="usage-chart">
<svg viewBox={`0 0 ${width} ${height + headerOffset}`} role="img" aria-label="Stacked usage chart">
<defs>
<pattern id="stats-usage-dot-grid" width="6" height="6" patternUnits="userSpaceOnUse">
<rect x="1" y="1" width="2" height="2" fill="var(--stats-dot)" />
</pattern>
</defs>
<For each={props.data}>
{(day, dayIndex) => {
const barX = x()(String(dayIndex())) ?? 0
const barWidth = x().bandwidth()
const stackTop = y()(usageTotal(day))
return (
<g
role="button"
tabIndex={0}
aria-label={`${day.date} ${formatTokens(usageTotal(day))}`}
data-active={activeIndex() === dayIndex() ? "true" : undefined}
onPointerEnter={() => {
setActiveIndex(dayIndex())
setActiveSegment(undefined)
}}
onPointerLeave={(event) => {
if (event.pointerType === "touch") return
setActiveIndex(undefined)
setActiveSegment(undefined)
}}
onClick={() => setActiveIndex(dayIndex())}
onFocus={() => {
setActiveIndex(dayIndex())
setActiveSegment(undefined)
}}
onBlur={() => {
setActiveIndex(undefined)
setActiveSegment(undefined)
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
setActiveIndex(dayIndex())
}}
>
<rect
x={barX}
y="0"
width={barWidth}
height={height + headerOffset}
fill="transparent"
pointer-events="all"
/>
<text x={barX} y="17" class="chart-total">
{formatTokens(usageTotal(day))}
</text>
<text x={barX} y="34" class="chart-date">
{day.date}
</text>
<rect x={barX} y={headerOffset} width={barWidth} height={stackTop} fill="url(#stats-usage-dot-grid)" />
<For each={day.segments}>
{(segment, index) => {
const previous = day.segments.slice(0, index()).reduce((sum, item) => sum + item.value, 0)
const segmentHeight = y()(previous) - y()(previous + segment.value)
const segmentInset = index() === day.segments.length - 1 ? 0 : segmentGap
return (
<rect
x={barX}
y={headerOffset + y()(previous + segment.value) + segmentInset}
width={barWidth}
height={Math.max(segmentHeight - segmentInset, 0)}
data-segment-active={
activeIndex() === dayIndex() && activeSegment() === index() ? "true" : undefined
}
opacity={getUsageSegmentOpacity(activeIndex() === dayIndex(), activeSegment(), index())}
fill={activeIndex() === dayIndex() ? usageColors[index()] : "var(--stats-bar-idle)"}
onPointerEnter={(event) => {
event.stopPropagation()
setActiveIndex(dayIndex())
setActiveSegment(index())
}}
/>
)
}}
</For>
</g>
)
}}
</For>
</svg>
<Show when={activeBar()}>
{(bar) => (
<div
data-component="chart-tooltip"
data-placement={bar().x > width * 0.62 ? "left" : "right"}
style={getUsageTooltipStyle(bar().x, bar().width, width)}
>
<strong>{bar().point.date}</strong>
<span>{formatTokens(usageTotal(bar().point))} total</span>
<div data-slot="tooltip-divider" />
<For each={bar().point.segments}>
{(segment, index) => (
<p data-active={activeSegment() === index() ? "true" : undefined}>
<span data-slot="tooltip-label">
<i style={{ background: usageColors[index()] }} /> {segment.model}
</span>
<b>{formatTokens(segment.value)}</b>
</p>
)}
</For>
</div>
)}
</Show>
</div>
)
}
function getUsageTooltipStyle(barX: number, barWidth: number, width: number) {
if (barX > width * 0.62) return { left: "auto", right: `${((width - barX + 12) / width) * 100}%` }
return { left: `${((barX + barWidth + 12) / width) * 100}%`, right: "auto" }
}
function getUsageSegmentOpacity(isActiveBar: boolean, activeSegment: number | undefined, index: number) {
if (!isActiveBar) return 1
if (activeSegment === undefined) return 1
return activeSegment === index ? 1 : 0.38
}
function usageTotal(point: UsagePoint) {
return point.segments.reduce((sum, item) => sum + item.value, 0)
}
function formatTokens(value: number) {
if (value >= 1) return `${value.toFixed(value >= 10 ? 0 : 1)}T`
return `${Math.round(value * 1000)}B`
}
function LeaderboardSection(props: { data: StatsHomeData["leaderboard"] }) {
const [product, setProduct] = createSignal<UsageProduct>("All Users")
const [range, setRange] = createSignal<UsageRange>("1W")
const data = createMemo(() => props.data[product()][range()])
return (
<ChartSection
title="Leaderboard"
description="Shown are the sum of prompt and completion tokens per model, including reasoning tokens."
>
<Show
when={data().length > 0}
fallback={
<EmptyState title="No leaderboard data" description="No model_stat rows matched this product and range." />
}
>
<Leaderboard data={data()} />
</Show>
<div data-slot="chart-footer">
<StatsFilters product={product()} range={range()} onProductSelect={setProduct} onRangeSelect={setRange} />
</div>
</ChartSection>
)
}
function Leaderboard(props: { data: LeaderboardEntry[] }) {
return (
<div data-component="leaderboard" aria-label="Model token leaderboard">
<div data-slot="leaderboard-grid">
<div data-slot="leaderboard-featured">
<For each={props.data.slice(0, 3)}>{(entry) => <LeaderboardCard entry={entry} size="featured" />}</For>
</div>
<div data-slot="leaderboard-compact">
<For each={props.data.slice(3)}>{(entry) => <LeaderboardCard entry={entry} size="compact" />}</For>
</div>
</div>
</div>
)
}
function LeaderboardCard(props: { entry: LeaderboardEntry; size: "featured" | "compact" }) {
return (
<article data-component="leader-card" data-size={props.size}>
<span data-slot="rank">{String(props.entry.rank).padStart(2, "0")}</span>
<ProviderIcon data-slot="leader-watermark" aria-hidden="true" id={getProviderIconId(props.entry.author)} />
<div data-slot="leader-body">
<ProviderIcon data-slot="leader-avatar" aria-hidden="true" id={getProviderIconId(props.entry.author)} />
<div data-slot="leader-copy">
<div>
<strong>{props.entry.model}</strong>
<span>{formatBillions(props.entry.tokens)}</span>
</div>
<div>
<span>{props.entry.author}</span>
<span data-slot="delta" data-negative={props.entry.change < 0 ? "true" : undefined}>
{formatChange(props.entry.change)}
</span>
</div>
</div>
</div>
</article>
)
}
function getProviderIconId(author: string) {
if (author === "MiniMax") return "minimax"
if (author === "Moonshot") return "moonshotai"
if (author === "Zhipu") return "zhipuai"
return author.toLowerCase()
}
function formatBillions(value: number) {
if (value >= 1000) return `${(value / 1000).toFixed(value >= 10000 ? 0 : 1)}T`
return `${value}B`
}
function formatChange(value: number) {
if (value > 0) return `+${value}%`
return `${value}%`
}
function MarketShareSection(props: { data: StatsHomeData["market"] }) {
const [range, setRange] = createSignal<UsageRange>("1W")
const [activeIndex, setActiveIndex] = createSignal(2)
const data = createMemo(() => props.data[range()])
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0)))
const activeDay = createMemo(() => data()[selectedIndex()])
return (
<ChartSection title="Market Share" description="Compare token share by model author.">
<Show
when={activeDay()}
fallback={<EmptyState title="No market data" description="No model_stat rows matched this range." />}
>
{(day) => (
<>
<MarketShare data={data()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
<MarketShareList data={day().authors} />
</>
)}
</Show>
<div data-slot="market-footer">
<p>
<span>[*]</span>
<strong>{activeDay()?.date ?? "No data"}</strong>
</p>
<FilterPills items={ranges} selected={range()} label="Date range" variant="range" onSelect={setRange} />
</div>
</ChartSection>
)
}
function MarketShare(props: { data: MarketDay[]; activeIndex: number; onActiveIndexChange: (index: number) => void }) {
return (
<div data-component="market-share" role="img" aria-label="Market share by model author">
<div data-slot="market-labels">
<For each={props.data}>
{(day, index) => (
<button
type="button"
data-active={props.activeIndex === index() ? "true" : undefined}
onClick={() => props.onActiveIndexChange(index())}
>
<span>{formatTrillions(day.total)}</span>
<span>{day.date}</span>
</button>
)}
</For>
</div>
<div data-slot="market-bars">
<For each={props.data}>
{(day, index) => (
<button
type="button"
aria-label={`${day.date} ${formatTrillions(day.total)}`}
data-active={props.activeIndex === index() ? "true" : undefined}
onClick={() => props.onActiveIndexChange(index())}
>
<For each={day.authors}>
{(author, authorIndex) => (
<span
style={{
"background-color": props.activeIndex === index() ? marketColors[authorIndex()] : undefined,
"flex-grow": author.share,
}}
/>
)}
</For>
</button>
)}
</For>
</div>
</div>
)
}
function MarketShareList(props: { data: MarketDay["authors"] }) {
return (
<ol data-component="market-share-list">
<For each={props.data}>
{(item, index) => (
<li>
<span>{String(index() + 1).padStart(2, "0")}</span>
<i style={{ background: marketColors[index()] }} />
<strong>{item.author}</strong>
<em>{formatTrillions(item.tokens)}</em>
<b>{item.share.toFixed(1)}%</b>
</li>
)}
</For>
</ol>
)
}
function formatTrillions(value: number) {
return `${value.toFixed(value >= 10 ? 0 : 1)}T`
}
function TokenCostSection(props: { data: StatsHomeData["tokenCost"] }) {
const [product, setProduct] = createSignal<TokenProduct>("Zen")
const [activeIndex, setActiveIndex] = createSignal(2)
const data = createMemo(() => props.data[product()])
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0)))
return (
<ChartSection title="Token Cost" description="Price per 1M tokens.">
<Show
when={data().length > 0}
fallback={
<EmptyState title="No token cost data" description="No cost-bearing model_stat rows matched this product." />
}
>
<TokenCostChart data={data()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
</Show>
<div data-slot="token-footer">
<FilterPills
items={tokenProducts}
selected={product()}
label="Product filter"
variant="product"
onSelect={setProduct}
/>
</div>
</ChartSection>
)
}
function TokenCostChart(props: {
data: TokenCostEntry[]
activeIndex: number
onActiveIndexChange: (index: number) => void
}) {
const max = createMemo(() => Math.max(1, ...props.data.map((item) => item.total)))
const active = createMemo(() => props.data[props.activeIndex] ?? props.data[0])
return (
<div data-component="token-cost">
<For each={props.data}>
{(item, index) => (
<button
type="button"
data-component="token-row"
data-active={props.activeIndex === index() ? "true" : undefined}
onClick={() => props.onActiveIndexChange(index())}
onPointerEnter={() => props.onActiveIndexChange(index())}
>
<strong>{formatDollars(item.total)}</strong>
<span>{item.model}</span>
<MetricBar value={item.total} max={max()} active={props.activeIndex === index()} />
</button>
)}
</For>
<Show when={active()}>
{(item) => (
<div data-component="token-tooltip" style={{ top: `${props.activeIndex * 28 + 2}px` }}>
<p>
<span>Input</span>
<strong>{formatDollars(item().input)}</strong>
</p>
<p>
<span>Output</span>
<strong>{formatDollars(item().output)}</strong>
</p>
<p>
<span>Cached</span>
<strong>{formatDollars(item().cached)}</strong>
</p>
</div>
)}
</Show>
</div>
)
}
function formatDollars(value: number) {
return `$${value.toFixed(2)}`
}
function MetricBar(props: { value: number; max: number; active: boolean }) {
return (
<i data-component="metric-bar" data-active={props.active ? "true" : undefined}>
<b style={{ "flex-grow": Math.max(props.value / Math.max(props.max, 1), 0.05) }} />
<em />
</i>
)
}
function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) {
const [product, setProduct] = createSignal<TokenProduct>("Zen")
const [activeIndex, setActiveIndex] = createSignal(2)
const data = createMemo(() => props.data[product()])
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0)))
return (
<ChartSection title="Session Cost" description="Average cost per session.">
<Show
when={data().length > 0}
fallback={
<EmptyState
title="No session cost data"
description="No session-bearing model_stat rows matched this product."
/>
}
>
<SessionCostChart data={data()} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
</Show>
<div data-slot="token-footer">
<FilterPills
items={tokenProducts}
selected={product()}
label="Product filter"
variant="product"
onSelect={setProduct}
/>
</div>
</ChartSection>
)
}
function SessionCostChart(props: {
data: SessionCostEntry[]
activeIndex: number
onActiveIndexChange: (index: number) => void
}) {
const maxCost = createMemo(() => Math.max(1, ...props.data.map((item) => item.cost)))
const maxTokens = createMemo(() => Math.max(1, ...props.data.map((item) => item.tokens)))
const active = createMemo(() => props.data[props.activeIndex] ?? props.data[0])
return (
<div data-component="session-cost">
<div data-slot="session-heading">
<span />
<p>COST / SESSION</p>
<p>TOKENS / SESSIONS</p>
</div>
<For each={props.data}>
{(item, index) => (
<button
type="button"
data-component="token-row"
data-variant="session"
data-active={props.activeIndex === index() ? "true" : undefined}
onClick={() => props.onActiveIndexChange(index())}
onPointerEnter={() => props.onActiveIndexChange(index())}
>
<strong>{formatSessionCost(item.cost)}</strong>
<span>{item.model}</span>
<MetricBar value={item.cost} max={maxCost()} active={props.activeIndex === index()} />
<MetricBar value={item.tokens} max={maxTokens()} active={props.activeIndex === index()} />
</button>
)}
</For>
<Show when={active()}>
{(item) => (
<div
data-component="token-tooltip"
data-variant="session"
style={{ top: `${props.activeIndex * 28 + 21}px` }}
>
<p>
<span>Cost/Session</span>
<strong>{formatSessionCost(item().cost)}</strong>
</p>
<p>
<span>Tokens/Session</span>
<strong>{formatTokenCount(item().tokens)}</strong>
</p>
</div>
)}
</Show>
</div>
)
}
function formatTokenCount(value: number) {
if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}M`
return `${Math.round(value / 1_000)}K`
}
function formatSessionCost(value: number) {
return `$${value.toFixed(4)}`
}
function CountrySection(props: { data: StatsHomeData["country"] }) {
const [range, setRange] = createSignal<UsageRange>("1W")
const data = createMemo(() => props.data[range()])
return (
<ChartSection title="Token by Country" description="Country-level token totals from geo_stat.">
<Show
when={data().length > 0}
fallback={<EmptyState title="No country data" description="No geo_stat rows matched this range." />}
>
<CountryChart data={data()} />
</Show>
<div data-slot="country-footer">
<p>
<span>[*]</span>
<strong>Top countries by tokens</strong>
</p>
<FilterPills items={ranges} selected={range()} label="Date range" variant="range" onSelect={setRange} />
</div>
</ChartSection>
)
}
function CountryChart(props: { data: CountryEntry[] }) {
const [activeIndex, setActiveIndex] = createSignal(0)
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(props.data.length - 1, 0)))
const active = createMemo(() => props.data[selectedIndex()])
const max = createMemo(() => Math.max(0.0001, ...props.data.map((item) => item.tokens)))
return (
<div data-component="country-map">
<svg viewBox="0 0 920 320" role="img" aria-label="Country token share bubble chart">
<For each={props.data.slice(0, countryPositions.length)}>
{(item, index) => {
const position = countryPositions[index()]
const radius = 18 + Math.sqrt(item.tokens / max()) * 58
return (
<g
role="button"
tabIndex={0}
aria-label={`${formatCountry(item.country)} ${formatTokens(item.tokens)}`}
data-active={selectedIndex() === index() ? "true" : undefined}
onPointerEnter={() => setActiveIndex(index())}
onClick={() => setActiveIndex(index())}
onFocus={() => setActiveIndex(index())}
>
<circle cx={position.x} cy={position.y} r={radius} />
<text x={position.x} y={position.y + 4} text-anchor="middle">
{item.country}
</text>
</g>
)
}}
</For>
</svg>
<Show when={active()}>
{(item) => (
<div data-component="map-tooltip">
<strong>{formatCountry(item().country)}</strong>
<span>{item().continent || "Unknown region"}</span>
<p>
<b>{formatTokens(item().tokens)}</b>
<em>{item().share.toFixed(1)}%</em>
</p>
</div>
)}
</Show>
<CountryList data={props.data.slice(0, 8)} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
</div>
)
}
function CountryList(props: {
data: CountryEntry[]
activeIndex: number
onActiveIndexChange: (index: number) => void
}) {
return (
<ol data-component="country-list">
<For each={props.data}>
{(item, index) => (
<li>
<button
type="button"
data-active={props.activeIndex === index() ? "true" : undefined}
onClick={() => props.onActiveIndexChange(index())}
onPointerEnter={() => props.onActiveIndexChange(index())}
>
<span>{String(item.rank).padStart(2, "0")}</span>
<strong>{formatCountry(item.country)}</strong>
<em>{formatTokens(item.tokens)}</em>
<b>{item.share.toFixed(1)}%</b>
</button>
</li>
)}
</For>
</ol>
)
}
function formatCountry(country: string) {
const known: Record<string, string> = {
AU: "Australia",
BR: "Brazil",
CA: "Canada",
CN: "China",
DE: "Germany",
FR: "France",
GB: "United Kingdom",
IN: "India",
JP: "Japan",
KR: "South Korea",
NL: "Netherlands",
SG: "Singapore",
US: "United States",
ZZ: "Unknown",
}
return known[country] ?? country
}
function Newsletter() {
return (
<section data-section="newsletter">
<div>
<h2>Be the first to know when we release new products</h2>
<p>Join the waitlist for early access.</p>
</div>
<form>
<input type="email" placeholder="Email address" />
<button>Subscribe</button>
</form>
</section>
)
}
function Header() {
return (
<section data-component="top">
<a data-slot="brand" href="https://opencode.ai/" aria-label="OpenCode home">
<img data-slot="logo light" src={logoLight} alt="OpenCode" width="234" height="42" />
<img data-slot="logo dark" src={logoDark} alt="OpenCode" width="234" height="42" />
</a>
<nav data-component="nav-desktop" aria-label="Main navigation">
<ul>
<li>
<a href="https://github.com/sst/opencode" target="_blank" rel="noreferrer">
GitHub
</a>
</li>
<li>
<a href="https://opencode.ai/docs">Docs</a>
</li>
<li>
<a href="https://opencode.ai/zen">Zen</a>
</li>
<li>
<a href="https://opencode.ai/go">Go</a>
</li>
<li>
<a href="https://opencode.ai/enterprise">Enterprise</a>
</li>
<li>
<a href="https://opencode.ai/download" data-slot="cta-button">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M12.1875 9.75L9.00001 12.9375L5.8125 9.75M9.00001 2.0625L9 12.375M14.4375 15.9375H3.5625"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
Download
</a>
</li>
</ul>
</nav>
</section>
)
}
function Footer() {
return (
<footer data-component="footer">
<div data-slot="cell">
<a href="https://github.com/sst/opencode" target="_blank" rel="noreferrer">
GitHub
</a>
</div>
<div data-slot="cell">
<a href="https://opencode.ai/docs">Docs</a>
</div>
<div data-slot="cell">
<a href="https://opencode.ai/changelog">Changelog</a>
</div>
<div data-slot="cell">
<a href="https://x.com/opencode_ai">X</a>
</div>
</footer>
)
}
function Legal() {
return (
<div data-component="legal">
<span>
©{new Date().getFullYear()} <a href="https://anoma.ly">Anomaly</a>
</span>
<span>
<a href="https://opencode.ai/brand">Brand</a>
</span>
<span>
<a href="https://opencode.ai/legal/privacy-policy">Privacy</a>
</span>
<span>
<a href="https://opencode.ai/legal/terms-of-service">Terms</a>
</span>
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../../sst-env.d.ts" />
import "sst"
export {}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"strict": true,
"noEmit": true,
"types": ["vite/client", "bun"],
"isolatedModules": true,
"paths": {
"~/*": ["./src/*"]
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import { solidStart } from "@solidjs/start/config"
import { nitro } from "nitro/vite"
import { defineConfig, type PluginOption } from "vite"
export default defineConfig({
plugins: [
solidStart() as PluginOption,
nitro({
compatibilityDate: "2024-09-19",
preset: "cloudflare-module",
cloudflare: {
nodeCompat: true,
},
}),
],
server: {
allowedHosts: true,
},
build: {
minify: false,
},
})
+21
View File
@@ -0,0 +1,21 @@
import { Resource } from "sst/resource"
import { defineConfig } from "drizzle-kit"
export default defineConfig({
dialect: "mysql",
schema: ["./src/database/schema.ts"],
// schema: ["./src/**/*.sql.ts"],
out: "./migrations/",
strict: true,
verbose: true,
dbCredentials: {
database: Resource.StatsDatabase.database,
host: Resource.StatsDatabase.host,
user: Resource.StatsDatabase.username,
password: Resource.StatsDatabase.password,
port: Resource.StatsDatabase.port,
ssl: {
rejectUnauthorized: false,
},
},
})
@@ -0,0 +1,42 @@
CREATE TABLE `stat` (
`id` bigint AUTO_INCREMENT PRIMARY KEY,
`grain` varchar(16) NOT NULL,
`period_start` datetime NOT NULL,
`period_end` datetime NOT NULL,
`dataset` varchar(64) NOT NULL DEFAULT 'all',
`tier` varchar(64) NOT NULL DEFAULT 'all',
`client` varchar(64) NOT NULL DEFAULT 'all',
`source` varchar(64) NOT NULL DEFAULT 'all',
`provider` varchar(128) NOT NULL,
`model` varchar(256) NOT NULL,
`provider_model` varchar(256) NOT NULL DEFAULT '',
`sessions` bigint NOT NULL DEFAULT 0,
`requests` bigint NOT NULL DEFAULT 0,
`input_tokens` bigint NOT NULL DEFAULT 0,
`output_tokens` bigint NOT NULL DEFAULT 0,
`reasoning_tokens` bigint NOT NULL DEFAULT 0,
`cache_read_tokens` bigint NOT NULL DEFAULT 0,
`total_tokens` bigint NOT NULL DEFAULT 0,
`input_cost_microcents` bigint NOT NULL DEFAULT 0,
`output_cost_microcents` bigint NOT NULL DEFAULT 0,
`total_cost_microcents` bigint NOT NULL DEFAULT 0,
`avg_duration_ms` decimal(12,2),
`p50_duration_ms` int,
`p95_duration_ms` int,
`avg_ttfb_ms` decimal(12,2),
`p50_ttfb_ms` int,
`p95_ttfb_ms` int,
`avg_output_tps` decimal(12,4),
`success_count` bigint NOT NULL DEFAULT 0,
`error_count` bigint NOT NULL DEFAULT 0,
`sample_count` bigint NOT NULL DEFAULT 0,
`rank_by_tokens` int,
`rank_by_requests` int,
`rank_by_cost` int,
`created_at` datetime NOT NULL DEFAULT (now()),
`updated_at` datetime NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `uniq_model_period` UNIQUE INDEX(`grain`,`period_start`,`dataset`,`tier`,`client`,`source`,`provider`,`model`)
);
--> statement-breakpoint
CREATE INDEX `idx_leaderboard_tokens` ON `stat` (`grain`,`period_start`,`dataset`,`tier`,`total_tokens`);--> statement-breakpoint
CREATE INDEX `idx_model` ON `stat` (`model`,`grain`,`period_start`);
@@ -0,0 +1,623 @@
{
"version": "6",
"dialect": "mysql",
"id": "72655266-65da-408e-bfd8-9f3a4ad817a5",
"prevIds": ["00000000-0000-0000-0000-000000000000"],
"ddl": [
{
"name": "stat",
"entityType": "tables"
},
{
"type": "bigint",
"notNull": true,
"autoIncrement": true,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "stat"
},
{
"type": "varchar(16)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "grain",
"entityType": "columns",
"table": "stat"
},
{
"type": "datetime",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "period_start",
"entityType": "columns",
"table": "stat"
},
{
"type": "datetime",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "period_end",
"entityType": "columns",
"table": "stat"
},
{
"type": "varchar(64)",
"notNull": true,
"autoIncrement": false,
"default": "'all'",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "dataset",
"entityType": "columns",
"table": "stat"
},
{
"type": "varchar(64)",
"notNull": true,
"autoIncrement": false,
"default": "'all'",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "tier",
"entityType": "columns",
"table": "stat"
},
{
"type": "varchar(64)",
"notNull": true,
"autoIncrement": false,
"default": "'all'",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "client",
"entityType": "columns",
"table": "stat"
},
{
"type": "varchar(64)",
"notNull": true,
"autoIncrement": false,
"default": "'all'",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "source",
"entityType": "columns",
"table": "stat"
},
{
"type": "varchar(128)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "provider",
"entityType": "columns",
"table": "stat"
},
{
"type": "varchar(256)",
"notNull": true,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "model",
"entityType": "columns",
"table": "stat"
},
{
"type": "varchar(256)",
"notNull": true,
"autoIncrement": false,
"default": "''",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "provider_model",
"entityType": "columns",
"table": "stat"
},
{
"type": "bigint",
"notNull": true,
"autoIncrement": false,
"default": "0",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "sessions",
"entityType": "columns",
"table": "stat"
},
{
"type": "bigint",
"notNull": true,
"autoIncrement": false,
"default": "0",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "requests",
"entityType": "columns",
"table": "stat"
},
{
"type": "bigint",
"notNull": true,
"autoIncrement": false,
"default": "0",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "input_tokens",
"entityType": "columns",
"table": "stat"
},
{
"type": "bigint",
"notNull": true,
"autoIncrement": false,
"default": "0",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "output_tokens",
"entityType": "columns",
"table": "stat"
},
{
"type": "bigint",
"notNull": true,
"autoIncrement": false,
"default": "0",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "reasoning_tokens",
"entityType": "columns",
"table": "stat"
},
{
"type": "bigint",
"notNull": true,
"autoIncrement": false,
"default": "0",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "cache_read_tokens",
"entityType": "columns",
"table": "stat"
},
{
"type": "bigint",
"notNull": true,
"autoIncrement": false,
"default": "0",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "total_tokens",
"entityType": "columns",
"table": "stat"
},
{
"type": "bigint",
"notNull": true,
"autoIncrement": false,
"default": "0",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "input_cost_microcents",
"entityType": "columns",
"table": "stat"
},
{
"type": "bigint",
"notNull": true,
"autoIncrement": false,
"default": "0",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "output_cost_microcents",
"entityType": "columns",
"table": "stat"
},
{
"type": "bigint",
"notNull": true,
"autoIncrement": false,
"default": "0",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "total_cost_microcents",
"entityType": "columns",
"table": "stat"
},
{
"type": "decimal(12,2)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "avg_duration_ms",
"entityType": "columns",
"table": "stat"
},
{
"type": "int",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "p50_duration_ms",
"entityType": "columns",
"table": "stat"
},
{
"type": "int",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "p95_duration_ms",
"entityType": "columns",
"table": "stat"
},
{
"type": "decimal(12,2)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "avg_ttfb_ms",
"entityType": "columns",
"table": "stat"
},
{
"type": "int",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "p50_ttfb_ms",
"entityType": "columns",
"table": "stat"
},
{
"type": "int",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "p95_ttfb_ms",
"entityType": "columns",
"table": "stat"
},
{
"type": "decimal(12,4)",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "avg_output_tps",
"entityType": "columns",
"table": "stat"
},
{
"type": "bigint",
"notNull": true,
"autoIncrement": false,
"default": "0",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "success_count",
"entityType": "columns",
"table": "stat"
},
{
"type": "bigint",
"notNull": true,
"autoIncrement": false,
"default": "0",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "error_count",
"entityType": "columns",
"table": "stat"
},
{
"type": "bigint",
"notNull": true,
"autoIncrement": false,
"default": "0",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "sample_count",
"entityType": "columns",
"table": "stat"
},
{
"type": "int",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "rank_by_tokens",
"entityType": "columns",
"table": "stat"
},
{
"type": "int",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "rank_by_requests",
"entityType": "columns",
"table": "stat"
},
{
"type": "int",
"notNull": false,
"autoIncrement": false,
"default": null,
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "rank_by_cost",
"entityType": "columns",
"table": "stat"
},
{
"type": "datetime",
"notNull": true,
"autoIncrement": false,
"default": "(now())",
"onUpdateNow": false,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "stat"
},
{
"type": "datetime",
"notNull": true,
"autoIncrement": false,
"default": "(now())",
"onUpdateNow": true,
"onUpdateNowFsp": null,
"charSet": null,
"collation": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "stat"
},
{
"columns": ["id"],
"name": "PRIMARY",
"table": "stat",
"entityType": "pks"
},
{
"columns": [
{
"value": "grain",
"isExpression": false
},
{
"value": "period_start",
"isExpression": false
},
{
"value": "dataset",
"isExpression": false
},
{
"value": "tier",
"isExpression": false
},
{
"value": "client",
"isExpression": false
},
{
"value": "source",
"isExpression": false
},
{
"value": "provider",
"isExpression": false
},
{
"value": "model",
"isExpression": false
}
],
"isUnique": true,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "uniq_model_period",
"entityType": "indexes",
"table": "stat"
},
{
"columns": [
{
"value": "grain",
"isExpression": false
},
{
"value": "period_start",
"isExpression": false
},
{
"value": "dataset",
"isExpression": false
},
{
"value": "tier",
"isExpression": false
},
{
"value": "total_tokens",
"isExpression": false
}
],
"isUnique": false,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "idx_leaderboard_tokens",
"entityType": "indexes",
"table": "stat"
},
{
"columns": [
{
"value": "model",
"isExpression": false
},
{
"value": "grain",
"isExpression": false
},
{
"value": "period_start",
"isExpression": false
}
],
"isUnique": false,
"using": null,
"algorithm": null,
"lock": null,
"nameExplicit": true,
"name": "idx_model",
"entityType": "indexes",
"table": "stat"
}
],
"renames": []
}
@@ -0,0 +1,94 @@
CREATE TABLE `geo_stat` (
`id` bigint AUTO_INCREMENT PRIMARY KEY,
`grain` varchar(16) NOT NULL,
`period_start` datetime NOT NULL,
`period_end` datetime NOT NULL,
`dataset` varchar(64) NOT NULL DEFAULT 'all',
`tier` varchar(64) NOT NULL DEFAULT 'all',
`client` varchar(64) NOT NULL DEFAULT 'all',
`source` varchar(64) NOT NULL DEFAULT 'all',
`country` char(2) NOT NULL,
`continent` varchar(8) NOT NULL DEFAULT '',
`sessions` bigint NOT NULL DEFAULT 0,
`requests` bigint NOT NULL DEFAULT 0,
`input_tokens` bigint NOT NULL DEFAULT 0,
`output_tokens` bigint NOT NULL DEFAULT 0,
`reasoning_tokens` bigint NOT NULL DEFAULT 0,
`cache_read_tokens` bigint NOT NULL DEFAULT 0,
`total_tokens` bigint NOT NULL DEFAULT 0,
`input_cost_microcents` bigint NOT NULL DEFAULT 0,
`output_cost_microcents` bigint NOT NULL DEFAULT 0,
`total_cost_microcents` bigint NOT NULL DEFAULT 0,
`avg_duration_ms` decimal(12,2),
`p50_duration_ms` int,
`p95_duration_ms` int,
`avg_ttfb_ms` decimal(12,2),
`p50_ttfb_ms` int,
`p95_ttfb_ms` int,
`avg_output_tps` decimal(12,4),
`success_count` bigint NOT NULL DEFAULT 0,
`error_count` bigint NOT NULL DEFAULT 0,
`sample_count` bigint NOT NULL DEFAULT 0,
`market_share_tokens` decimal(10,6),
`market_share_requests` decimal(10,6),
`market_share_sessions` decimal(10,6),
`rank_by_tokens` int,
`rank_by_requests` int,
`rank_by_sessions` int,
`rank_by_cost` int,
`created_at` datetime NOT NULL DEFAULT (now()),
`updated_at` datetime NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `uniq_country_period` UNIQUE INDEX(`grain`,`period_start`,`dataset`,`tier`,`client`,`source`,`country`)
);
--> statement-breakpoint
CREATE TABLE `provider_stat` (
`id` bigint AUTO_INCREMENT PRIMARY KEY,
`grain` varchar(16) NOT NULL,
`period_start` datetime NOT NULL,
`period_end` datetime NOT NULL,
`dataset` varchar(64) NOT NULL DEFAULT 'all',
`tier` varchar(64) NOT NULL DEFAULT 'all',
`client` varchar(64) NOT NULL DEFAULT 'all',
`source` varchar(64) NOT NULL DEFAULT 'all',
`provider` varchar(128) NOT NULL,
`sessions` bigint NOT NULL DEFAULT 0,
`requests` bigint NOT NULL DEFAULT 0,
`input_tokens` bigint NOT NULL DEFAULT 0,
`output_tokens` bigint NOT NULL DEFAULT 0,
`reasoning_tokens` bigint NOT NULL DEFAULT 0,
`cache_read_tokens` bigint NOT NULL DEFAULT 0,
`total_tokens` bigint NOT NULL DEFAULT 0,
`input_cost_microcents` bigint NOT NULL DEFAULT 0,
`output_cost_microcents` bigint NOT NULL DEFAULT 0,
`total_cost_microcents` bigint NOT NULL DEFAULT 0,
`avg_duration_ms` decimal(12,2),
`p50_duration_ms` int,
`p95_duration_ms` int,
`avg_ttfb_ms` decimal(12,2),
`p50_ttfb_ms` int,
`p95_ttfb_ms` int,
`avg_output_tps` decimal(12,4),
`success_count` bigint NOT NULL DEFAULT 0,
`error_count` bigint NOT NULL DEFAULT 0,
`sample_count` bigint NOT NULL DEFAULT 0,
`market_share_tokens` decimal(10,6),
`market_share_requests` decimal(10,6),
`market_share_sessions` decimal(10,6),
`rank_by_tokens` int,
`rank_by_requests` int,
`rank_by_sessions` int,
`rank_by_cost` int,
`created_at` datetime NOT NULL DEFAULT (now()),
`updated_at` datetime NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `uniq_provider_period` UNIQUE INDEX(`grain`,`period_start`,`dataset`,`tier`,`client`,`source`,`provider`)
);
--> statement-breakpoint
RENAME TABLE `stat` TO `model_stat`;--> statement-breakpoint
CREATE INDEX `idx_country_map_tokens` ON `geo_stat` (`grain`,`period_start`,`dataset`,`tier`,`total_tokens`);--> statement-breakpoint
CREATE INDEX `idx_country_rank` ON `geo_stat` (`grain`,`period_start`,`dataset`,`tier`,`rank_by_tokens`);--> statement-breakpoint
CREATE INDEX `idx_country` ON `geo_stat` (`country`,`grain`,`period_start`);--> statement-breakpoint
CREATE INDEX `idx_continent` ON `geo_stat` (`continent`,`grain`,`period_start`);--> statement-breakpoint
CREATE INDEX `idx_provider_leaderboard_tokens` ON `provider_stat` (`grain`,`period_start`,`dataset`,`tier`,`total_tokens`);--> statement-breakpoint
CREATE INDEX `idx_provider_market_share` ON `provider_stat` (`grain`,`period_start`,`dataset`,`tier`,`market_share_tokens`);--> statement-breakpoint
CREATE INDEX `idx_provider_rank` ON `provider_stat` (`grain`,`period_start`,`dataset`,`tier`,`rank_by_tokens`);--> statement-breakpoint
CREATE INDEX `idx_provider` ON `provider_stat` (`provider`,`grain`,`period_start`);
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-core",
"version": "1.14.50",
"private": true,
"type": "module",
"license": "MIT",
"exports": {
".": "./src/index.ts",
"./athena": "./src/athena.ts",
"./config": "./src/config.ts",
"./database": "./src/database.ts",
"./database/*": "./src/database/*.ts",
"./domain/*": "./src/domain/*.ts",
"./runtime": "./src/runtime.ts",
"./stat-sync": "./src/stat-sync.ts"
},
"scripts": {
"db:generate": "drizzle-kit generate --config=drizzle.config.ts",
"db:migrate": "bun src/migrate.ts",
"db:push": "drizzle-kit push --config=drizzle.config.ts",
"db:studio": "drizzle-kit studio --config=drizzle.config.ts",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"sst": "catalog:"
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
"typescript": "catalog:"
},
"engines": {
"node": ">=22"
}
}
+139
View File
@@ -0,0 +1,139 @@
import {
AthenaClient as AwsAthenaClient,
GetQueryExecutionCommand,
GetQueryResultsCommand,
StartQueryExecutionCommand,
type Row,
} from "@aws-sdk/client-athena"
import { Effect, Layer, Schema } from "effect"
import * as Context from "effect/Context"
import { Resource } from "sst/resource"
const ATHENA_MAX_POLL_ATTEMPTS = 60
const ATHENA_PAGE_SIZE = 1000
export type AthenaData = Record<string, string>
export class AthenaQueryError extends Schema.TaggedErrorClass<AthenaQueryError>()("AthenaQueryError", {
message: Schema.String,
queryExecutionId: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect),
}) {}
export class AthenaQueryTimeoutError extends Schema.TaggedErrorClass<AthenaQueryTimeoutError>()(
"AthenaQueryTimeoutError",
{
message: Schema.String,
queryExecutionId: Schema.String,
},
) {}
export declare namespace Athena {
export interface Service {
readonly query: (query: string) => Effect.Effect<AthenaData[], AthenaQueryError | AthenaQueryTimeoutError>
}
}
export class Athena extends Context.Service<Athena, Athena.Service>()("@opencode/stats/Athena") {
static readonly layer: Layer.Layer<Athena> = Layer.effect(
Athena,
Effect.sync(() => {
const client = new AwsAthenaClient({ region: Resource.InferenceEvent.region })
const query = Effect.fn("Athena.query")(function* (query: string) {
const started = yield* Effect.tryPromise({
try: () =>
client.send(
new StartQueryExecutionCommand({
QueryString: query,
WorkGroup: Resource.InferenceEvent.workgroup,
QueryExecutionContext: {
Catalog: Resource.InferenceEvent.catalog,
Database: Resource.InferenceEvent.database,
},
}),
),
catch: (cause) => new AthenaQueryError({ message: "Failed to start Athena stats query", cause }),
})
const queryExecutionId = started.QueryExecutionId
if (!queryExecutionId)
return yield* new AthenaQueryError({ message: "Athena did not return a query execution id" })
yield* poll(client, queryExecutionId)
return yield* results(client, queryExecutionId)
})
return Athena.of({ query })
}),
)
}
const poll: (
client: AwsAthenaClient,
queryExecutionId: string,
attempt?: number,
) => Effect.Effect<void, AthenaQueryError | AthenaQueryTimeoutError> = Effect.fn("Athena.poll")(function* (
client: AwsAthenaClient,
queryExecutionId: string,
attempt = 0,
) {
if (attempt > 0) yield* Effect.sleep("2 seconds")
const result = yield* Effect.tryPromise({
try: () => client.send(new GetQueryExecutionCommand({ QueryExecutionId: queryExecutionId })),
catch: (cause) => new AthenaQueryError({ message: "Failed to poll Athena stats query", queryExecutionId, cause }),
})
const status = result.QueryExecution?.Status
if (status?.State === "SUCCEEDED") return
if (status?.State === "FAILED" || status?.State === "CANCELLED")
return yield* new AthenaQueryError({
message: `Athena stats query ${status.State.toLowerCase()}: ${status.StateChangeReason ?? "unknown reason"}`,
queryExecutionId,
})
if (attempt >= ATHENA_MAX_POLL_ATTEMPTS - 1)
return yield* new AthenaQueryTimeoutError({
message: `Athena stats query ${queryExecutionId} did not complete`,
queryExecutionId,
})
return yield* poll(client, queryExecutionId, attempt + 1)
})
const results: (
client: AwsAthenaClient,
queryExecutionId: string,
nextToken?: string,
) => Effect.Effect<AthenaData[], AthenaQueryError> = Effect.fn("Athena.results")(function* (
client: AwsAthenaClient,
queryExecutionId: string,
nextToken?: string,
) {
const result = yield* Effect.tryPromise({
try: () =>
client.send(
new GetQueryResultsCommand({
QueryExecutionId: queryExecutionId,
NextToken: nextToken,
MaxResults: ATHENA_PAGE_SIZE,
}),
),
catch: (cause) => new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }),
})
const columns = result.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((item) => item.Name ?? "") ?? []
const rows = (result.ResultSet?.Rows ?? []).slice(nextToken ? 0 : 1).map((row) => rowData(columns, row))
if (!result.NextToken) return rows
return [...rows, ...(yield* results(client, queryExecutionId, result.NextToken))]
})
function rowData(columns: string[], row: Row): AthenaData {
return Object.fromEntries(
columns.flatMap((column, index) => {
const value = row.Data?.[index]?.VarCharValue
if (!column || value === undefined) return []
return [[column, value]]
}),
)
}
+23
View File
@@ -0,0 +1,23 @@
import { Config, ConfigProvider, Effect, Layer, Schema } from "effect"
import * as Context from "effect/Context"
import { Resource } from "sst/resource"
export class AppConfigValue extends Schema.Class<AppConfigValue>("AppConfigValue")({
stage: Schema.NonEmptyString,
publicUrl: Schema.NonEmptyString,
}) {}
const decodeAppConfigValue = Schema.decodeUnknownSync(AppConfigValue)
const config = Config.all({
stage: Config.succeed(Resource.App.stage),
publicUrl: Config.string("PUBLIC_URL").pipe(Config.withDefault("http://localhost:3000")),
}).pipe(Config.map(decodeAppConfigValue))
export class AppConfig extends Context.Service<AppConfig, AppConfigValue>()("@opencode/stats/AppConfig") {
static readonly config = config
static readonly layer: Layer.Layer<AppConfig, never, never> = Layer.effect(
AppConfig,
config.parse(ConfigProvider.fromEnv()).pipe(Effect.orDie),
)
}

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