Compare commits

..

57 Commits

Author SHA1 Message Date
opencode-agent[bot] 2d15d89140 chore: update nix node_modules hashes
deploy / deploy (push) Has been cancelled
generate / generate (push) Has been cancelled
nix-eval / nix-eval (push) Has been cancelled
publish / version (push) Has been cancelled
typecheck / typecheck (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
2026-05-28 18:05:19 +00:00
Frank 2eb0606998 zen: add opus 4.8
deploy / deploy (push) Has been cancelled
2026-05-28 13:48:05 -04:00
opencode-agent[bot] cc230feca1 chore: generate 2026-05-28 17:45:17 +00:00
Adam 3ce9b4be0d fix(stats): refine hero layout for responsive Figma match 2026-05-28 12:43:16 -05:00
Adam fff7781fa8 fix(stats): preload IBM Plex Mono weights 2026-05-28 12:43:15 -05:00
Adam 0a72298062 feat(stats): better hero 2026-05-28 12:43:15 -05:00
Adam 7c320fd463 feat(stats): better header 2026-05-28 12:43:15 -05:00
Aiden Cline 05e3c4ecee fix(opencode): enable adaptive reasoning for opus 4.7+ (#29769) 2026-05-28 12:19:34 -05:00
Filip 72d008bd5c fix(core): allow colons inside passwords (#29576)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-05-28 13:17:35 -04:00
Avnish Uba 202ec12056 docs: fix grammar in config.mdx (#29683)
Co-authored-by: dnsnav <dnsnav@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-28 11:25:34 -05:00
Aiden Cline af30b315b2 fix(tui): restore thinking spinner (#29765) 2026-05-28 11:24:45 -05:00
opencode-agent[bot] 425a4f584f chore: generate 2026-05-28 13:39:37 +00:00
Adam 7504daa602 fix(stats): batch honeycomb backfill 2026-05-28 08:37:49 -05:00
Adam 9ac0f3e9ac fix(stats): better stat table periods, provider/model norm 2026-05-28 08:37:48 -05:00
Adam cf2cd13fb8 chore: updated backfill 2026-05-28 08:37:48 -05:00
opencode-agent[bot] 6883309997 chore: generate 2026-05-28 13:24:10 +00:00
Shoubhit Dash c3562a4a51 test(acp): remove timing diagnostics (#29741) 2026-05-28 18:52:37 +05:30
opencode-agent[bot] 30a66dfc31 chore: generate 2026-05-28 11:51:48 +00:00
Victor Navarro fdc574ff81 feat: improve referral system (#29720) 2026-05-28 13:50:14 +02:00
opencode-agent[bot] 1e5ddbd812 chore: generate 2026-05-28 10:56:37 +00:00
Shoubhit Dash 3109060216 fix(acp): cover smoke parity gaps (#29719) 2026-05-28 16:25:00 +05:30
Shoubhit Dash 9031ce7b51 fix(acp): speed up acp-next warm switches (#29713) 2026-05-28 15:10:17 +05:30
Shoubhit Dash 2449b50585 fix(acp): improve acp-next first-session startup (#29709) 2026-05-28 14:53:47 +05:30
opencode-agent[bot] 66e6662440 chore: generate 2026-05-28 08:48:09 +00:00
Shoubhit Dash 56f3b9044d test(acp-next): add local verifier subprocess tests (#29700) 2026-05-28 14:16:49 +05:30
Luke Parker 913659890d fix(openai): keep websocket response timeouts active (#29699) 2026-05-28 18:28:01 +10:00
Aiden Cline 14e0b9b17f fix(openai): retry websocket stream failures (#29673) 2026-05-28 01:16:18 -05:00
opencode-agent[bot] e5524f5bf9 chore: generate 2026-05-28 05:15:21 +00:00
Shoubhit Dash 16234a0baf feat(acp): implement acp-next prompt (#29664) 2026-05-28 10:43:59 +05:30
Frank e5cf3ad3c2 zen: fix db issue 2026-05-28 00:50:15 -04:00
opencode-agent[bot] 82da3320ba chore: generate 2026-05-28 03:43:10 +00:00
Shoubhit Dash aa553dea94 fix(acp): handle acp-next permission events (#29656) 2026-05-28 09:11:49 +05:30
opencode-agent[bot] 0c9cad86ec chore: generate 2026-05-28 02:59:53 +00:00
James Long 69910f361c fix(server): use persisted session directory for existing-session routes (#29640) 2026-05-27 22:58:33 -04:00
Kit Langton 7bafbb309a fix(opencode): forward remote workspace request bodies (#29458) 2026-05-27 21:54:36 -04:00
DS ec26d78450 fix(openai): support websocket custom base URLs (#29636) 2026-05-27 18:46:30 -05:00
opencode-agent[bot] 49e8d324dd chore: update nix node_modules hashes 2026-05-27 22:16:44 +00:00
Sebastian 60b9ba4db7 upgrade opentui to 0.2.16 (#29463) 2026-05-28 00:01:32 +02:00
opencode-agent[bot] a1a401dc9b chore: update nix node_modules hashes 2026-05-27 20:22:29 +00:00
opencode-agent[bot] 2f1547ebaf chore: generate 2026-05-27 20:05:11 +00:00
Aiden Cline 62da1e7682 feat(openai): add responses websocket transport (#29477) 2026-05-27 15:02:37 -05:00
James Long 28a06e52fc feat(tui): add workspace management dialog (#29612) 2026-05-27 18:09:40 +00:00
Jack 5a5d981c4e docs(zen): add MiMo-V2.5 Free model (#29610) 2026-05-28 01:34:37 +08:00
opencode-agent[bot] 62a7781e04 chore: generate 2026-05-27 16:31:18 +00:00
Jack 2d03cf4a9a docs(go): update MiMo (#29598) 2026-05-28 00:29:31 +08:00
opencode-agent[bot] 4025d30b24 chore: generate 2026-05-27 15:53:52 +00:00
Adam dd3c443d8c fix: cpu bound ingest server 2026-05-27 10:51:36 -05:00
Adam 4aaa9f9e30 chore: better stats command 2026-05-27 10:50:34 -05:00
Adam 31c805bc97 chore: honeycomb backfill script 2026-05-27 10:37:02 -05:00
Sebastian 94f2ed1b84 keep session navigation active in prompt modes (#29464) 2026-05-27 17:26:56 +02:00
gotjoshua 86dc66eae9 docs: fix ts error in math examples (#18244) 2026-05-27 10:12:12 -05:00
Aiden Cline 9814dc6526 fix(tui): surface subagent retry status (#29591) 2026-05-27 10:11:01 -05:00
Adam f09c859974 fix: memory pressure in ingest 2026-05-27 09:22:08 -05:00
Adam 1fcdb0246a fix: bun install 2026-05-27 08:37:34 -05:00
opencode-agent[bot] 340ad9e9ed chore: generate 2026-05-27 12:47:27 +00:00
Kit Langton 76d814e747 refactor(server): unify instance httpapi middleware routing
publish-github-action / publish (push) Has been cancelled
Unify declared instance HTTP API endpoints under typed middleware routing, including event streaming and PTY WebSocket connect handling.\n\nPreserve PTY connect compatibility by checking missing PTYs before parsing optional cursor and ticket query fields, with regression coverage.
2026-05-27 08:45:11 -04:00
Adam 9941e70d29 fix: better dockerfile 2026-05-27 06:55:44 -05:00
141 changed files with 12968 additions and 1542 deletions
+25 -16
View File
@@ -478,6 +478,7 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
"@types/ws": "8.18.1",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ai-gateway-provider": "3.1.2",
@@ -519,6 +520,7 @@
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"ws": "8.21.0",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
@@ -575,9 +577,9 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.15",
"@opentui/keymap": ">=0.2.15",
"@opentui/solid": ">=0.2.15",
"@opentui/core": ">=0.2.16",
"@opentui/keymap": ">=0.2.16",
"@opentui/solid": ">=0.2.16",
},
"optionalPeers": [
"@opentui/core",
@@ -627,6 +629,7 @@
"name": "@opencode-ai/stats-app",
"version": "1.15.11",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -824,9 +827,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.15",
"@opentui/keymap": "0.2.15",
"@opentui/solid": "0.2.15",
"@opentui/core": "0.2.16",
"@opentui/keymap": "0.2.16",
"@opentui/solid": "0.2.16",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1716,23 +1719,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.15", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.15", "@opentui/core-darwin-x64": "0.2.15", "@opentui/core-linux-arm64": "0.2.15", "@opentui/core-linux-x64": "0.2.15", "@opentui/core-win32-arm64": "0.2.15", "@opentui/core-win32-x64": "0.2.15" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-YGHttdZWScMcSvtYgZkLR6VhUO1OoUiQzwYjZgIusf5eCkPLD8PapH+PTMVqAiX16CHO6JxfMlkHv5qDiHAccQ=="],
"@opentui/core": ["@opentui/core@0.2.16", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.16", "@opentui/core-darwin-x64": "0.2.16", "@opentui/core-linux-arm64": "0.2.16", "@opentui/core-linux-x64": "0.2.16", "@opentui/core-win32-arm64": "0.2.16", "@opentui/core-win32-x64": "0.2.16" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-4vWN15Zc3nsXJlOiHhhpqkBXD+wrNFKxCPtiTiillZYDRre+XsZogVTOOGUDwaBIC23OSxq7imezLmmtShVBEA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-s25f9GmZd6wxNM5ExRmwwnLT+NLCKxnTWuO9aObOlqsXfLMGHQZrb6YwgAn/PSTua98KmH7GJCVWdPgZ/P+0RQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aFb2Yp+oqDu3h6VCWi7xpQ9yjpKSQcROzGGfHgqC6Nd3U+uiLfPJBkmiI87iK0opCggCFj5TkKI004050DmGjg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-GyaipN+nOcEr8rcTO2mqKTGmOBk0C300I69fLtubD3BadHcMI1DVNlQrcf/J1mkQEuMYbmBTi/1hT1ybWGr2Mw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KimiHE0j7EsTB5P8doW0lr1eH5iZKLPKWQO+tmy1VcdYr/TzqhdHSvGuJXrZvfTFi9/rV57Eq0d7964Ri9O0vQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-h+uyufselGT4afKMP8Lg4yUl5Kp+DJBlhu3XpWXhphE5Pnq5+f0uGBr4P+34CNcWxMsDnvagSQLFRCS4rGrOWA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-4fCwRCfTtUgS/5QcSEkSuBjgQymSOUWXgrXG2ycrf3Swi0QhKDA/pVjwLrUJ6eF+/8mQyQSEV72T8MxMO3M2qg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.15", "", { "os": "linux", "cpu": "x64" }, "sha512-jx+NImPq4wSp3Apfe7tlixiEJNnRyECTRJRWhGF6ZJz4PwFfgK2UHZKYR0DZHbV8nYawoDNQPJDXEWcoZShnMg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.16", "", { "os": "linux", "cpu": "x64" }, "sha512-KgQBGjiucw4e7gM+R8qOzHWBFhjCY1IfCrGjW3Wzxv2hKUlL+mPhelaeJwnEqtNxMUdVTYjlwlu3IHxslXMJWQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-2SQQLvf3sgmToxrNika9AdcccKrjPJEn5jW6sSv0oEixNBzUzW41vSZZG4LM/V3lL8eg0LoYDnRZeKLB4gwSqQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-C6WqEI3VkXatXraMgSFXZjEXq0pzURGjRpFAJZYmuVDmpqE57o7E80Np2UkdZ6m5kpJDt4mRyu3krc/P825iNQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.15", "", { "os": "win32", "cpu": "x64" }, "sha512-SVMVgnC7LVEm+yVZKdmmhRBj/xAT94PanT+UCcHxaCWK+OLmv/AX+ohHq2m0odup6iXcEqj+7mAltO9fgJLFIg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.16", "", { "os": "win32", "cpu": "x64" }, "sha512-kCX3CMTns6DMCFDNTDV4sjmBKyA/iEvzaVhl/jYi4JRIVT2zcy1lo+lhXT5mPgYHmJZu8Uye6j3Zi3c7Z2Me5A=="],
"@opentui/keymap": ["@opentui/keymap@0.2.15", "", { "dependencies": { "@opentui/core": "0.2.15" }, "peerDependencies": { "@opentui/react": "0.2.15", "@opentui/solid": "0.2.15", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-wqQp6y7P2jZZJiOMwupxjGryuSWCs+njjglwW/xny9J17gomBmUvTIcIIWNG0Jv+EGO9ScBzCScGlwBHFhHyYw=="],
"@opentui/keymap": ["@opentui/keymap@0.2.16", "", { "dependencies": { "@opentui/core": "0.2.16" }, "peerDependencies": { "@opentui/react": "0.2.16", "@opentui/solid": "0.2.16", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-YBLQfNLbU2kx49bjEY9rrFoNlvIoi5qNJfRcOt6frvnR3C6MLl0/8hZY+vMQ2PEQWeEiNejFnl1lQw+z4Nk2FQ=="],
"@opentui/solid": ["@opentui/solid@0.2.15", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.15", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-CViepAjsCWXwrLndMt+qlLo7cooVX7DXwSJHNizw7mfrRJtOPzSYJZCIk1vF4IJTWffCHygoYMe3uSeKvzAcbw=="],
"@opentui/solid": ["@opentui/solid@0.2.16", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.16", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2Q+v1PPpXXr+sALi9Aj6I5Jvo7xDfbmstYjRLL7lW3Hghh9i7ONQKpt/gyDDRbhSsYrhxKYTNenF9OxgoXkTHg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -5138,7 +5141,7 @@
"write-file-atomic": ["write-file-atomic@7.0.1", "", { "dependencies": { "signal-exit": "^4.0.1" } }, "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg=="],
"ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
@@ -5496,6 +5499,8 @@
"@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"@cloudflare/vite-plugin/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
@@ -5958,6 +5963,8 @@
"miniflare/undici": ["undici@7.14.0", "", {}, "sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ=="],
"miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"miniflare/zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
"minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
@@ -6074,6 +6081,8 @@
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"storybook/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"storybook-solidjs-vite/vite-plugin-solid": ["vite-plugin-solid@2.11.12", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
+2 -2
View File
@@ -209,8 +209,8 @@ const ingestConfig = new sst.Linkable("LakeIngestConfig", {
const ingestService = new sst.aws.Service("LakeIngestService", {
cluster: lakeCluster,
architecture: "arm64",
cpu: "0.5 vCPU",
memory: "1 GB",
cpu: "1 vCPU",
memory: "4 GB",
image: {
context: ".",
dockerfile: "packages/stats/server/Dockerfile",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-FT8N4SBP7OmVu73OwNyPJvBoxFd2+IXzNnFubB8y6J0=",
"aarch64-linux": "sha256-rGst01voY1/qr/fIMEgfL2zep/+W2RhjJh5pKs+/P3s=",
"aarch64-darwin": "sha256-+oTdR3CuR88Ra3QQsDn7ixNwJf0YjOxySTSiRsPPUOg=",
"x86_64-darwin": "sha256-3ZkB83KWtp6N7u9xhzmCouv27UuisXYvr2SBap4k++E="
"x86_64-linux": "sha256-70om2TEJYJJOcf9lqex+Sxs27c1SVVgN7FNkLbTXBR0=",
"aarch64-linux": "sha256-/xTsjAFl6TmydJFXETctBoRePIFAkMyxv0HLWHFymJg=",
"aarch64-darwin": "sha256-/xbFzrnScm1x4xuz2rEJyyjockDMIIyqJtxm3Axghbg=",
"x86_64-darwin": "sha256-ZbJDPhmcvBlY80CPmzygw4S8mjdR+d/UyL+3R3nhZtM="
}
}
+4 -4
View File
@@ -10,7 +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:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"typecheck": "bun turbo typecheck",
@@ -38,9 +38,9 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.15",
"@opentui/keymap": "0.2.15",
"@opentui/solid": "0.2.15",
"@opentui/core": "0.2.16",
"@opentui/keymap": "0.2.16",
"@opentui/solid": "0.2.16",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -2,6 +2,7 @@ import { action, json, query, useAction, useSubmission } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import { Referral } from "@opencode-ai/console-core/referral.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { withActor } from "~/context/auth.withActor"
import { Modal } from "~/component/modal"
import { IconCheck, IconCopy } from "~/component/icon"
@@ -9,6 +10,7 @@ import { useI18n } from "~/context/i18n"
import { useLanguage } from "~/context/language"
import { formatResetTime, liteResetTimeKeys } from "~/lib/format-reset-time"
import { queryLiteSubscription } from "~/routes/workspace/[id]/go/lite-section"
import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite"
import "./go-referral.css"
type GoReferralSummary = Awaited<ReturnType<typeof Referral.summary>>
@@ -25,7 +27,21 @@ const emptyUsagePreview = {
export const queryGoReferral = query(async (workspaceID: string) => {
"use server"
return withActor(() => Referral.summary(), workspaceID)
return withActor(async () => {
const event = getRequestEvent()
const referralCode = referralCodeFromCookieHeader(event?.request.headers.get("cookie") ?? null)
if (referralCode) {
await Referral.createFromAccount({
accountID: Actor.account(),
referralCode,
}).catch((error) => {
console.error("Referral create failed", error)
})
event?.response.headers.append("set-cookie", clearReferralCookie())
}
return Referral.summary()
}, workspaceID)
}, "go.referral.get")
export const queryGoReferralUsagePreview = query(async (workspaceID: string, referralID?: string) => {
@@ -65,6 +81,8 @@ function rewardDescriptionKey(source: GoReferralReward["source"]) {
function rewardActionKey(reward: GoReferralReward, hasActiveGo: boolean) {
if (reward.status === "applied") return "workspace.referral.reward.action.applied" as const
if (reward.status === "pending" && reward.source === "inviter")
return "workspace.referral.reward.source.pendingInviter" as const
if (reward.status === "pending" || !hasActiveGo) return "workspace.referral.reward.action.subscribeUnlock" as const
return "workspace.referral.reward.action.view" as const
}
@@ -1,11 +1,9 @@
import { redirect } from "@solidjs/router"
import type { APIEvent } from "@solidjs/start/server"
import { Referral } from "@opencode-ai/console-core/referral.js"
import { AuthClient } from "~/context/auth"
import { useAuthSession } from "~/context/auth"
import { i18n } from "~/i18n"
import { localeFromRequest, route } from "~/lib/language"
import { clearReferralCookie, referralCodeFromCookieHeader } from "~/lib/referral-invite"
export async function GET(input: APIEvent) {
const url = new URL(input.request.url)
@@ -19,7 +17,6 @@ export async function GET(input: APIEvent) {
if (result.err) throw new Error(result.err.message)
const decoded = AuthClient.decode(result.tokens.access, {} as any)
if (decoded.err) throw new Error(decoded.err.message)
const referralCode = referralCodeFromCookieHeader(input.request.headers.get("cookie"))
const session = await useAuthSession()
const id = decoded.subject.properties.accountID
await session.update((value) => {
@@ -35,15 +32,8 @@ export async function GET(input: APIEvent) {
current: id,
}
})
if (decoded.subject.properties.newAccount && referralCode) {
await Referral.createFromAccount({ accountID: id, referralCode }).catch((error) => {
console.error("Referral create failed", error)
})
}
const next = url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "")
const response = redirect(route(locale, next))
if (referralCode) response.headers.append("set-cookie", clearReferralCookie())
return response
return redirect(route(locale, next))
} catch (e: any) {
return new Response(
JSON.stringify({
+2 -1
View File
@@ -64,11 +64,12 @@ function LimitsGraph(props: { href: string }) {
{ id: "glm-5.1", name: "GLM-5.1", req: 880, d: "100ms" },
{ id: "qwen3.7-max", name: "Qwen3.7 Max", req: 950, d: "280ms" },
{ id: "kimi-k2.6", name: "Kimi K2.6", req: 1150, d: "150ms" },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 1290, d: "150ms" },
{ id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "150ms" },
{ id: "qwen3.6-plus", name: "Qwen3.6 Plus", req: 3300, d: "280ms" },
{ id: "minimax-m2.7", name: "MiniMax M2.7", req: 3400, d: "300ms" },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "200ms" },
{ id: "qwen3.5-plus", name: "Qwen3.5 Plus", req: 10200, d: "360ms" },
{ id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" },
]
@@ -4,7 +4,6 @@ import { IconCopy, IconCheck } from "~/component/icon"
import { Key } from "@opencode-ai/console-core/key.js"
import { withActor } from "~/context/auth.withActor"
import { createStore } from "solid-js/store"
import { formatDateUTC, formatDateForTable } from "../../common"
import styles from "./key-section.module.css"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { useI18n } from "~/context/i18n"
@@ -124,7 +123,6 @@ export function KeySection() {
<th>{i18n.t("workspace.keys.table.name")}</th>
<th>{i18n.t("workspace.keys.table.key")}</th>
<th>{i18n.t("workspace.keys.table.createdBy")}</th>
<th>{i18n.t("workspace.keys.table.lastUsed")}</th>
<th></th>
</tr>
</thead>
@@ -156,9 +154,6 @@ export function KeySection() {
</Show>
</td>
<td data-slot="key-user-email">{key.email}</td>
<td data-slot="key-last-used" title={key.timeUsed ? formatDateUTC(key.timeUsed) : undefined}>
{key.timeUsed ? formatDateForTable(key.timeUsed) : "-"}
</td>
<td data-slot="key-actions">
<form action={removeKey} method="post">
<input type="hidden" name="id" value={key.id} />
@@ -506,7 +506,6 @@ export async function handler(
if (retry.retryCount !== MAX_FAILOVER_RETRIES) {
let topPriority = Infinity
const providers = allProviders
.filter((provider) => !provider.disabled)
.filter((provider) => provider.weight !== 0)
.filter((provider) => !retry.excludeProviders.includes(provider.id))
.filter((provider) => {
@@ -990,10 +989,6 @@ export async function handler(
return undefined
})(),
}),
db
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(and(eq(KeyTable.workspaceID, authInfo.workspaceID), eq(KeyTable.id, authInfo.apiKeyId))),
...(() => {
if (billingSource === "subscription") {
const plan = authInfo.billing.subscription!.plan
+1
View File
@@ -17,6 +17,7 @@ export default defineConfig({
],
server: {
allowedHosts: true,
port: 3001,
},
build: {
rollupOptions: {
+24 -4
View File
@@ -1,8 +1,8 @@
import { z } from "zod"
import { and, asc, eq, isNull, sql, Database } from "./drizzle"
import { and, asc, eq, inArray, isNull, sql, Database } from "./drizzle"
import { Actor } from "./actor"
import { Identifier } from "./identifier"
import { LiteTable } from "./schema/billing.sql"
import { LiteTable, PaymentTable } from "./schema/billing.sql"
import { ReferralCodeTable, ReferralRewardTable, ReferralTable } from "./schema/referral.sql"
import { AuthTable } from "./schema/auth.sql"
import { UserTable } from "./schema/user.sql"
@@ -318,6 +318,26 @@ export namespace Referral {
.then((rows) => rows[0])
if (selfReferral) throw new Error("Self-referral is not allowed")
const workspaceIDs = await tx
.select({ workspaceID: UserTable.workspaceID })
.from(UserTable)
.where(and(eq(UserTable.accountID, input.accountID), isNull(UserTable.timeDeleted)))
.then((rows) => rows.map((row) => row.workspaceID))
if (workspaceIDs.length === 0) return
const litePayment = await tx
.select({ id: PaymentTable.id })
.from(PaymentTable)
.where(
and(
inArray(PaymentTable.workspaceID, workspaceIDs),
isNull(PaymentTable.timeDeleted),
sql`JSON_UNQUOTE(JSON_EXTRACT(${PaymentTable.enrichment}, '$.type')) = 'lite'`,
),
)
.then((rows) => rows[0])
if (litePayment) return
const referralID = Identifier.create("referral")
await tx.insert(ReferralTable).ignore().values({
workspaceID: code.workspaceID,
@@ -355,7 +375,7 @@ export namespace Referral {
.from(ReferralTable)
.where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted)))
.then((rows) => rows[0])
if (!referral) throw new Error("Referral not found")
if (!referral) return
const result = await tx
.insert(ReferralRewardTable)
@@ -373,7 +393,7 @@ export namespace Referral {
},
])
if (result.rowsAffected === 0) throw new Error("Referral already completed")
if (result.rowsAffected === 0) return
})
}
+1
View File
@@ -29,6 +29,7 @@ export default defineConfig({
server: {
host: "0.0.0.0",
allowedHosts: true,
port: 3002,
},
worker: {
format: "es",
+2
View File
@@ -122,6 +122,7 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
"@types/ws": "8.18.1",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ai-gateway-provider": "3.1.2",
@@ -163,6 +164,7 @@
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
"ws": "8.21.0",
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:"
+10 -2
View File
@@ -11,6 +11,7 @@ import type {
} from "@opencode-ai/sdk/v2"
import { Effect } from "effect"
import { ACPNextSession } from "./session"
import { ACPNextPermission } from "./permission"
import {
duplicateRunningToolUpdate,
errorToolUpdate,
@@ -22,7 +23,8 @@ import {
const log = Log.create({ service: "acp-next-event" })
type Connection = Pick<AgentSideConnection, "sessionUpdate">
type Connection = Pick<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
type GlobalEventEnvelope = {
payload?: Event
}
@@ -40,6 +42,7 @@ export class Subscription {
private readonly abort = new AbortController()
private readonly shellSnapshots = new Map<string, string>()
private readonly toolStarts = new Set<string>()
private readonly permission: ACPNextPermission.Handler
private started = false
constructor(
@@ -48,7 +51,9 @@ export class Subscription {
connection: Connection
session: ACPNextSession.Interface
},
) {}
) {
this.permission = new ACPNextPermission.Handler(input)
}
start() {
if (this.started) return
@@ -65,6 +70,9 @@ export class Subscription {
async handle(event: Event) {
switch (event.type) {
case "permission.asked":
this.permission.handle(event)
return
case "message.part.updated":
return this.handlePartUpdated(event)
case "message.part.delta":
@@ -0,0 +1,145 @@
import type { AgentSideConnection, PermissionOption, RequestPermissionResponse } from "@agentclientprotocol/sdk"
import * as Log from "@opencode-ai/core/util/log"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { applyPatch } from "diff"
import { exists, readText } from "@/util/filesystem"
import type { ACPNextSession } from "./session"
import { toLocations, toToolKind, type ToolInput } from "./tool"
import { Effect } from "effect"
const log = Log.create({ service: "acp-next-permission" })
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type Reply = "once" | "always" | "reject"
type Connection = Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
const permissionOptions: PermissionOption[] = [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
]
export class Handler {
private readonly queues = new Map<string, Promise<void>>()
constructor(
private readonly input: {
sdk: OpencodeClient
connection: Connection
session: ACPNextSession.Interface
},
) {}
handle(event: PermissionEvent) {
const permission = event.properties
const previous = this.queues.get(permission.sessionID) ?? Promise.resolve()
const next = previous
.then(() => this.process(event))
.catch((error: unknown) => {
log.error("failed to handle permission", { error, permissionID: permission.id })
})
.finally(() => {
if (this.queues.get(permission.sessionID) === next) {
this.queues.delete(permission.sessionID)
}
})
this.queues.set(permission.sessionID, next)
}
private async process(event: PermissionEvent) {
const permission = event.properties
const session = await Effect.runPromise(this.input.session.tryGet(permission.sessionID))
if (!session) return
if (!this.input.connection.requestPermission) {
log.error("ACP connection cannot request permission", {
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.reply(permission.id, "reject", session.cwd)
return
}
const result = await this.input.connection
.requestPermission({
sessionId: permission.sessionID,
toolCall: {
toolCallId: permission.tool?.callID ?? permission.id,
status: "pending",
title: permission.permission,
rawInput: permission.metadata,
kind: toToolKind(permission.permission),
locations: toLocations(permission.permission, permission.metadata),
},
options: permissionOptions,
})
.catch(async (error: unknown) => {
log.error("failed to request permission from ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
await this.reply(permission.id, "reject", session.cwd)
return undefined
})
if (!result) return
const reply = selectedReply(result)
if (reply !== "once" && reply !== "always") {
await this.reply(permission.id, "reject", session.cwd)
return
}
if (permission.permission === "edit") {
await this.writeProposedEdit(session.id, permission.metadata).catch((error: unknown) => {
log.error("failed to write proposed edit through ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
})
})
}
await this.reply(permission.id, reply, session.cwd)
}
private async reply(requestID: string, reply: Reply, directory: string) {
await this.input.sdk.permission.reply({
requestID,
reply,
directory,
})
}
private async writeProposedEdit(sessionId: string, metadata: ToolInput) {
const filepath = stringValue(metadata.filepath)
const diff = stringValue(metadata.diff)
if (!filepath || !diff || !this.input.connection.writeTextFile) return
const content = (await exists(filepath)) ? await readText(filepath) : ""
const next = applyPatch(content, diff)
if (next === false) {
log.error("Failed to apply unified diff (context mismatch)")
return
}
void this.input.connection.writeTextFile({
sessionId,
path: filepath,
content: next,
})
}
}
function selectedReply(result: RequestPermissionResponse): Reply {
if (result.outcome.outcome !== "selected") return "reject"
if (result.outcome.optionId === "once" || result.outcome.optionId === "always") return result.outcome.optionId
return "reject"
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined
}
export * as ACPNextPermission from "./permission"
+42
View File
@@ -0,0 +1,42 @@
const enabled = process.env.OPENCODE_ACP_PROFILE === "1"
const started = performance.now()
export function mark(name: string, fields?: Record<string, string | number | boolean | undefined>) {
if (!enabled) return
write(`${name}.mark`, performance.now() - started, fields)
}
export function duration(
name: string,
startedAt: number,
fields?: Record<string, string | number | boolean | undefined>,
) {
if (!enabled) return
write(name, performance.now() - startedAt, fields)
}
export async function measure<T>(
name: string,
fn: () => Promise<T>,
fields?: Record<string, string | number | boolean | undefined>,
) {
if (!enabled) return fn()
const start = performance.now()
try {
return await fn()
} finally {
write(name, performance.now() - start, fields)
}
}
function write(name: string, durationMs: number, fields?: Record<string, string | number | boolean | undefined>) {
const extra = fields
? Object.entries(fields)
.filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined)
.map(([key, value]) => `${key}=${value}`)
.join(" ")
: ""
console.error(`[acp-profile] ${name} ${Math.round(durationMs)}ms${extra ? ` ${extra}` : ""}`)
}
export * as ACPNextProfile from "./profile"
+339 -99
View File
@@ -35,9 +35,12 @@ import type { Message, OpencodeClient, SessionMessageResponse } from "@opencode-
import { Context, Effect, Layer, ManagedRuntime } from "effect"
import * as ACPNextError from "./error"
import { buildConfigOptions, parseModelSelection } from "./config-option"
import { promptContentToParts } from "./content"
import { Directory } from "./directory"
import { ACPNextEvent } from "./event"
import { ACPNextSession } from "./session"
import { UsageService } from "./usage"
import { ACPNextProfile } from "./profile"
import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider"
import type { Command } from "@/command"
@@ -46,6 +49,8 @@ export const AuthMethodID = "opencode-login"
const log = Log.create({ service: "acp-next-service" })
export type Error = ACPNextError.Error
type ServiceConnection = Pick<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
export type Interface = {
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse, Error>
@@ -69,20 +74,23 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/AC
export function make(input: {
sdk: OpencodeClient
connection?: Pick<AgentSideConnection, "sessionUpdate">
connection?: ServiceConnection
directory?: Directory.Interface
session?: ACPNextSession.Interface
usage?: UsageService.Interface
eventSubscription?: (subscription: ACPNextEvent.Subscription) => void
}): Interface {
const session = input.session ?? makeSessionService()
const directoryService = input.directory ?? makeDirectoryService(input.sdk)
const registeredMcp = new Map<string, Set<string>>()
const sessionSnapshots = new Map<string, Directory.Snapshot>()
const events = input.connection
? ACPNextEvent.start({ sdk: input.sdk, connection: input.connection, session })
: undefined
if (events) input.eventSubscription?.(events)
const initialize = Effect.fn("ACPNext.initialize")(function* (params: InitializeRequest) {
const started = performance.now()
const authMethod: AuthMethod = {
description: "Run `opencode auth login` in the terminal",
name: "Login with opencode",
@@ -99,7 +107,7 @@ export function make(input: {
}
}
return {
const response = {
protocolVersion: 1,
agentCapabilities: {
loadSession: true,
@@ -124,6 +132,8 @@ export function make(input: {
version: InstallationVersion,
},
}
ACPNextProfile.duration("acp.initialize", started)
return response
})
const authenticate = Effect.fn("ACPNext.authenticate")(function* (params: AuthenticateRequest) {
@@ -134,15 +144,28 @@ export function make(input: {
})
const directorySnapshot = Effect.fn("ACPNext.directorySnapshot")(function* (cwd: string) {
return yield* directoryService.get(cwd)
const started = performance.now()
const snapshot = yield* directoryService.get(cwd)
ACPNextProfile.duration("acp.directory.snapshot", started)
return snapshot
})
const configSnapshot = Effect.fn("ACPNext.configSnapshot")(function* (state: ACPNextSession.Info) {
const snapshot = sessionSnapshots.get(state.id)
if (snapshot) return snapshot
const loaded = yield* directorySnapshot(state.cwd)
sessionSnapshots.set(state.id, loaded)
return loaded
})
const newSession = Effect.fn("ACPNext.newSession")(function* (params: NewSessionRequest) {
const started = performance.now()
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(
const created = yield* profiledRequest(
"acp.newSession.session.create",
() =>
input.sdk.session.create(
{
@@ -166,11 +189,12 @@ export function make(input: {
variant,
modeId,
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
yield* sendAvailableCommands(input.connection, state.id, snapshot)
return {
const response = {
sessionId: state.id,
configOptions: configOptions(snapshot, {
model: state.model ?? selected,
@@ -178,6 +202,8 @@ export function make(input: {
modeId: state.modeId,
}),
}
ACPNextProfile.duration("acp.newSession", started)
return response
})
const loadSession = Effect.fn("ACPNext.loadSession")(function* (params: LoadSessionRequest) {
@@ -204,6 +230,7 @@ export function make(input: {
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers)
yield* sendAvailableCommands(input.connection, state.id, snapshot)
@@ -232,21 +259,35 @@ export function make(input: {
),
"session",
)
const sorted = sessions.toSorted((a, b) => b.time.updated - a.time.updated)
const serverEntries = sessions.map(
(item): SessionInfo => ({
sessionId: item.id,
cwd: item.directory,
title: item.title,
updatedAt: new Date(item.time.updated).toISOString(),
}),
)
const liveEntries = (yield* session.list(params.cwd ?? undefined))
.filter((item) => !serverEntries.some((entry) => entry.sessionId === item.id))
.map(
(item): SessionInfo => ({
sessionId: item.id,
cwd: item.cwd,
updatedAt: item.createdAt.toISOString(),
}),
)
const sorted = [...liveEntries, ...serverEntries].toSorted(
(a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime(),
)
const filtered =
cursor === undefined || !Number.isFinite(cursor) ? sorted : sorted.filter((item) => item.time.updated < cursor)
cursor === undefined || !Number.isFinite(cursor)
? sorted
: sorted.filter((item) => new Date(item.updatedAt ?? 0).getTime() < cursor)
const page = filtered.slice(0, limit)
const last = page.at(-1)
return {
sessions: page.map(
(item): SessionInfo => ({
sessionId: item.id,
cwd: item.directory,
title: item.title,
updatedAt: new Date(item.time.updated).toISOString(),
}),
),
...(filtered.length > limit && last ? { nextCursor: String(last.time.updated) } : {}),
sessions: page,
...(filtered.length > limit && last ? { nextCursor: String(new Date(last.updatedAt ?? 0).getTime()) } : {}),
}
})
@@ -274,6 +315,7 @@ export function make(input: {
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
yield* sendAvailableCommands(input.connection, state.id, snapshot)
@@ -291,6 +333,7 @@ export function make(input: {
const closeSession = Effect.fn("ACPNext.closeSession")(function* (params: CloseSessionRequest) {
const removed = yield* session.remove(params.sessionId)
registeredMcp.delete(params.sessionId)
sessionSnapshots.delete(params.sessionId)
if (!removed) return {}
yield* request(
@@ -334,6 +377,7 @@ export function make(input: {
variant: restored.variant ?? selectVariant(snapshot, model),
modeId: restored.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined),
})
sessionSnapshots.set(state.id, snapshot)
yield* registerMcpServers(input.sdk, registeredMcp, params.cwd, state.id, params.mcpServers ?? [])
yield* sendAvailableCommands(input.connection, state.id, snapshot)
@@ -353,7 +397,7 @@ export function make(input: {
params: SetSessionConfigOptionRequest,
) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd)
const snapshot = yield* configSnapshot(current)
if (typeof params.value !== "string") {
return yield* new ACPNextError.InvalidConfigOptionError({ configId: params.configId })
}
@@ -408,7 +452,7 @@ export function make(input: {
const setSessionMode = Effect.fn("ACPNext.setSessionMode")(function* (params: SetSessionModeRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd)
const snapshot = yield* configSnapshot(current)
if (!snapshot.availableModes.some((mode) => mode.id === params.modeId)) {
return yield* new ACPNextError.InvalidModeError({ mode: params.modeId })
}
@@ -418,7 +462,7 @@ export function make(input: {
const setSessionModel = Effect.fn("ACPNext.setSessionModel")(function* (params: SetSessionModelRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd)
const snapshot = yield* configSnapshot(current)
const selected = yield* parseSelectedModel(snapshot, params.modelId)
yield* session
.setVariant(
@@ -443,8 +487,81 @@ export function make(input: {
setSessionConfigOption,
setSessionMode,
setSessionModel,
prompt: Effect.fn("ACPNext.prompt")(function* (_input: PromptRequest) {
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/prompt" })
prompt: Effect.fn("ACPNext.prompt")(function* (params: PromptRequest) {
const current = yield* session.get(params.sessionId)
const snapshot = yield* directorySnapshot(current.cwd)
const selected = current.model ?? selectDefaultModel(snapshot)
if (!current.model) {
yield* session.setModel(params.sessionId, selected)
}
const variant = current.variant ?? selectVariant(snapshot, selected)
const modeId = current.modeId ?? (snapshot.availableModes.length > 0 ? snapshot.defaultModeID : undefined)
const parts = promptContentToParts(params.prompt)
const command = detectSlashCommand(parts)
if (!command) {
const response = yield* request(
() =>
input.sdk.session.prompt(
{
sessionID: current.id,
model: {
providerID: selected.providerID,
modelID: selected.modelID,
},
...(variant ? { variant } : {}),
parts,
...(modeId ? { agent: modeId } : {}),
directory: current.cwd,
},
{ throwOnError: true },
),
"session",
)
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(response.info, params.messageId)
}
const known = snapshot.availableCommands.find((item) => item.name === command.name)
if (known) {
const response = yield* request(
() =>
input.sdk.session.command(
{
sessionID: current.id,
command: known.name,
arguments: command.args,
model: `${selected.providerID}/${selected.modelID}`,
...(variant ? { variant } : {}),
...(modeId ? { agent: modeId } : {}),
directory: current.cwd,
},
{ throwOnError: true },
),
"session",
)
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(response.info, params.messageId)
}
if (command.name === "compact") {
yield* request(
() =>
input.sdk.session.summarize(
{
sessionID: current.id,
directory: current.cwd,
providerID: selected.providerID,
modelID: selected.modelID,
},
{ throwOnError: true },
),
"session",
)
}
yield* sendUsageUpdate(input.usage, input.sdk, input.connection, current.id, current.cwd)
return promptResponse(undefined, params.messageId)
}),
cancel: Effect.fn("ACPNext.cancel")(function* (_input: CancelNotification) {
return yield* new ACPNextError.UnsupportedOperationError({ method: "session/cancel" })
@@ -473,6 +590,91 @@ function makeDirectoryService(sdk: OpencodeClient) {
).runSync(Directory.Service.use((service) => Effect.succeed(service)))
}
function makeUsageService(sdk: OpencodeClient) {
const limits = new Map<string, Promise<number | undefined>>()
const contextLimit: UsageService.Interface["contextLimit"] = Effect.fn("ACPNext.promptUsage.contextLimit")(
function* (params) {
const key = `${params.directory}\u0000${params.providerID}\u0000${params.modelID}`
const current = limits.get(key)
if (current) return yield* Effect.promise(() => current)
const next = sdk.config
.providers({ directory: params.directory }, { throwOnError: true })
.then((response) => {
const providers = Object.fromEntries(
(response.data?.providers ?? []).map((provider) => [provider.id, provider]),
) as Record<ProviderID, Provider.Info>
return UsageService.findContextLimit(providers, params.providerID, params.modelID)
})
.catch((error: unknown) => {
log.error("failed to get providers for usage context limit", { error })
return undefined
})
limits.set(key, next)
return yield* Effect.promise(() => next)
},
)
const sendUpdate: UsageService.Interface["sendUpdate"] = Effect.fn("ACPNext.promptUsage.sendUpdate")(
function* (params) {
const messages = yield* request(
() =>
sdk.session.messages(
{
sessionID: params.sessionID,
directory: params.directory,
},
{ throwOnError: true },
),
"session",
).pipe(
Effect.map((messages) => messages as readonly UsageService.SessionMessage[]),
Effect.catch((error) =>
Effect.sync(() => {
log.error("failed to fetch messages for usage update", { error })
return undefined
}),
),
)
if (!messages) return
const message = UsageService.latestAssistantMessage(messages)
if (!message?.providerID || !message.modelID) return
const size = yield* contextLimit({
directory: params.directory,
providerID: ProviderID.make(message.providerID),
modelID: ModelID.make(message.modelID),
})
if (!size) return
yield* Effect.promise(() =>
params.connection
.sessionUpdate({
sessionId: params.sessionID,
update: {
sessionUpdate: "usage_update",
used: message.tokens.input + message.tokens.cache.read,
size,
cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" },
},
})
.catch((error) => {
log.error("failed to send usage update", { error })
}),
)
},
)
return UsageService.Service.of({
buildUsage: UsageService.buildUsage,
latestAssistantMessage: UsageService.latestAssistantMessage,
totalSessionCost: UsageService.totalSessionCost,
contextLimit,
sendUpdate,
})
}
function replayMessages(subscription: ACPNextEvent.Subscription | undefined, messages: SessionMessageResponse[]) {
if (!subscription) return Effect.void
return Effect.promise(async () => {
@@ -505,6 +707,8 @@ type MessageInfo = {
readonly agent?: Message["agent"]
}
type AssistantInfo = UsageService.AssistantTokenCost | undefined
function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
return Effect.tryPromise({
try: async () => {
@@ -519,66 +723,79 @@ function request<T>(fn: () => Promise<T | SdkResponse<T>>, service?: string) {
})
}
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<
ProviderID,
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[]
function profiledRequest<T>(name: string, fn: () => Promise<T | SdkResponse<T>>, service?: string) {
return request(() => ACPNextProfile.measure(name, fn), service)
}
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 loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
return ACPNextProfile.measure("acp.directory.load", async () => {
const [providersResponse, agentsResponse, commandsResponse, skillsResponse, configResponse] = await Promise.all([
ACPNextProfile.measure("acp.directory.provider.list", () =>
sdk.config.providers({ directory }, { throwOnError: true }),
),
ACPNextProfile.measure("acp.directory.mode.defaultAgent.load", () =>
sdk.app.agents({ directory }, { throwOnError: true }),
),
ACPNextProfile.measure("acp.directory.command.list", () =>
sdk.command.list({ directory }, { throwOnError: true }),
),
ACPNextProfile.measure("acp.directory.skill.list", () => sdk.app.skills({ directory }, { throwOnError: true })),
ACPNextProfile.measure("acp.directory.defaultModel.config", () =>
sdk.config.get({ directory }, { throwOnError: true }).catch(() => undefined),
),
])
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<
ProviderID,
Provider.Info
>
const defaultModelStarted = performance.now()
const defaultModel = defaultModelFromConfig(configResponse?.data?.model, providers)
ACPNextProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel })
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,
function defaultModelFromConfig(
configuredModel: string | undefined,
providers: Record<ProviderID, 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)
): Directory.DefaultModel | undefined {
const configured = configuredModel ? Provider.parseModel(configuredModel) : undefined
if (configured && providers[configured.providerID]?.models[configured.modelID]) return configured
const lastUsed = await lastUsedModel(sdk, directory, providers)
if (lastUsed) return lastUsed
// First-session ACP startup must not scan historical sessions just to infer
// a default. Configured model, opencode provider, then sorted best model keep
// the protocol response deterministic without extra session/message reads.
const opencodeProvider = providers[ProviderID.make("opencode")]
const opencodeModel = opencodeProvider ? Provider.sort(Object.values(opencodeProvider.models))[0] : undefined
if (opencodeProvider && opencodeModel) return { providerID: opencodeProvider.id, modelID: opencodeModel.id }
@@ -588,30 +805,6 @@ async function defaultModelFromSdk(
if (configured) return configured
}
async function lastUsedModel(
sdk: OpencodeClient,
directory: string,
providers: Record<ProviderID, 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[ProviderID.make(lastUser.model.providerID)]?.models[ModelID.make(lastUser.model.modelID)]) return
return {
providerID: ProviderID.make(lastUser.model.providerID),
modelID: ModelID.make(lastUser.model.modelID),
}
}
function selectDefaultModel(snapshot: Directory.Snapshot) {
if (snapshot.defaultModel) return snapshot.defaultModel
const model = snapshot.modelOptions[0]
@@ -619,6 +812,43 @@ function selectDefaultModel(snapshot: Directory.Snapshot) {
return { providerID: "unknown" as ProviderID, modelID: "unknown" as ModelID }
}
function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
const text = parts
.filter((part): part is Extract<(typeof parts)[number], { type: "text" }> => part.type === "text")
.map((part) => part.text)
.join("")
.trim()
if (!text.startsWith("/")) return
const [name, ...rest] = text.slice(1).split(/\s+/)
if (!name) return
return { name, args: rest.join(" ").trim() }
}
function promptResponse(info: AssistantInfo, messageId: string | null | undefined): PromptResponse {
return {
stopReason: "end_turn",
...(info ? { usage: UsageService.buildUsage(info) } : {}),
...(messageId ? { userMessageId: messageId } : {}),
_meta: {},
}
}
function sendUsageUpdate(
usage: UsageService.Interface | undefined,
sdk: OpencodeClient,
connection: ServiceConnection | undefined,
sessionID: string,
directory: string,
) {
if (!connection) return Effect.void
return (usage ?? makeUsageService(sdk)).sendUpdate({
connection,
sessionID,
directory,
})
}
function selectVariant(snapshot: Directory.Snapshot, model: Directory.DefaultModel) {
const variants = Directory.variants(snapshot, model)
if (!variants) return
@@ -689,6 +919,7 @@ function registerMcpServers(
sessionId: string,
servers: readonly McpServer[],
) {
const started = performance.now()
const current = registered.get(sessionId) ?? new Set<string>()
registered.set(sessionId, current)
const pending = new Set<string>()
@@ -720,7 +951,16 @@ function registerMcpServers(
),
),
{ concurrency: "unbounded" },
).pipe(Effect.asVoid)
).pipe(
Effect.tap(() =>
Effect.sync(() =>
ACPNextProfile.duration("acp.mcp.register", started, {
count: pending.size,
}),
),
),
Effect.asVoid,
)
}
function mcpRegistrationKey(name: string, config: ReturnType<typeof mcpConfig>) {
@@ -60,6 +60,7 @@ export type PartMetadataLookupInput = {
export type Interface = {
readonly create: (input: StoreInput) => Effect.Effect<Info>
readonly load: (input: StoreInput) => Effect.Effect<Info>
readonly list: (cwd?: string) => Effect.Effect<readonly 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>
@@ -168,6 +169,12 @@ export const layer = Layer.effect(
return Service.of({
create: store,
load: store,
list: Effect.fn("ACPNext.Session.list")(function* (cwd?: string) {
return [...(yield* Ref.get(sessions)).values()]
.filter((session) => !cwd || session.cwd === cwd)
.map(snapshot)
.toSorted((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
}),
get,
tryGet,
remove,
+1 -1
View File
@@ -74,7 +74,7 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio
case "read":
case "edit":
case "write":
return locationFrom(input.filePath)
return locationFrom(input.filePath ?? input.filepath)
case "grep":
case "glob":
+6 -1
View File
@@ -9,6 +9,7 @@ import { ServerAuth } from "@/server/auth"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { ACPNextProfile } from "@/acp-next/profile"
const log = Log.create({ service: "acp-command" })
@@ -23,10 +24,13 @@ export const AcpCommand = effectCmd({
})
},
handler: Effect.fn("Cli.acp")(function* (args) {
ACPNextProfile.mark("cli.acp.handler")
process.env.OPENCODE_CLIENT = "acp"
const flags = yield* RuntimeFlags.Service
const opts = yield* resolveNetworkOptions(args)
const server = yield* Effect.promise(() => Server.listen(opts))
const server = yield* Effect.promise(() =>
ACPNextProfile.measure("cli.acp.server.listen", () => Server.listen(opts)),
)
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,
@@ -60,6 +64,7 @@ export const AcpCommand = effectCmd({
const agent = flags.acpNext ? ACPNext.init({ sdk }) : ACP.init({ sdk })
new AgentSideConnection((conn) => {
ACPNextProfile.mark("cli.acp.connection.create", { acpNext: flags.acpNext })
return agent.create(conn, { sdk })
}, stream)
+21 -2
View File
@@ -41,6 +41,7 @@ import { DialogThemeList } from "@tui/component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { DialogConsoleOrg } from "@tui/component/dialog-console-org"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
@@ -79,8 +80,7 @@ import {
import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant"
const appBindingCommands = [
"command.palette.show",
const appGlobalBindingCommands = [
"session.list",
"session.new",
"session.quick_switch.1",
@@ -92,6 +92,10 @@ const appBindingCommands = [
"session.quick_switch.7",
"session.quick_switch.8",
"session.quick_switch.9",
] as const
const appBindingCommands = [
"command.palette.show",
"model.list",
"model.cycle_recent",
"model.cycle_recent_reverse",
@@ -111,6 +115,7 @@ const appBindingCommands = [
"theme.mode.lock",
"help.show",
"docs.open",
"workspace.list",
"app.debug",
"app.console",
"app.heap_snapshot",
@@ -603,6 +608,16 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
dialog.clear()
},
},
{
name: "workspace.list",
title: "Manage workspaces",
category: "Workspace",
hidden: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
slashName: "workspaces",
run: () => {
dialog.replace(() => <DialogWorkspaceList />)
},
},
...Array.from({ length: 9 }, (_, i) => ({
name: `session.quick_switch.${i + 1}`,
title: `Switch to session in quick slot ${i + 1}`,
@@ -929,6 +944,10 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
}))
useBindings(() => ({
bindings: tuiConfig.keybinds.gather("app.global", appGlobalBindingCommands),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
enabled: () => {
@@ -0,0 +1,112 @@
import type { Workspace } from "@opencode-ai/sdk/v2"
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
import { useProject } from "@tui/context/project"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { useTheme } from "@tui/context/theme"
import { createMemo, createSignal, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { errorMessage } from "@/util/error"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
type WorkspaceOption = { workspace: Workspace }
export function DialogWorkspaceList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const project = useProject()
const { theme } = useTheme()
const [deleting, setDeleting] = createSignal<string>()
const [removing, setRemoving] = createSignal<string>()
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
const current = createMemo(() => {
if (route.data.type === "session") return sync.session.get(route.data.sessionID)?.workspaceID
return project.workspace.current()
})
const options = createMemo<DialogSelectOption<WorkspaceOption>[]>(() =>
project.workspace
.list()
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((workspace) => {
const status = project.workspace.status(workspace.id)
return {
title:
removing() === workspace.id
? "Deleting..."
: deleting() === workspace.id
? `Delete ${workspace.name}? Press delete again`
: workspace.name,
value: { workspace },
footer: workspace.type,
details: expanded[workspace.id] && workspace.directory ? [workspace.directory] : undefined,
gutter: () => <text fg={status === "connected" ? theme.success : theme.error}></text>,
}
}),
)
function showDetails(workspace: Workspace) {
setExpanded(workspace.id, (open) => !open)
}
async function remove(workspace: Workspace) {
if (removing()) return
if (deleting() !== workspace.id) {
setDeleting(workspace.id)
return
}
setDeleting(undefined)
setRemoving(workspace.id)
const result = await sdk.client.experimental.workspace.remove({ id: workspace.id }).catch((err) => ({
error: err,
}))
if (result?.error) {
setRemoving(undefined)
toast.show({
variant: "error",
title: "Failed to delete workspace",
message: errorMessage(result.error),
})
return
}
if (current() === workspace.id) {
project.workspace.set(undefined)
route.navigate({ type: "home" })
}
await project.workspace.sync()
await sync.bootstrap({ fatal: false }).catch(() => undefined)
setRemoving(undefined)
}
onMount(() => {
dialog.setSize("large")
void sdk.client.experimental.workspace.syncList().catch(() => undefined)
void project.workspace.sync()
})
return (
<DialogSelect
title="Workspaces"
options={options()}
onMove={(option) => {
setDeleting(undefined)
}}
onSelect={(option) => showDetails(option.value.workspace)}
actions={[
{
command: "session.delete",
title: "delete",
onTrigger: (option) => void remove(option.value.workspace),
},
]}
/>
)
}
@@ -442,16 +442,25 @@ function ReasoningHeader(props: { toggleable: boolean; open: boolean; done: bool
: theme.warning
return (
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>{props.done ? "Thought" : "Thinking"}</span>
<Show when={props.title}>
<span>: </span>
<span>{props.title}</span>
</Show>
</text>
<Switch>
<Match when={!props.done}>
<box flexDirection="row">
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
</box>
</Match>
<Match when={true}>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>Thought</span>
<Show when={props.title}>
<span>: </span>
<span>{props.title}</span>
</Show>
</text>
</Match>
</Switch>
)
}
@@ -54,6 +54,7 @@ import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useEditorContext } from "@tui/context/editor"
import { useDialog } from "../../ui/dialog"
import { DialogAlert } from "../../ui/dialog-alert"
import { TodoItem } from "../../component/todo-item"
import { DialogMessage } from "./dialog-message"
import type { PromptInfo } from "../../component/prompt/history"
@@ -133,12 +134,6 @@ const sessionBindingCommands = [
"session.toggle.actions",
"session.toggle.scrollbar",
"session.toggle.generic_tool_output",
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
"session.first",
"session.last",
"session.messages_last_user",
@@ -153,6 +148,17 @@ const sessionBindingCommands = [
"session.child.previous",
] as const
const sessionGlobalBindingCommands = [
"session.page.up",
"session.page.down",
"session.line.up",
"session.line.down",
"session.half.page.up",
"session.half.page.down",
] as const
const sessionGlobalUnfocusedBindingCommands = ["session.first", "session.last"] as const
const context = createContext<{
width: number
sessionID: string
@@ -409,15 +415,19 @@ export function Session() {
const local = useLocal()
function enterChild(sessionID: string) {
navigate({
type: "session",
sessionID,
})
const status = sync.data.session_status[sessionID]
if (status?.type === "retry") void DialogAlert.show(dialog, "Retry Error", status.message)
}
function moveFirstChild() {
if (children().length === 1) return
const next = children().find((x) => !!x.parentID)
if (next) {
navigate({
type: "session",
sessionID: next.id,
})
}
if (next) enterChild(next.id)
}
function moveChild(direction: number) {
@@ -428,12 +438,7 @@ export function Session() {
if (next >= sessions.length) next = 0
if (next < 0) next = sessions.length - 1
if (sessions[next]) {
navigate({
type: "session",
sessionID: sessions[next].id,
})
}
if (sessions[next]) enterChild(sessions[next].id)
}
function childSessionHandler(func: () => void) {
@@ -1007,8 +1012,8 @@ export function Session() {
category: "Session",
hidden: true,
run: () => {
moveFirstChild()
dialog.clear()
moveFirstChild()
},
},
{
@@ -1035,8 +1040,8 @@ export function Session() {
hidden: true,
enabled: !!session()?.parentID,
run: childSessionHandler(() => {
moveChild(1)
dialog.clear()
moveChild(1)
}),
},
{
@@ -1046,8 +1051,8 @@ export function Session() {
hidden: true,
enabled: !!session()?.parentID,
run: childSessionHandler(() => {
moveChild(-1)
dialog.clear()
moveChild(-1)
}),
},
])
@@ -1067,6 +1072,15 @@ export function Session() {
commands: sessionCommands(),
}))
useBindings(() => ({
bindings: tuiConfig.keybinds.gather("session.global", sessionGlobalBindingCommands),
}))
useBindings(() => ({
enabled: () => renderer.currentFocusedEditor === null,
bindings: tuiConfig.keybinds.gather("session.global.unfocused", sessionGlobalUnfocusedBindingCommands),
}))
useBindings(() => ({
mode: OPENCODE_BASE_MODE,
bindings: tuiConfig.keybinds.gather("session", sessionBindingCommands),
@@ -1581,24 +1595,33 @@ function ReasoningHeader(props: {
: theme.warning
return (
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>{props.done ? "Thought" : "Thinking"}</span>
<Show when={props.title || props.duration}>
<span>: </span>
</Show>
<Show when={props.title}>
<span>{props.title}</span>
</Show>
<Show when={props.duration}>
<span>
{props.title ? " · " : ""}
{props.duration}
</span>
</Show>
</text>
<Switch>
<Match when={!props.done}>
<box flexDirection="row">
<Spinner color={fg()}>{props.title ? "Thinking: " + props.title : "Thinking"}</Spinner>
</box>
</Match>
<Match when={true}>
<text fg={fg()} wrapMode="none">
<Show when={props.toggleable}>
<span>{props.open ? "- " : "+ "}</span>
</Show>
<span>Thought</span>
<Show when={props.title || props.duration}>
<span>: </span>
</Show>
<Show when={props.title}>
<span>{props.title}</span>
</Show>
<Show when={props.duration}>
<span>
{props.title ? " · " : ""}
{props.duration}
</span>
</Show>
</text>
</Match>
</Switch>
)
}
@@ -1758,6 +1781,7 @@ function GenericTool(props: ToolProps<any>) {
function InlineTool(props: {
icon: string
iconColor?: RGBA
color?: RGBA
complete: any
pending: string
spinner?: boolean
@@ -1779,6 +1803,7 @@ function InlineTool(props: {
})
const fg = createMemo(() => {
if (props.color) return props.color
if (permission()) return theme.warning
if (hover() && props.onClick) return theme.text
if (props.complete) return theme.textMuted
@@ -2063,8 +2088,10 @@ function WebSearch(props: ToolProps<typeof WebSearchTool>) {
}
function Task(props: ToolProps<typeof TaskTool>) {
const { theme } = useTheme()
const { navigate } = useRoute()
const sync = useSync()
const dialog = useDialog()
onMount(() => {
if (props.metadata.sessionId && !sync.data.message[props.metadata.sessionId]?.length)
@@ -2086,6 +2113,11 @@ function Task(props: ToolProps<typeof TaskTool>) {
)
const isRunning = createMemo(() => props.part.state.status === "running")
const retry = createMemo(() => {
const status = sync.data.session_status[props.metadata.sessionId ?? ""]
if (status?.type !== "retry") return
return status
})
const duration = createMemo(() => {
const first = messages().find((x) => x.role === "user")?.time.created
@@ -2100,7 +2132,10 @@ function Task(props: ToolProps<typeof TaskTool>) {
props.metadata.background === true ? `${props.input.description} (background)` : props.input.description
let content = [`${Locale.titlecase(props.input.subagent_type ?? "General")} Task — ${description}`]
if (isRunning() && tools().length > 0) {
const retrying = retry()
if (isRunning() && retrying) {
content.push(`${Locale.truncate(retrying.message, 80)} [retrying attempt #${retrying.attempt}]`)
} else if (isRunning() && tools().length > 0) {
// content[0] += ` · ${tools().length} toolcalls`
if (current()) {
const state = current()!.state
@@ -2123,6 +2158,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
return (
<InlineTool
icon="│"
color={retry() ? theme.error : undefined}
spinner={isRunning()}
complete={props.input.description}
pending="Delegating..."
@@ -2131,6 +2167,8 @@ function Task(props: ToolProps<typeof TaskTool>) {
if (props.metadata.sessionId) {
navigate({ type: "session", sessionID: props.metadata.sessionId })
}
const status = retry()
if (status) void DialogAlert.show(dialog, "Retry Error", status.message)
}}
>
{content()}
@@ -51,6 +51,7 @@ export interface DialogSelectOption<T = any> {
title: string
value: T
description?: string
details?: string[]
footer?: JSX.Element | string
category?: string
categoryView?: JSX.Element
@@ -167,7 +168,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (!category) return acc
return acc + (i > 0 ? 2 : 1)
}, 0)
return flat().length + headers
return flat().reduce((acc, option) => acc + 1 + (option.details?.length ?? 0), headers)
})
const dimensions = useTerminalDimensions()
@@ -426,7 +427,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return (
<box
id={JSON.stringify(option.value)}
flexDirection="row"
flexDirection="column"
position="relative"
onMouseMove={() => {
setStore("input", "mouse")
@@ -446,24 +447,37 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
if (index === -1) return
moveTo(index)
}}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={current() || option.gutter ? 1 : 3}
paddingRight={3}
gap={1}
>
<Show when={!current() && option.margin}>
<box position="absolute" left={1} flexShrink={0}>
{option.margin}
</box>
</Show>
<Option
title={option.title}
footer={flatten() ? (option.category ?? option.footer) : option.footer}
description={option.description !== category ? option.description : undefined}
active={active()}
current={current()}
gutter={option.gutter}
/>
<box
flexDirection="row"
paddingLeft={current() || option.gutter ? 1 : 3}
paddingRight={3}
gap={1}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
>
<Show when={!current() && option.margin}>
<box position="absolute" left={1} flexShrink={0}>
{option.margin}
</box>
</Show>
<Option
title={option.title}
footer={flatten() ? (option.category ?? option.footer) : option.footer}
description={option.description !== category ? option.description : undefined}
active={active()}
current={current()}
gutter={option.gutter}
/>
</box>
<For each={option.details}>
{(detail) => (
<box paddingLeft={3} paddingRight={3}>
<text fg={theme.textMuted} wrapMode="none">
{Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))}
</text>
</box>
)}
</For>
</box>
)
}}
@@ -54,6 +54,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
experimentalWebSockets: bool("OPENCODE_EXPERIMENTAL_WEBSOCKETS"),
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
}) {}
+24 -13
View File
@@ -10,7 +10,7 @@ import { Bus } from "../bus"
import * as Log from "@opencode-ai/core/util/log"
import { createOpencodeClient } from "@opencode-ai/sdk"
import { ServerAuth } from "@/server/auth"
import { CodexAuthPlugin } from "./codex"
import { CodexAuthPlugin } from "./openai/codex"
import { Session } from "@/session/session"
import { NamedError } from "@opencode-ai/core/util/error"
import { CopilotAuthPlugin } from "./github-copilot/copilot"
@@ -29,6 +29,7 @@ import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } fro
import { registerAdapter } from "@/control-plane/adapters"
import type { WorkspaceAdapter } from "@/control-plane/types"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { InstallationChannel } from "@opencode-ai/core/installation/version"
const log = Log.create({ service: "plugin" })
@@ -57,18 +58,28 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
export function experimentalWebSocketsEnabled(input: { enabled: boolean; channel?: string }) {
return input.enabled || ["local", "dev", "beta"].includes(input.channel ?? InstallationChannel)
}
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [
CodexAuthPlugin,
CopilotAuthPlugin,
GitlabAuthPlugin,
PoeAuthPlugin,
CloudflareWorkersAuthPlugin,
CloudflareAIGatewayAuthPlugin,
AzureAuthPlugin,
DigitalOceanAuthPlugin,
XaiAuthPlugin,
]
function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] {
return [
// Temporary rollout: pre-release builds use WebSockets by default; releases require explicit opt-in.
(input) =>
CodexAuthPlugin(input, {
experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }),
}),
CopilotAuthPlugin,
GitlabAuthPlugin,
PoeAuthPlugin,
CloudflareWorkersAuthPlugin,
CloudflareAIGatewayAuthPlugin,
AzureAuthPlugin,
DigitalOceanAuthPlugin,
XaiAuthPlugin,
]
}
function isServerPlugin(value: unknown): value is PluginInstance {
return typeof value === "function"
@@ -151,7 +162,7 @@ export const layer = Layer.effect(
$: typeof Bun === "undefined" ? undefined : Bun.$,
}
for (const plugin of flags.disableDefaultPlugins ? [] : INTERNAL_PLUGINS) {
for (const plugin of flags.disableDefaultPlugins ? [] : internalPlugins(flags)) {
log.info("loading internal plugin", { name: plugin.name })
const init = yield* Effect.tryPromise({
try: () => plugin(input),
@@ -0,0 +1,31 @@
# OpenAI Responses WebSocket
Enabled by default on `local`, `dev`, and `beta`. On `latest` and `prod`, set `OPENCODE_EXPERIMENTAL_WEBSOCKETS=true`.
## Flow
1. A streamed `POST /responses` request arrives.
2. If it has no `session-id` or `x-session-affinity` header, use HTTP.
3. Title requests use HTTP.
4. If that session's socket is busy or already in fallback mode, use HTTP.
5. Otherwise, reuse its open socket or open a new one.
6. Send `response.create` and return WebSocket events as SSE.
## Lifetime
- Connect timeout: 15 seconds.
- Idle timeout: 5 minutes.
- After a completed response, keep the socket for reuse.
- Reuse a socket for up to 55 minutes, then replace it on the next request.
## Retries
- Retry WebSocket stream/setup failures up to 5 times, then use HTTP for that session until the pool entry is idle-pruned.
- `websocket_connection_limit_reached` consumes the same retry budget and HTTP fallback.
- If a WebSocket fails after its first event, fail it as retryable rather than replaying partial output in transport.
- Abort or cancel closes the socket.
## Next Steps
- `previous_response_id` continuation.
- Optional second WebSocket for concurrent requests in one session. Currently these use HTTP.
@@ -1,10 +1,11 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import * as Log from "@opencode-ai/core/util/log"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OAUTH_DUMMY_KEY } from "../auth"
import { OAUTH_DUMMY_KEY } from "../../auth"
import os from "os"
import { setTimeout as sleep } from "node:timers/promises"
import { createServer } from "http"
import { OpenAIWebSocketPool } from "./ws-pool"
const log = Log.create({ service: "plugin.codex" })
@@ -28,20 +29,12 @@ interface PkceCodes {
}
async function generatePKCE(): Promise<PkceCodes> {
const verifier = generateRandomString(43)
const encoder = new TextEncoder()
const data = encoder.encode(verifier)
const hash = await crypto.subtle.digest("SHA-256", data)
const challenge = base64UrlEncode(hash)
return { verifier, challenge }
}
function generateRandomString(length: number): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
const bytes = crypto.getRandomValues(new Uint8Array(length))
return Array.from(bytes)
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)))
.map((b) => chars[b % chars.length])
.join("")
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
return { verifier, challenge }
}
function base64UrlEncode(buffer: ArrayBuffer): string {
@@ -50,10 +43,6 @@ function base64UrlEncode(buffer: ArrayBuffer): string {
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
function generateState(): string {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
}
export interface IdTokenClaims {
chatgpt_account_id?: string
organizations?: Array<{ id: string }>
@@ -120,6 +109,7 @@ interface TokenResponse {
interface CodexAuthPluginOptions {
issuer?: string
codexApiEndpoint?: string
experimentalWebSockets?: boolean
}
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
@@ -371,8 +361,14 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPluginOptions = {}): Promise<Hooks> {
const issuer = options.issuer ?? ISSUER
const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT
let websocketFetchInstalled = false
const websocketFetches: Array<ReturnType<typeof OpenAIWebSocketPool.createWebSocketFetch>> = []
return {
async dispose() {
for (const websocketFetch of websocketFetches) websocketFetch.close()
websocketFetches.length = 0
},
provider: {
id: "openai",
async models(provider, ctx) {
@@ -410,7 +406,14 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
provider: "openai",
async loader(getAuth) {
const auth = await getAuth()
if (auth.type !== "oauth") return {}
const websocketFetch = options.experimentalWebSockets
? OpenAIWebSocketPool.createWebSocketFetch({ httpFetch: fetch })
: undefined
if (websocketFetch) {
websocketFetches.push(websocketFetch)
websocketFetchInstalled = true
}
if (auth.type !== "oauth") return websocketFetch ? { fetch: websocketFetch } : {}
let refreshPromise:
| Promise<{
@@ -422,7 +425,6 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
return {
apiKey: OAUTH_DUMMY_KEY,
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
// Remove dummy API key authorization header
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.delete("authorization")
@@ -436,12 +438,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
}
const currentAuth = await getAuth()
if (currentAuth.type !== "oauth") return fetch(requestInput, init)
if (currentAuth.type !== "oauth")
return websocketFetch ? websocketFetch(requestInput, init) : fetch(requestInput, init)
// Cast to include accountId field
const authWithAccount = currentAuth as typeof currentAuth & { accountId?: string }
// Check if token needs refresh
if (!currentAuth.access || currentAuth.expires < Date.now()) {
if (!refreshPromise) {
log.info("refreshing codex access token")
@@ -473,7 +474,6 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
authWithAccount.accountId = refreshed.accountId
}
// Build headers
const headers = new Headers()
if (init?.headers) {
if (init.headers instanceof Headers) {
@@ -488,16 +488,11 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
}
}
}
// Set authorization header with access token
headers.set("authorization", `Bearer ${currentAuth.access}`)
// Set ChatGPT-Account-Id header for organization subscriptions
if (authWithAccount.accountId) {
headers.set("ChatGPT-Account-Id", authWithAccount.accountId)
}
// Rewrite URL to Codex endpoint
const parsed =
requestInput instanceof URL
? requestInput
@@ -507,10 +502,12 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
? new URL(codexApiEndpoint)
: parsed
return fetch(url, {
const requestInit = {
...init,
headers,
})
}
if (websocketFetch && parsed.pathname.endsWith("/responses")) return websocketFetch(url, requestInit)
return fetch(url, OpenAIWebSocketPool.withoutInternalHeaders(requestInit))
},
}
},
@@ -521,7 +518,7 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
authorize: async () => {
const { redirectUri } = await startOAuthServer()
const pkce = await generatePKCE()
const state = generateState()
const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
const authUrl = buildAuthorizeUrl(redirectUri, pkce, state)
const callbackPromise = waitForOAuthCallback(pkce, state)
@@ -639,6 +636,10 @@ export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPlug
output.headers.originator = "opencode"
output.headers["User-Agent"] = `opencode/${InstallationVersion} (${os.platform()} ${os.release()}; ${os.arch()})`
output.headers["session-id"] = input.sessionID
// Temporary fetch-layer hack: title generation currently shares the conversation
// session ID, so the OpenAI plugin marks it for HTTP fallback until transport
// context can be passed directly instead of smuggled through headers.
if (websocketFetchInstalled && input.agent === "title") output.headers[OpenAIWebSocketPool.TITLE_HEADER] = "true"
},
"chat.params": async (input, output) => {
if (input.model.providerID !== "openai") return
@@ -0,0 +1,272 @@
import WebSocket from "ws"
import * as Log from "@opencode-ai/core/util/log"
import { ProviderError } from "@/provider/error"
import { isRecord } from "@/util/record"
import { OpenAIWebSocket } from "./ws"
export const TITLE_HEADER = "x-opencode-title"
const log = Log.create({ service: "plugin.openai.ws" })
export interface CreateWebSocketFetchOptions {
httpFetch?: typeof globalThis.fetch
url?: string
connectTimeout?: number
idleTimeout?: number
maxConnectionAge?: number
streamRetries?: number
}
interface PoolEntry {
socket?: WebSocket
connectedAt?: number
lastUsedAt: number
busy: boolean
fallback: boolean
streamFailures: number
}
const DEFAULT_CONNECT_TIMEOUT = 15_000
const DEFAULT_IDLE_TIMEOUT = 5 * 60 * 1000
const DEFAULT_MAX_CONNECTION_AGE = 55 * 60 * 1000
const CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached"
export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
const httpFetch = options?.httpFetch ?? globalThis.fetch
const pool = new Map<string, PoolEntry>()
const connectTimeout = options?.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT
const idleTimeout = options?.idleTimeout ?? DEFAULT_IDLE_TIMEOUT
const maxConnectionAge = options?.maxConnectionAge ?? DEFAULT_MAX_CONNECTION_AGE
const streamRetries = options?.streamRetries ?? 5
const pruneTimer = setInterval(() => prune(), Math.min(idleTimeout, 60_000))
if (typeof pruneTimer === "object" && "unref" in pruneTimer && typeof pruneTimer.unref === "function") {
pruneTimer.unref()
}
async function websocketFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const url = input instanceof URL ? input.toString() : typeof input === "string" ? input : input.url
const internalHeaders = OpenAIWebSocket.normalizeHeaders(init?.headers)
const httpInit = withoutInternalHeaders(init)
if (init?.method !== "POST" || !new URL(url).pathname.endsWith("/responses")) {
return httpFetch(input, httpInit)
}
const body = (() => {
try {
if (typeof init?.body !== "string") return undefined
const parsed = JSON.parse(init.body)
return typeof parsed === "object" && parsed !== null ? parsed : undefined
} catch {
return undefined
}
})()
if (!body?.stream) return httpFetch(input, httpInit)
if (internalHeaders[TITLE_HEADER] === "true") {
log.debug("http fallback", { reason: "title" })
return httpFetch(input, httpInit)
}
const sessionID = internalHeaders["x-session-affinity"] ?? internalHeaders["session-id"]
if (!sessionID) {
log.debug("http fallback", { reason: "missing_session" })
return httpFetch(input, httpInit)
}
const key = `${sessionID}:conversation`
const entry = pool.get(key) ?? { lastUsedAt: Date.now(), busy: false, fallback: false, streamFailures: 0 }
pool.set(key, entry)
if (entry.fallback) {
log.debug("http fallback", { key, reason: "fallback_active" })
return httpFetch(input, httpInit)
}
if (entry.busy) {
log.debug("http fallback", { key, reason: "busy" })
return httpFetch(input, httpInit)
}
entry.busy = true
entry.lastUsedAt = Date.now()
try {
entry.socket = await socket(
entry,
options?.url ?? url,
OpenAIWebSocket.normalizeHeaders(httpInit?.headers),
connectTimeout,
maxConnectionAge,
init?.signal,
)
let resolveFirstEvent: (started: boolean) => void = () => {}
let rejectFirstEvent: (error: Error) => void = () => {}
const firstEvent = new Promise<boolean>((resolve, reject) => {
resolveFirstEvent = resolve
rejectFirstEvent = reject
})
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket: entry.socket,
body,
idleTimeout,
signal: init?.signal ?? undefined,
onFirstEvent: () => resolveFirstEvent(true),
onTerminal: (event) => {
entry.busy = false
entry.lastUsedAt = Date.now()
entry.streamFailures = 0
if (event.type !== "response.completed" && event.type !== "response.done") {
log.warn("websocket terminal failure", { key, type: event.type })
invalidate(entry)
}
},
onConnectionInvalid: (error) => {
log.warn("websocket invalidated", { key, error: error.message })
entry.busy = false
if (!entry.fallback) recordStreamFailure(entry)
invalidate(entry)
resolveFirstEvent(false)
},
onAbort: (error) => {
log.debug("websocket aborted", { key })
entry.busy = false
entry.lastUsedAt = Date.now()
entry.streamFailures = 0
invalidate(entry)
rejectFirstEvent(error)
},
onRetryableTerminal: async (event) => {
const error = connectionLimitError(event)
if (!error) return undefined
log.warn("websocket connection limit reached", { key })
throw error
},
})
if (await firstEvent) return response
if (!entry.fallback) return response
log.debug("http fallback", { key, reason: "websocket_retries_exhausted" })
return httpFetch(input, httpInit)
} catch (error) {
entry.busy = false
entry.lastUsedAt = Date.now()
if (OpenAIWebSocket.isAbortError(error)) {
entry.streamFailures = 0
invalidate(entry)
throw error
}
recordStreamFailure(entry)
log.warn("websocket setup failed", {
key,
error: error instanceof Error ? error.message : String(error),
fallback: entry.fallback ? "http" : undefined,
})
invalidate(entry)
if (entry.fallback) return httpFetch(input, httpInit)
return failedResponse(
new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), {
cause: error,
}),
)
}
}
function recordStreamFailure(entry: PoolEntry) {
entry.streamFailures++
// Codex counts retries after the initial failed WebSocket attempt.
if (entry.streamFailures > streamRetries) entry.fallback = true
}
function prune() {
const now = Date.now()
for (const [key, entry] of pool) {
if (entry.busy) continue
if (now - entry.lastUsedAt < idleTimeout) continue
log.debug("websocket idle prune", { key })
invalidate(entry)
pool.delete(key)
}
}
function close() {
log.debug("websocket pool close", { count: pool.size })
clearInterval(pruneTimer)
for (const entry of pool.values()) invalidate(entry)
pool.clear()
}
return Object.assign(websocketFetch, { close })
}
function connectionLimitError(event: Record<string, unknown>) {
if (event.type !== "error" || !isRecord(event.error) || event.error.code !== CONNECTION_LIMIT_REACHED_CODE) return
return new Error(typeof event.error.message === "string" ? event.error.message : CONNECTION_LIMIT_REACHED_CODE)
}
function failedResponse(error: ProviderError.ResponseStreamError) {
return new Response(
new ReadableStream({
start(controller) {
controller.error(error)
},
}),
{
status: 200,
headers: { "content-type": "text/event-stream" },
},
)
}
async function socket(
entry: PoolEntry,
url: string,
headers: Record<string, string>,
connectTimeout: number,
maxConnectionAge: number,
signal?: AbortSignal | null,
) {
if (
entry.socket?.readyState === WebSocket.OPEN &&
entry.connectedAt &&
Date.now() - entry.connectedAt < maxConnectionAge
) {
return entry.socket
}
invalidate(entry)
const next = await OpenAIWebSocket.connectResponsesWebSocket({
url: OpenAIWebSocket.toWebSocketUrl(url),
headers,
timeout: connectTimeout,
signal: signal ?? undefined,
})
entry.connectedAt = Date.now()
return next
}
function invalidate(entry: PoolEntry) {
if (entry.socket) {
entry.socket.on("error", () => {})
entry.socket.terminate()
entry.socket = undefined
}
entry.connectedAt = undefined
}
export function withoutInternalHeaders<T extends { headers?: HeadersInit }>(init: T | undefined): T | undefined {
if (!init?.headers) return init
if (init.headers instanceof Headers) {
const headers = new Headers(init.headers)
headers.delete(TITLE_HEADER)
return { ...init, headers }
}
if (Array.isArray(init.headers)) {
return { ...init, headers: init.headers.filter((item) => item[0].toLowerCase() !== TITLE_HEADER) }
}
return {
...init,
headers: Object.fromEntries(Object.entries(init.headers).filter(([key]) => key.toLowerCase() !== TITLE_HEADER)),
}
}
export * as OpenAIWebSocketPool from "./ws-pool"
+326
View File
@@ -0,0 +1,326 @@
// Low-level OpenAI Responses WebSocket protocol helpers. Session pooling,
// fallback, and continuation state intentionally live above this file.
import WebSocket from "ws"
import { ProviderError } from "@/provider/error"
export const PROTOCOL_HEADER = "responses_websockets=2026-02-06"
export interface ConnectResponsesWebSocketOptions {
url: string
headers: Record<string, string>
timeout?: number
signal?: AbortSignal
}
export interface StreamResponsesWebSocketOptions {
socket: WebSocket
body: Record<string, unknown>
idleTimeout?: number
signal?: AbortSignal
onFirstEvent?: () => void
onComplete?: (event: Record<string, unknown>) => void
onTerminal?: (event: Record<string, unknown>) => void
onRetryableTerminal?: (event: Record<string, unknown>) => Promise<WebSocket | undefined>
onConnectionInvalid?: (error: ProviderError.ResponseStreamError) => void
onAbort?: (error: Error) => void
}
export function toWebSocketUrl(url: string) {
return url.replace(/^http/, "ws")
}
export function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
const result: Record<string, string> = {}
if (!headers) return result
if (headers instanceof Headers) {
headers.forEach((value, key) => {
result[key.toLowerCase()] = value
})
return result
}
if (Array.isArray(headers)) {
for (const [key, value] of headers) {
result[key.toLowerCase()] = value
}
return result
}
for (const [key, value] of Object.entries(headers)) {
if (value != null) result[key.toLowerCase()] = value
}
return result
}
export function isAbortError(error: unknown): error is DOMException {
return error instanceof DOMException && error.name === "AbortError"
}
export function connectResponsesWebSocket(options: ConnectResponsesWebSocketOptions) {
return new Promise<WebSocket>((resolve, reject) => {
if (options.signal?.aborted) {
reject(abortError(options.signal))
return
}
const headers: Record<string, string> = {
...options.headers,
"openai-beta": options.headers["openai-beta"] ?? PROTOCOL_HEADER,
}
delete headers["content-length"]
const socket = new WebSocket(options.url, { headers })
const timeout = options.timeout
? setTimeout(() => {
cleanup()
socket.on("error", () => {})
socket.terminate()
reject(new Error("WebSocket connect timed out"))
}, options.timeout)
: undefined
function cleanup() {
if (timeout) clearTimeout(timeout)
socket.off("open", onOpen)
socket.off("error", onError)
socket.off("close", onClose)
options.signal?.removeEventListener("abort", onAbort)
}
function onOpen() {
cleanup()
resolve(socket)
}
function onError(error: Error) {
socket.on("error", () => {})
cleanup()
reject(error)
}
function onClose(code: number, reason: Buffer) {
cleanup()
reject(new Error(closeMessage("WebSocket closed before open", code, reason)))
}
function onAbort() {
cleanup()
socket.on("error", () => {})
socket.terminate()
reject(abortError(options.signal))
}
socket.once("open", onOpen)
socket.once("error", onError)
socket.once("close", onClose)
options.signal?.addEventListener("abort", onAbort, { once: true })
})
}
export function streamResponsesWebSocket(options: StreamResponsesWebSocketOptions) {
const encoder = new TextEncoder()
let socket = options.socket
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
let cleanupSocket = () => {}
let completed = false
let emitted = false
let idleTimer: ReturnType<typeof setTimeout> | undefined
function cleanup() {
if (idleTimer) clearTimeout(idleTimer)
cleanupSocket()
options.signal?.removeEventListener("abort", onAbort)
}
function terminateSocket(target = socket) {
target.on("error", () => {})
target.terminate()
}
function closeCompleted() {
cleanup()
controller?.enqueue(encoder.encode("data: [DONE]\n\n"))
controller?.close()
}
function invalidate(error: ProviderError.ResponseStreamError) {
if (completed) return
completed = true
cleanup()
options.onConnectionInvalid?.(error)
controller?.error(error)
}
function resetIdleTimeout(message: string) {
if (completed) return
if (!options.idleTimeout) return
if (idleTimer) clearTimeout(idleTimer)
idleTimer = setTimeout(() => invalidate(new ProviderError.ResponseStreamError(message)), options.idleTimeout)
}
async function onMessage(data: WebSocket.RawData, isBinary: boolean) {
if (completed) return
if (isBinary) {
invalidate(new ProviderError.ResponseStreamError("Unexpected binary WebSocket frame"))
return
}
const text = data.toString()
const event = (() => {
try {
const parsed = JSON.parse(text)
return typeof parsed === "object" && parsed !== null ? parsed : undefined
} catch {
return undefined
}
})()
if (event?.type === "error" && !emitted && options.onRetryableTerminal) {
cleanupSocket()
if (idleTimer) clearTimeout(idleTimer)
idleTimer = undefined
try {
const next = await options.onRetryableTerminal(event)
if (completed) {
if (next) terminateSocket(next)
return
}
if (next) {
attach(next)
return
}
} catch (error) {
invalidate(
new ProviderError.ResponseStreamError(error instanceof Error ? error.message : String(error), {
cause: error,
}),
)
return
}
}
if (!emitted) options.onFirstEvent?.()
controller?.enqueue(
encoder.encode(
`${text
.split(/\r?\n/)
.map((line) => `data: ${line}`)
.join("\n")}\n\n`,
),
)
emitted = true
resetIdleTimeout("idle timeout waiting for websocket")
if (!event) return
if (event.type === "response.completed" || event.type === "response.done") {
completed = true
options.onComplete?.(event)
options.onTerminal?.(event)
closeCompleted()
return
}
if (event.type === "response.failed" || event.type === "response.incomplete" || event.type === "error") {
completed = true
options.onTerminal?.(event)
closeCompleted()
}
}
function onError(error: Error) {
invalidate(new ProviderError.ResponseStreamError(error.message, { cause: error }))
}
function onClose(code: number, reason: Buffer) {
if (completed) return
invalidate(
new ProviderError.ResponseStreamError(closeMessage("WebSocket closed before response.completed", code, reason)),
)
}
function onAbort() {
const error = abortError(options.signal)
if (completed) return
completed = true
cleanup()
terminateSocket()
options.onAbort?.(error)
controller?.error(error)
}
function onCancel(reason: unknown) {
if (completed) return
completed = true
cleanup()
terminateSocket()
options.onAbort?.(cancelError(reason))
}
function attach(next: WebSocket) {
cleanupSocket()
socket = next
socket.on("message", onMessage)
socket.once("error", onError)
socket.once("close", onClose)
cleanupSocket = () => {
socket.off("message", onMessage)
socket.off("error", onError)
socket.off("close", onClose)
}
const { stream: _stream, background: _background, ...payload } = options.body
resetIdleTimeout("idle timeout sending websocket request")
socket.send(JSON.stringify({ type: "response.create", ...payload }), (error) => {
if (completed) return
resetIdleTimeout("idle timeout waiting for websocket")
if (error) invalidate(new ProviderError.ResponseStreamError(error.message, { cause: error }))
})
}
return new Response(
new ReadableStream<Uint8Array>({
start(next) {
controller = next
options.signal?.addEventListener("abort", onAbort, { once: true })
if (options.signal?.aborted) {
onAbort()
return
}
attach(socket)
},
cancel(reason) {
onCancel(reason)
},
}),
{
status: 200,
headers: { "content-type": "text/event-stream" },
},
)
}
function cancelError(reason: unknown) {
if (isAbortError(reason)) return reason
if (reason instanceof Error) return reason
return new DOMException(typeof reason === "string" ? reason : "Aborted", "AbortError")
}
function abortError(signal: AbortSignal | undefined) {
const reason = signal?.reason
if (isAbortError(reason)) return reason
return new DOMException(reason instanceof Error ? reason.message : "Aborted", "AbortError")
}
function closeMessage(message: string, code: number, reason: Buffer) {
const details = [`code ${code}`]
if (code === 1009) details.push("message too big")
if (reason.length > 0) details.push(reason.toString())
return `${message} (${details.join(": ")})`
}
export * as OpenAIWebSocket from "./ws"
+8
View File
@@ -11,6 +11,14 @@ export class HeaderTimeoutError extends Error {
}
}
export class ResponseStreamError extends Error {
public override readonly name = "ProviderResponseStreamError"
constructor(message: string, options?: ErrorOptions) {
super(message, options)
}
}
// Adapted from overflow detection patterns in:
// https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts
const OVERFLOW_PATTERNS = [
+12 -7
View File
@@ -596,8 +596,16 @@ function openaiCompatibleReasoningEfforts(id: string) {
return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS
}
function anthropicOpus47OrLater(apiId: string) {
const version = /opus-(\d+)[.-](\d+)(?:[.-]|$)/i.exec(apiId)
if (!version) return false
const major = Number(version[1])
const minor = Number(version[2])
return major > 4 || (major === 4 && minor >= 7)
}
function anthropicAdaptiveEfforts(apiId: string): string[] | null {
if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) {
if (anthropicOpus47OrLater(apiId)) {
return ["low", "medium", "high", "xhigh", "max"]
}
if (["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => apiId.includes(v))) {
@@ -625,6 +633,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
if (!model.capabilities.reasoning) return {}
const id = model.id.toLowerCase()
const adaptiveOpus = anthropicOpus47OrLater(model.api.id)
const adaptiveEfforts = anthropicAdaptiveEfforts(model.api.id)
if (
id.includes("deepseek-chat") ||
@@ -833,9 +842,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
{
thinking: {
type: "adaptive",
...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
? { display: "summarized" }
: {}),
...(adaptiveOpus ? { display: "summarized" } : {}),
},
effort,
},
@@ -872,9 +879,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: effort,
...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
? { display: "summarized" }
: {}),
...(adaptiveOpus ? { display: "summarized" } : {}),
},
},
]),
@@ -14,18 +14,20 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
For SSE endpoints, stay in `HttpApiBuilder.group(...)` and return `HttpServerResponse.stream(...)` from the handler. Annotate the endpoint success schema with `HttpApiSchema.asText({ contentType: "text/event-stream" })` so OpenAPI documents the stream content type.
Use raw `HttpRouter.use(...)` only for routes that do not fit the request/response HttpApi model, such as WebSocket upgrade routes or catch-all fallback routes. Yield stable services at route-layer construction and close over them in `router.add(...)` callbacks.
Use `HttpApiBuilder.group(...)` with `handleRaw(...)` for declared endpoints that need the raw request or response, including WebSocket upgrade routes. This keeps endpoint middleware, routing context, and OpenAPI metadata on one typed route tree.
```ts
export const rawRoute = HttpRouter.use((router) =>
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
yield* router.add("GET", PtyPaths.connect, (request) => connectPty(request, pty))
return handlers.handleRaw("connect", (ctx) => connectPty(ctx.request, pty))
}),
)
```
Use raw `HttpRouter.use(...)` only for routes outside the declared API surface, such as a catch-all UI fallback.
Avoid `Effect.provide(SomeLayer)` inside request handlers or raw route callbacks. Stable layers should be provided once at the application/layer boundary, not rebuilt or scoped per request.
Avoid `HttpRouter.provideRequest(...)` unless the dependency is intentionally request-level. Prefer `HttpRouter.use(...)` for stable app services.
@@ -34,4 +36,4 @@ Use `Effect.provideService(...)` in middleware only for request-derived context,
Public JSON errors should be explicit `Schema.ErrorClass` contracts declared on each endpoint. Use built-in `HttpApiError.*` classes only when their empty/tagged body is the intended wire shape; for SDK-visible errors with messages, define an API error schema such as `ApiNotFoundError` and fail with that exact declared error. Keep domain and storage services free of HttpApi types, and translate expected domain errors at the handler boundary.
When adding middleware, compose it at the layer boundary and keep the route tree explicit in `server.ts`. Shared router middleware such as auth, workspace routing, and instance context should stay visible where routes are assembled.
When adding middleware, declare endpoint-contract middleware on the owning `HttpApiGroup` and provide its implementation layer at the assembly boundary in `server.ts`. Keep router middleware for truly raw fallback routes or global transport policy.
@@ -1,6 +1,8 @@
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { WorkspaceRoutingQuery } from "../middleware/workspace-routing"
import { Authorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
export const EventPaths = {
event: "/event",
@@ -20,5 +22,8 @@ export const EventApi = HttpApi.make("event").add(
}),
),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization)
.annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })),
)
@@ -1,9 +1,10 @@
import { Pty } from "@/pty"
import { PtyTicket } from "@/pty/ticket"
import { PtyID } from "@/pty/schema"
import { PTY_CONNECT_TICKET_QUERY } from "@/server/shared/pty-ticket"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../middleware/authorization"
import { Authorization, PtyConnectAuthorization } from "../middleware/authorization"
import { InstanceContextMiddleware } from "../middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
@@ -138,9 +139,10 @@ export const PtyApi = HttpApi.make("pty")
export const PtyConnectApi = HttpApi.make("pty-connect").add(
HttpApiGroup.make("pty-connect")
.add(
// Decode PTY connection query fields in the raw handler after checking
// existence, preserving the established empty-404 response ordering.
HttpApiEndpoint.get("connect", PtyPaths.connect, {
params: Params,
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Connected session"),
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
}).annotateMerge(
@@ -149,8 +151,22 @@ export const PtyConnectApi = HttpApi.make("pty-connect").add(
summary: "Connect to PTY session",
description:
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
transform: (operation) => ({
...operation,
parameters: [
...(operation.parameters ?? []),
...["directory", "workspace", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({
in: "query",
name,
schema: { type: "string" },
})),
],
}),
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." }))
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(PtyConnectAuthorization),
)
@@ -10,13 +10,13 @@ import {
PTY_CONNECT_TOKEN_HEADER,
PTY_CONNECT_TOKEN_HEADER_VALUE,
} from "@/server/shared/pty-ticket"
import { Effect } from "effect"
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { Effect, Option, Schema } from "effect"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { InstanceHttpApi } from "../api"
import * as ApiError from "../errors"
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
import { CursorQuery, PtyConnectApi } from "../groups/pty"
import { WebSocketTracker } from "../websocket-tracker"
function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) {
@@ -121,37 +121,39 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
}),
)
export const ptyConnectRoute = HttpRouter.use((router) =>
export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const tickets = yield* PtyTicket.Service
const cors = yield* CorsConfig
yield* router.add(
"GET",
PtyPaths.connect,
Effect.gen(function* () {
const params = yield* HttpRouter.schemaPathParams(Params)
const exists = yield* pty.get(params.ptyID).pipe(
return handlers.handleRaw(
"connect",
Effect.fn("PtyHttpApi.connect")(function* (ctx: {
params: { ptyID: PtyID }
request: HttpServerRequest.HttpServerRequest
}) {
const exists = yield* pty.get(ctx.params.ptyID).pipe(
Effect.as(true),
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)),
)
if (!exists) return HttpServerResponse.empty({ status: 404 })
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
const request = yield* HttpServerRequest.HttpServerRequest
const ticket = new URL(request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
const query = Schema.decodeUnknownOption(CursorQuery)(yield* HttpServerRequest.ParsedSearchParams)
if (Option.isNone(query)) return HttpServerResponse.empty({ status: 400 })
const ticket = new URL(ctx.request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
if (ticket) {
const valid = validOrigin(request, cors)
? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) })
const valid = validOrigin(ctx.request, cors)
? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
: false
if (!valid) return HttpServerResponse.empty({ status: 403 })
}
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
const parsedCursor = query.value.cursor === undefined ? undefined : Number(query.value.cursor)
const cursor =
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1
? parsedCursor
: undefined
const socket = yield* Effect.orDie(request.upgrade)
const socket = yield* Effect.orDie(ctx.request.upgrade)
const write = yield* socket.writer
const closeAccepted = (event: Socket.CloseEvent) =>
socket
@@ -186,7 +188,7 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
},
}
const handler = yield* pty
.connect(params.ptyID, adapter, cursor)
.connect(ctx.params.ptyID, adapter, cursor)
.pipe(
Effect.catchTag("Pty.NotFoundError", () =>
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
@@ -194,12 +196,8 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
)
if (!handler) return HttpServerResponse.empty()
// No `pending[]`-style early-frame buffer (the legacy handler had one).
// `request.upgrade` returns a Socket without running the WS handshake; the
// handshake fires inside `socket.runRaw` below, AFTER `pty.connect` resolves
// and the message callback is registered. The client therefore can't fire
// `open` and start sending until the listener is already wired. Don't move
// `runRaw` ahead of `pty.connect` without re-introducing a buffer.
// The handshake runs inside `socket.runRaw`, after the input callback is
// registered, so the client cannot send frames before PTY input is wired.
yield* socket
.runRaw((message) => handlePtyInput(handler, message))
.pipe(
@@ -27,6 +27,13 @@ export class V2Authorization extends HttpApiMiddleware.Service<V2Authorization>(
},
) {}
export class PtyConnectAuthorization extends HttpApiMiddleware.Service<PtyConnectAuthorization>()(
"@opencode/ExperimentalHttpApiPtyConnectAuthorization",
{
error: HttpApiError.UnauthorizedNoContent,
},
) {}
function emptyCredential() {
return {
username: "",
@@ -56,11 +63,11 @@ function decodeCredential(input: string) {
Effect.match({
onFailure: emptyCredential,
onSuccess: (header) => {
const parts = header.split(":")
if (parts.length !== 2) return emptyCredential()
const separator = header.indexOf(":")
if (separator === -1) return emptyCredential()
return {
username: parts[0],
password: Redacted.make(parts[1]),
username: header.slice(0, separator),
password: Redacted.make(header.slice(separator + 1)),
}
},
}),
@@ -105,7 +112,6 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
if (isPublicUIPath(request.method, url.pathname)) return yield* effect
if (hasPtyConnectTicketURL(url)) return yield* effect
return yield* credentialFromURL(url, request).pipe(
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
)
@@ -129,6 +135,24 @@ export const authorizationLayer = Layer.effect(
}),
)
export const ptyConnectAuthorizationLayer = Layer.effect(
PtyConnectAuthorization,
Effect.gen(function* () {
const config = yield* ServerAuth.Config
if (!ServerAuth.required(config)) return PtyConnectAuthorization.of((effect) => effect)
return PtyConnectAuthorization.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
if (hasPtyConnectTicketURL(url)) return yield* effect
return yield* credentialFromURL(url, request).pipe(
Effect.flatMap((credential) => validateCredential(effect, credential, config)),
)
}),
)
}),
)
export const v2AuthorizationLayer = Layer.effect(
V2Authorization,
Effect.gen(function* () {
@@ -1,7 +1,7 @@
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
import { InstanceStore } from "@/project/instance-store"
import { Effect, Layer } from "effect"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
import { HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { WorkspaceRouteContext } from "./workspace-routing"
@@ -41,10 +41,3 @@ export const instanceContextLayer = Layer.effect(
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store))
}),
)
export const instanceRouterMiddleware = HttpRouter.middleware()(
Effect.gen(function* () {
const store = yield* InstanceStore.Service
return (effect) => provideInstanceContext(effect, store)
}),
)
@@ -4,17 +4,11 @@ import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerR
import * as Socket from "effect/unstable/socket/Socket"
import { WebSocketTracker } from "../websocket-tracker"
function webSource(request: HttpServerRequest.HttpServerRequest): Request | undefined {
return request.source instanceof Request ? request.source : undefined
}
function requestBody(request: HttpServerRequest.HttpServerRequest) {
if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
if (request.source instanceof Request && request.source.body === null) return HttpBody.empty
const len = request.headers["content-length"]
return HttpBody.raw(webSource(request)?.body ?? null, {
contentType: request.headers["content-type"],
contentLength: len ? Number(len) : undefined,
})
return HttpBody.stream(request.stream, request.headers["content-type"], len ? Number(len) : undefined)
}
export function websocket(
@@ -9,7 +9,7 @@ import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL }
import { NotFoundError } from "@/storage/storage"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Context, Data, Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { InvalidRequestError } from "../errors"
@@ -159,14 +159,14 @@ function planWorkspaceRequest(
function planRequest(
request: HttpServerRequest.HttpServerRequest,
sessionWorkspaceID?: WorkspaceID,
session?: Session.Info,
): Effect.Effect<RequestPlan, never, Workspace.Service> {
return Effect.gen(function* () {
const url = requestURL(request)
const envWorkspaceID = configuredWorkspaceID()
const workspaceID = url.pathname.startsWith("/api/")
? selectedV2WorkspaceID(url, sessionWorkspaceID)
: selectedWorkspaceID(url, sessionWorkspaceID)
? selectedV2WorkspaceID(url, session?.workspaceID)
: selectedWorkspaceID(url, session?.workspaceID)
if (workspaceID === InvalidWorkspaceID) return RequestPlan.InvalidWorkspace()
const workspace = yield* resolveWorkspace(workspaceID, envWorkspaceID)
@@ -178,7 +178,10 @@ function planRequest(
return yield* planWorkspaceRequest(request, url, workspace)
}
return RequestPlan.Local({ directory: defaultDirectory(request, url), workspaceID: envWorkspaceID ?? workspaceID })
return RequestPlan.Local({
directory: session?.directory || defaultDirectory(request, url),
workspaceID: envWorkspaceID ?? workspaceID,
})
})
}
@@ -219,11 +222,14 @@ function routeHttpApiWorkspace<E>(
const sessionID = getWorkspaceRouteSessionID(requestURL(request))
const session = sessionID
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(
Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)),
Effect.catchIf(
(error): error is NotFoundError => NotFoundError.isInstance(error),
() => Effect.succeed(undefined),
),
Effect.catchDefect(() => Effect.succeed(undefined)),
)
: undefined
const plan = yield* planRequest(request, session?.workspaceID)
const plan = yield* planRequest(request, session)
return yield* routeWorkspace(client, effect, plan)
})
}
@@ -242,20 +248,3 @@ export const workspaceRoutingLayer = Layer.effect(
)
}),
)
export const workspaceRouterMiddleware = HttpRouter.middleware<{ provides: WorkspaceRouteContext }>()(
Effect.gen(function* () {
const makeWebSocket = yield* Socket.WebSocketConstructor
const workspace = yield* Workspace.Service
const client = yield* HttpClient.HttpClient
return (effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const plan = yield* planRequest(request)
return yield* routeWorkspace(client, effect, plan)
}).pipe(
Effect.provideService(Socket.WebSocketConstructor, makeWebSocket),
Effect.provideService(Workspace.Service, workspace),
)
}),
)
@@ -59,8 +59,14 @@ import { serveUIEffect } from "@/server/shared/ui"
import { ServerAuth } from "@/server/auth"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { PublicApi } from "./public"
import { authorizationLayer, authorizationRouterMiddleware, v2AuthorizationLayer } from "./middleware/authorization"
import {
authorizationLayer,
authorizationRouterMiddleware,
ptyConnectAuthorizationLayer,
v2AuthorizationLayer,
} from "./middleware/authorization"
import { EventApi } from "./groups/event"
import { PtyConnectApi } from "./groups/pty"
import { eventHandlers } from "./handlers/event"
import { configHandlers } from "./handlers/config"
import { controlHandlers } from "./handlers/control"
@@ -72,15 +78,15 @@ import { mcpHandlers } from "./handlers/mcp"
import { permissionHandlers } from "./handlers/permission"
import { projectHandlers } from "./handlers/project"
import { providerHandlers } from "./handlers/provider"
import { ptyConnectRoute, ptyHandlers } from "./handlers/pty"
import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty"
import { questionHandlers } from "./handlers/question"
import { sessionHandlers } from "./handlers/session"
import { syncHandlers } from "./handlers/sync"
import { tuiHandlers } from "./handlers/tui"
import { v2Handlers } from "./handlers/v2"
import { workspaceHandlers } from "./handlers/workspace"
import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context"
import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing"
import { instanceContextLayer } from "./middleware/instance-context"
import { workspaceRoutingLayer } from "./middleware/workspace-routing"
import { disposeMiddleware } from "./lifecycle"
import { memoMap } from "@opencode-ai/core/effect/memo-map"
import { compressionLayer } from "./middleware/compression"
@@ -102,24 +108,27 @@ const cors = (corsOptions?: CorsOptions) =>
// Route tree:
// - rootApiRoutes: typed /global/* and control routes; auth is declared by RootHttpApi.
// - eventApiRoutes/rawInstanceRoutes: raw instance routes; auth and workspace routing happen as router middleware.
// - instanceApiRoutes: schema routes; auth is declared on each group and workspace context is provided below.
// - eventApiRoutes: typed SSE route with instance routing context and its existing API contract.
// - ptyConnectApiRoutes: typed WebSocket upgrade route with ticket-aware auth.
// - instanceApiRoutes: remaining typed instance routes.
// - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it.
const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const v2HttpApiAuthLayer = v2AuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
Layer.provide([controlHandlers, globalHandlers]),
Layer.provide(schemaErrorLayer),
Layer.provide(httpApiAuthLayer),
)
const instanceRouterLayer = authorizationRouterMiddleware
.combine(instanceRouterMiddleware)
.combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal), Layer.provide(ServerAuth.Config.defaultLayer))
const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe(
Layer.provide(eventHandlers),
Layer.provide(instanceRouterLayer),
Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]),
)
const ptyConnectApiRoutes = HttpApiBuilder.layer(PtyConnectApi).pipe(
Layer.provide(ptyConnectHandlers),
Layer.provide([ptyConnectHttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]),
)
const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
Layer.provide([
@@ -141,15 +150,8 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
]),
)
const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(instanceRouterLayer))
const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
Layer.provide([
httpApiAuthLayer,
v2HttpApiAuthLayer,
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
instanceContextLayer,
schemaErrorLayer,
]),
const instanceRoutes = instanceApiRoutes.pipe(
Layer.provide([httpApiAuthLayer, v2HttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
)
// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
@@ -184,7 +186,7 @@ type RouteRequirements =
export function createRoutes(
corsOptions?: CorsOptions,
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
Layer.provide([
errorLayer,
compressionLayer,
@@ -1155,6 +1155,17 @@ export function fromError(
},
{ cause: e },
).toObject()
case e instanceof ProviderError.ResponseStreamError:
return new APIError(
{
message: e.message,
isRetryable: true,
metadata: {
code: e.name,
},
},
{ cause: e },
).toObject()
case APICallError.isInstance(e):
const parsed = ProviderError.parseAPICallError({
providerID: ctx.providerID,
+1 -1
View File
@@ -351,7 +351,7 @@ export const layer: Layer.Layer<
return yield* new ListFailedError({ message: result.stderr || result.text || "Failed to read git worktrees" })
}
const primary = yield* canonical(ctx.worktree)
const primary = yield* canonical(ctx.project.worktree)
const primaryName = pathSvc.basename(primary).toLowerCase()
return yield* Effect.forEach(parseWorktreeList(result.text), (entry) =>
Effect.gen(function* () {
@@ -0,0 +1,237 @@
import { describe, expect, it } from "bun:test"
import type {
AgentSideConnection,
RequestPermissionRequest,
RequestPermissionResponse,
SessionUpdate,
} from "@agentclientprotocol/sdk"
import type { Event, OpencodeClient } from "@opencode-ai/sdk/v2"
import { Effect, ManagedRuntime } from "effect"
import { ACPNextEvent } from "@/acp-next/event"
import { ACPNextSession } from "@/acp-next/session"
type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type PermissionReplyParams = Parameters<OpencodeClient["permission"]["reply"]>[0]
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
const pollUntil = async (
check: () => boolean | Promise<boolean>,
message: string,
opts?: { timeoutMs?: number; intervalMs?: number },
) => {
const started = Date.now()
while (true) {
if (await check()) return
if (Date.now() - started > (opts?.timeoutMs ?? 2000)) throw new Error(message)
await new Promise((resolve) => setTimeout(resolve, opts?.intervalMs ?? 5))
}
}
function makeSessionService() {
return ManagedRuntime.make(ACPNextSession.defaultLayer).runSync(
ACPNextSession.Service.use((service) => Effect.succeed(service)),
)
}
function createHarness(
requestPermission: (params: RequestPermissionRequest) => Promise<RequestPermissionResponse> = () =>
Promise.resolve({ outcome: { outcome: "selected", optionId: "once" } }),
) {
const replies: PermissionReplyParams[] = []
const requests: RequestPermissionRequest[] = []
const updates: SessionUpdateParams[] = []
const session = makeSessionService()
const sdk = {
permission: {
reply: (params: PermissionReplyParams) => {
replies.push(params)
return Promise.resolve({ data: true })
},
},
session: {
message: () => Promise.resolve({ data: undefined }),
},
} as unknown as OpencodeClient
const connection = {
requestPermission: (params: RequestPermissionRequest) => {
requests.push(params)
return requestPermission(params)
},
sessionUpdate: (params: SessionUpdateParams) => {
updates.push(params)
return Promise.resolve()
},
} satisfies Pick<AgentSideConnection, "requestPermission" | "sessionUpdate">
const subscription = new ACPNextEvent.Subscription({ sdk, connection, session })
return { connection, replies, requests, sdk, session, subscription, updates }
}
async function createSession(session: ACPNextSession.Interface, sessionId: string, cwd = "/workspace") {
await Effect.runPromise(session.create({ id: sessionId, cwd }))
}
async function createKnownTextPart(
session: ACPNextSession.Interface,
sessionId: string,
messageId: string,
partId: string,
) {
await Effect.runPromise(
session.recordPartMetadata({
sessionId,
messageId,
partId,
partType: "text",
role: "assistant",
}),
)
}
function permissionAsked(
sessionID: string,
id: string,
input: {
permission?: string
metadata?: Record<string, unknown>
tool?: { messageID: string; callID: string }
} = {},
) {
return {
id: `evt_${id}`,
type: "permission.asked",
properties: {
id,
sessionID,
permission: input.permission ?? "bash",
patterns: ["*"],
metadata: input.metadata ?? { command: "printf hello" },
always: [],
...(input.tool ? { tool: input.tool } : {}),
},
} as PermissionEvent
}
function textDelta(sessionID: string, messageID: string, partID: string, delta: string) {
return {
id: `evt_${sessionID}_${messageID}_${partID}`,
type: "message.part.delta",
properties: {
sessionID,
messageID,
partID,
field: "text",
delta,
},
} as Event
}
function textFromUpdates(updates: SessionUpdateParams[], sessionId: string) {
return updates
.filter((item) => item.sessionId === sessionId)
.map((item) => item.update)
.filter((update): update is Extract<SessionUpdate, { sessionUpdate: "agent_message_chunk" }> => {
return update.sessionUpdate === "agent_message_chunk"
})
.map((update) => (update.content.type === "text" ? update.content.text : ""))
.join("")
}
describe("acp-next permissions", () => {
it("sends requestPermission and replies with the selected outcome", async () => {
const harness = createHarness()
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_1", { tool: { messageID: "msg_1", callID: "call_1" } }))
await pollUntil(() => harness.replies.length === 1, "permission was never replied")
expect(harness.requests[0]).toMatchObject({
sessionId: "ses_a",
toolCall: {
toolCallId: "call_1",
status: "pending",
title: "bash",
rawInput: { command: "printf hello" },
kind: "execute",
locations: [],
},
options: [
{ optionId: "once", kind: "allow_once", name: "Allow once" },
{ optionId: "always", kind: "allow_always", name: "Always allow" },
{ optionId: "reject", kind: "reject_once", name: "Reject" },
],
})
expect(harness.replies).toEqual([{ requestID: "perm_1", reply: "once", directory: "/workspace" }])
})
it("rejects non-selected outcomes", async () => {
const harness = createHarness(() => Promise.resolve({ outcome: { outcome: "cancelled" } }))
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_cancelled"))
await pollUntil(() => harness.replies.length === 1, "cancelled permission was never replied")
expect(harness.replies[0]).toMatchObject({ requestID: "perm_cancelled", reply: "reject" })
})
it("rejects when requestPermission fails", async () => {
const harness = createHarness(() => Promise.reject(new Error("client permission UI failed")))
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_failed"))
await pollUntil(() => harness.replies.length === 1, "failed permission was never rejected")
expect(harness.replies[0]).toMatchObject({ requestID: "perm_failed", reply: "reject" })
})
it("does not let a blocked session A permission block session B message updates", async () => {
let releasePermission: (() => void) | undefined
const blocked = new Promise<RequestPermissionResponse>((resolve) => {
releasePermission = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
})
const harness = createHarness(() => blocked)
await createSession(harness.session, "ses_a")
await createSession(harness.session, "ses_b")
await createKnownTextPart(harness.session, "ses_b", "msg_b", "part_b")
harness.subscription.handle(permissionAsked("ses_a", "perm_blocked"))
await pollUntil(() => harness.requests.length === 1, "blocked permission was never requested")
await harness.subscription.handle(textDelta("ses_b", "msg_b", "part_b", "session_b_message"))
expect(textFromUpdates(harness.updates, "ses_b")).toBe("session_b_message")
expect(harness.replies).toHaveLength(0)
releasePermission?.()
await pollUntil(() => harness.replies.length === 1, "blocked permission was never replied after release")
})
it("serializes permission requests per session", async () => {
let releaseFirst: (() => void) | undefined
const first = new Promise<RequestPermissionResponse>((resolve) => {
releaseFirst = () => resolve({ outcome: { outcome: "selected", optionId: "once" } })
})
const harness = createHarness(() =>
harness.requests.length === 1 ? first : Promise.resolve({ outcome: { outcome: "selected", optionId: "always" } }),
)
await createSession(harness.session, "ses_a")
harness.subscription.handle(permissionAsked("ses_a", "perm_1"))
harness.subscription.handle(permissionAsked("ses_a", "perm_2"))
await pollUntil(() => harness.requests.length === 1, "first permission was never requested")
expect(harness.requests.map((request) => request.toolCall.toolCallId)).toEqual(["perm_1"])
releaseFirst?.()
await pollUntil(() => harness.requests.length === 2, "second permission was not requested after first resolved")
await pollUntil(() => harness.replies.length === 2, "serialized permissions were not both replied")
expect(harness.replies.map((reply) => [reply.requestID, reply.reply])).toEqual([
["perm_1", "once"],
["perm_2", "always"],
])
})
})
@@ -4,6 +4,7 @@ import type {
ForkSessionResponse,
LoadSessionResponse,
NewSessionResponse,
SessionNotification,
ResumeSessionResponse,
SessionConfigOption,
SessionConfigSelectOption,
@@ -14,6 +15,7 @@ import { Effect, ManagedRuntime } from "effect"
import * as ACPNextService from "@/acp-next/service"
import * as ACPNextError from "@/acp-next/error"
import { ACPNextSession } from "@/acp-next/session"
import { UsageService } from "@/acp-next/usage"
import { ModelID, ProviderID } from "@/provider/schema"
import type { Provider } from "@/provider/provider"
@@ -141,10 +143,14 @@ const provider: Provider.Info = {
describe("ACP next service sessions", () => {
const makeService = (messages: readonly { info: unknown; parts: readonly unknown[] }[] = []) => {
const updates: unknown[] = []
const updates: SessionNotification[] = []
const mcpAdds: string[] = []
const aborts: string[] = []
const forks: string[] = []
const prompts: unknown[] = []
const commands: unknown[] = []
const summarizes: unknown[] = []
const usageUpdates: string[] = []
const sessions = Array.from({ length: 102 }, (_, index) => ({
id: `ses_${index + 1}`,
directory: index % 2 === 0 ? "/workspace" : "/other",
@@ -184,6 +190,36 @@ describe("ACP next service sessions", () => {
data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
}),
messages: () => Promise.resolve({ data: messages }),
prompt: (input: unknown) => {
prompts.push(input)
return Promise.resolve({
data: {
info: assistantInfo({
input: 100,
output: 40,
reasoning: 7,
cache: { read: 11, write: 13 },
}),
},
})
},
command: (input: unknown) => {
commands.push(input)
return Promise.resolve({
data: {
info: assistantInfo({
input: 3,
output: 4,
reasoning: 0,
cache: { read: 0, write: 0 },
}),
},
})
},
summarize: (input: unknown) => {
summarizes.push(input)
return Promise.resolve({ data: true })
},
abort: (input: { sessionID: string }) => {
aborts.push(input.sessionID)
return Promise.resolve({ data: true })
@@ -201,13 +237,33 @@ describe("ACP next service sessions", () => {
},
} as unknown as OpencodeClient
const connection = {
sessionUpdate: (update: unknown) => {
sessionUpdate: (update: SessionNotification) => {
updates.push(update)
return Promise.resolve()
},
} as Pick<AgentSideConnection, "sessionUpdate">
const usage = UsageService.Service.of({
buildUsage: UsageService.buildUsage,
latestAssistantMessage: UsageService.latestAssistantMessage,
totalSessionCost: UsageService.totalSessionCost,
contextLimit: () => Effect.succeed(128000),
sendUpdate: (input) =>
Effect.sync(() => {
usageUpdates.push(input.sessionID)
}),
})
return { service: ACPNextService.make({ sdk, connection }), updates, mcpAdds, aborts, forks }
return {
service: ACPNextService.make({ sdk, connection, usage }),
updates,
mcpAdds,
aborts,
forks,
prompts,
commands,
summarizes,
usageUpdates,
}
}
it("creates a backed session with config options and command update", async () => {
@@ -267,6 +323,15 @@ describe("ACP next service sessions", () => {
expect(second.sessions).toEqual(first.sessions)
})
it("includes live ACP sessions before they appear in server-backed session list", async () => {
const { service } = makeService()
const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const listed = await Effect.runPromise(service.listSessions({ cwd: "/workspace" }))
expect(listed.sessions[0]?.sessionId).toBe(created.sessionId)
expect(listed.sessions[0]?.cwd).toBe("/workspace")
})
it("lists all sessions with next cursor when the first page is full", async () => {
const { service } = makeService()
const first = await Effect.runPromise(service.listSessions({}))
@@ -546,6 +611,46 @@ describe("ACP next service sessions", () => {
expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model")
})
it("does not scan last-used sessions when resolving the new session default", async () => {
const historyCalls: 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: {} }] }),
skills: () => Promise.resolve({ data: [] }),
},
command: {
list: () => Promise.resolve({ data: [] }),
},
session: {
create: (input: { model?: { id?: string } }) => Promise.resolve({ data: { id: input.model?.id } }),
list: () => {
historyCalls.push("list")
return Promise.resolve({ data: [{ id: "ses_recent" }] })
},
messages: () => {
historyCalls.push("messages")
return Promise.resolve({
data: [{ info: { role: "user", model: { providerID: "test", modelID: "second-model" } } }],
})
},
},
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("test-model")
expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/test-model")
expect(historyCalls).toEqual([])
})
it("switches model and returns updated model and effort options", async () => {
const { service } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
@@ -611,24 +716,35 @@ describe("ACP next service sessions", () => {
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
it("does not refetch providers modes or commands when switching effort from session snapshot", async () => {
const calls = {
providers: 0,
agents: 0,
commands: 0,
skills: 0,
mcpAdds: 0,
}
const sdk = {
config: {
providers: () => {
providersCalls++
calls.providers++
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: [] }),
agents: () => {
calls.agents++
return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
},
skills: () => {
calls.skills++
return Promise.resolve({ data: [] })
},
},
command: {
list: () => {
commandCalls++
calls.commands++
return Promise.resolve({ data: [] })
},
},
@@ -637,14 +753,16 @@ describe("ACP next service sessions", () => {
list: () => Promise.resolve({ data: [] }),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
add: () => {
calls.mcpAdds++
return 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)
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 })
await Effect.runPromise(
service.setSessionConfigOption({
@@ -654,11 +772,350 @@ describe("ACP next service sessions", () => {
}),
)
expect(providersCalls).toBe(1)
expect(commandCalls).toBe(1)
expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 })
})
it("switches model against the warm provider snapshot without refetching", async () => {
const calls = {
providers: 0,
agents: 0,
commands: 0,
skills: 0,
}
const sdk = {
config: {
providers: () => {
calls.providers++
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
},
get: () => Promise.resolve({ data: {} }),
},
app: {
agents: () => {
calls.agents++
return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
},
skills: () => {
calls.skills++
return Promise.resolve({ data: [] })
},
},
command: {
list: () => {
calls.commands++
return Promise.resolve({ data: [] })
},
},
session: {
create: () => Promise.resolve({ data: { id: "ses_model_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: [] }))
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(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1 })
})
it("reuses the warm directory snapshot for a second new session in the same cwd", async () => {
const calls = {
providers: 0,
config: 0,
agents: 0,
commands: 0,
skills: 0,
sessionList: 0,
messages: 0,
creates: 0,
}
const sdk = {
config: {
providers: () => {
calls.providers++
return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
},
get: () => {
calls.config++
return Promise.resolve({ data: {} })
},
},
app: {
agents: () => {
calls.agents++
return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
},
skills: () => {
calls.skills++
return Promise.resolve({ data: [] })
},
},
command: {
list: () => {
calls.commands++
return Promise.resolve({ data: [] })
},
},
session: {
create: () => {
calls.creates++
return Promise.resolve({ data: { id: `ses_warm_${calls.creates}` } })
},
list: () => {
calls.sessionList++
return Promise.resolve({ data: [] })
},
messages: () => {
calls.messages++
return 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: [] }))
const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
expect(first.sessionId).toBe("ses_warm_1")
expect(second.sessionId).toBe("ses_warm_2")
expect(calls).toEqual({
providers: 1,
config: 1,
agents: 1,
commands: 1,
skills: 1,
sessionList: 0,
messages: 0,
creates: 2,
})
})
it("normal text prompt sends model variant mode and converted parts", async () => {
const { service, prompts, usageUpdates } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
await Effect.runPromise(
service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "effort",
value: "high",
}),
)
await Effect.runPromise(
service.setSessionConfigOption({
sessionId: session.sessionId,
configId: "mode",
value: "plan",
}),
)
const result = await Effect.runPromise(
service.prompt({
sessionId: session.sessionId,
messageId: "00000000-0000-4000-8000-000000000001",
prompt: [{ type: "text", text: "hello" }],
}),
)
expect(prompts).toEqual([
{
sessionID: session.sessionId,
model: { providerID, modelID },
variant: "high",
parts: [{ type: "text", text: "hello" }],
agent: "plan",
directory: "/workspace",
},
])
expect(result).toEqual({
stopReason: "end_turn",
usage: {
inputTokens: 100,
outputTokens: 40,
thoughtTokens: 7,
cachedReadTokens: 11,
cachedWriteTokens: 13,
totalTokens: 171,
},
userMessageId: "00000000-0000-4000-8000-000000000001",
_meta: {},
})
expect(usageUpdates).toEqual([session.sessionId])
})
it("prompt maps assistant and user audience annotations", async () => {
const { service, prompts } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
await Effect.runPromise(
service.prompt({
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "assistant context", annotations: { audience: ["assistant"] } },
{ type: "text", text: "user context", annotations: { audience: ["user"] } },
],
}),
)
expect(prompts).toContainEqual({
sessionID: session.sessionId,
model: { providerID, modelID },
variant: "default",
parts: [
{ type: "text", text: "assistant context", synthetic: true },
{ type: "text", text: "user context", ignored: true },
],
agent: "build",
directory: "/workspace",
})
})
it("prompt sends image and resource parts", async () => {
const { service, prompts } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
await Effect.runPromise(
service.prompt({
sessionId: session.sessionId,
prompt: [
{ type: "image", data: "AAAA", mimeType: "image/png", uri: "file:///tmp/screenshot.png" },
{
type: "resource",
resource: {
uri: "file:///tmp/report.pdf",
mimeType: "application/pdf",
blob: "JVBERg==",
},
},
],
}),
)
expect((prompts[0] as { parts?: unknown }).parts).toEqual([
{
type: "file",
url: "data:image/png;base64,AAAA",
filename: "screenshot.png",
mime: "image/png",
},
{
type: "file",
url: "data:application/pdf;base64,JVBERg==",
filename: "report.pdf",
mime: "application/pdf",
},
])
})
it("slash command prompt calls session command", async () => {
const { service, prompts, commands } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const result = await Effect.runPromise(
service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/init now" }] }),
)
expect(prompts).toEqual([])
expect(commands).toEqual([
{
sessionID: session.sessionId,
command: "init",
arguments: "now",
model: "test/test-model",
variant: "default",
agent: "build",
directory: "/workspace",
},
])
expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 4, totalTokens: 7 })
})
it("compact slash command calls summarize path", async () => {
const { service, prompts, commands, summarizes } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
await Effect.runPromise(
service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/compact" }] }),
)
expect(prompts).toEqual([])
expect(commands).toEqual([])
expect(summarizes).toEqual([
{
sessionID: session.sessionId,
directory: "/workspace",
providerID,
modelID,
},
])
})
it("maps prompt auth failures to auth-required request errors", async () => {
const { service } = makeService()
const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
const failing = ACPNextService.make({
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: () => Promise.resolve({ data: { id: session.sessionId } }),
list: () => Promise.resolve({ data: [] }),
prompt: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }),
},
mcp: {
add: () => Promise.resolve({ data: {} }),
},
} as unknown as OpencodeClient,
usage: UsageService.Service.of({
buildUsage: UsageService.buildUsage,
latestAssistantMessage: UsageService.latestAssistantMessage,
totalSessionCost: UsageService.totalSessionCost,
contextLimit: () => Effect.succeed(128000),
sendUpdate: () => Effect.void,
}),
})
await Effect.runPromise(failing.newSession({ cwd: "/workspace", mcpServers: [] }))
const error = await Effect.runPromise(
failing
.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
.pipe(Effect.mapError(ACPNextError.toRequestError), Effect.flip),
)
expect(error.code).toBe(-32000)
})
})
function assistantInfo(tokens: UsageService.AssistantTokenCost["tokens"]): UsageService.AssistantMessage {
return {
role: "assistant",
providerID: "test",
modelID: "test-model",
cost: 0,
tokens,
}
}
function categories(result: NewSessionResponse | LoadSessionResponse) {
return result.configOptions?.map((option) => option.category) ?? []
}
@@ -1,282 +0,0 @@
import { describe, expect } from "bun:test"
import type {
AuthenticateResponse,
CloseSessionResponse,
InitializeResponse,
LoadSessionResponse,
NewSessionResponse,
ResumeSessionResponse,
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?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
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(
"advertises and supports close 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)),
},
}),
)
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
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(
"advertises and supports resume 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)),
},
}),
)
const initialized = expectOk(yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 }))
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
const session = expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd: home, mcpServers: [] }))
const resumed = expectOk(
yield* acp.request<ResumeSessionResponse>("session/resume", {
cwd: home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
}),
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,103 @@
import { describe, expect } from "bun:test"
import type { SetSessionConfigOptionResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { expectOk, flattenSelectOptions, selectConfigOption } from "../acp/acp-test-client"
import {
createAcpNextClient,
expectAlternateValue,
expectSelectOption,
initialize,
newSession,
verifierConfig,
} from "./helpers"
describe("opencode acp-next config option subprocess", () => {
cliIt.live(
'model option is listed with category "model"',
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const model = expectSelectOption((yield* newSession(acp, home)).configOptions, "model")
expect(model.category).toBe("model")
expect(model.currentValue).toBe("test/test-model")
expect(flattenSelectOptions(model).length).toBeGreaterThanOrEqual(2)
}),
60_000,
)
cliIt.live(
"model switch updates currentValue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const model = expectSelectOption(session.configOptions, "model")
const nextModel = flattenSelectOptions(model).find((option) => option.value === "test/second-model")?.value
expect(nextModel).toBe("test/second-model")
const updated = expectOk(
yield* acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: nextModel,
}),
)
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
}),
60_000,
)
cliIt.live(
'effort option is listed with category "thought_level" when selected model supports variants',
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const effort = expectSelectOption((yield* newSession(acp, home)).configOptions, "effort")
expect(effort.category).toBe("thought_level")
expect(effort.currentValue).toBe("low")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"])
}),
60_000,
)
cliIt.live(
"effort switch updates currentValue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const nextEffort = expectAlternateValue(expectSelectOption(session.configOptions, "effort"))
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,
)
})
@@ -0,0 +1,103 @@
import { expect } from "bun:test"
import type { InitializeResponse, NewSessionResponse, SessionConfigOption } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import type { CliFixture } from "../../lib/cli-process"
import { testProviderConfig } from "../../lib/test-provider"
import {
createAcpClient,
expectOk,
flattenSelectOptions,
selectConfigOption,
type AcpClient,
} from "../acp/acp-test-client"
export function createAcpNextClient(input: Pick<CliFixture, "opencode">, env?: Record<string, string>) {
return Effect.gen(function* () {
return createAcpClient(
yield* input.opencode.acp({
env: {
OPENCODE_ACP_NEXT: "1",
...env,
},
}),
)
})
}
export function initialize(acp: AcpClient) {
return Effect.gen(function* () {
return expectOk(
yield* acp.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: { _meta: { "terminal-auth": true } },
clientInfo: { name: "opencode-local-acp-next", version: "0.1.0" },
}),
)
})
}
export function newSession(acp: AcpClient, cwd: string) {
return Effect.gen(function* () {
return expectOk(yield* acp.request<NewSessionResponse>("session/new", { cwd, mcpServers: [] }))
})
}
export 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",
variants: {
medium: {},
max: {},
},
},
},
},
},
}
}
export function expectErrorCode(error: unknown, code: number) {
if (!error || typeof error !== "object" || !("code" in error)) {
expect(error).toEqual({ code })
return
}
expect(error.code).toBe(code)
}
export function expectSelectOption(options: SessionConfigOption[] | null | undefined, id: string) {
const option = selectConfigOption(options, id)
expect(option).toBeDefined()
return option!
}
export function expectAlternateValue(option: ReturnType<typeof expectSelectOption>) {
const value = flattenSelectOptions(option).find((item) => item.value !== option.currentValue)?.value
expect(value).toBeDefined()
return value!
}
export const verifierSkill = `---
name: verifier-skill
description: Verifier compatibility skill.
---
# Verifier Skill
`
@@ -0,0 +1,61 @@
import { describe, expect } from "bun:test"
import type { AuthenticateResponse, InitializeResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { createAcpNextClient, expectErrorCode, initialize } from "./helpers"
describe("opencode acp-next initialize/auth subprocess", () => {
cliIt.live(
"initialize responds with capabilities",
({ opencode }) =>
Effect.gen(function* () {
const initialized = yield* initialize(yield* createAcpNextClient({ opencode }))
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?.close).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
expect(initialized.agentInfo?.name).toBe("OpenCode")
}),
60_000,
)
cliIt.live(
"auth negotiation is explicit and safe",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient({ opencode })
const initialized = yield* initialize(acp)
expect(initialized.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.authMethods?.[0]?._meta?.["terminal-auth"]).toBeDefined()
expect(yield* acp.request<AuthenticateResponse>("authenticate", { methodId: "opencode-login" })).toMatchObject({
result: {},
})
const rejected = yield* acp.request<AuthenticateResponse>("authenticate", { methodId: "missing-auth-method" })
expectErrorCode(rejected.error, -32602)
expect(JSON.stringify(rejected.error)).not.toContain(process.env.OPENCODE_AUTH_CONTENT ?? "not-present")
}),
60_000,
)
cliIt.live(
"initialize without terminal-auth metadata keeps auth command implicit",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient({ opencode })
const initialized = yield* acp.request<InitializeResponse>("initialize", { protocolVersion: 1 })
expect(initialized.result?.authMethods?.[0]?.id).toBe("opencode-login")
expect(initialized.result?.authMethods?.[0]?._meta?.["terminal-auth"]).toBeUndefined()
}),
60_000,
)
})
@@ -0,0 +1,118 @@
import { describe, expect } from "bun:test"
import type {
CloseSessionResponse,
ListSessionsResponse,
LoadSessionResponse,
ResumeSessionResponse,
} from "@agentclientprotocol/sdk"
import { Duration, Effect } from "effect"
import { cliIt } from "../../lib/cli-process"
import { expectOk, selectConfigOption } from "../acp/acp-test-client"
import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers"
describe("opencode acp-next lifecycle subprocess", () => {
cliIt.live(
"stdin EOF exits cleanly",
({ opencode }) =>
Effect.gen(function* () {
const acp = yield* opencode.acp({ env: { OPENCODE_ACP_NEXT: "1" } })
acp.close()
const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(5)))
expect(code).toBe(0)
}),
60_000,
)
cliIt.live(
"close capability and close request",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
const initialized = yield* initialize(acp)
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
const session = yield* newSession(acp, home)
expectOk(yield* acp.request<CloseSessionResponse>("session/close", { sessionId: session.sessionId }))
}),
60_000,
)
cliIt.live(
"loadSession capability and load request return session config options",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
const initialized = yield* initialize(acp)
expect(initialized.agentCapabilities?.loadSession).toBe(true)
const session = yield* newSession(acp, home)
const loaded = expectOk(
yield* acp.request<LoadSessionResponse>("session/load", {
cwd: home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(loaded.configOptions, "model")?.category).toBe("model")
}),
60_000,
)
cliIt.live(
"list request includes a live ACP-created session",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const listed = expectOk(yield* acp.request<ListSessionsResponse>("session/list", { cwd: home }))
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
}),
60_000,
)
cliIt.live(
"resume capability advertisement",
({ opencode }) =>
Effect.gen(function* () {
const initialized = yield* initialize(yield* createAcpNextClient({ opencode }))
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}),
60_000,
)
cliIt.live(
"resume request returns session config options",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
const resumed = expectOk(
yield* acp.request<ResumeSessionResponse>("session/resume", {
cwd: home,
sessionId: session.sessionId,
mcpServers: [],
}),
)
expect(selectConfigOption(resumed.configOptions, "model")?.category).toBe("model")
}),
60_000,
)
})
@@ -0,0 +1,97 @@
import { describe, expect } from "bun:test"
import type { PromptResponse } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { writeFile } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { cliIt } from "../../lib/cli-process"
import { expectOk } from "../acp/acp-test-client"
import { createAcpNextClient, initialize, newSession, verifierConfig } from "./helpers"
const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
describe("opencode acp-next prompt content subprocess", () => {
cliIt.live(
"accepts embedded text resource image and file resource link prompt content",
({ home, llm, opencode }) =>
Effect.gen(function* () {
yield* Effect.promise(() => writeFile(path.join(home, "README.md"), "# ACP content smoke\n"))
const acp = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(promptContentConfig(llm.url)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
yield* llm.text("embedded resource accepted")
expectOk(
yield* acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this embedded resource." },
{
type: "resource",
resource: { uri: "file:///context.txt", mimeType: "text/plain", text: "embedded context" },
},
],
}),
)
yield* llm.text("image accepted")
expectOk(
yield* acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this image." },
{
type: "image",
mimeType: "image/png",
data: tinyPng,
},
],
}),
)
yield* llm.text("file link accepted")
const linked = expectOk(
yield* acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this linked file." },
{
type: "resource_link",
uri: pathToFileURL(path.join(home, "README.md")).href,
name: "README.md",
mimeType: "text/markdown",
},
],
}),
)
expect(linked.stopReason).toBe("end_turn")
}),
60_000,
)
})
function promptContentConfig(llmUrl: string) {
const config = verifierConfig(llmUrl)
return {
...config,
provider: {
test: {
...config.provider.test,
models: Object.fromEntries(
Object.entries(config.provider.test.models).map(([id, model]) => [
id,
{
...model,
attachment: true,
reasoning: true,
},
]),
),
},
},
}
}
@@ -0,0 +1,38 @@
import { describe, expect } from "bun:test"
import type { SessionNotification } 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 { createAcpNextClient, initialize, newSession, verifierConfig, verifierSkill } from "./helpers"
describe("opencode acp-next skills subprocess", () => {
cliIt.live(
"skill slash command 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 = yield* createAcpNextClient(
{ opencode },
{ OPENCODE_CONFIG_CONTENT: JSON.stringify(verifierConfig(llm.url, skills)) },
)
yield* initialize(acp)
const session = yield* newSession(acp, home)
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" && command.description.length > 0,
),
)
expect(update.params?.sessionId).toBe(session.sessionId)
}),
60_000,
)
})
@@ -40,7 +40,7 @@ describe("opencode acp verifier compatibility baseline", () => {
)
cliIt.live(
"first session timing diagnostic stays bounded and returns model options",
"first session returns model options",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
@@ -50,7 +50,6 @@ describe("opencode acp verifier compatibility baseline", () => {
},
}),
)
const started = Date.now()
yield* acp.request<InitializeResponse>("initialize", {
protocolVersion: 1,
clientCapabilities: {},
@@ -62,9 +61,6 @@ describe("opencode acp verifier compatibility baseline", () => {
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")
@@ -74,7 +70,7 @@ describe("opencode acp verifier compatibility baseline", () => {
)
cliIt.live(
"warm newSession timing diagnostic stays bounded",
"newSession can be called repeatedly",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
@@ -87,22 +83,19 @@ describe("opencode acp verifier compatibility baseline", () => {
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",
"model switch updates currentValue",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const acp = createAcpClient(
@@ -121,7 +114,6 @@ describe("opencode acp verifier compatibility baseline", () => {
: 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,
@@ -129,9 +121,7 @@ describe("opencode acp verifier compatibility baseline", () => {
value: nextModel,
}),
)
const durationMs = Date.now() - started
expect(durationMs).toBeLessThan(15_000)
expect(selectConfigOption(updated.configOptions, "model")?.currentValue).toBe(nextModel)
}),
60_000,
@@ -189,7 +179,7 @@ describe("opencode acp verifier compatibility baseline", () => {
)
cliIt.live(
"skill slash command timing diagnostic appears through available_commands_update",
"skill slash command appears through available_commands_update",
({ home, llm, opencode }) =>
Effect.gen(function* () {
const skills = path.join(home, "skills")
@@ -214,20 +204,6 @@ describe("opencode acp verifier compatibility baseline", () => {
)
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,
)
+83 -1
View File
@@ -4,7 +4,12 @@ import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { onCleanup } from "solid-js"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
import {
getOpencodeModeStack,
OPENCODE_BASE_MODE,
OpencodeKeymapProvider,
registerOpencodeKeymap,
} from "@/cli/cmd/tui/keymap"
test("legacy page key aliases compile as page keys", async () => {
const sequences: Record<string, string[][]> = {}
@@ -52,3 +57,80 @@ test("legacy page key aliases compile as page keys", async () => {
app.renderer.destroy()
}
})
test("mode-less bindings stay active when opencode mode changes", async () => {
const counts: Record<string, Record<string, number>> = {}
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const config = createTuiResolvedConfig()
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
const offGlobal = keymap.registerLayer({
commands: [
{ name: "session.list", run() {} },
{ name: "session.new", run() {} },
{ name: "session.page.up", run() {} },
{ name: "session.first", run() {} },
],
bindings: config.keybinds.gather("test.global", [
"session.list",
"session.new",
"session.page.up",
"session.first",
]),
})
const offBase = keymap.registerLayer({
mode: OPENCODE_BASE_MODE,
commands: [{ name: "model.list", run() {} }],
bindings: config.keybinds.gather("test.base", ["model.list"]),
})
const activeCounts = () =>
Object.fromEntries(
Array.from(
keymap.getCommandBindings({
visibility: "active",
commands: ["session.list", "session.new", "session.page.up", "session.first", "model.list"],
}),
([command, bindings]) => [command, bindings.length],
),
)
counts.base = activeCounts()
const popQuestion = getOpencodeModeStack(keymap).push("question")
counts.question = activeCounts()
popQuestion()
const popAutocomplete = getOpencodeModeStack(keymap).push("autocomplete")
counts.autocomplete = activeCounts()
popAutocomplete()
onCleanup(() => {
offBase()
offGlobal()
offKeymap()
})
return (
<OpencodeKeymapProvider keymap={keymap}>
<box />
</OpencodeKeymapProvider>
)
}
const app = await testRender(() => <Harness />)
try {
expect(counts).toEqual({
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
autocomplete: {
"session.list": 1,
"session.new": 1,
"session.page.up": 2,
"session.first": 2,
"model.list": 0,
},
})
} finally {
app.renderer.destroy()
}
})
@@ -63,6 +63,7 @@ describe("RuntimeFlags", () => {
expect(flags.experimentalWorkspaces).toBe(true)
expect(flags.experimentalIconDiscovery).toBe(true)
expect(flags.experimentalNativeLlm).toBe(false)
expect(flags.experimentalWebSockets).toBe(false)
expect(flags.client).toBe("desktop")
}),
)
@@ -91,6 +92,16 @@ describe("RuntimeFlags", () => {
}),
)
it.effect("enables WebSockets via dedicated flag only", () =>
Effect.gen(function* () {
const explicit = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_WEBSOCKETS: "true" })))
const umbrella = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL: "true" })))
expect(explicit.experimentalWebSockets).toBe(true)
expect(umbrella.experimentalWebSockets).toBe(false)
}),
)
it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () =>
Effect.gen(function* () {
const flags = yield* readFlags.pipe(
+19 -1
View File
@@ -5,7 +5,7 @@ import {
extractAccountIdFromClaims,
extractAccountId,
type IdTokenClaims,
} from "../../src/plugin/codex"
} from "../../src/plugin/openai/codex"
function createTestJwt(payload: object): string {
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")
@@ -122,6 +122,24 @@ describe("plugin.codex", () => {
})
})
test("installs websocket transport only when experimental websockets are enabled", async () => {
const disabled = await CodexAuthPlugin({} as never)
const enabled = await CodexAuthPlugin({} as never, { experimentalWebSockets: true })
const disabledOptions = await disabled.auth!.loader!(
async () => ({ type: "api", key: "sk-test" }) as never,
{} as never,
)
const enabledOptions = await enabled.auth!.loader!(
async () => ({ type: "api", key: "sk-test" }) as never,
{} as never,
)
expect(disabledOptions.fetch).toBeUndefined()
expect(enabledOptions.fetch).toBeFunction()
await enabled.dispose?.()
})
test("deduplicates concurrent Codex token refreshes", async () => {
let auth = {
type: "oauth" as const,
@@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test"
import { experimentalWebSocketsEnabled } from "../../src/plugin"
describe("plugin.openai.websocket rollout", () => {
test("enables websockets by default only on pre-release channels", () => {
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "local" })).toBe(true)
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "dev" })).toBe(true)
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "beta" })).toBe(true)
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "latest" })).toBe(false)
expect(experimentalWebSocketsEnabled({ enabled: false, channel: "prod" })).toBe(false)
})
test("allows releases to opt in through the experimental flag", () => {
expect(experimentalWebSocketsEnabled({ enabled: true, channel: "latest" })).toBe(true)
expect(experimentalWebSocketsEnabled({ enabled: true, channel: "prod" })).toBe(true)
})
})
@@ -0,0 +1,700 @@
import { describe, expect, test } from "bun:test"
import { EventEmitter } from "node:events"
import { createServer, type IncomingMessage, type Server as HttpServer } from "node:http"
import net, { type AddressInfo, type Socket } from "node:net"
import WebSocket, { WebSocketServer } from "ws"
import { ProviderError } from "../../src/provider/error"
import { OpenAIWebSocket } from "../../src/plugin/openai/ws"
import { OpenAIWebSocketPool, TITLE_HEADER } from "../../src/plugin/openai/ws-pool"
describe("plugin.openai.ws", () => {
test("derives websocket URLs and sends auth plus protocol headers", async () => {
let headers: IncomingMessage["headers"] | undefined
await using server = await createWebSocketServer((_socket, request) => {
headers = request.headers
})
const socket = await OpenAIWebSocket.connectResponsesWebSocket({
url: server.wsUrl,
headers: { authorization: "Bearer test", "content-length": "123" },
})
expect(OpenAIWebSocket.toWebSocketUrl("http://example.com/v1/responses")).toBe("ws://example.com/v1/responses")
expect(OpenAIWebSocket.toWebSocketUrl("https://example.com/v1/responses")).toBe("wss://example.com/v1/responses")
expect(headers?.authorization).toBe("Bearer test")
expect(headers?.["openai-beta"]).toBe(OpenAIWebSocket.PROTOCOL_HEADER)
expect(headers?.["content-length"]).toBeUndefined()
socket.terminate()
})
test("enforces websocket connect timeout", async () => {
await using server = await createHangingTcpServer()
await expect(
OpenAIWebSocket.connectResponsesWebSocket({
url: server.wsUrl,
headers: {},
timeout: 20,
}),
).rejects.toThrow("WebSocket connect timed out")
})
test("enforces websocket send idle timeout", async () => {
const socket = new (class extends EventEmitter {
send(_data: string, _callback: (error?: Error) => void) {}
})() as unknown as WebSocket
const invalid: string[] = []
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket,
body: { stream: true, input: "hi" },
idleTimeout: 20,
onConnectionInvalid: (error) => invalid.push(error.message),
})
expect((await readTextError(response.text())).message).toContain("idle timeout sending websocket request")
expect(invalid).toEqual(["idle timeout sending websocket request"])
})
test("streams websocket events as SSE and handles response.done", async () => {
let requestBody: unknown
await using server = await createWebSocketServer((socket) => {
socket.once("message", (data) => {
requestBody = JSON.parse(data.toString())
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "hello" }))
socket.send(JSON.stringify({ type: "response.done", response: { id: "resp_123" } }))
socket.close(1000, "done")
})
})
const socket = await OpenAIWebSocket.connectResponsesWebSocket({
url: server.wsUrl,
headers: { authorization: "Bearer test", "content-length": "123" },
})
const completed: Record<string, unknown>[] = []
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket,
body: { stream: true, background: true, input: "hi" },
onComplete: (event) => completed.push(event),
})
expect(await response.text()).toBe(
'data: {"type":"response.output_text.delta","delta":"hello"}\n\ndata: {"type":"response.done","response":{"id":"resp_123"}}\n\ndata: [DONE]\n\n',
)
expect(requestBody).toEqual({ type: "response.create", input: "hi" })
expect(completed).toHaveLength(1)
expect(completed[0]?.type).toBe("response.done")
})
test("errors the SSE stream when the server closes before a terminal event", async () => {
const invalid: Error[] = []
await using server = await createWebSocketServer((socket) => {
socket.once("message", () => {
socket.close(1009, "payload too large")
})
})
const socket = await OpenAIWebSocket.connectResponsesWebSocket({ url: server.wsUrl, headers: {} })
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket,
body: { stream: true, input: "hi" },
onConnectionInvalid: (error) => invalid.push(error),
})
expect((await readTextError(response.text())).message).toContain(
"WebSocket closed before response.completed (code 1009: message too big: payload too large)",
)
expect(invalid[0]).toBeInstanceOf(ProviderError.ResponseStreamError)
expect(invalid.map((error) => error.message)).toEqual([
"WebSocket closed before response.completed (code 1009: message too big: payload too large)",
])
})
test("rejects unexpected binary websocket frames", async () => {
const invalid: string[] = []
await using server = await createWebSocketServer((socket) => {
socket.once("message", () => {
socket.send(Buffer.from("not json text"))
})
})
const socket = await OpenAIWebSocket.connectResponsesWebSocket({ url: server.wsUrl, headers: {} })
const response = OpenAIWebSocket.streamResponsesWebSocket({
socket,
body: { stream: true, input: "hi" },
onConnectionInvalid: (error) => invalid.push(error.message),
})
expect((await readTextError(response.text())).message).toContain("Unexpected binary WebSocket frame")
expect(invalid).toEqual(["Unexpected binary WebSocket frame"])
})
})
describe("plugin.openai.ws-pool", () => {
test("reuses one healthy websocket for sequential requests", async () => {
let connections = 0
let messages = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.on("message", () => {
messages += 1
socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${messages}` } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toContain("data: [DONE]")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(1)
expect(messages).toBe(2)
fetch.close()
})
test("rotates a socket that exceeds max connection age", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.on("message", () => {
socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${connections}` } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
maxConnectionAge: 0,
})
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toContain("data: [DONE]")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(2)
fetch.close()
})
test("falls back to HTTP after websocket setup retries are exhausted", async () => {
const attempts: string[] = []
await using server = await createRejectingWebSocketServer(() => attempts.push("websocket"))
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
connectTimeout: 100,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" }))
expect(await readTextError(first.text())).toBeInstanceOf(ProviderError.ResponseStreamError)
const second = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" }))
const third = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "false" }))
expect(await second.text()).toBe("http")
expect(await third.text()).toBe("http")
expect(attempts).toEqual(["websocket", "websocket"])
expect(server.httpRequests).toHaveLength(2)
expect(server.httpRequests[0]?.headers[TITLE_HEADER]).toBeUndefined()
expect(server.httpRequests[1]?.headers[TITLE_HEADER]).toBeUndefined()
fetch.close()
})
test("prunes HTTP fallback after its idle timeout", async () => {
let websocketAttempts = 0
await using server = await createRejectingWebSocketServer(() => websocketAttempts++)
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
connectTimeout: 100,
idleTimeout: 20,
streamRetries: 0,
})
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toBe("http")
await new Promise((resolve) => setTimeout(resolve, 50))
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(websocketAttempts).toBe(2)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
test("invalidates but does not reuse a socket after terminal failure frames", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
socket.send(JSON.stringify({ type: connections === 1 ? "response.failed" : "response.completed" }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest())
expect(await first.text()).toContain('data: {"type":"response.failed"}')
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain('data: {"type":"response.completed"}')
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
test("retries websocket connection limit errors on the next stream attempt", async () => {
let connections = 0
let messages = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
messages += 1
if (connections === 1) {
socket.send(
JSON.stringify({
type: "error",
status: 400,
error: {
type: "invalid_request_error",
code: "websocket_connection_limit_reached",
message: "Responses websocket connection limit reached",
},
}),
)
return
}
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_retry" } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("Responses websocket connection limit reached")
const second = await fetch(server.url, streamRequest())
const text = await second.text()
expect(text).not.toContain("websocket_connection_limit_reached")
expect(text).toContain('data: {"type":"response.completed","response":{"id":"resp_retry"}}')
expect(text).toContain("data: [DONE]")
expect(connections).toBe(2)
expect(messages).toBe(2)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
test("falls back to HTTP after websocket connection limit retries are exhausted", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
socket.send(
JSON.stringify({
type: "error",
status: 400,
error: {
type: "invalid_request_error",
code: "websocket_connection_limit_reached",
message: "Responses websocket connection limit reached",
},
}),
)
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
streamRetries: 2,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("Responses websocket connection limit reached")
const second = await fetch(server.url, streamRequest())
expect((await readTextError(second.text())).message).toContain("Responses websocket connection limit reached")
const third = await fetch(server.url, streamRequest())
const fourth = await fetch(server.url, streamRequest())
expect(await third.text()).toBe("http")
expect(await fourth.text()).toBe("http")
expect(connections).toBe(3)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
test("shares the websocket retry budget across stream and connection limit failures", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
if (connections === 1) {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
socket.terminate()
return
}
socket.send(
JSON.stringify({
type: "error",
error: {
code: "websocket_connection_limit_reached",
message: "Responses websocket connection limit reached",
},
}),
)
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(1)
fetch.close()
})
test("retries websocket idle failures before first event then falls back to HTTP", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
idleTimeout: 20,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket")
const second = await fetch(server.url, streamRequest())
const third = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(await third.text()).toBe("http")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
test("retries failed websocket streams before using HTTP fallback", async () => {
await using server = await createWebSocketServer((socket) => {
socket.once("message", () => {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
idleTimeout: 20,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("idle timeout waiting for websocket")
const second = await fetch(server.url, streamRequest())
expect((await readTextError(second.text())).message).toContain("idle timeout waiting for websocket")
const third = await fetch(server.url, streamRequest())
expect(await third.text()).toBe("http")
expect(server.httpRequests).toHaveLength(1)
fetch.close()
})
test("resets websocket stream failures after a completed response", async () => {
let connections = 0
let requests = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.on("message", () => {
requests += 1
if (requests === 1 || requests === 3) {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
socket.terminate()
return
}
socket.send(JSON.stringify({ type: "response.completed", response: { id: `resp_${requests}` } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
const third = await fetch(server.url, streamRequest())
expect((await readTextError(third.text())).message).toContain("WebSocket closed before response.completed")
const fourth = await fetch(server.url, streamRequest())
expect(await fourth.text()).toContain("data: [DONE]")
expect(connections).toBe(3)
expect(requests).toBe(4)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
test("falls back to HTTP for missing session and title requests", async () => {
await using server = await createWebSocketServer(() => {})
const fetch = OpenAIWebSocketPool.createWebSocketFetch()
const missingSession = await fetch(server.url, {
method: "POST",
headers: { [TITLE_HEADER]: "false" },
body: JSON.stringify({ stream: true }),
})
const title = await fetch(server.url, streamRequest({ [TITLE_HEADER]: "true" }))
expect(await missingSession.text()).toBe("http")
expect(await title.text()).toBe("http")
expect(server.httpRequests).toHaveLength(2)
expect(server.httpRequests[0]?.headers[TITLE_HEADER]).toBeUndefined()
expect(server.httpRequests[1]?.headers[TITLE_HEADER]).toBeUndefined()
fetch.close()
})
test("falls back to HTTP while a websocket lane is busy", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
})
})
const abort = new AbortController()
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest({}, abort.signal))
const firstText = first.text()
await waitFor(() => connections === 1, "websocket did not connect")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(server.httpRequests).toHaveLength(1)
expect(connections).toBe(1)
abort.abort(new Error("stop"))
expect((await readTextError(firstText)).message).toContain("stop")
fetch.close()
})
test("reserves a websocket lane while its socket is connecting", async () => {
await using server = await createHangingTcpServer()
await using fallback = await createHttpServer()
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
connectTimeout: 20,
streamRetries: 0,
})
const first = fetch(fallback.url, streamRequest())
await waitFor(() => server.connections() === 1, "first websocket did not begin connecting")
const second = fetch(fallback.url, streamRequest())
expect(await (await second).text()).toBe("http")
expect(await (await first).text()).toBe("http")
expect(server.connections()).toBe(1)
expect(fallback.httpRequests).toHaveLength(2)
fetch.close()
})
test("retries unexpected closes before first event then falls back to HTTP", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
socket.close(1001, "server shutdown")
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
streamRetries: 1,
})
const first = await fetch(server.url, streamRequest())
expect((await readTextError(first.text())).message).toContain("WebSocket closed before response.completed")
const second = await fetch(server.url, streamRequest())
const third = await fetch(server.url, streamRequest())
expect(await second.text()).toBe("http")
expect(await third.text()).toBe("http")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(2)
fetch.close()
})
test("does not keep HTTP fallback active after aborting a websocket response", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
if (connections === 1) {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
return
}
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_456" } }))
})
})
const abort = new AbortController()
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest({}, abort.signal))
const firstText = first.text()
await waitFor(() => connections === 1, "first websocket did not connect")
abort.abort(new Error("stop"))
expect((await readTextError(firstText)).message).toContain("stop")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
test("releases the websocket lane when the response body is cancelled", async () => {
let connections = 0
await using server = await createWebSocketServer((socket) => {
connections += 1
socket.once("message", () => {
if (connections === 1) {
socket.send(JSON.stringify({ type: "response.output_text.delta", delta: "started" }))
return
}
socket.send(JSON.stringify({ type: "response.completed", response: { id: "resp_after_cancel" } }))
})
})
const fetch = OpenAIWebSocketPool.createWebSocketFetch({
url: server.url,
})
const first = await fetch(server.url, streamRequest())
await waitFor(() => connections === 1, "first websocket did not connect")
await first.body!.cancel("stop")
const second = await fetch(server.url, streamRequest())
expect(await second.text()).toContain("data: [DONE]")
expect(connections).toBe(2)
expect(server.httpRequests).toHaveLength(0)
fetch.close()
})
})
function streamRequest(headers?: Record<string, string>, signal?: AbortSignal): RequestInit {
return {
method: "POST",
headers: {
"session-id": "session-1",
authorization: "Bearer test",
...headers,
},
body: JSON.stringify({ stream: true, input: "hi" }),
signal,
}
}
async function readTextError(promise: Promise<string>) {
// Bun 1.3.14 hangs on expect(response.text()).rejects for streams errored from ws callbacks.
return promise.then(
() => {
throw new Error("Expected response text to reject")
},
(error) => {
expect(error).toBeInstanceOf(Error)
return error as Error
},
)
}
async function createWebSocketServer(onConnection: (socket: WebSocket, request: IncomingMessage) => void) {
const http = await createHttpServer()
const server = new WebSocketServer({ server: http.server })
server.on("connection", onConnection)
return websocketServerHandle(server, http)
}
async function createHangingTcpServer() {
const sockets = new Set<Socket>()
let connections = 0
const server = net.createServer((socket) => {
connections += 1
sockets.add(socket)
socket.on("close", () => sockets.delete(socket))
})
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
const address = server.address() as AddressInfo
return {
url: `http://127.0.0.1:${address.port}/v1/responses`,
wsUrl: `ws://127.0.0.1:${address.port}/v1/responses`,
connections: () => connections,
async [Symbol.asyncDispose]() {
for (const socket of sockets) socket.destroy()
server.close()
},
}
}
async function createRejectingWebSocketServer(onAttempt: () => void) {
const http = await createHttpServer()
const server = new WebSocketServer({
server: http.server,
verifyClient(_info, callback) {
onAttempt()
callback(false, 401, "denied")
},
})
return websocketServerHandle(server, http)
}
async function createHttpServer() {
const httpRequests: IncomingMessage[] = []
const server = createServer((request, response) => {
httpRequests.push(request)
response.writeHead(200, { "content-type": "text/plain" })
response.end("http")
})
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))
const address = server.address() as AddressInfo
return {
server,
httpRequests,
url: `http://127.0.0.1:${address.port}/v1/responses`,
async [Symbol.asyncDispose]() {
await closeHttpServer(server)
},
}
}
function websocketServerHandle(server: WebSocketServer, http: Awaited<ReturnType<typeof createHttpServer>>) {
return {
url: http.url,
wsUrl: http.url.replace(/^http/, "ws"),
httpRequests: http.httpRequests,
async [Symbol.asyncDispose]() {
for (const socket of server.clients) socket.terminate()
server.close()
http.server.close()
},
}
}
function closeHttpServer(server: HttpServer) {
return new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
}
async function waitFor(predicate: () => boolean, message: string) {
const started = Date.now()
while (!predicate()) {
if (Date.now() - started > 1_000) throw new Error(message)
await new Promise((resolve) => setTimeout(resolve, 1))
}
}
@@ -214,6 +214,22 @@ describe("Worktree", () => {
{ git: true },
)
it.instance(
"lists the active linked worktree but not the project checkout",
() =>
withCreatedWorktree(undefined, ({ info }) =>
Effect.gen(function* () {
const test = yield* TestInstance
const svc = yield* Worktree.Service
const list = yield* svc.list().pipe(provideInstance(info.directory))
expect(list.map((item) => item.name)).toContain(info.name)
expect(list.map((item) => item.name)).not.toContain(path.basename(test.directory).toLowerCase())
}),
),
{ git: true },
)
it.instance(
"create with custom name",
() =>
@@ -3223,6 +3223,12 @@ describe("ProviderTransform.variants", () => {
efforts: ["low", "medium", "high", "xhigh", "max"],
expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
},
{
name: "opus 4.8",
apiIds: ["claude-opus-4-8", "claude-opus-4.8"],
efforts: ["low", "medium", "high", "xhigh", "max"],
expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" },
},
]) {
for (const apiId of testCase.apiIds) {
test(`${testCase.name} ${apiId} returns supported reasoning efforts`, () => {
@@ -3341,6 +3347,28 @@ describe("ProviderTransform.variants", () => {
})
})
test("anthropic opus 4.8 returns adaptive reasoning options with xhigh", () => {
const result = ProviderTransform.variants(
createMockModel({
id: "bedrock/anthropic-claude-opus-4.8",
providerID: "bedrock",
api: {
id: "anthropic.claude-opus-4.8",
url: "https://bedrock.amazonaws.com",
npm: "@ai-sdk/amazon-bedrock",
},
}),
)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
expect(result.high).toEqual({
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: "high",
display: "summarized",
},
})
})
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => {
const model = createMockModel({
id: "bedrock/llama-4",
+2 -2
View File
@@ -4,8 +4,8 @@ Use these patterns for server and HttpApi middleware tests in this directory.
- Prefer focused middleware tests with tiny fake routes over full API route trees when testing routing, context, proxying, or middleware policy.
- Use `testEffect(...)` with `NodeHttpServer.layerTest` for the primary in-test server and make relative `HttpClient` requests against it.
- Use `HttpRouter.add(...)` probe routes that expose the context under test, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
- Compose middleware in the same order as production when testing interactions, for example `instanceRouterMiddleware.combine(workspaceRouterMiddleware)`.
- Use tiny `HttpApiBuilder` probe groups that declare the typed middleware under test and expose context such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
- Declare middleware in the same order as production when testing interactions, for example `InstanceContextMiddleware` followed by `WorkspaceRoutingMiddleware`.
- For secondary upstream servers, build Effect `NodeHttpServer.layer(...)` into the current test scope with `Layer.build(...)` so the listener stays alive until the test scope exits.
- Avoid `Bun.serve` when testing Effect HTTP middleware. Keep the test in the Effect HTTP stack unless the production path being tested is Bun-specific.
- For WebSocket paths, use `Socket.makeWebSocket(...)` from the test client and assert protocol forwarding or frame relay when relevant.
@@ -1,7 +1,8 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
import { Effect, Fiber, Layer, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import * as Socket from "effect/unstable/socket/Socket"
import { mkdir } from "node:fs/promises"
import path from "node:path"
@@ -12,9 +13,17 @@ import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { InstanceLayer } from "../../src/project/instance-layer"
import { Project } from "../../src/project/project"
import { Session } from "../../src/session/session"
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import {
InstanceContextMiddleware,
instanceContextLayer,
} from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
workspaceRoutingLayer,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { withFixedWorkspaceID } from "../fixture/flag"
@@ -47,9 +56,10 @@ const it = testEffect(
),
)
const instanceContextTestLayer = instanceRouterMiddleware
.combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const instanceContextTestLayer = Layer.mergeAll(
instanceContextLayer,
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
)
const localAdapter = (directory: string): WorkspaceAdapter => ({
name: "Local Test",
@@ -80,20 +90,57 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
const probeInstanceContext = Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return yield* HttpServerResponse.json({
return {
directory: instance?.directory,
worktree: instance?.worktree,
projectID: instance?.project.id,
workspaceID,
})
}
})
const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
HttpRouter.add("GET", probePath, probeInstanceContext).pipe(
Layer.provide(instanceContextTestLayer),
HttpRouter.serve,
Layer.build,
)
const ProbeResult = Schema.Struct({
directory: Schema.optional(Schema.String),
worktree: Schema.optional(Schema.String),
projectID: Schema.optional(Schema.String),
workspaceID: Schema.optional(Schema.String),
})
const ProbeApi = HttpApi.make("instance-context-probe").add(
HttpApiGroup.make("probe")
.add(
HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
HttpApiEndpoint.post("dispose", "/dispose-probe", {
query: WorkspaceRoutingQuery,
success: Schema.Boolean,
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware),
)
const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
handlers
.handle("get", () => probeInstanceContext)
.handle("session", () => probeInstanceContext)
.handle(
"dispose",
Effect.fn("InstanceContextProbe.dispose")(function* () {
const instance = yield* InstanceRef
if (!instance) return false
yield* markInstanceForDisposal(instance)
return true
}),
),
)
const probeRoutes = HttpApiBuilder.layer(ProbeApi).pipe(
Layer.provide(probeHandlers),
Layer.provide(instanceContextTestLayer),
Layer.provide(Layer.mock(Session.Service)({})),
)
const serveProbe = () => probeRoutes.pipe(HttpRouter.serve, Layer.build)
const waitDisposedEvent = waitGlobalBusEvent({
message: "timed out waiting for instance disposal",
@@ -101,19 +148,9 @@ const waitDisposedEvent = waitGlobalBusEvent({
}).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace })))
const serveDisposeProbe = () =>
HttpRouter.serve(
HttpRouter.add(
"POST",
"/dispose-probe",
Effect.gen(function* () {
const instance = yield* InstanceRef
if (!instance) return HttpServerResponse.empty({ status: 500 })
yield* markInstanceForDisposal(instance)
return yield* HttpServerResponse.json(true)
}),
).pipe(Layer.provide(instanceContextTestLayer)),
{ middleware: disposeMiddleware, disableListenLog: true, disableLogger: true },
).pipe(Layer.build)
HttpRouter.serve(probeRoutes, { middleware: disposeMiddleware, disableListenLog: true, disableLogger: true }).pipe(
Layer.build,
)
describe("HttpApi instance context middleware", () => {
it.live("provides instance context from the routed directory", () =>
@@ -129,6 +166,7 @@ describe("HttpApi instance context middleware", () => {
directory: dir,
worktree: dir,
projectID: project.project.id,
workspaceID: null,
})
}),
)
@@ -156,7 +194,7 @@ describe("HttpApi instance context middleware", () => {
type: "instance-context-workspace-ref",
directory: workspaceDir,
})
yield* serveProbe("/session")
yield* serveProbe()
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
HttpClientRequest.setHeader("x-opencode-directory", dir),
@@ -269,7 +307,7 @@ describe("HttpApi instance context middleware", () => {
// is true. Combined with the env override, the route must stay Local with
// the configured workspace id (not divert to the requested workspace's
// local directory).
yield* serveProbe("/session")
yield* serveProbe()
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
HttpClientRequest.setHeader("x-opencode-directory", dir),
@@ -4,6 +4,7 @@ import { HttpRouter } from "effect/unstable/http"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
import { ServerAuth } from "../../src/server/auth"
import { PtyID } from "../../src/pty/schema"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
@@ -35,7 +36,7 @@ function app(input: { password?: string; username?: string }) {
}
function basic(username: string, password: string) {
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
return ServerAuth.header({ username, password }) ?? ""
}
async function cancelBody(response: Response) {
@@ -47,8 +48,8 @@ afterEach(async () => {
await resetDatabase()
})
describe("HttpApi raw route authorization", () => {
test("requires configured auth before opening the raw instance event stream", async () => {
describe("HttpApi instance route authorization", () => {
test("requires configured auth before opening the instance event stream", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const server = app({ password: "secret" })
const headers = { "x-opencode-directory": tmp.path }
@@ -64,7 +65,7 @@ describe("HttpApi raw route authorization", () => {
expect(authed.status).toBe(200)
})
test("requires configured auth before resolving the raw PTY websocket route", async () => {
test("requires configured auth before resolving the PTY websocket route", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const server = app({ password: "secret" })
const route = PtyPaths.connect.replace(":ptyID", PtyID.ascending())
@@ -9,10 +9,11 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Layer, Scope } from "effect"
import { Deferred, Effect, Layer, Schema, Scope } from "effect"
import * as Stream from "effect/Stream"
import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
import { mkdir } from "node:fs/promises"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
@@ -20,8 +21,16 @@ import { Workspace } from "../../src/control-plane/workspace"
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
import { InstanceLayer } from "../../src/project/instance-layer"
import { Project } from "../../src/project/project"
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { Session } from "../../src/session/session"
import {
InstanceContextMiddleware,
instanceContextLayer,
} from "../../src/server/routes/instance/httpapi/middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
workspaceRoutingLayer,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
@@ -52,9 +61,10 @@ const it = testEffect(
),
)
const instanceContextTestLayer = instanceRouterMiddleware
.combine(workspaceRouterMiddleware)
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
const instanceContextTestLayer = Layer.mergeAll(
instanceContextLayer,
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
)
const localAdapter = (directory: string): WorkspaceAdapter => ({
name: "Local Test",
@@ -87,6 +97,46 @@ const captureInstance = Effect.gen(function* () {
return { directory: instance?.directory, workspaceID } satisfies Capture
})
const ProbeApi = HttpApi.make("handler-context-probe").add(
HttpApiGroup.make("probe")
.add(
HttpApiEndpoint.post("fork", "/fork-probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
HttpApiEndpoint.post("streamWithout", "/stream-probe-without", {
query: WorkspaceRoutingQuery,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
}),
HttpApiEndpoint.post("streamWith", "/stream-probe-with", {
query: WorkspaceRoutingQuery,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware),
)
const serveProbes = (input: {
fork?: Effect.Effect<boolean, never, Scope.Scope>
streamWithout?: Effect.Effect<HttpServerResponse.HttpServerResponse>
streamWith?: Effect.Effect<HttpServerResponse.HttpServerResponse>
}) =>
HttpApiBuilder.layer(ProbeApi).pipe(
Layer.provide(
HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
handlers
.handle("fork", () => input.fork ?? Effect.succeed(false))
.handleRaw(
"streamWithout",
() => input.streamWithout ?? Effect.succeed(HttpServerResponse.empty({ status: 404 })),
)
.handleRaw("streamWith", () => input.streamWith ?? Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
),
),
Layer.provide(instanceContextTestLayer),
Layer.provide(Layer.mock(Session.Service)({})),
HttpRouter.serve,
Layer.build,
)
describe("HttpApi handler context inheritance", () => {
// Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits
// the request's Context — including InstanceRef and WorkspaceRef provided
@@ -96,22 +146,20 @@ describe("HttpApi handler context inheritance", () => {
const { dir, workspace } = yield* setupWorkspace("local-fork")
const capture = yield* Deferred.make<Capture>()
yield* HttpRouter.add(
"POST",
"/fork-probe",
Effect.gen(function* () {
yield* serveProbes({
fork: Effect.gen(function* () {
const scope = yield* Scope.Scope
yield* Effect.gen(function* () {
yield* Deferred.succeed(capture, yield* captureInstance)
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
return HttpServerResponse.empty({ status: 204 })
return true
}),
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
})
const response = yield* HttpClient.post(
`/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`,
)
expect(response.status).toBe(204)
expect(response.status).toBe(200)
const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds"))
expect(observed.directory).toBe(dir)
@@ -129,10 +177,8 @@ describe("HttpApi handler context inheritance", () => {
const withoutCapture = yield* Deferred.make<Capture>()
const withCapture = yield* Deferred.make<Capture>()
yield* HttpRouter.add(
"POST",
"/stream-probe-without",
Effect.gen(function* () {
yield* serveProbes({
streamWithout: Effect.gen(function* () {
return HttpServerResponse.stream(
Stream.fromEffect(
Effect.gen(function* () {
@@ -143,12 +189,7 @@ describe("HttpApi handler context inheritance", () => {
{ contentType: "application/json" },
)
}),
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
yield* HttpRouter.add(
"POST",
"/stream-probe-with",
Effect.gen(function* () {
streamWith: Effect.gen(function* () {
const instance = yield* InstanceRef
const workspaceID = yield* WorkspaceRef
return HttpServerResponse.stream(
@@ -161,7 +202,7 @@ describe("HttpApi handler context inheritance", () => {
{ contentType: "application/json" },
)
}),
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
})
const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`
const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`)
@@ -147,6 +147,14 @@ describe("pty HttpApi bridge", () => {
expect(response.status).toBe(404)
})
test("returns 404 for missing PTY websocket before decoding cursor query", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const response = await app().request(`${PtyPaths.connect.replace(":ptyID", PtyID.ascending())}?cursor=a&cursor=b`, {
headers: { "x-opencode-directory": tmp.path },
})
expect(response.status).toBe(404)
})
test("returns typed not found errors for missing PTY HTTP resources", async () => {
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
const headers = { "x-opencode-directory": tmp.path }
@@ -9,6 +9,7 @@ type OpenApiResponse = {
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
}
type OpenApiOperation = {
readonly parameters?: ReadonlyArray<{ readonly name: string; readonly in: string }>
readonly responses?: Record<string, OpenApiResponse>
readonly security?: unknown
}
@@ -207,6 +208,11 @@ describe("PublicApi OpenAPI v2 errors", () => {
expect(componentName(responseRef(spec.paths["/pty/{ptyID}/connect-token"]?.post?.responses?.["403"]) ?? "")).toBe(
"PtyForbiddenError",
)
expect(
spec.paths["/pty/{ptyID}/connect"]?.get?.parameters
?.filter((parameter) => parameter.in === "query")
.map((parameter) => parameter.name),
).toEqual(["directory", "workspace", "cursor", "ticket"])
})
test("documents project not-found errors", () => {
@@ -2,6 +2,7 @@ import { afterEach, describe, expect } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { Cause, Effect, Exit, Layer } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Flag } from "@opencode-ai/core/flag/flag"
import { registerAdapter } from "../../src/control-plane/adapters"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
@@ -27,7 +28,9 @@ import * as DateTime from "effect/DateTime"
import * as Log from "@opencode-ai/core/util/log"
import { eq } from "drizzle-orm"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { disposeAllInstances, provideInstanceEffect, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { TestLLMServer } from "../lib/llm-server"
import { testProviderConfig } from "../lib/test-provider"
import { testEffect } from "../lib/effect"
void Log.init({ print: false })
@@ -368,6 +371,45 @@ describe("session HttpApi", () => {
{ git: true, config: { formatter: false, lsp: false } },
)
it.live("uses the persisted session directory for prompt requests", () =>
Effect.gen(function* () {
const llm = yield* TestLLMServer
yield* llm.text("ok", { usage: { input: 1, output: 1 } })
const config = testProviderConfig(llm.url)
const sessionDirectory = yield* tmpdirScoped({ git: true, config })
const requestDirectory = yield* tmpdirScoped({ git: true, config })
const session = yield* createSession({ title: "directory regression" }).pipe(
provideInstanceEffect(sessionDirectory),
)
const response = yield* request(
`${pathFor(SessionPaths.prompt, { sessionID: session.id })}?directory=${encodeURIComponent(requestDirectory)}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
agent: "build",
model: { providerID: "test", modelID: "test-model" },
parts: [{ type: "text", text: "which directory?" }],
}),
},
)
expect(response.status).toBe(200)
yield* responseJson(response)
const messages = yield* Session.use
.messages({ sessionID: session.id })
.pipe(provideInstanceEffect(sessionDirectory), Effect.orDie)
const assistant = messages.find((message) => message.info.role === "assistant")
expect(assistant?.info.role === "assistant" ? assistant.info.path : undefined).toEqual({
cwd: sessionDirectory,
root: sessionDirectory,
})
}).pipe(Effect.provide(TestLLMServer.layer), Effect.provide(CrossSpawnSpawner.defaultLayer)),
)
it.instance(
"returns v2 public request errors for cursor and workspace query failures",
() =>
@@ -406,6 +406,20 @@ describe("HttpApi UI fallback", () => {
}),
)
it.live("accepts basic auth passwords containing colons for the web UI", () =>
Effect.gen(function* () {
const response = yield* uiApp({
password: "sec:ret",
username: "opencode",
disableEmbeddedWebUi: true,
}).request("/", {
headers: { authorization: `Basic ${btoa("opencode:sec:ret")}` },
})
expect(response.status).toBe(200)
}),
)
// Regression for #25698 (Ope): the browser fetches the PWA manifest and
// its icons via flows that don't carry app-managed credentials (the
// `<link rel="manifest">` request is not under page-auth control), so the
@@ -1,6 +1,6 @@
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
import { describe, expect } from "bun:test"
import { Context, Effect, Layer, Queue, Ref } from "effect"
import { Context, Effect, Layer, Queue, Ref, Schema, Stream } from "effect"
import {
FetchHttpClient,
HttpClient,
@@ -11,6 +11,7 @@ import {
HttpServerResponse,
} from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
import Http from "node:http"
import { mkdir } from "node:fs/promises"
import path from "node:path"
@@ -20,10 +21,13 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
import { Project } from "../../src/project/project"
import { Session } from "../../src/session/session"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRouteContext,
workspaceRouterMiddleware,
workspaceRoutingLayer,
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
import { Database } from "../../src/storage/db"
@@ -60,13 +64,14 @@ type ProxiedRequest = {
url: string
method: string
headers: Record<string, string>
body: string
}
type TestHandler<E, R> = (
request: HttpServerRequest.HttpServerRequest,
) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe(
const workspaceRoutingTestLayer = workspaceRoutingLayer.pipe(
Layer.provide([Socket.layerWebSocketConstructorGlobal, FetchHttpClient.layer]),
)
@@ -177,7 +182,12 @@ const startRemoteWorkspaceHttpServer = <E, R>(
// everything else is the request being proxied by the middleware.
const sync = syncResponse(request)
if (sync) return yield* sync
return yield* handler({ url: request.url, method: request.method, headers: request.headers })
return yield* handler({
url: request.url,
method: request.method,
headers: request.headers,
body: yield* request.text,
})
}),
)
@@ -203,16 +213,45 @@ const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) =>
return HttpServerResponse.empty()
})
const serveRouteContextProbe = HttpRouter.add(
"GET",
"/probe",
Effect.gen(function* () {
// The fake route exposes the context installed by the middleware, so tests
// can assert routing decisions without pulling in the production API tree.
const route = yield* WorkspaceRouteContext
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
}),
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
const ProbeResult = Schema.Struct({
directory: Schema.String,
workspaceID: Schema.optional(Schema.String),
})
const ProbeApi = HttpApi.make("workspace-routing-probe").add(
HttpApiGroup.make("probe")
.add(
HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
HttpApiEndpoint.patch("patch", "/probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
HttpApiEndpoint.get("workspace", WorkspacePaths.list, {
query: WorkspaceRoutingQuery,
success: ProbeResult,
}),
)
.middleware(WorkspaceRoutingMiddleware),
)
const routeContextResponse = Effect.gen(function* () {
const route = yield* WorkspaceRouteContext
return { directory: route.directory, workspaceID: route.workspaceID }
})
const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
handlers
.handle("get", () => routeContextResponse)
.handle("patch", () => Effect.succeed(false))
.handle("session", () => routeContextResponse)
.handle("workspace", () => routeContextResponse),
)
const serveProbe = HttpApiBuilder.layer(ProbeApi).pipe(
Layer.provide(probeHandlers),
Layer.provide(workspaceRoutingTestLayer),
Layer.provide(Layer.mock(Session.Service)({})),
HttpRouter.serve,
Layer.build,
)
describe("HttpApi workspace routing middleware", () => {
it.live("proxies remote workspace HTTP requests through the selected workspace target", () =>
@@ -250,19 +289,20 @@ describe("HttpApi workspace routing middleware", () => {
// The local /probe handler should not run. Selecting a remote workspace
// should make the middleware call HttpApiProxy.http instead.
yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe(
Layer.provide(workspaceRoutingTestLayer),
HttpRouter.serve,
Layer.build,
)
yield* serveProbe
const body = '{"title":"Remote workspace request"}'
const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe(
HttpClientRequest.setHeaders({
"content-type": "application/json",
"x-opencode-directory": "/secret/path",
"x-opencode-workspace": "internal",
}),
HttpClientRequest.bodyStream(
Stream.make(new TextEncoder().encode('{"title":"Remote '), new TextEncoder().encode('workspace request"}')),
{ contentType: "application/json" },
),
HttpClient.execute,
Effect.timeout("2 seconds"),
)
expect(response.status).toBe(201)
@@ -275,6 +315,7 @@ describe("HttpApi workspace routing middleware", () => {
expect(forwardedURL?.searchParams.get("keep")).toBe("yes")
expect(forwardedURL?.searchParams.get("workspace")).toBeNull()
expect(forwarded?.method).toBe("PATCH")
expect(forwarded?.body).toBe(body)
expect(forwarded?.headers["content-type"]).toBe("application/json")
expect(forwarded?.headers["x-target-auth"]).toBe("secret")
expect(forwarded?.headers["x-opencode-directory"]).toBeUndefined()
@@ -325,9 +366,11 @@ describe("HttpApi workspace routing middleware", () => {
startWorkspaceSyncing: () => Effect.die("unused"),
})
yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe(
yield* HttpApiBuilder.layer(ProbeApi).pipe(
Layer.provide(probeHandlers),
Layer.provide(workspaceRoutingTestLayer),
Layer.provide(Layer.succeed(Workspace.Service, workspace)),
Layer.provide(Layer.mock(Session.Service)({})),
HttpRouter.serve,
Layer.build,
)
@@ -351,11 +394,7 @@ describe("HttpApi workspace routing middleware", () => {
url: "http://127.0.0.1:1/base",
})
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
Layer.provide(workspaceRoutingTestLayer),
HttpRouter.serve,
Layer.build,
)
yield* serveProbe
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
@@ -378,11 +417,7 @@ describe("HttpApi workspace routing middleware", () => {
// The client connects to the local test server. The middleware should
// detect the WebSocket upgrade and proxy it to the remote /base/probe.
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
Layer.provide(workspaceRoutingTestLayer),
HttpRouter.serve,
Layer.build,
)
yield* serveProbe
const socket = yield* Socket.makeWebSocket(
`${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`,
@@ -406,11 +441,7 @@ describe("HttpApi workspace routing middleware", () => {
const workspaceID = WorkspaceID.ascending("wrk_missing")
// If the middleware resolves the workspace first, this handler is never
// reached and the response should be the middleware error response.
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
Layer.provide(workspaceRoutingTestLayer),
HttpRouter.serve,
Layer.build,
)
yield* serveProbe
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
@@ -433,14 +464,7 @@ describe("HttpApi workspace routing middleware", () => {
// GET /session is a control-plane route: it lists sessions for the main
// process and should not be redirected into the selected workspace target.
yield* HttpRouter.add(
"GET",
"/session",
Effect.gen(function* () {
const route = yield* WorkspaceRouteContext
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
}),
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
yield* serveProbe
const response = yield* HttpClient.get(`/session?workspace=${workspace.id}`)
@@ -463,14 +487,7 @@ describe("HttpApi workspace routing middleware", () => {
// Workspace CRUD/status routes manage the control plane itself. Selecting
// a workspace should preserve the selected id for handlers, but must not
// swap the route context to the workspace target directory.
yield* HttpRouter.add(
"GET",
WorkspacePaths.list,
Effect.gen(function* () {
const route = yield* WorkspaceRouteContext
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
}),
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
yield* serveProbe
const response = yield* HttpClient.get(`${WorkspacePaths.list}?workspace=${workspace.id}`)
@@ -484,7 +501,7 @@ describe("HttpApi workspace routing middleware", () => {
const dir = yield* tmpdirScoped()
const queryDir = path.join(dir, "query-target")
const headerDir = path.join(dir, "header-target")
yield* serveRouteContextProbe
yield* serveProbe
// Without a selected workspace, the middleware falls back to request
// directory hints before using the process cwd.
@@ -495,9 +512,9 @@ describe("HttpApi workspace routing middleware", () => {
)
expect(queryResponse.status).toBe(200)
expect(yield* queryResponse.json).toEqual({ directory: queryDir })
expect(yield* queryResponse.json).toEqual({ directory: queryDir, workspaceID: null })
expect(headerResponse.status).toBe(200)
expect(yield* headerResponse.json).toEqual({ directory: headerDir })
expect(yield* headerResponse.json).toEqual({ directory: headerDir, workspaceID: null })
}),
)
@@ -513,7 +530,7 @@ describe("HttpApi workspace routing middleware", () => {
directory: workspaceDir,
})
yield* serveRouteContextProbe
yield* serveProbe
// /probe is not a control-plane route, so selecting a local workspace
// should swap the route context to the workspace target directory.
@@ -9,6 +9,7 @@ import { WorkspaceID } from "../../src/control-plane/schema"
import type { WorkspaceAdapter } from "../../src/control-plane/types"
import { Workspace } from "../../src/control-plane/workspace"
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
import { Session } from "@/session/session"
import * as Log from "@opencode-ai/core/util/log"
import { Server } from "../../src/server/server"
@@ -344,6 +345,7 @@ describe("workspace HttpApi", () => {
proxied.push(request)
const url = new URL(request.url)
if (url.pathname === "/base/global/event") return eventStreamResponse()
if (url.pathname === "/base/event") return eventStreamResponse()
if (url.pathname === "/base/sync/history") return Response.json([])
return new Response(
JSON.stringify({
@@ -413,6 +415,18 @@ describe("workspace HttpApi", () => {
])
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory")
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace")
const eventURL = new URL(`http://localhost${EventPaths.event}`)
eventURL.searchParams.set("workspace", workspace.id)
const eventResponse = yield* request(eventURL.toString(), dir)
expect(eventResponse.status).toBe(200)
expect(eventResponse.headers.get("content-type")).toContain("text/event-stream")
if (!eventResponse.body) throw new Error("missing proxied event response body")
const eventReader = eventResponse.body.getReader()
const event = yield* Effect.promise(() => eventReader.read())
yield* Effect.promise(() => eventReader.cancel())
expect(new TextDecoder().decode(event.value)).toContain("server.connected")
expect(proxied.some((item) => new URL(item.url).pathname === "/base/event")).toBe(true)
} finally {
void remote.stop(true)
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
@@ -465,6 +479,16 @@ describe("workspace HttpApi", () => {
method: "POST",
}),
])
const aborted = yield* request(`http://localhost/session/${session.id}/abort`, dir, { method: "POST" })
expect(aborted.status).toBe(200)
expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/abort`)).toEqual([
expect.objectContaining({
url: `http://127.0.0.1:${remote.port}/base/session/${session.id}/abort`,
method: "POST",
body: "",
}),
])
} finally {
void remote.stop(true)
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
@@ -112,6 +112,22 @@ describe("HttpApi workspace proxy", () => {
}),
)
it.live("proxies bodyless Web mutation requests as an empty body", () =>
Effect.gen(function* () {
const url = yield* listenServer(
Effect.fnUntraced(function* (req: HttpServerRequest.HttpServerRequest) {
return yield* HttpServerResponse.json({ method: req.method, body: yield* req.text })
}),
)
const request = HttpServerRequest.fromWeb(new Request("http://localhost/session/abc/abort", { method: "POST" }))
const httpClient = yield* HttpClient.HttpClient
const response = yield* HttpApiProxy.http(httpClient, `${url}/session/abc/abort`, undefined, request)
expect(response.status).toBe(200)
expect(yield* HttpServerResponse.toClientResponse(response).json).toEqual({ method: "POST", body: "" })
}),
)
it.live("strips opencode-internal headers and merges extra headers", () =>
Effect.gen(function* () {
let forwarded: Record<string, string> = {}
@@ -172,6 +172,17 @@ describe("session.retry.retryable", () => {
})
})
test("retries websocket stream transport errors", () => {
const request = MessageV2.fromError(
new ProviderError.ResponseStreamError("WebSocket closed before response.completed (code 1006: Connection ended)"),
{ providerID },
)
expect(MessageV2.APIError.isInstance(request)).toBe(true)
expect(SessionRetry.retryable(request, retryProvider)).toEqual({
message: "WebSocket closed before response.completed (code 1006: Connection ended)",
})
})
test("does not retry context overflow errors", () => {
const error = new MessageV2.ContextOverflowError({
message: "Input exceeds context window of this model",
+3 -3
View File
@@ -22,9 +22,9 @@
"zod": "catalog:"
},
"peerDependencies": {
"@opentui/core": ">=0.2.15",
"@opentui/keymap": ">=0.2.15",
"@opentui/solid": ">=0.2.15"
"@opentui/core": ">=0.2.16",
"@opentui/keymap": ">=0.2.16",
"@opentui/solid": ">=0.2.16"
},
"peerDependenciesMeta": {
"@opentui/core": {
+4
View File
@@ -2633,6 +2633,8 @@ export class Pty extends HeyApiClient {
ptyID: string
directory?: string
workspace?: string
cursor?: string
ticket?: string
},
options?: Options<never, ThrowOnError>,
) {
@@ -2644,6 +2646,8 @@ export class Pty extends HeyApiClient {
{ in: "path", key: "ptyID" },
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
{ in: "query", key: "cursor" },
{ in: "query", key: "ticket" },
],
},
],
+2
View File
@@ -8200,6 +8200,8 @@ export type PtyConnectData = {
query?: {
directory?: string
workspace?: string
cursor?: string
ticket?: string
}
url: "/pty/{ptyID}/connect"
}
+18 -6
View File
@@ -10403,20 +10403,32 @@
"required": true
},
{
"name": "directory",
"in": "query",
"name": "directory",
"schema": {
"type": "string"
},
"required": false
}
},
{
"name": "workspace",
"in": "query",
"name": "workspace",
"schema": {
"type": "string"
},
"required": false
}
},
{
"in": "query",
"name": "cursor",
"schema": {
"type": "string"
}
},
{
"in": "query",
"name": "ticket",
"schema": {
"type": "string"
}
}
],
"responses": {
+1
View File
@@ -12,6 +12,7 @@
"start": "vite start"
},
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
+429 -72
View File
@@ -1,3 +1,47 @@
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Regular-Latin1.woff2") format("woff2");
unicode-range:
U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E,
U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02;
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2") format("woff2");
unicode-range:
U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E,
U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02;
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-SemiBold-Latin1.woff2") format("woff2");
unicode-range:
U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E,
U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02;
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Bold-Latin1.woff2") format("woff2");
unicode-range:
U+0020-007E, U+00A0-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2013-2014, U+2018-201A, U+201C-201E,
U+2020-2022, U+2026, U+2030, U+2039-203A, U+2044, U+20AC, U+2122, U+2212, U+FB01-FB02;
}
[data-page="stats"] {
--color-background: #ffffff;
--color-background-weak: #fafafa;
@@ -21,12 +65,16 @@
--stats-accent-text: #6c7dff;
--stats-bar-idle: #d4d4d4;
--stats-dot: #d4d4d4;
--stats-hero-muted: #5c5c5c;
--stats-hero-pattern: #eeeeee;
--stats-page-padding: 5rem;
--stats-section-padding: 6rem;
min-height: 100vh;
display: flex;
flex-direction: column;
gap: 4rem;
font-synthesis: none;
overflow-x: clip;
padding-bottom: 5rem;
background: var(--stats-bg);
}
@@ -39,7 +87,9 @@
}
[data-page="stats"] [data-component="container"] {
max-width: 67.5rem;
box-sizing: border-box;
width: 100%;
max-width: calc(80rem + 2px);
margin: 0 auto;
border-left: 1px solid var(--stats-line);
border-right: 1px solid var(--stats-line);
@@ -49,74 +99,245 @@
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
height: 80px;
min-height: 80px;
padding: 24px var(--stats-page-padding);
color: var(--stats-muted);
box-sizing: border-box;
width: 100%;
color: var(--stats-text);
background: var(--stats-bg);
border-bottom: 1px solid var(--stats-line);
font-family:
"IBM Plex Mono",
var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
}
[data-page="stats"] [data-component="top"] * {
box-sizing: border-box;
}
[data-page="stats"] [data-slot="header-bar"] {
display: flex;
align-items: center;
gap: 16px;
min-height: 72px;
padding: 20px 20px 20px 24px;
overflow: hidden;
}
[data-page="stats"] [data-component="top"] a {
color: var(--stats-text);
font-size: 14px;
line-height: 18px;
text-decoration: none;
}
[data-page="stats"] [data-component="top"] a:hover {
text-decoration: underline;
text-underline-offset: 4px;
text-decoration: none;
}
[data-page="stats"] [data-slot="brand"] {
flex: 0 0 auto;
min-width: 0;
display: flex;
align-items: center;
margin-right: auto;
color: var(--stats-text);
}
[data-page="stats"] [data-slot="brand"] img {
width: auto;
height: 34px;
[data-page="stats"] [data-slot="stats-wordmark"] {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 12px;
}
[data-page="stats"] [data-slot="logo dark"] {
[data-page="stats"] [data-slot="brand-mark"] {
display: block;
width: 19px;
height: 24px;
}
[data-page="stats"] [data-slot="brand-label"] {
display: block;
width: 50.851px;
height: 14px;
}
[data-page="stats"] [data-component="section-nav"] {
display: none;
align-items: center;
justify-content: center;
min-width: 0;
}
[data-page="stats"] [data-component="nav-desktop"] ul {
[data-page="stats"] [data-component="section-nav"] ul {
display: flex;
align-items: center;
gap: 32px;
margin: 0;
padding: 0;
list-style: none;
}
[data-page="stats"] [data-component="nav-desktop"] a span {
color: var(--stats-faint);
}
[data-page="stats"] [data-component="nav-desktop"] [data-slot="cta-button"] {
[data-page="stats"] [data-component="section-nav"] a {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
padding: 0 15px;
color: var(--stats-muted);
font-size: 13px;
line-height: 1;
white-space: nowrap;
}
[data-page="stats"] [data-component="section-nav"] a:hover {
color: var(--stats-text);
}
[data-page="stats"] [data-slot="header-actions"] {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
padding: 8px 16px 8px 10px;
border-radius: 4px;
background: var(--color-background-strong);
color: var(--color-text-inverted);
}
[data-page="stats"] [data-slot="header-button"],
[data-page="stats"] [data-slot="menu-button"] {
position: relative;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
height: 32px;
overflow: hidden;
margin: 0;
border: 0;
border-radius: 0;
appearance: none;
font: inherit;
font-size: 13px;
line-height: 1.1;
cursor: pointer;
}
[data-page="stats"] [data-slot="header-button"]::before,
[data-page="stats"] [data-slot="menu-button"]::before {
position: absolute;
top: 0;
right: 0;
left: 0;
height: 16px;
pointer-events: none;
content: "";
background: linear-gradient(180deg, #ffffff00 0%, #ffffff12 100%);
}
[data-page="stats"] [data-slot="header-button"] {
padding: 0 12px;
}
[data-page="stats"] [data-slot="header-button"] strong,
[data-page="stats"] [data-slot="header-button"] span,
[data-page="stats"] [data-slot="menu-button"] svg {
position: relative;
z-index: 1;
}
[data-page="stats"] [data-slot="header-button"] strong {
font-weight: 500;
white-space: nowrap;
}
[data-page="stats"] [data-component="nav-desktop"] [data-slot="cta-button"]:hover {
background: var(--color-background-strong-hover);
text-decoration: none;
[data-page="stats"] [data-slot="header-button"][data-variant="neutral"] {
display: none;
gap: 6px;
color: #161616;
background: #ffffff;
box-shadow:
0 0 0 0 #00000024,
0 0 0 0.5px #00000024,
0 1px 1.5px 0 #0000001a;
}
[data-page="stats"] [data-slot="header-button"][data-variant="neutral"] span {
color: #5c5c5c;
font-weight: 400;
font-variant-numeric: tabular-nums;
}
[data-page="stats"] [data-slot="header-button"][data-variant="contrast"] {
gap: 8px;
color: #ffffff;
background: #242424;
box-shadow:
0 0 0 0 #00000000,
0 0 0 0.5px #3a3a3a,
0 1px 1.5px 0 #00000033,
inset 0 -1px 2px 0 #0000000f,
inset 0 1px 2px 0 #ffffff24;
}
[data-page="stats"] [data-slot="menu-button"] {
display: inline-flex;
width: 32px;
padding: 0;
color: #3a3a3a;
background: #ffffff;
box-shadow:
0 0 0 0 #00000024,
0 0 0 0.5px #00000024,
0 1px 1.5px 0 #0000001a;
}
[data-page="stats"] [data-slot="header-button"]:focus-visible,
[data-page="stats"] [data-slot="menu-button"]:focus-visible,
[data-page="stats"] [data-component="section-nav"] a:focus-visible,
[data-page="stats"] [data-slot="mobile-menu-item"]:focus-visible,
[data-page="stats"] [data-slot="brand"]:focus-visible {
outline: 2px solid var(--stats-accent);
outline-offset: 2px;
}
[data-page="stats"] [data-slot="mobile-menu"] {
position: fixed;
top: 72px;
right: 0;
bottom: 0;
left: 0;
z-index: 9;
display: none;
overflow: auto;
background: var(--stats-bg);
border-top: 1px solid var(--stats-line);
}
[data-page="stats"] [data-menu-open="true"] [data-slot="mobile-menu"]:not([hidden]) {
display: block;
}
[data-page="stats"] [data-slot="mobile-menu-item"] {
display: flex;
align-items: center;
gap: 12px;
min-height: 65px;
padding: 24px;
color: var(--stats-text);
border-bottom: 1px solid var(--stats-line);
font-size: 13px;
line-height: 16px;
white-space: nowrap;
}
[data-page="stats"] [data-slot="mobile-menu-item"]:hover {
color: var(--stats-text);
background: var(--stats-layer);
}
[data-page="stats"] [data-slot="mobile-menu-item"] strong {
font-weight: 400;
}
[data-page="stats"] [data-slot="mobile-menu-item"] span {
min-width: 0;
overflow: hidden;
color: var(--stats-muted);
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
}
[data-page="stats"] [data-component="footer"] {
@@ -192,11 +413,16 @@
}
[data-page="stats"] [data-section="hero"] {
min-height: 270px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 64px;
align-items: start;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
gap: 24px;
min-height: 0;
overflow: hidden;
padding: 128px 24px 48px;
border-bottom: 0;
}
[data-page="stats"] h1,
@@ -210,6 +436,7 @@
line-height: 1;
letter-spacing: normal;
font-weight: 600;
max-width: none;
}
[data-page="stats"] h2 {
@@ -219,52 +446,146 @@
font-weight: 500;
}
[data-page="stats"] [data-section="hero"] > p,
[data-page="stats"] [data-slot="section-header"] p {
color: var(--stats-muted);
font-size: 16px;
line-height: 1.5;
}
[data-page="stats"] [data-section="hero"] > div {
[data-page="stats"] [data-slot="hero-canvas"] {
display: flex;
flex-direction: column;
gap: 16px;
gap: 24px;
width: 100%;
min-width: 0;
}
[data-page="stats"] [data-slot="meta"] {
[data-page="stats"] [data-section="hero"] h1 {
order: 1;
color: var(--stats-text);
font-size: 64px;
font-weight: 500;
line-height: 1;
letter-spacing: 0;
}
[data-page="stats"] [data-slot="hero-copy"] {
order: 3;
color: var(--stats-hero-muted);
font-size: 16px;
font-weight: 400;
line-height: 1.5;
}
[data-page="stats"] [data-slot="hero-copy-break"] {
display: none;
}
[data-page="stats"] [data-slot="hero-pattern"] {
order: 2;
flex: 0 0 auto;
width: 100%;
height: 16px;
overflow: hidden;
background: var(--stats-hero-pattern);
mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
mask-repeat: repeat;
mask-size: 6px 6px;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 0H2V2H0V0Z' fill='black'/%3E%3C/svg%3E");
-webkit-mask-repeat: repeat;
-webkit-mask-size: 6px 6px;
}
[data-page="stats"] [data-slot="hero-meta"] {
display: flex;
align-items: center;
gap: 4px;
width: fit-content;
max-width: 100%;
height: 24px;
padding: 0 8px 0 4px;
background: var(--stats-layer-2);
color: var(--stats-faint);
color: var(--stats-hero-muted);
font-size: 13px;
font-weight: 600;
font-weight: 500;
line-height: 1.1;
overflow: hidden;
white-space: nowrap;
}
[data-page="stats"] [data-slot="meta"] svg {
[data-page="stats"] [data-slot="hero-meta"] svg {
width: 16px;
height: 16px;
color: var(--stats-faint);
flex: 0 0 auto;
}
[data-page="stats"] [data-slot="meta"] span {
color: var(--stats-muted);
[data-page="stats"] [data-slot="hero-meta"] span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
[data-page="stats"] [data-slot="meta"] b,
[data-page="stats"] [data-slot="meta"] em {
color: var(--stats-faint);
font-style: normal;
font-weight: 600;
@media (min-width: 48rem) {
[data-page="stats"] [data-section="hero"] {
gap: 16px;
padding: 128px 32px 32px;
}
[data-page="stats"] [data-slot="hero-canvas"] {
position: relative;
display: block;
height: 232px;
overflow: hidden;
}
[data-page="stats"] [data-slot="hero-pattern"] {
position: absolute;
top: 50%;
left: 50%;
width: 1280px;
height: 351px;
transform: translate(-50%, -50%);
}
[data-page="stats"] [data-section="hero"] h1 {
position: absolute;
top: 0;
left: 0;
z-index: 1;
width: max-content;
max-width: 100%;
padding: 0 12px 12px 0;
background: var(--stats-bg);
white-space: nowrap;
}
[data-page="stats"] [data-slot="hero-copy"] {
position: absolute;
right: 0;
bottom: 0;
z-index: 1;
width: min(563px, 100%);
padding: 12px 0 0 16px;
background: var(--stats-bg);
text-align: right;
}
[data-page="stats"] [data-slot="hero-copy-break"] {
display: block;
}
}
@media (min-width: 75rem) {
[data-page="stats"] [data-section="hero"] {
padding: 128px 40px 24px;
}
}
@media (min-width: 90rem) {
[data-page="stats"] [data-section="hero"] {
padding-right: 0;
padding-left: 0;
}
}
[data-page="stats"] [data-section="chart"] {
@@ -1154,6 +1475,8 @@
--stats-faint: #808080;
--stats-bar-idle: #303030;
--stats-dot: #303030;
--stats-hero-muted: #808080;
--stats-hero-pattern: #303030;
}
[data-page="stats"] [data-component="chart-tooltip"],
@@ -1168,44 +1491,82 @@
0 2px 4px #0000003d;
}
[data-page="stats"] [data-slot="logo light"] {
display: none;
[data-page="stats"] [data-slot="header-button"][data-variant="neutral"],
[data-page="stats"] [data-slot="menu-button"] {
color: #fafafa;
background: #ffffff0f;
box-shadow:
0 -0.5px 0 0 #ffffff33,
0 0 0 0.5px #ffffff33,
0 1px 2px 0 #00000066;
}
[data-page="stats"] [data-slot="logo dark"] {
display: block;
[data-page="stats"] [data-slot="header-button"][data-variant="neutral"] span {
color: #aeaeae;
}
[data-page="stats"] [data-slot="header-button"][data-variant="contrast"] {
color: #ffffff;
background: #5c5c5c;
box-shadow:
0 -0.5px 0 0 #ffffff4d,
0 0 0 0.5px #ffffff66,
0 1px 2px 0 #00000066;
}
}
@media (max-width: 74rem) {
@media (max-width: 80rem) {
[data-page="stats"] [data-component="container"] {
border: 0;
}
}
@media (min-width: 48rem) {
[data-page="stats"] [data-slot="header-button"][data-variant="neutral"] {
display: inline-flex;
}
}
@media (min-width: 75rem) {
[data-page="stats"] [data-slot="header-bar"] {
gap: 32px;
}
[data-page="stats"] [data-slot="brand"],
[data-page="stats"] [data-component="section-nav"],
[data-page="stats"] [data-slot="header-actions"] {
flex: 1 1 0;
}
[data-page="stats"] [data-slot="brand"] {
margin-right: 0;
}
[data-page="stats"] [data-component="section-nav"] {
display: flex;
}
[data-page="stats"] [data-slot="menu-button"] {
display: none;
}
[data-page="stats"] [data-slot="mobile-menu"] {
display: none !important;
}
}
@media (max-width: 58rem) {
[data-page="stats"] {
--stats-page-padding: 24px;
--stats-section-padding: 4rem;
}
[data-page="stats"] [data-component="top"] {
padding-left: 24px;
padding-right: 24px;
}
[data-page="stats"] [data-component="nav-desktop"] ul {
gap: 18px;
}
[data-page="stats"] [data-section="hero"],
[data-page="stats"] [data-section="chart"],
[data-page="stats"] [data-section="newsletter"] {
padding-left: 24px;
padding-right: 24px;
}
[data-page="stats"] [data-section="hero"],
[data-page="stats"] [data-section="newsletter"] {
grid-template-columns: 1fr;
}
@@ -1301,10 +1662,6 @@
}
@media (max-width: 40rem) {
[data-page="stats"] [data-component="nav-desktop"] li:not(:last-child) {
display: none;
}
[data-page="stats"] [data-component="footer"],
[data-page="stats"] [data-component="legal"] {
flex-wrap: wrap;
+180 -61
View File
@@ -1,6 +1,10 @@
import "./index.css"
import { Meta, Title } from "@solidjs/meta"
import { Link, Meta, Title } from "@solidjs/meta"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import ibmPlexMonoRegularLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Regular-Latin1.woff2?url"
import ibmPlexMonoMediumLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2?url"
import ibmPlexMonoSemiBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-SemiBold-Latin1.woff2?url"
import ibmPlexMonoBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Bold-Latin1.woff2?url"
import {
type CountryEntry,
getStatsHomeData,
@@ -14,14 +18,19 @@ import {
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 { createEffect, createMemo, createSignal, For, onCleanup, onMount, 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 headerLinks = [
{ href: "#top-models", label: "Top Models" },
{ href: "#leaderboard", label: "Leaderboard" },
{ href: "#market-share", label: "Market Share" },
{ href: "#token-cost", label: "Token Cost" },
{ href: "#session-cost", label: "Session Cost" },
] as const
const usageColors = ["#ff5d64", "#ff8a00", "#8bef00", "#12c8b3", "#18c7dc", "#6c7dff", "#9d73f7"]
const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"]
const countryPositions = [
@@ -59,6 +68,10 @@ export default function StatsHome() {
<main data-page="stats">
<Title>OpenCode Stats</Title>
<Meta name="description" content="OpenCode usage, market share, token cost, and session cost stats." />
<Link rel="preload" href={ibmPlexMonoRegularLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
<Link rel="preload" href={ibmPlexMonoMediumLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
<Link rel="preload" href={ibmPlexMonoSemiBoldLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
<Link rel="preload" href={ibmPlexMonoBoldLatin1} as="font" type="font/woff2" crossorigin="anonymous" />
<div data-component="container">
<Header />
<div data-component="content">
@@ -85,21 +98,30 @@ export default function StatsHome() {
}
function Hero(props: { updatedAt: string | null }) {
const [timeZone, setTimeZone] = createSignal("UTC")
onMount(() => setTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"))
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 data-slot="hero-meta">
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 16 16">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M13 13H3V3H13V13ZM6.46777 6.81641V7.81641H7.5791V11.3721H8.5791V6.81641H6.46777ZM7.30078 4.62891V5.62891H8.85645V4.62891H7.30078Z"
fill="currentColor"
/>
</svg>
<span>{props.updatedAt ? `Updated ${formatUpdatedAt(props.updatedAt, timeZone())}` : "No rows yet"}</span>
</p>
<div data-slot="hero-canvas">
<div data-slot="hero-pattern" aria-hidden="true" />
<h1>Model Stats</h1>
<p data-slot="hero-copy">
See which models are winning real usage, how the mix <br data-slot="hero-copy-break" />
shifts over time, and where momentum is moving each week.
</p>
</div>
<p>See how model usage, provider share, cost, and geography move across OpenCode traffic.</p>
</section>
)
}
@@ -115,9 +137,15 @@ function StatsLoading() {
)
}
function ChartSection(props: { title: string; description?: string; controls?: JSX.Element; children: JSX.Element }) {
function ChartSection(props: {
id?: string
title: string
description?: string
controls?: JSX.Element
children: JSX.Element
}) {
return (
<section data-section="chart">
<section id={props.id} data-section="chart">
<div data-slot="section-header">
<div>
<h2>{props.title}</h2>
@@ -139,7 +167,7 @@ function EmptyState(props: { title: string; description: string }) {
)
}
function formatUpdatedAt(value: string) {
function formatUpdatedAt(value: string, timeZone: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return "just now"
return new Intl.DateTimeFormat("en", {
@@ -147,7 +175,7 @@ function formatUpdatedAt(value: string) {
day: "numeric",
hour: "numeric",
minute: "2-digit",
timeZone: "UTC",
timeZone,
timeZoneName: "short",
}).format(date)
}
@@ -158,7 +186,7 @@ function UsageSection(props: { data: StatsHomeData["usage"] }) {
const data = createMemo(() => props.data[product()][range()])
return (
<ChartSection title="Usage">
<ChartSection id="top-models" 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." />}
@@ -394,6 +422,7 @@ function LeaderboardSection(props: { data: StatsHomeData["leaderboard"] }) {
return (
<ChartSection
id="leaderboard"
title="Leaderboard"
description="Shown are the sum of prompt and completion tokens per model, including reasoning tokens."
>
@@ -476,7 +505,7 @@ function MarketShareSection(props: { data: StatsHomeData["market"] }) {
const activeDay = createMemo(() => data()[selectedIndex()])
return (
<ChartSection title="Market Share" description="Compare token share by model author.">
<ChartSection id="market-share" 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." />}
@@ -572,7 +601,7 @@ function TokenCostSection(props: { data: StatsHomeData["tokenCost"] }) {
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0)))
return (
<ChartSection title="Token Cost" description="Price per 1M tokens.">
<ChartSection id="token-cost" title="Token Cost" description="Price per 1M tokens.">
<Show
when={data().length > 0}
fallback={
@@ -661,7 +690,7 @@ function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) {
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(data().length - 1, 0)))
return (
<ChartSection title="Session Cost" description="Average cost per session.">
<ChartSection id="session-cost" title="Session Cost" description="Average cost per session.">
<Show
when={data().length > 0}
fallback={
@@ -886,47 +915,137 @@ function Newsletter() {
}
function Header() {
const [menuOpen, setMenuOpen] = createSignal(false)
const [menuViewport, setMenuViewport] = createSignal(false)
createEffect(() => {
if (typeof window === "undefined") return
const media = window.matchMedia("(max-width: 74.999rem)")
const update = () => setMenuViewport(media.matches)
update()
media.addEventListener("change", update)
onCleanup(() => media.removeEventListener("change", update))
})
createEffect(() => {
if (!menuOpen()) return
if (!menuViewport()) return
if (typeof document === "undefined") return
const page = document.querySelector<HTMLElement>('[data-page="stats"]')
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
const htmlOverflow = document.documentElement.style.overflow
const pagePaddingRight = page?.style.paddingRight
const bodyOverflow = document.body.style.overflow
document.documentElement.style.overflow = "hidden"
if (scrollbarWidth > 0 && page) page.style.paddingRight = `${scrollbarWidth}px`
document.body.style.overflow = "hidden"
onCleanup(() => {
document.documentElement.style.overflow = htmlOverflow
if (page && pagePaddingRight !== undefined) page.style.paddingRight = pagePaddingRight
document.body.style.overflow = bodyOverflow
})
})
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
<header data-component="top" data-menu-open={menuOpen() ? "true" : undefined}>
<div data-slot="header-bar">
<a data-slot="brand" href="/" aria-label="OpenCode home">
<StatsWordmark />
</a>
<nav data-component="section-nav" aria-label="Stats sections">
<ul>
<For each={headerLinks}>
{(link) => (
<li>
<a href={link.href}>{link.label}</a>
</li>
)}
</For>
</ul>
</nav>
<div data-slot="header-actions">
<a
data-slot="header-button"
data-variant="neutral"
href="https://github.com/sst/opencode"
target="_blank"
rel="noreferrer"
>
<strong>GitHub</strong>
<span>[150K]</span>
</a>
<a data-slot="header-button" data-variant="contrast" href="https://opencode.ai/">
<strong>Try OpenCode</strong>
</a>
<button
data-slot="menu-button"
type="button"
aria-controls="stats-mobile-nav"
aria-expanded={menuOpen() ? "true" : "false"}
aria-label={menuOpen() ? "Close navigation" : "Open navigation"}
onClick={() => setMenuOpen((value) => !value)}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<Show when={menuOpen()} fallback={<path d="M2 4.72H14M2 8.5H14M2 12.28H14" stroke="currentColor" />}>
<path d="M4.44 4.44L11.56 11.56M11.56 4.44L4.44 11.56" stroke="currentColor" />
</Show>
</svg>
</button>
</div>
</div>
<nav id="stats-mobile-nav" data-slot="mobile-menu" aria-label="Stats sections" hidden={!menuOpen()}>
<a
data-slot="mobile-menu-item"
data-variant="github"
href="https://github.com/sst/opencode"
target="_blank"
rel="noreferrer"
>
<strong>GitHub</strong>
<span>[150K]</span>
</a>
<For each={headerLinks}>
{(link) => (
<a data-slot="mobile-menu-item" href={link.href} onClick={() => setMenuOpen(false)}>
{link.label}
</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>
)}
</For>
</nav>
</section>
</header>
)
}
function StatsWordmark() {
return (
<span data-slot="stats-wordmark" aria-hidden="true">
<svg data-slot="brand-mark" width="19" height="24" viewBox="0 0 19 24" fill="none">
<path opacity="0.2" d="M14.25 19.2H4.75V9.6H14.25V19.2Z" fill="currentColor" />
<path d="M14.25 4.8H4.75V19.2H14.25V4.8ZM19 24H0V0H19V24Z" fill="currentColor" />
</svg>
<svg data-slot="brand-label" width="51" height="14" viewBox="0 0 50.8509 14" fill="none">
<path
d="M46.2359 14C45.2276 14 44.3356 13.819 43.56 13.4571C42.7973 13.0822 42.138 12.5328 41.5822 11.8089L43.1722 10.277C43.56 10.807 44.0124 11.2142 44.5295 11.4986C45.0466 11.7701 45.6283 11.9058 46.2747 11.9058C47.7225 11.9058 48.4464 11.2465 48.4464 9.92798C48.4464 9.38504 48.3172 8.97138 48.0586 8.68698C47.8001 8.40259 47.3735 8.19575 46.7788 8.06648L45.596 7.8338C44.3679 7.57525 43.463 7.13573 42.8813 6.51524C42.2996 5.89474 42.0088 5.02862 42.0088 3.9169C42.0088 2.62419 42.3901 1.6482 43.1528 0.98892C43.9284 0.32964 45.0272 0 46.4492 0C47.4187 0 48.2461 0.161588 48.9312 0.484764C49.6293 0.795014 50.2239 1.28624 50.7151 1.95845L49.1251 3.45152C48.789 2.99908 48.4076 2.66297 47.9811 2.44321C47.5545 2.21053 47.0309 2.09418 46.4104 2.09418C45.7253 2.09418 45.2211 2.22992 44.898 2.50139C44.5748 2.77285 44.4132 3.21237 44.4132 3.81995C44.4132 4.3241 44.536 4.71191 44.7816 4.98338C45.0401 5.25485 45.4538 5.45522 46.0226 5.58449L47.2054 5.83656C47.8647 5.97876 48.4206 6.15328 48.873 6.36011C49.3384 6.56694 49.7133 6.82548 49.9977 7.13573C50.295 7.44598 50.5083 7.8144 50.6376 8.241C50.7798 8.65466 50.8509 9.14589 50.8509 9.71468C50.8509 11.1108 50.4501 12.1773 49.6486 12.9141C48.8601 13.638 47.7225 14 46.2359 14Z"
fill="currentColor"
/>
<path
d="M36.9543 2.34643V13.7675H34.5305V2.34643H31.1371V0.232856H40.367V2.34643H36.9543Z"
fill="currentColor"
/>
<path
d="M28.6196 13.7675L27.6695 10.2384H23.3066L22.3565 13.7675H20.0296L23.9853 0.232856H27.049L31.0047 13.7675H28.6196ZM26.0407 4.57635L25.6141 2.42399H25.3426L24.916 4.57635L23.8883 8.27995H27.0878L26.0407 4.57635Z"
fill="currentColor"
/>
<path
d="M16.4849 2.34643V13.7675H14.0611V2.34643H10.6678V0.232856H19.8977V2.34643H16.4849Z"
fill="currentColor"
/>
<path
d="M4.65374 14C3.64543 14 2.75346 13.819 1.97784 13.4571C1.21514 13.0822 0.555863 12.5328 0 11.8089L1.59003 10.277C1.97784 10.807 2.43029 11.2142 2.94737 11.4986C3.46445 11.7701 4.04617 11.9058 4.69252 11.9058C6.14035 11.9058 6.86427 11.2465 6.86427 9.92798C6.86427 9.38504 6.735 8.97138 6.47646 8.68698C6.21791 8.40259 5.79132 8.19575 5.19668 8.06648L4.01385 7.8338C2.78578 7.57525 1.88089 7.13573 1.29917 6.51524C0.717452 5.89474 0.426593 5.02862 0.426593 3.9169C0.426593 2.62419 0.807941 1.6482 1.57064 0.98892C2.34626 0.32964 3.44506 0 4.86704 0C5.83657 0 6.6639 0.161588 7.34903 0.484764C8.04709 0.795014 8.64174 1.28624 9.13297 1.95845L7.54294 3.45152C7.20683 2.99908 6.82549 2.66297 6.39889 2.44321C5.9723 2.21053 5.44875 2.09418 4.82826 2.09418C4.14312 2.09418 3.63897 2.22992 3.31579 2.50139C2.99261 2.77285 2.83103 3.21237 2.83103 3.81995C2.83103 4.3241 2.95383 4.71191 3.19945 4.98338C3.45799 5.25485 3.87165 5.45522 4.44044 5.58449L5.62327 5.83656C6.28255 5.97876 6.83841 6.15328 7.29086 6.36011C7.75623 6.56694 8.13112 6.82548 8.41551 7.13573C8.71284 7.44598 8.92613 7.8144 9.0554 8.241C9.1976 8.65466 9.2687 9.14589 9.2687 9.71468C9.2687 11.1108 8.86796 12.1773 8.06648 12.9141C7.27793 13.638 6.14035 14 4.65374 14Z"
fill="currentColor"
/>
</svg>
</span>
)
}
+2 -1
View File
@@ -17,5 +17,6 @@
"paths": {
"~/*": ["./src/*"]
}
}
},
"include": ["*.ts", "src", "../core/src/resource.d.ts"]
}
@@ -0,0 +1,5 @@
ALTER TABLE `geo_stat` ADD `provider` varchar(128) DEFAULT 'all' NOT NULL;--> statement-breakpoint
ALTER TABLE `geo_stat` ADD `model` varchar(256) DEFAULT 'all' NOT NULL;--> statement-breakpoint
ALTER TABLE `geo_stat` DROP INDEX `uniq_country_period`;--> statement-breakpoint
CREATE UNIQUE INDEX `uniq_country_period` ON `geo_stat` (`grain`,`period_start`,`dataset`,`tier`,`client`,`source`,`provider`,`model`,`country`);--> statement-breakpoint
CREATE INDEX `idx_country_model` ON `geo_stat` (`model`,`country`,`grain`,`period_start`);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
ALTER TABLE `geo_stat` ADD `period_key` varchar(32) NOT NULL;--> statement-breakpoint
ALTER TABLE `model_stat` ADD `period_key` varchar(32) NOT NULL;--> statement-breakpoint
ALTER TABLE `provider_stat` ADD `period_key` varchar(32) NOT NULL;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
ALTER TABLE `geo_stat` DROP COLUMN `period_start`;--> statement-breakpoint
ALTER TABLE `geo_stat` DROP COLUMN `period_end`;--> statement-breakpoint
ALTER TABLE `model_stat` DROP COLUMN `period_start`;--> statement-breakpoint
ALTER TABLE `model_stat` DROP COLUMN `period_end`;--> statement-breakpoint
ALTER TABLE `provider_stat` DROP COLUMN `period_start`;--> statement-breakpoint
ALTER TABLE `provider_stat` DROP COLUMN `period_end`;
File diff suppressed because it is too large Load Diff
+1
View File
@@ -20,6 +20,7 @@
"db:migrate": "bun src/migrate.ts",
"db:push": "drizzle-kit push --config=drizzle.config.ts",
"db:studio": "drizzle-kit studio --config=drizzle.config.ts",
"honeycomb:backfill": "bun src/honeycomb-backfill.ts",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
+19 -15
View File
@@ -16,7 +16,7 @@ export const modelStat = mysqlTable(
(table) => [
uniqueIndex("uniq_model_period").on(
table.grain,
table.period_start,
table.period_key,
table.dataset,
table.tier,
table.client,
@@ -24,8 +24,8 @@ export const modelStat = mysqlTable(
table.provider,
table.model,
),
index("idx_leaderboard_tokens").on(table.grain, table.period_start, table.dataset, table.tier, table.total_tokens),
index("idx_model").on(table.model, table.grain, table.period_start),
index("idx_leaderboard_tokens").on(table.grain, table.period_key, table.dataset, table.tier, table.total_tokens),
index("idx_model").on(table.model, table.grain, table.period_key),
],
)
@@ -45,7 +45,7 @@ export const providerStat = mysqlTable(
(table) => [
uniqueIndex("uniq_provider_period").on(
table.grain,
table.period_start,
table.period_key,
table.dataset,
table.tier,
table.client,
@@ -54,20 +54,20 @@ export const providerStat = mysqlTable(
),
index("idx_provider_leaderboard_tokens").on(
table.grain,
table.period_start,
table.period_key,
table.dataset,
table.tier,
table.total_tokens,
),
index("idx_provider_market_share").on(
table.grain,
table.period_start,
table.period_key,
table.dataset,
table.tier,
table.market_share_tokens,
),
index("idx_provider_rank").on(table.grain, table.period_start, table.dataset, table.tier, table.rank_by_tokens),
index("idx_provider").on(table.provider, table.grain, table.period_start),
index("idx_provider_rank").on(table.grain, table.period_key, table.dataset, table.tier, table.rank_by_tokens),
index("idx_provider").on(table.provider, table.grain, table.period_key),
],
)
@@ -75,6 +75,8 @@ export const geoStat = mysqlTable(
"geo_stat",
{
...periodColumns(),
provider: varchar({ length: 128 }).notNull().default("all"),
model: varchar({ length: 256 }).notNull().default("all"),
country: char({ length: 2 }).notNull(),
continent: varchar({ length: 8 }).notNull().default(""),
...metricColumns(),
@@ -88,17 +90,20 @@ export const geoStat = mysqlTable(
(table) => [
uniqueIndex("uniq_country_period").on(
table.grain,
table.period_start,
table.period_key,
table.dataset,
table.tier,
table.client,
table.source,
table.provider,
table.model,
table.country,
),
index("idx_country_map_tokens").on(table.grain, table.period_start, table.dataset, table.tier, table.total_tokens),
index("idx_country_rank").on(table.grain, table.period_start, table.dataset, table.tier, table.rank_by_tokens),
index("idx_country").on(table.country, table.grain, table.period_start),
index("idx_continent").on(table.continent, table.grain, table.period_start),
index("idx_country_map_tokens").on(table.grain, table.period_key, table.dataset, table.tier, table.total_tokens),
index("idx_country_rank").on(table.grain, table.period_key, table.dataset, table.tier, table.rank_by_tokens),
index("idx_country").on(table.country, table.grain, table.period_key),
index("idx_continent").on(table.continent, table.grain, table.period_key),
index("idx_country_model").on(table.model, table.country, table.grain, table.period_key),
],
)
@@ -106,8 +111,7 @@ function periodColumns() {
return {
id: bigint({ mode: "number" }).autoincrement().primaryKey(),
grain: varchar({ length: 16 }).notNull(),
period_start: datetime({ mode: "date" }).notNull(),
period_end: datetime({ mode: "date" }).notNull(),
period_key: varchar({ length: 32 }).notNull(),
dataset: varchar({ length: 64 }).notNull().default("all"),
tier: varchar({ length: 64 }).notNull().default("all"),
client: varchar({ length: 64 }).notNull().default("all"),
+54 -22
View File
@@ -8,6 +8,7 @@ import {
collapseRows,
inserted,
rankRowsWithMarketShare,
statPeriodKey,
synthesizeAllTierRows,
toStatBaseRow,
UPSERT_CHUNK_SIZE,
@@ -15,11 +16,18 @@ import {
} from "./stat"
export type GeoStatRow = typeof geoStat.$inferInsert
export type GeoStatAggregate = StatBaseAggregate & { country: string; continent: string }
export type GeoStatAggregate = StatBaseAggregate & {
provider: string
model: string
country: string
continent: string
}
export type GeoStatMetric = {
periodStart: Date
periodEnd: Date
periodKey: string
updatedAt: Date
tier: string
provider: string
model: string
country: string
continent: string
totalTokens: number
@@ -30,11 +38,13 @@ export declare namespace GeoStatRepo {
readonly listDaily: () => Effect.Effect<GeoStatMetric[], DatabaseError>
readonly listByPeriod: (opts: {
readonly grain: string
readonly periodStart: Date
readonly periodKey: string
readonly dataset?: string
readonly tier?: string
readonly client?: string
readonly source?: string
readonly provider?: string
readonly model?: string
}) => Effect.Effect<GeoStatRow[], DatabaseError>
readonly upsert: (rows: GeoStatRow[]) => Effect.Effect<void, DatabaseError>
}
@@ -51,27 +61,39 @@ export class GeoStatRepo extends Context.Service<GeoStatRepo, GeoStatRepo.Servic
try: () =>
db
.select({
periodStart: geoStat.period_start,
periodEnd: geoStat.period_end,
periodKey: geoStat.period_key,
updatedAt: geoStat.updated_at,
tier: geoStat.tier,
provider: geoStat.provider,
model: geoStat.model,
country: geoStat.country,
continent: geoStat.continent,
totalTokens: geoStat.total_tokens,
})
.from(geoStat)
.where(and(eq(geoStat.grain, "day"), eq(geoStat.client, "all"), eq(geoStat.source, "all")))
.orderBy(asc(geoStat.period_start)),
.where(
and(
eq(geoStat.grain, "day"),
eq(geoStat.client, "all"),
eq(geoStat.source, "all"),
eq(geoStat.provider, "all"),
eq(geoStat.model, "all"),
),
)
.orderBy(asc(geoStat.period_key)),
catch: (cause) => DatabaseError.make({ cause }),
})
})
const listByPeriod = Effect.fn("GeoStatRepo.listByPeriod")(function* (opts: {
readonly grain: string
readonly periodStart: Date
readonly periodKey: string
readonly dataset?: string
readonly tier?: string
readonly client?: string
readonly source?: string
readonly provider?: string
readonly model?: string
}) {
return yield* Effect.tryPromise({
try: () =>
@@ -81,11 +103,13 @@ export class GeoStatRepo extends Context.Service<GeoStatRepo, GeoStatRepo.Servic
.where(
and(
eq(geoStat.grain, opts.grain),
eq(geoStat.period_start, opts.periodStart),
eq(geoStat.period_key, opts.periodKey),
eq(geoStat.dataset, opts.dataset ?? "zen"),
eq(geoStat.tier, opts.tier ?? "all"),
eq(geoStat.client, opts.client ?? "all"),
eq(geoStat.source, opts.source ?? "all"),
eq(geoStat.provider, opts.provider ?? "all"),
eq(geoStat.model, opts.model ?? "all"),
),
),
catch: (cause) => DatabaseError.make({ cause }),
@@ -103,7 +127,6 @@ export class GeoStatRepo extends Context.Service<GeoStatRepo, GeoStatRepo.Servic
.values(chunk)
.onDuplicateKeyUpdate({
set: {
period_end: inserted("period_end"),
continent: inserted("continent"),
sessions: inserted("sessions"),
requests: inserted("requests"),
@@ -146,26 +169,35 @@ export class GeoStatRepo extends Context.Service<GeoStatRepo, GeoStatRepo.Servic
}
export function rowsFromAggregates(aggregates: GeoStatAggregate[]) {
return rankRowsWithMarketShare([
...synthesizeAllTierRows(
collapseRows(aggregates.filter((item) => item.grain === "week").map(toRow), dimensionKey),
dimensionKey,
),
...synthesizeAllTierRows(
collapseRows(aggregates.filter((item) => item.grain === "day").map(toRow), dimensionKey),
dimensionKey,
),
])
return rankRowsWithMarketShare(
[
...synthesizeAllTierRows(
collapseRows(aggregates.filter((item) => item.grain === "week").map(toRow), dimensionKey),
dimensionKey,
),
...synthesizeAllTierRows(
collapseRows(aggregates.filter((item) => item.grain === "day").map(toRow), dimensionKey),
dimensionKey,
),
],
marketShareKey,
)
}
function toRow(data: GeoStatAggregate): GeoStatRow {
return {
...toStatBaseRow(data),
provider: data.provider,
model: data.model,
country: data.country,
continent: data.continent,
}
}
function dimensionKey(row: GeoStatRow) {
return row.country
return [row.provider, row.model, row.country].join("\u0000")
}
function marketShareKey(row: GeoStatRow) {
return [statPeriodKey(row), row.provider, row.model].join("\u0000")
}
+37 -21
View File
@@ -28,17 +28,17 @@ const TOKEN_SCALE = 1_000_000
const DOLLARS_PER_MICROCENT = 1 / 100_000_000
const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] as const
type StatMetricRow = Omit<ModelStatMetric, "periodStart" | "periodEnd"> & {
type StatMetricRow = Omit<ModelStatMetric, "updatedAt"> & {
periodStart: number
periodEnd: number
updatedAt: number
}
type ProviderMetricRow = Omit<ProviderStatMetric, "periodStart" | "periodEnd"> & {
type ProviderMetricRow = Omit<ProviderStatMetric, "updatedAt"> & {
periodStart: number
periodEnd: number
updatedAt: number
}
type GeoMetricRow = Omit<GeoStatMetric, "periodStart" | "periodEnd"> & {
type GeoMetricRow = Omit<GeoStatMetric, "updatedAt"> & {
periodStart: number
periodEnd: number
updatedAt: number
}
type DateWindow = { start: number; end: number; previousStart: number; previousEnd: number }
@@ -85,10 +85,10 @@ function buildStatsHomeData(
const earliest = Math.min(...periods.map((row) => row.periodStart))
const latest = Math.max(...periods.map((row) => row.periodStart))
const latestEnd = Math.max(...periods.map((row) => row.periodEnd))
const latestUpdate = Math.max(...periods.map((row) => row.updatedAt))
return {
updatedAt: new Date(latestEnd).toISOString(),
updatedAt: new Date(latestUpdate).toISOString(),
usage: createUsageProductRecord((product) =>
createRangeRecord((range) => buildUsagePoints(normalized, product, range, getWindow(range, earliest, latest))),
),
@@ -361,14 +361,14 @@ function createRangeRecord<T>(value: (range: UsageRange) => T): Record<UsageRang
}
function normalizeStatRow(row: ModelStatMetric): StatMetricRow[] {
const periodStart = dateTime(row.periodStart)
const periodEnd = dateTime(row.periodEnd)
if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd)) return []
const periodStart = periodKeyTime(row.periodKey)
const updatedAt = dateTime(row.updatedAt)
if (!Number.isFinite(periodStart) || !Number.isFinite(updatedAt)) return []
return [
{
...row,
periodStart,
periodEnd,
updatedAt,
tier: normalizeTier(row.tier),
provider: row.provider || "unknown",
model: row.model || "unknown",
@@ -377,14 +377,14 @@ function normalizeStatRow(row: ModelStatMetric): StatMetricRow[] {
}
function normalizeProviderRow(row: ProviderStatMetric): ProviderMetricRow[] {
const periodStart = dateTime(row.periodStart)
const periodEnd = dateTime(row.periodEnd)
if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd)) return []
const periodStart = periodKeyTime(row.periodKey)
const updatedAt = dateTime(row.updatedAt)
if (!Number.isFinite(periodStart) || !Number.isFinite(updatedAt)) return []
return [
{
...row,
periodStart,
periodEnd,
updatedAt,
tier: normalizeTier(row.tier),
provider: row.provider || "unknown",
},
@@ -392,15 +392,17 @@ function normalizeProviderRow(row: ProviderStatMetric): ProviderMetricRow[] {
}
function normalizeGeoRow(row: GeoStatMetric): GeoMetricRow[] {
const periodStart = dateTime(row.periodStart)
const periodEnd = dateTime(row.periodEnd)
if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd)) return []
const periodStart = periodKeyTime(row.periodKey)
const updatedAt = dateTime(row.updatedAt)
if (!Number.isFinite(periodStart) || !Number.isFinite(updatedAt)) return []
return [
{
...row,
periodStart,
periodEnd,
updatedAt,
tier: normalizeTier(row.tier),
provider: row.provider || "all",
model: row.model || "all",
country: row.country || "ZZ",
continent: row.continent || "",
},
@@ -420,6 +422,12 @@ function dateTime(value: Date | string) {
return (value instanceof Date ? value : new Date(value)).getTime()
}
function periodKeyTime(value: string) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value)
if (!match) return Number.NaN
return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
}
function formatBucketLabel(value: number, range: UsageRange) {
const date = new Date(value)
if (range === "YTD") return months[date.getUTCMonth()]
@@ -433,11 +441,19 @@ function formatBucketLabel(value: number, range: UsageRange) {
function formatProvider(provider: string) {
const known: Record<string, string> = {
anthropic: "Anthropic",
deepseek: "DeepSeek",
google: "Google",
minimax: "MiniMax",
moonshot: "Moonshot",
moonshotai: "Moonshot",
nvidia: "Nvidia",
nvidia: "NVIDIA",
opencode: "opencode",
openai: "OpenAI",
qwen: "Qwen",
tencent: "Tencent",
xai: "xAI",
xiaomi: "Xiaomi",
zhipu: "Zhipu",
zhipuai: "Zhipu",
}
const normalized = provider.toLowerCase().replace(/[^a-z0-9]/g, "")
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test"
import { toModelAggregate } from "./inference"
import { modelAuthor, normalizeInferenceModel } from "./model-normalization"
describe("inference stat normalization", () => {
test("normalizes model suffixes used by router/provider variants", () => {
expect(normalizeInferenceModel("deepseek-v4-flash-free")).toBe("deepseek-v4-flash")
expect(normalizeInferenceModel("deepseek-v4-flash:global")).toBe("deepseek-v4-flash")
expect(normalizeInferenceModel("mimo-v2.5-free")).toBe("mimo-v2.5")
expect(normalizeInferenceModel("nemotron-3-super-free")).toBe("nemotron-3-super")
expect(normalizeInferenceModel("mimo-v2.5-free:global")).toBe("mimo-v2.5")
})
test("maps normalized model ids to public authors", () => {
expect(modelAuthor("big-pickle")).toBe("opencode")
expect(modelAuthor("claude-sonnet-4-5")).toBe("anthropic")
expect(modelAuthor("deepseek-v4-pro")).toBe("deepseek")
expect(modelAuthor("gemini-3.5-flash")).toBe("google")
expect(modelAuthor("glm-5.1")).toBe("zhipu")
expect(modelAuthor("gpt-5.5-pro")).toBe("openai")
expect(modelAuthor("grok-build-0.1")).toBe("xai")
expect(modelAuthor("hy3-preview")).toBe("tencent")
expect(modelAuthor("kimi-k2.6")).toBe("moonshot")
expect(modelAuthor("mimo-v2-omni")).toBe("xiaomi")
expect(modelAuthor("minimax-m2.7")).toBe("minimax")
expect(modelAuthor("nemotron-3-super-free")).toBe("nvidia")
expect(modelAuthor("qwen3.7-max")).toBe("qwen")
expect(modelAuthor("alpha-gpt-next")).toBeUndefined()
})
test("model aggregates ignore datalake provider and use normalized author/model", () => {
expect(toModelAggregate(aggregate("alpha-gpt-next", "openai"))).toEqual([])
expect(toModelAggregate(aggregate("deepseek-v4-flash-free", "not-public-provider"))).toMatchObject([
{
period_key: "2026-05-20",
provider: "deepseek",
model: "deepseek-v4-flash",
},
])
})
test("model aggregates use ISO week period keys", () => {
expect(
toModelAggregate({
...aggregate("gpt-5.5-pro", "openai"),
grain: "week",
period_key: "2026-W20",
}),
).toMatchObject([{ period_key: "2026-W20" }])
})
})
function aggregate(model: string, provider: string) {
return {
grain: "day",
period_key: "2026-05-20",
dataset: "zen",
tier: "Paid",
provider,
model,
sessions: "1",
requests: "1",
sample_count: "1",
}
}
+86 -46
View File
@@ -2,10 +2,17 @@ import { Resource } from "sst/resource"
import type { AthenaData } from "../athena"
import type { GeoStatAggregate } from "./geo"
import type { ModelStatAggregate } from "./model"
import {
EXCLUDED_MODELS,
MODEL_AUTHOR_OVERRIDES,
MODEL_AUTHOR_RULES,
modelAuthor,
normalizeInferenceModel,
} from "./model-normalization"
import type { ProviderStatAggregate } from "./provider"
import { normalizeCountry, normalizeTier, type StatBaseAggregate } from "./stat"
export type StatDimension = "model" | "provider" | "geo"
export type StatDimension = "model" | "provider" | "geo" | "geo_model"
export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: StatDimension) {
const periodStartValue = sqlString(periodStart.toISOString())
@@ -20,8 +27,13 @@ export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: S
groupBy: "provider, model",
}
if (dimension === "provider") return { select: "provider", groupBy: "provider" }
if (dimension === "geo_model")
return {
select: "provider, model, country, COALESCE(MAX(NULLIF(continent, '')), '') AS continent",
groupBy: "provider, model, country",
}
return {
select: "country, COALESCE(MAX(NULLIF(continent, '')), '') AS continent",
select: "'all' AS provider, 'all' AS model, country, COALESCE(MAX(NULLIF(continent, '')), '') AS continent",
groupBy: "country",
}
})()
@@ -48,32 +60,56 @@ export function buildStatsQuery(periodStart: Date, periodEnd: Date, dimension: S
COUNT(*) AS sample_count`
return `
WITH filtered AS (
WITH normalized AS (
SELECT
from_iso8601_timestamp(event_timestamp) AS event_time,
CASE
WHEN source = 'lite' THEN 'Go'
WHEN model IN ('gpt-5-nano', 'grok-code', 'big-pickle') OR model LIKE '%-free' THEN 'Free'
ELSE 'Paid'
END AS tier,
COALESCE(NULLIF(
CASE
WHEN starts_with(provider, 'minimax-plan') THEN 'minimax-plan'
WHEN starts_with(provider, 'zai-plan') THEN 'zai-plan'
WHEN starts_with(provider, 'azure-databricks') THEN 'azure-databricks'
WHEN regexp_like(provider, '^azure[0-9]+') THEN 'azure-openai'
ELSE provider
END,
''
), 'unknown') AS provider,
model AS raw_model,
COALESCE(NULLIF(regexp_replace(model, '(-free|:global)+$', ''), ''), 'unknown') AS model,
COALESCE(NULLIF(provider_model, ''), '') AS provider_model,
COALESCE(NULLIF(model, ''), 'unknown') AS model,
UPPER(COALESCE(NULLIF(cf_country, ''), 'ZZ')) AS country,
COALESCE(NULLIF(cf_continent, ''), '') AS continent,
session,
status,
duration AS duration_ms,
time_to_first_byte AS ttfb_ms,
timestamp_first_byte,
timestamp_last_byte,
tokens_input,
tokens_output,
tokens_reasoning,
tokens_cache_read,
tokens_cache_write_5m,
cost_input_microcents,
cost_output_microcents,
cost_total_microcents,
cost_input,
cost_output,
cost_total,
source
FROM ${sourceTable}
WHERE event_type = 'completions'
AND model IS NOT NULL
AND model <> ''
AND (strpos(COALESCE(user_agent, ''), 'ai-sdk') > 0 OR strpos(COALESCE(user_agent, ''), 'opencode') > 0)
AND event_timestamp >= ${periodStartValue}
AND event_timestamp < ${periodEndValue}
), filtered AS (
SELECT
event_time,
CASE
WHEN source = 'lite' THEN 'Go'
WHEN model IN ('gpt-5-nano', 'grok-code', 'big-pickle') OR regexp_like(raw_model, '-free(:global)?$') THEN 'Free'
ELSE 'Paid'
END AS tier,
${modelAuthorSql("model")} AS provider,
provider_model,
model,
country,
continent,
session,
status,
duration_ms,
ttfb_ms,
CASE
WHEN timestamp_last_byte - timestamp_first_byte < 100 THEN null
ELSE CAST(tokens_output AS double) / (timestamp_last_byte - timestamp_first_byte) * 1000
@@ -86,50 +122,47 @@ WITH filtered AS (
COALESCE(cost_input_microcents, cost_input * 1000000) AS cost_input_microcents,
COALESCE(cost_output_microcents, cost_output * 1000000) AS cost_output_microcents,
COALESCE(cost_total_microcents, cost_total * 1000000) AS cost_total_microcents
FROM ${sourceTable}
WHERE event_type = 'completions'
AND model IS NOT NULL
AND model <> ''
AND (strpos(COALESCE(user_agent, ''), 'ai-sdk') > 0 OR strpos(COALESCE(user_agent, ''), 'opencode') > 0)
AND event_timestamp >= ${periodStartValue}
AND event_timestamp < ${periodEndValue}
FROM normalized
WHERE lower(model) NOT IN (${[...EXCLUDED_MODELS].map(sqlString).join(", ")})
), weekly AS (
SELECT
concat(CAST(year_of_week(event_time) AS varchar), '-W', lpad(CAST(week(event_time) AS varchar), 2, '0')) AS week_key,
*
FROM filtered
), daily AS (
SELECT date_trunc('day', event_time) AS day, *
SELECT substr(to_iso8601(date_trunc('day', event_time)), 1, 10) AS day_key, *
FROM filtered
)
SELECT
'week' AS grain,
${periodStartValue} AS period_start,
${periodEndValue} AS period_end,
week_key AS period_key,
${sqlString(Resource.StatsSyncConfig.dataset)} AS dataset,
tier,
${dimensionSql.select},
${aggregateColumns}
FROM filtered
GROUP BY tier, ${dimensionSql.groupBy}
FROM weekly
GROUP BY week_key, tier, ${dimensionSql.groupBy}
UNION ALL
SELECT
'day' AS grain,
to_iso8601(day) AS period_start,
to_iso8601(least(day + INTERVAL '1' DAY, from_iso8601_timestamp(${periodEndValue}))) AS period_end,
day_key AS period_key,
${sqlString(Resource.StatsSyncConfig.dataset)} AS dataset,
tier,
${dimensionSql.select},
${aggregateColumns}
FROM daily
GROUP BY day, tier, ${dimensionSql.groupBy}
ORDER BY grain, period_start, total_tokens DESC
GROUP BY day_key, tier, ${dimensionSql.groupBy}
ORDER BY grain, period_key, total_tokens DESC
`
}
export function toModelAggregate(data: AthenaData): ModelStatAggregate[] {
const model = normalizeInferenceModel(data.model)
const author = modelAuthor(model)
if (!author) return []
return toStatBaseAggregate(data).flatMap((base) => [
{
...base,
provider: data.provider || "unknown",
model: data.model || "unknown",
provider_model: data.provider_model || "",
},
{ ...base, provider: author, model, provider_model: data.provider_model || "" },
])
}
@@ -141,6 +174,8 @@ export function toGeoAggregate(data: AthenaData): GeoStatAggregate[] {
return toStatBaseAggregate(data).flatMap((base) => [
{
...base,
provider: data.provider || "all",
model: normalizeInferenceModel(data.model || "all"),
country: normalizeCountry(data.country),
continent: data.continent || "",
},
@@ -149,15 +184,12 @@ export function toGeoAggregate(data: AthenaData): GeoStatAggregate[] {
function toStatBaseAggregate(data: AthenaData): StatBaseAggregate[] {
const grain = data.grain === "day" || data.grain === "week" ? data.grain : undefined
const periodStart = new Date(data.period_start ?? "")
const periodEnd = new Date(data.period_end ?? "")
if (!grain || Number.isNaN(periodStart.getTime()) || Number.isNaN(periodEnd.getTime())) return []
if (!grain || !data.period_key) return []
return [
{
grain,
period_start: periodStart,
period_end: periodEnd,
period_key: data.period_key,
dataset: data.dataset || Resource.StatsSyncConfig.dataset,
tier: normalizeTier(data.tier || "unknown"),
sessions: integer(data, "sessions"),
@@ -210,3 +242,11 @@ function sqlIdentifier(value: string) {
function sqlString(value: string) {
return `'${value.replace(/'/g, "''")}'`
}
function modelAuthorSql(model: string) {
return `CASE
${MODEL_AUTHOR_OVERRIDES.map((item) => ` WHEN lower(${model}) = ${sqlString(item.model)} THEN ${sqlString(item.author)}`).join("\n")}
${MODEL_AUTHOR_RULES.map((item) => ` WHEN strpos(lower(${model}), ${sqlString(item.match)}) > 0 THEN ${sqlString(item.author)}`).join("\n")}
ELSE 'unknown'
END`
}
@@ -0,0 +1,30 @@
export const MODEL_AUTHOR_OVERRIDES = [{ model: "big-pickle", author: "opencode" }] as const
export const MODEL_AUTHOR_RULES = [
{ match: "claude", author: "anthropic" },
{ match: "gemini", author: "google" },
{ match: "deepseek", author: "deepseek" },
{ match: "glm", author: "zhipu" },
{ match: "gpt", author: "openai" },
{ match: "grok", author: "xai" },
{ match: "hy3", author: "tencent" },
{ match: "kimi", author: "moonshot" },
{ match: "mimo", author: "xiaomi" },
{ match: "minimax", author: "minimax" },
{ match: "nemotron", author: "nvidia" },
{ match: "qwen", author: "qwen" },
] as const
export const EXCLUDED_MODELS = new Set(["alpha-gpt-next"])
export function normalizeInferenceModel(value: string | undefined) {
return (value || "unknown").replace(/(-free|:global)+$/, "") || "unknown"
}
export function modelAuthor(value: string | undefined) {
const model = normalizeInferenceModel(value).toLowerCase()
if (EXCLUDED_MODELS.has(model)) return undefined
const override = MODEL_AUTHOR_OVERRIDES.find((item) => item.model === model)
if (override) return override.author
return MODEL_AUTHOR_RULES.find((item) => model.includes(item.match))?.author ?? "unknown"
}
+5 -6
View File
@@ -19,8 +19,8 @@ export type ModelStatRow = typeof modelStat.$inferInsert
export type ModelStatAggregate = StatBaseAggregate & { provider: string; model: string; provider_model: string }
export type ModelStatMetric = {
periodStart: Date
periodEnd: Date
periodKey: string
updatedAt: Date
tier: string
provider: string
model: string
@@ -55,8 +55,8 @@ export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.
try: () =>
db
.select({
periodStart: modelStat.period_start,
periodEnd: modelStat.period_end,
periodKey: modelStat.period_key,
updatedAt: modelStat.updated_at,
tier: modelStat.tier,
provider: modelStat.provider,
model: modelStat.model,
@@ -72,7 +72,7 @@ export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.
})
.from(modelStat)
.where(and(eq(modelStat.grain, "day"), eq(modelStat.client, "all"), eq(modelStat.source, "all")))
.orderBy(asc(modelStat.period_start)),
.orderBy(asc(modelStat.period_key)),
catch: (cause) => DatabaseError.make({ cause }),
})
})
@@ -88,7 +88,6 @@ export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.
.values(chunk)
.onDuplicateKeyUpdate({
set: {
period_end: inserted("period_end"),
provider_model: inserted("provider_model"),
sessions: inserted("sessions"),
requests: inserted("requests"),
+8 -9
View File
@@ -17,8 +17,8 @@ import {
export type ProviderStatRow = typeof providerStat.$inferInsert
export type ProviderStatAggregate = StatBaseAggregate & { provider: string }
export type ProviderStatMetric = {
periodStart: Date
periodEnd: Date
periodKey: string
updatedAt: Date
tier: string
provider: string
totalTokens: number
@@ -29,7 +29,7 @@ export declare namespace ProviderStatRepo {
readonly listDaily: () => Effect.Effect<ProviderStatMetric[], DatabaseError>
readonly listByPeriod: (opts: {
readonly grain: string
readonly periodStart: Date
readonly periodKey: string
readonly dataset?: string
readonly tier?: string
readonly client?: string
@@ -52,22 +52,22 @@ export class ProviderStatRepo extends Context.Service<ProviderStatRepo, Provider
try: () =>
db
.select({
periodStart: providerStat.period_start,
periodEnd: providerStat.period_end,
periodKey: providerStat.period_key,
updatedAt: providerStat.updated_at,
tier: providerStat.tier,
provider: providerStat.provider,
totalTokens: providerStat.total_tokens,
})
.from(providerStat)
.where(and(eq(providerStat.grain, "day"), eq(providerStat.client, "all"), eq(providerStat.source, "all")))
.orderBy(asc(providerStat.period_start)),
.orderBy(asc(providerStat.period_key)),
catch: (cause) => DatabaseError.make({ cause }),
})
})
const listByPeriod = Effect.fn("ProviderStatRepo.listByPeriod")(function* (opts: {
readonly grain: string
readonly periodStart: Date
readonly periodKey: string
readonly dataset?: string
readonly tier?: string
readonly client?: string
@@ -81,7 +81,7 @@ export class ProviderStatRepo extends Context.Service<ProviderStatRepo, Provider
.where(
and(
eq(providerStat.grain, opts.grain),
eq(providerStat.period_start, opts.periodStart),
eq(providerStat.period_key, opts.periodKey),
eq(providerStat.dataset, opts.dataset ?? "zen"),
eq(providerStat.tier, opts.tier ?? "all"),
eq(providerStat.client, opts.client ?? "all"),
@@ -103,7 +103,6 @@ export class ProviderStatRepo extends Context.Service<ProviderStatRepo, Provider
.values(chunk)
.onDuplicateKeyUpdate({
set: {
period_end: inserted("period_end"),
sessions: inserted("sessions"),
requests: inserted("requests"),
input_tokens: inserted("input_tokens"),
+40 -27
View File
@@ -1,13 +1,13 @@
import { sql } from "drizzle-orm"
export const UPSERT_CHUNK_SIZE = 500
const DAY_MS = 86_400_000
export type StatGrain = "day" | "week"
export type StatBaseAggregate = {
grain: StatGrain
period_start: Date
period_end: Date
period_key: string
dataset: string
tier: string
sessions: number
@@ -34,8 +34,7 @@ export type StatBaseAggregate = {
export type StatBaseRow = {
grain: string
period_start: Date
period_end: Date
period_key: string
dataset?: string
tier?: string
client?: string
@@ -65,8 +64,7 @@ export type StatBaseRow = {
export function toStatBaseRow(data: StatBaseAggregate) {
return {
grain: data.grain,
period_start: data.period_start,
period_end: data.period_end,
period_key: data.period_key,
dataset: data.dataset,
tier: data.tier,
client: "all",
@@ -99,14 +97,7 @@ export function synthesizeAllTierRows<T extends StatBaseRow>(rows: T[], dimensio
...rows,
...Object.values(
rows.reduce<Record<string, T>>((result, row) => {
const key = [
row.grain,
row.period_start.toISOString(),
row.dataset,
row.client,
row.source,
dimensionKey(row),
].join("\u0000")
const key = [row.grain, row.period_key, row.dataset, row.client, row.source, dimensionKey(row)].join("\u0000")
result[key] = result[key] ? combineRows(result[key], row) : { ...row, tier: "all" }
return result
}, {}),
@@ -117,15 +108,9 @@ export function synthesizeAllTierRows<T extends StatBaseRow>(rows: T[], dimensio
export function collapseRows<T extends StatBaseRow>(rows: T[], dimensionKey: (row: T) => string) {
return Object.values(
rows.reduce<Record<string, T>>((result, row) => {
const key = [
row.grain,
row.period_start.toISOString(),
row.dataset,
row.tier,
row.client,
row.source,
dimensionKey(row),
].join("\u0000")
const key = [row.grain, row.period_key, row.dataset, row.tier, row.client, row.source, dimensionKey(row)].join(
"\u0000",
)
result[key] = result[key] ? combineRows(result[key], row) : row
return result
}, {}),
@@ -135,7 +120,6 @@ export function collapseRows<T extends StatBaseRow>(rows: T[], dimensionKey: (ro
export function combineRows<T extends StatBaseRow>(left: T, right: T): T {
return {
...left,
period_end: right.period_end > left.period_end ? right.period_end : left.period_end,
sessions: (left.sessions ?? 0) + (right.sessions ?? 0),
requests: (left.requests ?? 0) + (right.requests ?? 0),
input_tokens: (left.input_tokens ?? 0) + (right.input_tokens ?? 0),
@@ -160,17 +144,46 @@ export function combineRows<T extends StatBaseRow>(left: T, right: T): T {
}
export function statPeriodKey(row: StatBaseRow) {
return [row.grain, row.period_start.toISOString(), row.dataset, row.tier, row.client, row.source].join("\u0000")
return [row.grain, row.period_key, row.dataset, row.tier, row.client, row.source].join("\u0000")
}
export function periodKeyFor(grain: StatGrain, periodStart: Date) {
if (grain === "week") return isoWeekId(periodStart)
return utcDateId(periodStart)
}
export function startOfUtcDay(value: Date) {
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()))
}
export function startOfIsoWeek(value: Date) {
return new Date(
Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate() - (value.getUTCDay() || 7) + 1),
)
}
export function isoWeekId(value: Date) {
const thursday = new Date(
Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate() + 4 - (value.getUTCDay() || 7)),
)
return `${thursday.getUTCFullYear()}-W${String(Math.ceil(((thursday.getTime() - Date.UTC(thursday.getUTCFullYear(), 0, 1)) / DAY_MS + 1) / 7)).padStart(2, "0")}`
}
function utcDateId(value: Date) {
return `${value.getUTCFullYear()}-${String(value.getUTCMonth() + 1).padStart(2, "0")}-${String(value.getUTCDate()).padStart(2, "0")}`
}
export function rankBy<T extends StatBaseRow>(rows: T[], value: (row: T) => number) {
return new Map(rows.toSorted((a, b) => value(b) - value(a)).map((row, index) => [row, index + 1]))
}
export function rankRowsWithMarketShare<T extends StatBaseRow>(rows: T[]) {
export function rankRowsWithMarketShare<T extends StatBaseRow>(
rows: T[],
groupKey: (row: T) => string = statPeriodKey,
) {
return Object.values(
rows.reduce<Record<string, T[]>>((result, row) => {
const key = statPeriodKey(row)
const key = groupKey(row)
result[key] = [...(result[key] ?? []), row]
return result
}, {}),
@@ -0,0 +1,974 @@
import { Client } from "@planetscale/database"
import { readdir } from "node:fs/promises"
import path from "node:path"
import { drizzle } from "drizzle-orm/planetscale-serverless"
import { geoStat, modelStat, providerStat } from "./database/schema"
import { modelAuthor, normalizeInferenceModel } from "./domain/model-normalization"
import {
chunks,
collapseRows,
inserted,
isoWeekId,
normalizeCountry,
normalizeTier,
periodKeyFor,
rankBy,
rankRowsWithMarketShare,
startOfIsoWeek,
startOfUtcDay,
statPeriodKey,
synthesizeAllTierRows,
toStatBaseRow,
type StatBaseAggregate,
} from "./domain/stat"
const DAY_MS = 86_400_000
const DEFAULT_UPSERT_CHUNK_SIZE = 100
const DEFAULT_TIERS = ["Go", "Free", "Paid"]
const FREE_MODELS = new Set(["gpt-5-nano", "grok-code", "big-pickle"])
type Grain = "day" | "week"
type MetricDimension = "model" | "provider" | "geo" | "geo-model"
type LookupDimension = "model-provider-model" | "geo-continent"
type ImportKey = `${MetricDimension | LookupDimension}-${Grain}`
type QuerySpec = {
name: string
importKey: ImportKey
importFlag: `--${ImportKey}`
query: ReturnType<typeof metricQuery>
}
type RawRow = Record<string, string>
type ImportOptions = {
dataset: string
databaseUrl: string | undefined
directories: string[]
dryRun: boolean
periodStart: Date | undefined
upsertChunkSize: number
files: Partial<Record<ImportKey, string[]>>
}
type ModelAggregate = StatBaseAggregate & { provider: string; model: string; provider_model: string }
type ProviderAggregate = StatBaseAggregate & { provider: string }
type GeoAggregate = StatBaseAggregate & { provider: string; model: string; country: string; continent: string }
type ModelStatRow = typeof modelStat.$inferInsert
type ProviderStatRow = typeof providerStat.$inferInsert
type GeoStatRow = typeof geoStat.$inferInsert
const inputKeys = [
"model-day",
"model-week",
"model-provider-model-day",
"model-provider-model-week",
"provider-day",
"provider-week",
"geo-day",
"geo-week",
"geo-model-day",
"geo-model-week",
"geo-continent-day",
"geo-continent-week",
] as const satisfies ImportKey[]
if (import.meta.main) await main()
async function main() {
const command = process.argv[2]
if (command === "queries") return printQueries(process.argv.slice(3))
if (command === "import") return importFiles(process.argv.slice(3))
usage()
}
function printQueries(args: string[]) {
const flags = parseFlags(args)
const limit = parseIntegerFlag(flags, "limit") ?? 1000
const tiers = parseListFlag(flags, "tiers") ?? DEFAULT_TIERS
const queries = buildQueries(limit, tiers)
const only = flags.get("only")?.[0]
if (only) {
const item = queries.find((query) => query.name === only)
if (!item) fail(`Unknown --only ${only}. Expected one of: ${queries.map((query) => query.name).join(", ")}`)
console.log(JSON.stringify(item.query, null, 2))
return
}
console.log(
JSON.stringify(
{
tiers,
import_hint: "bun src/honeycomb-backfill.ts import --dir downloads",
queries,
},
null,
2,
),
)
}
async function importFiles(args: string[]) {
const parsed = parseImportOptions(args)
const opts = { ...parsed, files: mergeFiles(parsed.files, await discoverFiles(parsed.directories)) }
if (!inputKeys.some((key) => opts.files[key]?.length)) fail("No CSV or JSON import files were provided or discovered")
const providerModelLookup = new Map([
...(await lookupRows(opts.files["model-provider-model-day"], "day", opts, modelProviderModelLookup)),
...(await lookupRows(opts.files["model-provider-model-week"], "week", opts, modelProviderModelLookup)),
])
const continentLookup = new Map([
...(await lookupRows(opts.files["geo-continent-day"], "day", opts, geoContinentLookup)),
...(await lookupRows(opts.files["geo-continent-week"], "week", opts, geoContinentLookup)),
])
const modelAggregates = [
...(await metricRows(opts.files["model-day"], "day", opts, (row, base) =>
modelAggregate(row, base, providerModelLookup),
)),
...(await metricRows(opts.files["model-week"], "week", opts, (row, base) =>
modelAggregate(row, base, providerModelLookup),
)),
]
const modelRows = modelRowsFromAggregates(modelAggregates)
const providerRows = providerRowsFromAggregates([
...(await metricRows(opts.files["provider-day"], "day", opts, (row, base) => ({
...base,
provider: provider(row) ?? "unknown",
}))),
...(await metricRows(opts.files["provider-week"], "week", opts, (row, base) => ({
...base,
provider: provider(row) ?? "unknown",
}))),
])
const geoRows = geoRowsFromAggregates([
...(await metricRows(opts.files["geo-day"], "day", opts, (row, base) => ({
...base,
provider: "all",
model: "all",
country: country(row),
continent: continentLookup.get(lookupKey(base, country(row))) ?? continent(row),
}))),
...(await metricRows(opts.files["geo-week"], "week", opts, (row, base) => ({
...base,
provider: "all",
model: "all",
country: country(row),
continent: continentLookup.get(lookupKey(base, country(row))) ?? continent(row),
}))),
...(await metricRows(opts.files["geo-model-day"], "day", opts, (row, base) =>
geoModelAggregate(row, base, continentLookup),
)),
...(await metricRows(opts.files["geo-model-week"], "week", opts, (row, base) =>
geoModelAggregate(row, base, continentLookup),
)),
])
console.log(
JSON.stringify(
{
inputs: Object.fromEntries(
inputKeys.flatMap((key) => (opts.files[key]?.length ? [[key, opts.files[key].length]] : [])),
),
modelRows: modelRows.length,
providerRows: providerRows.length,
geoRows: geoRows.length,
dryRun: opts.dryRun,
upsertChunkSize: opts.upsertChunkSize,
},
null,
2,
),
)
if (opts.dryRun) return
if (!opts.databaseUrl) fail("DATABASE_URL is required unless --dry-run is set")
const db = drizzle({ client: new Client({ url: opts.databaseUrl }) })
await upsertModelRows(db, modelRows, opts.upsertChunkSize)
await upsertProviderRows(db, providerRows, opts.upsertChunkSize)
await upsertGeoRows(db, geoRows, opts.upsertChunkSize)
}
function buildQueries(limit: number, tiers: string[]): QuerySpec[] {
const daily = tiers.flatMap((tier) => [
querySpec(
"model-day",
tier,
metricQuery(["date", "tier", "stat_provider", "stat_model"], limit, tierFilters(tier)),
),
querySpec("provider-day", tier, metricQuery(["date", "tier", "stat_provider"], limit, tierFilters(tier))),
querySpec("geo-day", tier, metricQuery(["date", "tier", "country", "continent"], limit, tierFilters(tier))),
querySpec(
"geo-model-day",
tier,
metricQuery(["date", "tier", "stat_provider", "stat_model", "country", "continent"], limit, tierFilters(tier)),
),
])
const weekly = tiers.flatMap((tier) => [
querySpec(
"model-week",
tier,
metricQuery(["week", "tier", "stat_provider", "stat_model"], limit, tierFilters(tier)),
),
querySpec("provider-week", tier, metricQuery(["week", "tier", "stat_provider"], limit, tierFilters(tier))),
querySpec("geo-week", tier, metricQuery(["week", "tier", "country", "continent"], limit, tierFilters(tier))),
querySpec(
"geo-model-week",
tier,
metricQuery(["week", "tier", "stat_provider", "stat_model", "country", "continent"], limit, tierFilters(tier)),
),
])
return [...daily, ...weekly]
}
function querySpec(importKey: ImportKey, tier: string, query: ReturnType<typeof metricQuery>) {
return {
name: `${importKey}-${queryNameSegment(tier)}`,
importKey,
importFlag: `--${importKey}` as const,
query,
}
}
function metricQuery(breakdowns: string[], limit: number, filters: ReturnType<typeof commonFilters> = []) {
return {
granularity: 0,
breakdowns,
calculations: [
{ op: "COUNT_DISTINCT", column: "session" },
{ op: "COUNT" },
{ op: "SUM", column: "tokens.input" },
{ op: "SUM", column: "tokens.output" },
{ op: "SUM", column: "tokens.reasoning" },
{ op: "SUM", column: "tokens.cache_read" },
{ op: "SUM", column: "tokens" },
{ op: "SUM", column: "cost.input.microcents" },
{ op: "SUM", column: "cost.output.microcents" },
{ op: "SUM", column: "cost.total.microcents" },
{ op: "AVG", column: "duration" },
{ op: "P50", column: "duration" },
{ op: "P95", column: "duration" },
{ op: "AVG", column: "time_to_first_byte" },
{ op: "P50", column: "time_to_first_byte" },
{ op: "P95", column: "time_to_first_byte" },
{ op: "AVG", column: "tps.output" },
],
filters: [...commonFilters(), ...filters],
filter_combination: "AND",
orders: [{ column: "tokens", op: "SUM", order: "descending" }],
havings: [],
limit,
formulas: [],
}
}
function tierFilters(tier: string) {
if (tier === "all") return []
return [{ column: "tier", op: "=", value: tier }]
}
function queryNameSegment(value: string) {
return (
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "all"
)
}
function commonFilters() {
return [
{ column: "event_type", op: "=", value: "completions" },
{ column: "model", op: "exists" },
{ column: "model", op: "!=", value: "" },
{ column: "model", op: "!=", value: "alpha-gpt-next" },
]
}
function metricRows<T extends StatBaseAggregate>(
files: string[] | undefined,
grain: Grain,
opts: ImportOptions,
map: (row: RawRow, base: StatBaseAggregate) => T | T[],
) {
if (!files) return Promise.resolve([])
return readFiles(files).then((rows) => rows.flatMap((row) => map(row, baseAggregate(row, grain, opts))))
}
function lookupRows(
files: string[] | undefined,
grain: Grain,
opts: ImportOptions,
map: (row: RawRow, grain: Grain, opts: ImportOptions) => readonly (readonly [string, string])[],
) {
if (!files) return Promise.resolve([])
return readFiles(files).then((rows) =>
Array.from(
rows
.flatMap((row) => map(row, grain, opts))
.reduce((result, [key, value]) => {
if (value && value > (result.get(key) ?? "")) result.set(key, value)
return result
}, new Map<string, string>()),
),
)
}
async function readFiles(files: string[]) {
return (await Promise.all(files.map(readRows))).flat()
}
async function discoverFiles(directories: string[]) {
const classified = await Promise.all(
(await Promise.all(directories.map(filesInDirectory))).flat().map(async (file) => ({
file,
key: classifyRows(file, await readRows(file)),
})),
)
return classified.reduce<Partial<Record<ImportKey, string[]>>>((result, item) => {
return { ...result, [item.key]: [...(result[item.key] ?? []), item.file] }
}, {})
}
async function filesInDirectory(directory: string): Promise<string[]> {
return (
await Promise.all(
(await readdir(directory, { withFileTypes: true })).map((entry) => {
const file = path.join(directory, entry.name)
if (entry.isDirectory()) return filesInDirectory(file)
if (entry.isFile() && /\.(csv|json)$/i.test(entry.name)) return Promise.resolve([file])
return Promise.resolve([])
}),
)
).flat()
}
function classifyRows(file: string, rows: RawRow[]): ImportKey {
if (rows.length === 0) fail(`Cannot classify empty export: ${file}`)
const headers = new Set(rows.flatMap((row) => Object.keys(row).map(normalizeHeader)))
const grain: Grain = headers.has("date") ? "day" : "week"
if (hasHeader(headers, ["country", "cf.country"])) {
if (hasHeader(headers, ["model", "stat_model"]) && hasMetricHeaders(headers)) return `geo-model-${grain}`
return hasMetricHeaders(headers) ? `geo-${grain}` : `geo-continent-${grain}`
}
if (hasHeader(headers, ["model", "stat_model"]))
return hasMetricHeaders(headers) ? `model-${grain}` : `model-provider-model-${grain}`
if (hasHeader(headers, ["provider", "provider.normalized", "stat_provider"])) return `provider-${grain}`
fail(`Cannot classify export from columns in ${file}`)
}
function hasMetricHeaders(headers: Set<string>) {
return ["sumtokens", "sumtokensinput", "inputtokens", "totaltokens", "avgduration", "countdistinctsession"].some(
(header) => headers.has(header),
)
}
function hasHeader(headers: Set<string>, names: string[]) {
return names.some((name) => headers.has(normalizeHeader(name)))
}
function mergeFiles(left: Partial<Record<ImportKey, string[]>>, right: Partial<Record<ImportKey, string[]>>) {
return inputKeys.reduce<Partial<Record<ImportKey, string[]>>>((result, key) => {
const files = [...(left[key] ?? []), ...(right[key] ?? [])]
if (files.length === 0) return result
return { ...result, [key]: files }
}, {})
}
function modelProviderModelLookup(row: RawRow, grain: Grain, opts: ImportOptions): [string, string][] {
const base = basePeriod(row, grain, opts)
const value = providerModel(row)
const author = provider(row)
if (!value || !author) return []
return [[lookupKey({ ...base, dataset: opts.dataset, tier: tier(row), grain }, author, model(row)), value]]
}
function modelAggregate(
row: RawRow,
base: StatBaseAggregate,
providerModelLookup: Map<string, string>,
): ModelAggregate[] {
const author = provider(row)
if (!author) return []
return [
{
...base,
provider: author,
model: model(row),
provider_model: providerModelLookup.get(lookupKey(base, author, model(row))) ?? providerModel(row),
},
]
}
function geoContinentLookup(row: RawRow, grain: Grain, opts: ImportOptions): [string, string][] {
const base = basePeriod(row, grain, opts)
const value = continent(row)
if (!value) return []
return [[lookupKey({ ...base, dataset: opts.dataset, tier: tier(row), grain }, country(row)), value]]
}
function geoModelAggregate(row: RawRow, base: StatBaseAggregate, continentLookup: Map<string, string>): GeoAggregate[] {
const author = provider(row)
if (!author) return []
return [
{
...base,
provider: author,
model: model(row),
country: country(row),
continent: continentLookup.get(lookupKey(base, country(row))) ?? continent(row),
},
]
}
function baseAggregate(row: RawRow, grain: Grain, opts: ImportOptions): StatBaseAggregate {
return {
...basePeriod(row, grain, opts),
grain,
dataset: opts.dataset,
tier: tier(row),
sessions: integer(row, "sessions", ["COUNT_DISTINCT(session)"]),
requests: integer(row, "requests", ["COUNT", "COUNT()"]),
input_tokens: integer(row, "input_tokens", ["SUM(tokens.input)", "SUM(tokens_input)"]),
output_tokens: integer(row, "output_tokens", ["SUM(tokens.output)", "SUM(tokens_output)"]),
reasoning_tokens: integer(row, "reasoning_tokens", ["SUM(tokens.reasoning)", "SUM(tokens_reasoning)"]),
cache_read_tokens: integer(row, "cache_read_tokens", ["SUM(tokens.cache_read)", "SUM(tokens_cache_read)"]),
total_tokens: integer(row, "total_tokens", ["SUM(stat_tokens_total)", "SUM(tokens)", "SUM(tokens_total)"]),
input_cost_microcents: integer(row, "input_cost_microcents", [
"SUM(cost.input.microcents)",
"SUM(stat_cost_input_microcents)",
]),
output_cost_microcents: integer(row, "output_cost_microcents", [
"SUM(cost.output.microcents)",
"SUM(stat_cost_output_microcents)",
]),
total_cost_microcents: integer(row, "total_cost_microcents", [
"SUM(cost.total.microcents)",
"SUM(stat_cost_total_microcents)",
]),
avg_duration_ms: nullableNumber(row, "avg_duration_ms", ["AVG(duration)", "AVG(duration_ms)"]),
p50_duration_ms: nullableInteger(row, "p50_duration_ms", ["P50(duration)", "P50(duration_ms)"]),
p95_duration_ms: nullableInteger(row, "p95_duration_ms", ["P95(duration)", "P95(duration_ms)"]),
avg_ttfb_ms: nullableNumber(row, "avg_ttfb_ms", ["AVG(time_to_first_byte)", "AVG(ttfb_ms)"]),
p50_ttfb_ms: nullableInteger(row, "p50_ttfb_ms", ["P50(time_to_first_byte)", "P50(ttfb_ms)"]),
p95_ttfb_ms: nullableInteger(row, "p95_ttfb_ms", ["P95(time_to_first_byte)", "P95(ttfb_ms)"]),
avg_output_tps: nullableNumber(row, "avg_output_tps", ["AVG(tps.output)", "AVG(stat_output_tps)"]),
success_count: integer(row, "success_count", ["SUM(success)", "SUM(is_success)", "SUM(stat_success)"]),
error_count: integer(row, "error_count", ["SUM(error)", "SUM(is_error)", "SUM(stat_error)"]),
sample_count: integer(row, "sample_count", ["COUNT", "COUNT()"]),
}
}
function basePeriod(row: RawRow, grain: Grain, opts: ImportOptions) {
return { period_key: periodKey(row, grain, opts) }
}
function periodKey(row: RawRow, grain: Grain, opts: ImportOptions) {
if (grain === "week") {
const week = parseWeek(row)
if (week) return week
fail("weekly imports require a week or period_key column")
}
const time = parseTime(row)
const start = time ? startOfUtcDay(time) : opts.periodStart
if (!start) fail("daily imports require a time column or --period-start")
return periodKeyFor("day", start)
}
function modelRowsFromAggregates(aggregates: ModelAggregate[]) {
return rankModelRows([
...synthesizeAllTierRows(
collapseRows(aggregates.filter((item) => item.grain === "week").map(toModelRow), modelDimensionKey),
modelDimensionKey,
),
...synthesizeAllTierRows(
collapseRows(aggregates.filter((item) => item.grain === "day").map(toModelRow), modelDimensionKey),
modelDimensionKey,
),
])
}
function providerRowsFromAggregates(aggregates: ProviderAggregate[]) {
return rankRowsWithMarketShare([
...synthesizeAllTierRows(
collapseRows(aggregates.filter((item) => item.grain === "week").map(toProviderRow), providerDimensionKey),
providerDimensionKey,
),
...synthesizeAllTierRows(
collapseRows(aggregates.filter((item) => item.grain === "day").map(toProviderRow), providerDimensionKey),
providerDimensionKey,
),
])
}
function geoRowsFromAggregates(aggregates: GeoAggregate[]) {
return rankRowsWithMarketShare(
[
...synthesizeAllTierRows(
collapseRows(aggregates.filter((item) => item.grain === "week").map(toGeoRow), geoDimensionKey),
geoDimensionKey,
),
...synthesizeAllTierRows(
collapseRows(aggregates.filter((item) => item.grain === "day").map(toGeoRow), geoDimensionKey),
geoDimensionKey,
),
],
geoMarketShareKey,
)
}
function toModelRow(data: ModelAggregate): ModelStatRow {
return { ...toStatBaseRow(data), provider: data.provider, model: data.model, provider_model: data.provider_model }
}
function toProviderRow(data: ProviderAggregate): ProviderStatRow {
return { ...toStatBaseRow(data), provider: data.provider }
}
function toGeoRow(data: GeoAggregate): GeoStatRow {
return {
...toStatBaseRow(data),
provider: data.provider,
model: data.model,
country: data.country,
continent: data.continent,
}
}
function rankModelRows(rows: ModelStatRow[]) {
return Object.values(
rows.reduce<Record<string, ModelStatRow[]>>((result, row) => {
const key = statPeriodKey(row)
result[key] = [...(result[key] ?? []), row]
return result
}, {}),
).flatMap((group) => {
const tokenRanks = rankBy(group, (row) => row.total_tokens ?? 0)
const requestRanks = rankBy(group, (row) => row.requests ?? 0)
const costRanks = rankBy(group, (row) => row.total_cost_microcents ?? 0)
return group.map((row) => ({
...row,
rank_by_tokens: tokenRanks.get(row) ?? null,
rank_by_requests: requestRanks.get(row) ?? null,
rank_by_cost: costRanks.get(row) ?? null,
}))
})
}
function modelDimensionKey(row: ModelStatRow) {
return [row.provider, row.model].join("\u0000")
}
function providerDimensionKey(row: ProviderStatRow) {
return row.provider
}
function geoDimensionKey(row: GeoStatRow) {
return [row.provider, row.model, row.country].join("\u0000")
}
function geoMarketShareKey(row: GeoStatRow) {
return [statPeriodKey(row), row.provider, row.model].join("\u0000")
}
function lookupKey(base: { grain: string; period_key: string; dataset: string; tier: string }, ...dimension: string[]) {
return [base.grain, base.period_key, base.dataset, base.tier, ...dimension].join("\u0000")
}
function tier(row: RawRow) {
return normalizeTier(cell(row, ["stat_tier", "tier"]) || deriveTier(row))
}
function deriveTier(row: RawRow) {
const source = cell(row, ["source"])
const value = model(row)
if (source === "lite") return "Go"
if (FREE_MODELS.has(value) || /-free(:global)?$/.test(rawModel(row))) return "Free"
return "Zen"
}
function provider(row: RawRow) {
return cell(row, ["stat_provider"]) || modelAuthor(model(row))
}
function model(row: RawRow) {
return normalizeInferenceModel(cell(row, ["stat_model"]) || rawModel(row))
}
function rawModel(row: RawRow) {
return cell(row, ["model"]) || "unknown"
}
function providerModel(row: RawRow) {
return cell(row, ["provider.model", "provider_model"]) || ""
}
function country(row: RawRow) {
return normalizeCountry(cell(row, ["stat_country", "cf.country", "cf_country", "country"]))
}
function continent(row: RawRow) {
return cell(row, ["cf.continent", "cf_continent", "continent"]) || ""
}
function integer(row: RawRow, name: string, aliases: string[] = []) {
return Math.round(number(row, name, aliases))
}
function nullableInteger(row: RawRow, name: string, aliases: string[] = []) {
if (!hasCell(row, [name, ...aliases])) return null
return Math.round(number(row, name, aliases))
}
function nullableNumber(row: RawRow, name: string, aliases: string[] = []) {
if (!hasCell(row, [name, ...aliases])) return null
return Number(number(row, name, aliases).toFixed(2))
}
function number(row: RawRow, name: string, aliases: string[] = []) {
const value = Number(cell(row, [name, ...aliases]).replace(/,/g, ""))
return Number.isFinite(value) ? value : 0
}
function hasCell(row: RawRow, names: string[]) {
return names.some((name) => row[name] !== undefined && row[name] !== "")
}
function cell(row: RawRow, names: string[]) {
const normalized = normalizedCells(row)
return (
names.flatMap((name) => [row[name], normalized.get(normalizeHeader(name))]).find((value) => value !== undefined) ??
""
)
}
function normalizedCells(row: RawRow) {
return new Map(Object.entries(row).map(([key, value]) => [normalizeHeader(key), value]))
}
function normalizeHeader(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "")
}
function parseTime(row: RawRow) {
const value = cell(row, ["date", "time", "timestamp", "datetime", "bucket"])
if (!value) return undefined
const numeric = Number(value)
const date = Number.isFinite(numeric)
? new Date(numeric > 10_000_000_000 ? numeric : numeric * 1000)
: new Date(value)
if (Number.isNaN(date.getTime())) fail(`Invalid time value: ${value}`)
return date
}
function parseWeek(row: RawRow) {
const value = cell(row, ["period_key", "week", "stat_week"])
if (!value) return undefined
const match = /^(\d{4})-W(\d{1,2})$/.exec(value)
if (!match) fail(`Invalid week value: ${value}`)
const year = Number(match[1])
const week = Number(match[2])
if (week < 1 || week > 53) fail(`Invalid week value: ${value}`)
const start = new Date(startOfIsoWeek(new Date(Date.UTC(year, 0, 4))).getTime() + (week - 1) * 7 * DAY_MS)
const id = `${year}-W${String(week).padStart(2, "0")}`
if (isoWeekId(start) !== id) fail(`Invalid week value: ${value}`)
return id
}
async function readRows(file: string) {
const text = await Bun.file(file).text()
if (file.toLowerCase().endsWith(".json")) {
const parsed: unknown = JSON.parse(text)
return rowsFromJson(parsed)
}
return rowsFromCsv(text)
}
function rowsFromJson(value: unknown): RawRow[] {
if (Array.isArray(value)) return value.flatMap(rowFromUnknown)
if (!isRecord(value)) fail("JSON imports must be an array of rows or an object with results/data/rows")
const rows = [value.results, value.data, value.rows].flatMap((candidate) =>
Array.isArray(candidate) ? candidate.flatMap(rowFromUnknown) : [],
)
if (rows.length === 0) fail("JSON import did not contain rows")
return rows
}
function rowFromUnknown(value: unknown): RawRow[] {
if (!isRecord(value)) return []
const nested = isRecord(value.data) ? value.data : {}
return [
Object.fromEntries(
Object.entries({ ...value, ...nested }).flatMap(([key, item]) => {
if (key === "data") return []
return [[key, cellValue(item)]]
}),
),
]
}
function rowsFromCsv(text: string): RawRow[] {
const [headers, ...rows] = csvRecords(text).filter((row) => row.some((value) => value.trim() !== ""))
if (!headers) return []
return rows.map((row) =>
Object.fromEntries(headers.map((header, index) => [header.trim(), row[index]?.trim() ?? ""])),
)
}
function csvRecords(text: string) {
const rows: string[][] = []
let row: string[] = []
let field = ""
let quoted = false
for (let index = 0; index < text.length; index++) {
const char = text[index]
const next = text[index + 1]
if (quoted) {
if (char === '"' && next === '"') {
field += '"'
index++
continue
}
if (char === '"') {
quoted = false
continue
}
field += char
continue
}
if (char === '"') {
quoted = true
continue
}
if (char === ",") {
row.push(field)
field = ""
continue
}
if (char === "\n") {
row.push(field)
rows.push(row)
row = []
field = ""
continue
}
if (char === "\r") continue
field += char
}
row.push(field)
rows.push(row)
return rows
}
function cellValue(value: unknown) {
if (value === null || value === undefined) return ""
if (typeof value === "string") return value
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value)
return JSON.stringify(value) ?? ""
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
async function upsertModelRows(db: ReturnType<typeof drizzle>, rows: ModelStatRow[], chunkSize: number) {
const batches = chunks(rows, chunkSize)
console.log(JSON.stringify({ table: "model_stat", batches: batches.length, chunkSize }))
for (const chunk of batches) {
await db
.insert(modelStat)
.values(chunk)
.onDuplicateKeyUpdate({
set: {
provider_model: inserted("provider_model"),
sessions: inserted("sessions"),
requests: inserted("requests"),
input_tokens: inserted("input_tokens"),
output_tokens: inserted("output_tokens"),
reasoning_tokens: inserted("reasoning_tokens"),
cache_read_tokens: inserted("cache_read_tokens"),
total_tokens: inserted("total_tokens"),
input_cost_microcents: inserted("input_cost_microcents"),
output_cost_microcents: inserted("output_cost_microcents"),
total_cost_microcents: inserted("total_cost_microcents"),
avg_duration_ms: inserted("avg_duration_ms"),
p50_duration_ms: inserted("p50_duration_ms"),
p95_duration_ms: inserted("p95_duration_ms"),
avg_ttfb_ms: inserted("avg_ttfb_ms"),
p50_ttfb_ms: inserted("p50_ttfb_ms"),
p95_ttfb_ms: inserted("p95_ttfb_ms"),
avg_output_tps: inserted("avg_output_tps"),
success_count: inserted("success_count"),
error_count: inserted("error_count"),
sample_count: inserted("sample_count"),
rank_by_tokens: inserted("rank_by_tokens"),
rank_by_requests: inserted("rank_by_requests"),
rank_by_cost: inserted("rank_by_cost"),
},
})
}
}
async function upsertProviderRows(db: ReturnType<typeof drizzle>, rows: ProviderStatRow[], chunkSize: number) {
const batches = chunks(rows, chunkSize)
console.log(JSON.stringify({ table: "provider_stat", batches: batches.length, chunkSize }))
for (const chunk of batches) {
await db
.insert(providerStat)
.values(chunk)
.onDuplicateKeyUpdate({
set: {
sessions: inserted("sessions"),
requests: inserted("requests"),
input_tokens: inserted("input_tokens"),
output_tokens: inserted("output_tokens"),
reasoning_tokens: inserted("reasoning_tokens"),
cache_read_tokens: inserted("cache_read_tokens"),
total_tokens: inserted("total_tokens"),
input_cost_microcents: inserted("input_cost_microcents"),
output_cost_microcents: inserted("output_cost_microcents"),
total_cost_microcents: inserted("total_cost_microcents"),
avg_duration_ms: inserted("avg_duration_ms"),
p50_duration_ms: inserted("p50_duration_ms"),
p95_duration_ms: inserted("p95_duration_ms"),
avg_ttfb_ms: inserted("avg_ttfb_ms"),
p50_ttfb_ms: inserted("p50_ttfb_ms"),
p95_ttfb_ms: inserted("p95_ttfb_ms"),
avg_output_tps: inserted("avg_output_tps"),
success_count: inserted("success_count"),
error_count: inserted("error_count"),
sample_count: inserted("sample_count"),
market_share_tokens: inserted("market_share_tokens"),
market_share_requests: inserted("market_share_requests"),
market_share_sessions: inserted("market_share_sessions"),
rank_by_tokens: inserted("rank_by_tokens"),
rank_by_requests: inserted("rank_by_requests"),
rank_by_sessions: inserted("rank_by_sessions"),
rank_by_cost: inserted("rank_by_cost"),
},
})
}
}
async function upsertGeoRows(db: ReturnType<typeof drizzle>, rows: GeoStatRow[], chunkSize: number) {
const batches = chunks(rows, chunkSize)
console.log(JSON.stringify({ table: "geo_stat", batches: batches.length, chunkSize }))
for (const chunk of batches) {
await db
.insert(geoStat)
.values(chunk)
.onDuplicateKeyUpdate({
set: {
continent: inserted("continent"),
sessions: inserted("sessions"),
requests: inserted("requests"),
input_tokens: inserted("input_tokens"),
output_tokens: inserted("output_tokens"),
reasoning_tokens: inserted("reasoning_tokens"),
cache_read_tokens: inserted("cache_read_tokens"),
total_tokens: inserted("total_tokens"),
input_cost_microcents: inserted("input_cost_microcents"),
output_cost_microcents: inserted("output_cost_microcents"),
total_cost_microcents: inserted("total_cost_microcents"),
avg_duration_ms: inserted("avg_duration_ms"),
p50_duration_ms: inserted("p50_duration_ms"),
p95_duration_ms: inserted("p95_duration_ms"),
avg_ttfb_ms: inserted("avg_ttfb_ms"),
p50_ttfb_ms: inserted("p50_ttfb_ms"),
p95_ttfb_ms: inserted("p95_ttfb_ms"),
avg_output_tps: inserted("avg_output_tps"),
success_count: inserted("success_count"),
error_count: inserted("error_count"),
sample_count: inserted("sample_count"),
market_share_tokens: inserted("market_share_tokens"),
market_share_requests: inserted("market_share_requests"),
market_share_sessions: inserted("market_share_sessions"),
rank_by_tokens: inserted("rank_by_tokens"),
rank_by_requests: inserted("rank_by_requests"),
rank_by_sessions: inserted("rank_by_sessions"),
rank_by_cost: inserted("rank_by_cost"),
},
})
}
}
function parseImportOptions(args: string[]): ImportOptions {
const flags = parseFlags(args)
const files = inputKeys.reduce<Partial<Record<ImportKey, string[]>>>((result, key) => {
const values = flags.get(key)
if (!values) return result
return { ...result, [key]: values }
}, {})
return {
dataset: flags.get("dataset")?.[0] ?? "zen",
databaseUrl: flags.get("database-url")?.[0] ?? process.env.DATABASE_URL,
directories: flags.get("dir") ?? flags.get("directory") ?? [],
dryRun: flags.has("dry-run"),
periodStart: parseDateFlag(flags, "period-start"),
upsertChunkSize: parseIntegerFlag(flags, "upsert-chunk-size") ?? DEFAULT_UPSERT_CHUNK_SIZE,
files,
}
}
function parseFlags(args: string[]) {
const result = new Map<string, string[]>()
for (let index = 0; index < args.length; index++) {
const arg = args[index]
if (!arg.startsWith("--")) fail(`Unexpected argument: ${arg}`)
const name = arg.slice(2)
if (name === "dry-run" || name === "include-weekly") {
result.set(name, ["true"])
continue
}
const nextFlag = args.findIndex((value, valueIndex) => valueIndex > index && value.startsWith("--"))
const values = args.slice(index + 1, nextFlag === -1 ? args.length : nextFlag)
if (values.length === 0) fail(`Missing value for --${name}`)
result.set(name, [...(result.get(name) ?? []), ...values])
index += values.length
}
return result
}
function parseDateFlag(flags: Map<string, string[]>, name: string) {
const value = flags.get(name)?.[0]
if (!value) return undefined
const date = new Date(value)
if (Number.isNaN(date.getTime())) fail(`Invalid --${name}: ${value}`)
return date
}
function parseIntegerFlag(flags: Map<string, string[]>, name: string) {
const value = flags.get(name)?.[0]
if (!value) return undefined
const parsed = Number(value)
if (!Number.isInteger(parsed) || parsed <= 0) fail(`Invalid --${name}: ${value}`)
return parsed
}
function parseListFlag(flags: Map<string, string[]>, name: string) {
const value = flags.get(name)?.[0]
if (!value) return undefined
if (value === "all") return ["all"]
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean)
}
function usage(): never {
fail(`Usage:
bun src/honeycomb-backfill.ts queries [--tiers Go,Free,Paid] [--limit 1000]
bun src/honeycomb-backfill.ts import [--dry-run] [--upsert-chunk-size 100] [--database-url URL] --dir downloads
bun src/honeycomb-backfill.ts import [--dry-run] [--upsert-chunk-size 100] [--database-url URL] --model-day file.csv [--model-day more.csv] ...`)
}
function fail(message: string): never {
console.error(message)
process.exit(1)
}
+8 -5
View File
@@ -6,8 +6,10 @@ import { GeoStatRepo, rowsFromAggregates as geoRowsFromAggregates } from "./doma
import { buildStatsQuery, toGeoAggregate, toModelAggregate, toProviderAggregate } from "./domain/inference"
import { ModelStatRepo, rowsFromAggregates as modelRowsFromAggregates } from "./domain/model"
import { ProviderStatRepo, rowsFromAggregates as providerRowsFromAggregates } from "./domain/provider"
import { startOfIsoWeek } from "./domain/stat"
const DATALAKE_INGESTION_LAG_MS = 5 * 60_000
const WEEK_MS = 7 * 86_400_000
export type SyncStatsResult = { ok: true; rows: number; startedAt: string; periodStart: string; periodEnd: string }
export type SyncStatsError = AthenaQueryError | AthenaQueryTimeoutError | DatabaseError
@@ -19,9 +21,7 @@ export const syncStats: () => Effect.Effect<
> = Effect.fn("StatSync.sync")(function* () {
const startedAt = yield* DateTime.nowAsDate
const periodEnd = new Date(Math.floor((startedAt.getTime() - DATALAKE_INGESTION_LAG_MS) / 60_000) * 60_000)
const periodStart = new Date(
Date.UTC(periodEnd.getUTCFullYear(), periodEnd.getUTCMonth(), periodEnd.getUTCDate() - 6),
)
const periodStart = new Date(startOfIsoWeek(periodEnd).getTime() - WEEK_MS)
const athena = yield* Athena
const modelStats = yield* ModelStatRepo
const providerStats = yield* ProviderStatRepo
@@ -29,7 +29,7 @@ export const syncStats: () => Effect.Effect<
yield* logRuntimeCheck()
const [modelAggregates, providerAggregates, geoAggregates] = yield* Effect.all(
const [modelAggregates, providerAggregates, geoAggregates, geoModelAggregates] = yield* Effect.all(
[
athena
.query(buildStatsQuery(periodStart, periodEnd, "model"))
@@ -40,12 +40,15 @@ export const syncStats: () => Effect.Effect<
athena
.query(buildStatsQuery(periodStart, periodEnd, "geo"))
.pipe(Effect.map((rows) => rows.flatMap(toGeoAggregate))),
athena
.query(buildStatsQuery(periodStart, periodEnd, "geo_model"))
.pipe(Effect.map((rows) => rows.flatMap(toGeoAggregate))),
],
{ concurrency: "unbounded" },
)
const modelRows = modelRowsFromAggregates(modelAggregates)
const providerRows = providerRowsFromAggregates(providerAggregates)
const geoRows = geoRowsFromAggregates(geoAggregates)
const geoRows = geoRowsFromAggregates([...geoAggregates, ...geoModelAggregates])
yield* Effect.all([modelStats.upsert(modelRows), providerStats.upsert(providerRows), geoStats.upsert(geoRows)], {
concurrency: "unbounded",
+18 -30
View File
@@ -1,41 +1,29 @@
FROM oven/bun:1.3.14-alpine
FROM oven/bun:1.3.14-alpine AS base
WORKDIR /app
ENV NODE_ENV=production
ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=0
COPY package.json bun.lock ./
COPY patches ./patches
COPY packages/app/package.json ./packages/app/package.json
COPY packages/console/app/package.json ./packages/console/app/package.json
COPY packages/console/core/package.json ./packages/console/core/package.json
COPY packages/console/function/package.json ./packages/console/function/package.json
COPY packages/console/mail/package.json ./packages/console/mail/package.json
COPY packages/console/resource/package.json ./packages/console/resource/package.json
COPY packages/console/support/package.json ./packages/console/support/package.json
COPY packages/core/package.json ./packages/core/package.json
COPY packages/desktop/package.json ./packages/desktop/package.json
COPY packages/effect-drizzle-sqlite/package.json ./packages/effect-drizzle-sqlite/package.json
COPY packages/enterprise/package.json ./packages/enterprise/package.json
COPY packages/function/package.json ./packages/function/package.json
COPY packages/http-recorder/package.json ./packages/http-recorder/package.json
COPY packages/llm/package.json ./packages/llm/package.json
COPY packages/opencode/package.json ./packages/opencode/package.json
COPY packages/plugin/package.json ./packages/plugin/package.json
COPY packages/script/package.json ./packages/script/package.json
COPY packages/sdk/js/package.json ./packages/sdk/js/package.json
COPY packages/slack/package.json ./packages/slack/package.json
COPY packages/stats/app/package.json ./packages/stats/app/package.json
COPY packages/stats/core/package.json ./packages/stats/core/package.json
COPY packages/stats/server/package.json ./packages/stats/server/package.json
COPY packages/storybook/package.json ./packages/storybook/package.json
COPY packages/ui/package.json ./packages/ui/package.json
COPY packages/web/package.json ./packages/web/package.json
FROM base AS pruner
RUN bun install --frozen-lockfile --production --ignore-scripts
COPY . .
COPY packages ./packages
RUN bunx turbo@2.8.13 prune @opencode-ai/stats-server --docker --no-update-notifier --no-color
FROM base AS installer
COPY --from=pruner /app/out/json/ ./
# Bun 1.3.x needs the pruned workspace globs and lockfile metadata refreshed before the frozen production install.
RUN bun -e 'const packageJson = await Bun.file("package.json").json(); packageJson.workspaces.packages = Array.from(new Bun.Glob("packages/**/package.json").scanSync(".")).map((file) => file.slice(0, -"/package.json".length)).sort(); await Bun.write("package.json", JSON.stringify(packageJson, null, 2) + "\n")'
RUN rm -f bun.lock && bun install --filter @opencode-ai/stats-server --lockfile-only --ignore-scripts
RUN bun install --filter @opencode-ai/stats-server --frozen-lockfile --production --ignore-scripts
FROM base AS runner
COPY --from=installer /app ./
COPY --from=pruner /app/out/full/ ./
WORKDIR /app/packages/stats/server

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