Compare commits

..
100 Commits
Author SHA1 Message Date
opencode 28e1d67ea4 release: v0.11.0 2025-09-21 08:04:21 +00:00
GitHub Action c1940d1d2c chore: format code 2025-09-21 04:23:40 +00:00
Frank 869f629c14 wip: zen 2025-09-21 00:23:05 -04:00
Frank a55943e469 wip: zen 2025-09-21 00:06:50 -04:00
Aiden ClineandGitHub 84d95a0d2a ignore: lsp log (#2715) 2025-09-20 23:02:06 -05:00
opencode 7dfed8ca35 release: v0.10.4 2025-09-20 23:00:32 +00:00
Dax Raad 38ea0fc051 turn back on compaction summaries 2025-09-20 18:52:30 -04:00
9223b6ed8f Enhance theme documentation with links (#2707)
Co-authored-by: rekram1-node <aidenpcline@gmail.com>
2025-09-20 11:25:12 -05:00
GitHub Action f8528c52d9 ignore: update download stats 2025-09-20 2025-09-20 12:04:03 +00:00
Aiden ClineandGitHub d63ce40af2 fix: no payment method (#2706) 2025-09-20 04:15:44 -04:00
Jay V 5acdd70587 docs: zen 2025-09-19 20:13:57 -04:00
opencode b04df6c0d2 release: v0.10.3 2025-09-19 21:22:00 +00:00
GitHub Action f1cbdf441c chore: format code 2025-09-19 18:18:56 +00:00
Frank 9420d80b73 zen: data share 2025-09-19 14:16:53 -04:00
Aiden ClineandGitHub c21161b75e docs: fix bad docs (#2691) 2025-09-19 12:40:40 -05:00
GitHub Action aaff066457 chore: format code 2025-09-19 17:29:44 +00:00
Jay V c7fbf9de44 ignore: zen 2025-09-19 13:29:04 -04:00
Adam d88c17dad0 wip: desktop progress 2025-09-19 10:53:49 -05:00
opencode f57c3f7cf6 release: v0.10.2 2025-09-19 15:18:11 +00:00
Adam 2460108223 fix: remove grok msg 2025-09-19 09:59:47 -05:00
Adam 84e8eea52e wip: desktop cleanup 2025-09-19 09:55:46 -05:00
Adam 9efc2eaf2e wip: desktop cleanup 2025-09-19 09:42:39 -05:00
Adam 37e2644452 wip: desktop visual tweaks 2025-09-19 09:38:10 -05:00
neriousyandAdam 22a78cf13f chore: opencode favicon 2025-09-19 09:33:28 -05:00
GitHub Action 2e9806b320 chore: format code 2025-09-19 14:32:55 +00:00
Adam ba839d4446 chore: normalize theme hex 2025-09-19 09:32:19 -05:00
GitHub Action 2bec21d81d chore: format code 2025-09-19 12:19:13 +00:00
Adam e5271f3d1a wip: desktop work 2025-09-19 07:18:39 -05:00
Adam 1edb23c2c7 wip: desktop work 2025-09-19 07:18:39 -05:00
Adam b1e6b9c7c9 wip: desktop work 2025-09-19 07:18:39 -05:00
Adam 20cb5a7c56 wip: desktop starting state 2025-09-19 07:18:38 -05:00
GitHub Action b1d44482bc ignore: update download stats 2025-09-19 2025-09-19 12:04:16 +00:00
GitHub Action e11102c9df chore: format code 2025-09-19 10:14:30 +00:00
Dax Raad 7be9dc8e49 ignore: fix 2025-09-19 06:13:45 -04:00
Dax Raad 824e035815 ci: stuff 2025-09-19 06:10:27 -04:00
GitHub Action d652b94a14 chore: format code 2025-09-19 09:29:24 +00:00
Dax Raad ebef2ea2d0 ci: stuff 2025-09-19 05:28:46 -04:00
GitHub Action b5b8a0555d chore: format code 2025-09-19 09:12:20 +00:00
Dax Raad ae6154e1c3 ignore: rework bootstrap so server lazy starts it 2025-09-19 05:11:29 -04:00
opencode 0e19ca21ed release: v0.10.1 2025-09-19 05:15:32 +00:00
Dax Raad baaff81a06 fix task tool ui disappearing once done 2025-09-19 01:09:52 -04:00
Aiden ClineandGitHub ffa5689885 docs: subtask config option (#2682) 2025-09-18 17:52:23 -05:00
Aiden ClineandGitHub 0e409842e8 docs: rm incorrect lsp mention (#2677) 2025-09-18 16:08:50 -05:00
opencode 5a7a725787 release: v0.10.0 2025-09-18 20:51:03 +00:00
GitHub Action f277512938 chore: format code 2025-09-18 14:59:48 +00:00
Frank 4ceabdffa0 wip: zen 2025-09-18 10:59:01 -04:00
GitHub Action c87480cf93 ignore: update download stats 2025-09-18 2025-09-18 12:04:18 +00:00
Timo ClasenandGitHub 0df6fc1226 fix(config): keybinds should not be required in config schema (#2669) 2025-09-18 06:30:44 -05:00
GitHub Action 32ba2e02aa chore: format code 2025-09-18 09:43:35 +00:00
Dax Raad 1ffc8be2b6 rework custom tools 2025-09-18 05:42:59 -04:00
Dax Raad 5f2945ae71 docs: add custom tools section to plugins documentation 2025-09-18 04:29:08 -04:00
DaxandGitHub 65baf76df6 Plugin tool updates (#2670) 2025-09-18 04:26:57 -04:00
DaxandGitHub 3b6c0ec0b3 support custom tools (#2668) 2025-09-18 03:58:21 -04:00
Frank e9d902d844 wip: zen 2025-09-18 01:36:54 -04:00
GitHub Action e8b4f593a6 chore: format code 2025-09-18 05:33:32 +00:00
Frank fc4f281408 wip: zen 2025-09-18 01:32:40 -04:00
GitHub Action f8c4f713a5 chore: format code 2025-09-18 03:39:57 +00:00
Jason QuenseandGitHub 63c8874d2d fix: type exports in package.json for SDK package (#2654) 2025-09-17 23:39:26 -04:00
71076d5c68 fix: add synthetic user prompt after session compaction (#2659)
Co-authored-by: Julián Díaz <git@jdiaz.io>
2025-09-17 23:27:37 -04:00
Frank 0319043b49 Support GLM coding plan 2025-09-17 16:54:00 -04:00
Frank e0334d5569 wip: zen 2025-09-17 16:03:47 -04:00
Aiden ClineandGitHub ff6a93f355 fix: only keep aborted messages if they have sufficient parts (#2651) 2025-09-17 14:24:53 -05:00
opencode 733b21e22b release: v0.9.11 2025-09-17 16:29:48 +00:00
Aiden ClineandGitHub 3c3d6b65c2 Revert "fix: type 'reasoning' was provided without its required follo… (#2648) 2025-09-17 11:17:26 -05:00
opencode 9ca48d3a39 release: v0.9.10 2025-09-17 14:54:25 +00:00
Timo ClasenandGitHub 16f9edc1a0 fix(TUI): display correct branch name in git worktree (#2626) 2025-09-17 09:46:18 -05:00
Aiden ClineandGitHub 8c2aec43b8 fix: type 'reasoning' was provided without its required following item (#2633) 2025-09-17 09:45:13 -05:00
Aiden ClineandGitHub 2564801bde tweak: adjust title gen when using models like gpt-5-nano on non open… (#2646) 2025-09-17 08:39:34 -05:00
GitHub Action 7c99a03493 ignore: update download stats 2025-09-17 2025-09-17 12:04:20 +00:00
opencode 0e0460f6c0 release: v0.9.9 2025-09-17 07:40:40 +00:00
Dax Raad 8acd537d1d ci: turborepo typecheck 2025-09-17 03:33:54 -04:00
Dax Raad 40c206c2f9 add opencode attach command to connect to a remote opencode server 2025-09-17 03:30:25 -04:00
Dax Raad 259c722208 only prune messages from more than 2 turns ago 2025-09-17 03:30:09 -04:00
opencode e618cbc447 release: v0.9.8 2025-09-17 07:14:33 +00:00
Dax Raad abd99aeb7d ignore: fix event type gen 2025-09-17 01:17:56 -04:00
opencode ad5fc76b11 release: v0.9.7 2025-09-17 05:09:08 +00:00
Dax Raad ff1f4d6bf9 disable reading .env file automatically 2025-09-17 01:02:23 -04:00
GitHub Action 170ea9c32b chore: format code 2025-09-16 23:53:59 +00:00
Jay V 65ced67432 ignore: zen 2025-09-16 19:53:18 -04:00
Jay V 9f46068c57 ignore: mobile styles zen 2025-09-16 19:39:36 -04:00
Jay V 479cf2fa4f ignore: zen 2025-09-16 19:39:36 -04:00
Frank 39c54f367f wip: zen 2025-09-16 18:13:05 -04:00
Frank 8c71107a93 wip: zen 2025-09-16 17:49:39 -04:00
GitHub Action ef10097329 chore: format code 2025-09-16 21:17:56 +00:00
Jay V 36ee4b5ede ignore: zen 2025-09-16 17:17:17 -04:00
Jay V ae84d5a734 ignore: zen 2025-09-16 17:17:17 -04:00
GitHub Action cd53770734 chore: format code 2025-09-16 20:17:16 +00:00
Jay V 4b1eca73eb ignore: zen 2025-09-16 16:16:30 -04:00
opencode fffcf69cd4 release: v0.9.6 2025-09-16 17:56:45 +00:00
Dax Raad d4c01f858b disable thinking for opencode zen and gpt-5 temporarily 2025-09-16 13:49:37 -04:00
GitHub Action 8e17570c53 chore: format code 2025-09-16 17:35:32 +00:00
Jay V 7f9d08b556 docs: zen 2025-09-16 13:26:49 -04:00
Frank 32a045f60b wip: zen 2025-09-16 09:01:13 -04:00
Aiden ClineandGitHub 91adc3cd41 docs: remove dup section (#2629) 2025-09-16 07:30:29 -05:00
GitHub Action 2bb9b4212f ignore: update download stats 2025-09-16 2025-09-16 12:04:17 +00:00
opencode 3472a50928 release: v0.9.5 2025-09-16 08:58:35 +00:00
Dax Raad 3aeac02bf1 enable session pruning and allow disabling with OPENCODE_DISABLE_PRUNE 2025-09-16 04:53:17 -04:00
opencode 52fcdcc37b release: v0.9.4 2025-09-16 08:35:55 +00:00
Dax Raad 78d6b3a963 fix crash when todo content is empty fixes #2622 2025-09-16 04:28:35 -04:00
15df2710fa fix(windows): force npm cmd shim generation and update install docs (#2558)
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: GitHub Action <action@github.com>
2025-09-16 03:40:19 -04:00
234 changed files with 7979 additions and 5530 deletions
+1
View File
@@ -9,3 +9,4 @@ openapi.json
playground
tmp
dist
.turbo
+11
View File
@@ -0,0 +1,11 @@
import { tool } from "@opencode-ai/plugin"
export default tool({
description: "call this tool when you want to give up",
args: {
message: tool.schema.string().describe("give up message"),
},
async execute(args) {
return "Hey fuck you!"
},
})
+5
View File
@@ -80,3 +80,8 @@
| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) |
| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) |
| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) |
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) |
| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) |
| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) |
| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) |
+122 -104
View File
@@ -9,79 +9,12 @@
"devDependencies": {
"prettier": "3.6.2",
"sst": "3.17.13",
},
},
"cloud/app": {
"name": "@opencode/cloud-app",
"dependencies": {
"@ibm/plex": "6.4.1",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/cloud-core": "workspace:*",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.1.0",
"solid-js": "catalog:",
"vinxi": "^0.5.7",
},
},
"cloud/core": {
"name": "@opencode/cloud-core",
"version": "0.9.3",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@opencode/cloud-resource": "workspace:*",
"@planetscale/database": "1.19.0",
"drizzle-orm": "0.41.0",
"postgres": "3.4.7",
"stripe": "18.0.0",
"ulid": "3.0.0",
},
"devDependencies": {
"drizzle-kit": "0.30.5",
"mysql2": "3.14.4",
},
},
"cloud/function": {
"name": "@opencode/cloud-function",
"version": "0.9.3",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
"@ai-sdk/openai-compatible": "1.0.1",
"@hono/zod-validator": "catalog:",
"@openauthjs/openauth": "0.0.0-20250322224806",
"ai": "catalog:",
"hono": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
"@cloudflare/workers-types": "4.20250522.0",
"@types/node": "catalog:",
"openai": "5.11.0",
"typescript": "catalog:",
},
},
"cloud/resource": {
"name": "@opencode/cloud-resource",
"dependencies": {
"@cloudflare/workers-types": "^4.20250830.0",
},
},
"cloud/scripts": {
"name": "@opencode/cloud-scripts",
"version": "0.9.3",
"dependencies": {
"@opencode/cloud-core": "workspace:*",
"tsx": "4.20.5",
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:",
"turbo": "2.5.6",
},
},
"packages/app": {
"name": "@opencode/app",
"version": "0.9.3",
"version": "0.11.0",
"dependencies": {
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "workspace:*",
@@ -91,7 +24,8 @@
"@solidjs/router": "0.15.3",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "8.0.2",
"luxon": "3.7.1",
"fuzzysort": "catalog:",
"luxon": "catalog:",
"marked": "16.2.0",
"marked-shiki": "1.2.1",
"remeda": "catalog:",
@@ -111,9 +45,78 @@
"vite-plugin-solid": "^2.11.6",
},
},
"packages/console/app": {
"name": "@opencode/console-app",
"dependencies": {
"@ibm/plex": "6.4.1",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.1.0",
"solid-js": "catalog:",
"vinxi": "^0.5.7",
"zod": "catalog:",
},
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "0.11.0",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@opencode/console-resource": "workspace:*",
"@planetscale/database": "1.19.0",
"drizzle-orm": "0.41.0",
"postgres": "3.4.7",
"stripe": "18.0.0",
"ulid": "3.0.0",
},
"devDependencies": {
"drizzle-kit": "0.30.5",
"mysql2": "3.14.4",
},
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "0.11.0",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
"@ai-sdk/openai-compatible": "1.0.1",
"@hono/zod-validator": "catalog:",
"@openauthjs/openauth": "0.0.0-20250322224806",
"ai": "catalog:",
"hono": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
"@cloudflare/workers-types": "4.20250522.0",
"@types/node": "catalog:",
"openai": "5.11.0",
"typescript": "catalog:",
},
},
"packages/console/resource": {
"name": "@opencode/console-resource",
"dependencies": {
"@cloudflare/workers-types": "^4.20250830.0",
},
},
"packages/console/scripts": {
"name": "@opencode/console-scripts",
"version": "0.11.0",
"dependencies": {
"@opencode/console-core": "workspace:*",
"tsx": "4.20.5",
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:",
},
},
"packages/function": {
"name": "@opencode/function",
"version": "0.9.3",
"version": "0.11.0",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@@ -128,7 +131,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "0.9.3",
"version": "0.11.0",
"bin": {
"opencode": "./bin/opencode",
},
@@ -179,9 +182,10 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "0.9.3",
"version": "0.11.0",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
},
"devDependencies": {
"@tsconfig/node22": "catalog:",
@@ -190,7 +194,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "0.9.3",
"version": "0.11.0",
"dependencies": {
"@hey-api/openapi-ts": "0.81.0",
},
@@ -202,7 +206,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "0.9.3",
"version": "0.11.0",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -216,11 +220,11 @@
"diff": "8.0.2",
"js-base64": "3.7.7",
"lang-map": "0.4.0",
"luxon": "3.6.1",
"luxon": "catalog:",
"marked": "15.0.12",
"marked-shiki": "1.2.1",
"rehype-autolink-headings": "7.1.0",
"remeda": "2.26.0",
"remeda": "catalog:",
"sharp": "0.32.5",
"shiki": "3.4.2",
"solid-js": "catalog:",
@@ -247,7 +251,9 @@
"@types/bun": "1.2.21",
"@types/node": "22.13.9",
"ai": "5.0.8",
"fuzzysort": "3.1.0",
"hono": "4.7.10",
"luxon": "3.6.1",
"remeda": "2.26.0",
"solid-js": "1.9.9",
"typescript": "5.8.2",
@@ -692,15 +698,15 @@
"@opencode/app": ["@opencode/app@workspace:packages/app"],
"@opencode/cloud-app": ["@opencode/cloud-app@workspace:cloud/app"],
"@opencode/console-app": ["@opencode/console-app@workspace:packages/console/app"],
"@opencode/cloud-core": ["@opencode/cloud-core@workspace:cloud/core"],
"@opencode/console-core": ["@opencode/console-core@workspace:packages/console/core"],
"@opencode/cloud-function": ["@opencode/cloud-function@workspace:cloud/function"],
"@opencode/console-function": ["@opencode/console-function@workspace:packages/console/function"],
"@opencode/cloud-resource": ["@opencode/cloud-resource@workspace:cloud/resource"],
"@opencode/console-resource": ["@opencode/console-resource@workspace:packages/console/resource"],
"@opencode/cloud-scripts": ["@opencode/cloud-scripts@workspace:cloud/scripts"],
"@opencode/console-scripts": ["@opencode/console-scripts@workspace:packages/console/scripts"],
"@opencode/function": ["@opencode/function@workspace:packages/function"],
@@ -912,11 +918,11 @@
"@smithy/abort-controller": ["@smithy/abort-controller@4.1.1", "", { "dependencies": { "@smithy/types": "^4.5.0", "tslib": "^2.6.2" } }, "sha512-vkzula+IwRvPR6oKQhMYioM3A/oX/lFCZiwuxkQbRhqJS2S4YRY2k7k/SyR2jMf3607HLtbEwlRxi0ndXHMjRg=="],
"@smithy/config-resolver": ["@smithy/config-resolver@4.2.1", "", { "dependencies": { "@smithy/node-config-provider": "^4.2.1", "@smithy/types": "^4.5.0", "@smithy/util-config-provider": "^4.1.0", "@smithy/util-middleware": "^4.1.1", "tslib": "^2.6.2" } }, "sha512-FXil8q4QN7mgKwU2hCLm0ltab8NyY/1RiqEf25Jnf6WLS3wmb11zGAoLETqg1nur2Aoibun4w4MjeN9CMJ4G6A=="],
"@smithy/config-resolver": ["@smithy/config-resolver@4.2.2", "", { "dependencies": { "@smithy/node-config-provider": "^4.2.2", "@smithy/types": "^4.5.0", "@smithy/util-config-provider": "^4.1.0", "@smithy/util-middleware": "^4.1.1", "tslib": "^2.6.2" } }, "sha512-IT6MatgBWagLybZl1xQcURXRICvqz1z3APSCAI9IqdvfCkrA7RaQIEfgC6G/KvfxnDfQUDqFV+ZlixcuFznGBQ=="],
"@smithy/core": ["@smithy/core@3.11.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.1.1", "@smithy/protocol-http": "^5.2.1", "@smithy/types": "^4.5.0", "@smithy/util-base64": "^4.1.0", "@smithy/util-body-length-browser": "^4.1.0", "@smithy/util-middleware": "^4.1.1", "@smithy/util-stream": "^4.3.1", "@smithy/util-utf8": "^4.1.0", "@types/uuid": "^9.0.1", "tslib": "^2.6.2", "uuid": "^9.0.1" } }, "sha512-Abs5rdP1o8/OINtE49wwNeWuynCu0kme1r4RI3VXVrHr4odVDG7h7mTnw1WXXfN5Il+c25QOnrdL2y56USfxkA=="],
"@smithy/core": ["@smithy/core@3.11.1", "", { "dependencies": { "@smithy/middleware-serde": "^4.1.1", "@smithy/protocol-http": "^5.2.1", "@smithy/types": "^4.5.0", "@smithy/util-base64": "^4.1.0", "@smithy/util-body-length-browser": "^4.1.0", "@smithy/util-middleware": "^4.1.1", "@smithy/util-stream": "^4.3.2", "@smithy/util-utf8": "^4.1.0", "@types/uuid": "^9.0.1", "tslib": "^2.6.2", "uuid": "^9.0.1" } }, "sha512-REH7crwORgdjSpYs15JBiIWOYjj0hJNC3aCecpJvAlMMaaqL5i2CLb1i6Hc4yevToTKSqslLMI9FKjhugEwALA=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.1.1", "", { "dependencies": { "@smithy/node-config-provider": "^4.2.1", "@smithy/property-provider": "^4.1.1", "@smithy/types": "^4.5.0", "@smithy/url-parser": "^4.1.1", "tslib": "^2.6.2" } }, "sha512-1WdBfM9DwA59pnpIizxnUvBf/de18p4GP+6zP2AqrlFzoW3ERpZaT4QueBR0nS9deDMaQRkBlngpVlnkuuTisQ=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.1.2", "", { "dependencies": { "@smithy/node-config-provider": "^4.2.2", "@smithy/property-provider": "^4.1.1", "@smithy/types": "^4.5.0", "@smithy/url-parser": "^4.1.1", "tslib": "^2.6.2" } }, "sha512-JlYNq8TShnqCLg0h+afqe2wLAwZpuoSgOyzhYvTgbiKBWRov+uUve+vrZEQO6lkdLOWPh7gK5dtb9dS+KGendg=="],
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.1.1", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.5.0", "@smithy/util-hex-encoding": "^4.1.0", "tslib": "^2.6.2" } }, "sha512-PwkQw1hZwHTQB6X5hSUWz2OSeuj5Z6enWuAqke7DgWoP3t6vg3ktPpqPz3Erkn6w+tmsl8Oss6nrgyezoea2Iw=="],
@@ -930,15 +936,15 @@
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.1.1", "", { "dependencies": { "@smithy/protocol-http": "^5.2.1", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" } }, "sha512-9wlfBBgTsRvC2JxLJxv4xDGNBrZuio3AgSl0lSFX7fneW2cGskXTYpFxCdRYD2+5yzmsiTuaAJD1Wp7gWt9y9w=="],
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.2.1", "", { "dependencies": { "@smithy/core": "^3.11.0", "@smithy/middleware-serde": "^4.1.1", "@smithy/node-config-provider": "^4.2.1", "@smithy/shared-ini-file-loader": "^4.1.1", "@smithy/types": "^4.5.0", "@smithy/url-parser": "^4.1.1", "@smithy/util-middleware": "^4.1.1", "tslib": "^2.6.2" } }, "sha512-fUTMmQvQQZakXOuKizfu7fBLDpwvWZjfH6zUK2OLsoNZRZGbNUdNSdLJHpwk1vS208jtDjpUIskh+JoA8zMzZg=="],
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.2.3", "", { "dependencies": { "@smithy/core": "^3.11.1", "@smithy/middleware-serde": "^4.1.1", "@smithy/node-config-provider": "^4.2.2", "@smithy/shared-ini-file-loader": "^4.2.0", "@smithy/types": "^4.5.0", "@smithy/url-parser": "^4.1.1", "@smithy/util-middleware": "^4.1.1", "tslib": "^2.6.2" } }, "sha512-+1H5A28DeffRVrqmVmtqtRraEjoaC6JVap3xEQdVoBh2EagCVY7noPmcBcG4y7mnr9AJitR1ZAse2l+tEtK5vg=="],
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.2.1", "", { "dependencies": { "@smithy/node-config-provider": "^4.2.1", "@smithy/protocol-http": "^5.2.1", "@smithy/service-error-classification": "^4.1.1", "@smithy/smithy-client": "^4.6.1", "@smithy/types": "^4.5.0", "@smithy/util-middleware": "^4.1.1", "@smithy/util-retry": "^4.1.1", "@types/uuid": "^9.0.1", "tslib": "^2.6.2", "uuid": "^9.0.1" } }, "sha512-JzfvjwSJXWRl7LkLgIRTUTd2Wj639yr3sQGpViGNEOjtb0AkAuYqRAHs+jSOI/LPC0ZTjmFVVtfrCICMuebexw=="],
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.2.4", "", { "dependencies": { "@smithy/node-config-provider": "^4.2.2", "@smithy/protocol-http": "^5.2.1", "@smithy/service-error-classification": "^4.1.2", "@smithy/smithy-client": "^4.6.3", "@smithy/types": "^4.5.0", "@smithy/util-middleware": "^4.1.1", "@smithy/util-retry": "^4.1.2", "@types/uuid": "^9.0.1", "tslib": "^2.6.2", "uuid": "^9.0.1" } }, "sha512-amyqYQFewnAviX3yy/rI/n1HqAgfvUdkEhc04kDjxsngAUREKuOI24iwqQUirrj6GtodWmR4iO5Zeyl3/3BwWg=="],
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.1.1", "", { "dependencies": { "@smithy/protocol-http": "^5.2.1", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" } }, "sha512-lh48uQdbCoj619kRouev5XbWhCwRKLmphAif16c4J6JgJ4uXjub1PI6RL38d3BLliUvSso6klyB/LTNpWSNIyg=="],
"@smithy/middleware-stack": ["@smithy/middleware-stack@4.1.1", "", { "dependencies": { "@smithy/types": "^4.5.0", "tslib": "^2.6.2" } }, "sha512-ygRnniqNcDhHzs6QAPIdia26M7e7z9gpkIMUe/pK0RsrQ7i5MblwxY8078/QCnGq6AmlUUWgljK2HlelsKIb/A=="],
"@smithy/node-config-provider": ["@smithy/node-config-provider@4.2.1", "", { "dependencies": { "@smithy/property-provider": "^4.1.1", "@smithy/shared-ini-file-loader": "^4.1.1", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" } }, "sha512-AIA0BJZq2h295J5NeCTKhg1WwtdTA/GqBCaVjk30bDgMHwniUETyh5cP9IiE9VrId7Kt8hS7zvREVMTv1VfA6g=="],
"@smithy/node-config-provider": ["@smithy/node-config-provider@4.2.2", "", { "dependencies": { "@smithy/property-provider": "^4.1.1", "@smithy/shared-ini-file-loader": "^4.2.0", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" } }, "sha512-SYGTKyPvyCfEzIN5rD8q/bYaOPZprYUPD2f5g9M7OjaYupWOoQFYJ5ho+0wvxIRf471i2SR4GoiZ2r94Jq9h6A=="],
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.2.1", "", { "dependencies": { "@smithy/abort-controller": "^4.1.1", "@smithy/protocol-http": "^5.2.1", "@smithy/querystring-builder": "^4.1.1", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" } }, "sha512-REyybygHlxo3TJICPF89N2pMQSf+p+tBJqpVe1+77Cfi9HBPReNjTgtZ1Vg73exq24vkqJskKDpfF74reXjxfw=="],
@@ -950,13 +956,13 @@
"@smithy/querystring-parser": ["@smithy/querystring-parser@4.1.1", "", { "dependencies": { "@smithy/types": "^4.5.0", "tslib": "^2.6.2" } }, "sha512-63TEp92YFz0oQ7Pj9IuI3IgnprP92LrZtRAkE3c6wLWJxfy/yOPRt39IOKerVr0JS770olzl0kGafXlAXZ1vng=="],
"@smithy/service-error-classification": ["@smithy/service-error-classification@4.1.1", "", { "dependencies": { "@smithy/types": "^4.5.0" } }, "sha512-Iam75b/JNXyDE41UvrlM6n8DNOa/r1ylFyvgruTUx7h2Uk7vDNV9AAwP1vfL1fOL8ls0xArwEGVcGZVd7IO/Cw=="],
"@smithy/service-error-classification": ["@smithy/service-error-classification@4.1.2", "", { "dependencies": { "@smithy/types": "^4.5.0" } }, "sha512-Kqd8wyfmBWHZNppZSMfrQFpc3M9Y/kjyN8n8P4DqJJtuwgK1H914R471HTw7+RL+T7+kI1f1gOnL7Vb5z9+NgQ=="],
"@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.1.1", "", { "dependencies": { "@smithy/types": "^4.5.0", "tslib": "^2.6.2" } }, "sha512-YkpikhIqGc4sfXeIbzSj10t2bJI/sSoP5qxLue6zG+tEE3ngOBSm8sO3+djacYvS/R5DfpxN/L9CyZsvwjWOAQ=="],
"@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.2.0", "", { "dependencies": { "@smithy/types": "^4.5.0", "tslib": "^2.6.2" } }, "sha512-OQTfmIEp2LLuWdxa8nEEPhZmiOREO6bcB6pjs0AySf4yiZhl6kMOfqmcwcY8BaBPX+0Tb+tG7/Ia/6mwpoZ7Pw=="],
"@smithy/signature-v4": ["@smithy/signature-v4@5.2.1", "", { "dependencies": { "@smithy/is-array-buffer": "^4.1.0", "@smithy/protocol-http": "^5.2.1", "@smithy/types": "^4.5.0", "@smithy/util-hex-encoding": "^4.1.0", "@smithy/util-middleware": "^4.1.1", "@smithy/util-uri-escape": "^4.1.0", "@smithy/util-utf8": "^4.1.0", "tslib": "^2.6.2" } }, "sha512-M9rZhWQLjlQVCCR37cSjHfhriGRN+FQ8UfgrYNufv66TJgk+acaggShl3KS5U/ssxivvZLlnj7QH2CUOKlxPyA=="],
"@smithy/smithy-client": ["@smithy/smithy-client@4.6.1", "", { "dependencies": { "@smithy/core": "^3.11.0", "@smithy/middleware-endpoint": "^4.2.1", "@smithy/middleware-stack": "^4.1.1", "@smithy/protocol-http": "^5.2.1", "@smithy/types": "^4.5.0", "@smithy/util-stream": "^4.3.1", "tslib": "^2.6.2" } }, "sha512-WolVLDb9UTPMEPPOncrCt6JmAMCSC/V2y5gst2STWJ5r7+8iNac+EFYQnmvDCYMfOLcilOSEpm5yXZXwbLak1Q=="],
"@smithy/smithy-client": ["@smithy/smithy-client@4.6.3", "", { "dependencies": { "@smithy/core": "^3.11.1", "@smithy/middleware-endpoint": "^4.2.3", "@smithy/middleware-stack": "^4.1.1", "@smithy/protocol-http": "^5.2.1", "@smithy/types": "^4.5.0", "@smithy/util-stream": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-K27LqywsaqKz4jusdUQYJh/YP2VbnbdskZ42zG8xfV+eovbTtMc2/ZatLWCfSkW0PDsTUXlpvlaMyu8925HsOw=="],
"@smithy/types": ["@smithy/types@4.5.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-RkUpIOsVlAwUIZXO1dsz8Zm+N72LClFfsNqf173catVlvRZiwPy0x2u0JLEA4byreOPKDZPGjmPDylMoP8ZJRg=="],
@@ -972,19 +978,19 @@
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-swXz2vMjrP1ZusZWVTB/ai5gK+J8U0BWvP10v9fpcFvg+Xi/87LHvHfst2IgCs1i0v4qFZfGwCmeD/KNCdJZbQ=="],
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.1.1", "", { "dependencies": { "@smithy/property-provider": "^4.1.1", "@smithy/smithy-client": "^4.6.1", "@smithy/types": "^4.5.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-hA1AKIHFUMa9Tl6q6y8p0pJ9aWHCCG8s57flmIyLE0W7HcJeYrYtnqXDcGnftvXEhdQnSexyegXnzzTGk8bKLA=="],
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.1.3", "", { "dependencies": { "@smithy/property-provider": "^4.1.1", "@smithy/smithy-client": "^4.6.3", "@smithy/types": "^4.5.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-5fm3i2laE95uhY6n6O6uGFxI5SVbqo3/RWEuS3YsT0LVmSZk+0eUqPhKd4qk0KxBRPaT5VNT/WEBUqdMyYoRgg=="],
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.1.1", "", { "dependencies": { "@smithy/config-resolver": "^4.2.1", "@smithy/credential-provider-imds": "^4.1.1", "@smithy/node-config-provider": "^4.2.1", "@smithy/property-provider": "^4.1.1", "@smithy/smithy-client": "^4.6.1", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" } }, "sha512-RGSpmoBrA+5D2WjwtK7tto6Pc2wO9KSXKLpLONhFZ8VyuCbqlLdiDAfuDTNY9AJe4JoE+Cx806cpTQQoQ71zPQ=="],
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.1.3", "", { "dependencies": { "@smithy/config-resolver": "^4.2.2", "@smithy/credential-provider-imds": "^4.1.2", "@smithy/node-config-provider": "^4.2.2", "@smithy/property-provider": "^4.1.1", "@smithy/smithy-client": "^4.6.3", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" } }, "sha512-lwnMzlMslZ9GJNt+/wVjz6+fe9Wp5tqR1xAyQn+iywmP+Ymj0F6NhU/KfHM5jhGPQchRSCcau5weKhFdLIM4cA=="],
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.1.1", "", { "dependencies": { "@smithy/node-config-provider": "^4.2.1", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" } }, "sha512-qB4R9kO0SetA11Rzu6MVGFIaGYX3p6SGGGfWwsKnC6nXIf0n/0AKVwRTsYsz9ToN8CeNNtNgQRwKFBndGJZdyw=="],
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.1.2", "", { "dependencies": { "@smithy/node-config-provider": "^4.2.2", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" } }, "sha512-+AJsaaEGb5ySvf1SKMRrPZdYHRYSzMkCoK16jWnIMpREAnflVspMIDeCVSZJuj+5muZfgGpNpijE3mUNtjv01Q=="],
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-1LcueNN5GYC4tr8mo14yVYbh/Ur8jHhWOxniZXii+1+ePiIbsLZ5fEI0QQGtbRRP5mOhmooos+rLmVASGGoq5w=="],
"@smithy/util-middleware": ["@smithy/util-middleware@4.1.1", "", { "dependencies": { "@smithy/types": "^4.5.0", "tslib": "^2.6.2" } }, "sha512-CGmZ72mL29VMfESz7S6dekqzCh8ZISj3B+w0g1hZFXaOjGTVaSqfAEFAq8EGp8fUL+Q2l8aqNmt8U1tglTikeg=="],
"@smithy/util-retry": ["@smithy/util-retry@4.1.1", "", { "dependencies": { "@smithy/service-error-classification": "^4.1.1", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" } }, "sha512-jGeybqEZ/LIordPLMh5bnmnoIgsqnp4IEimmUp5c5voZ8yx+5kAlN5+juyr7p+f7AtZTgvhmInQk4Q0UVbrZ0Q=="],
"@smithy/util-retry": ["@smithy/util-retry@4.1.2", "", { "dependencies": { "@smithy/service-error-classification": "^4.1.2", "@smithy/types": "^4.5.0", "tslib": "^2.6.2" } }, "sha512-NCgr1d0/EdeP6U5PSZ9Uv5SMR5XRRYoVr1kRVtKZxWL3tixEL3UatrPIMFZSKwHlCcp2zPLDvMubVDULRqeunA=="],
"@smithy/util-stream": ["@smithy/util-stream@4.3.1", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.2.1", "@smithy/node-http-handler": "^4.2.1", "@smithy/types": "^4.5.0", "@smithy/util-base64": "^4.1.0", "@smithy/util-buffer-from": "^4.1.0", "@smithy/util-hex-encoding": "^4.1.0", "@smithy/util-utf8": "^4.1.0", "tslib": "^2.6.2" } }, "sha512-khKkW/Jqkgh6caxMWbMuox9+YfGlsk9OnHOYCGVEdYQb/XVzcORXHLYUubHmmda0pubEDncofUrPNniS9d+uAA=="],
"@smithy/util-stream": ["@smithy/util-stream@4.3.2", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.2.1", "@smithy/node-http-handler": "^4.2.1", "@smithy/types": "^4.5.0", "@smithy/util-base64": "^4.1.0", "@smithy/util-buffer-from": "^4.1.0", "@smithy/util-hex-encoding": "^4.1.0", "@smithy/util-utf8": "^4.1.0", "tslib": "^2.6.2" } }, "sha512-Ka+FA2UCC/Q1dEqUanCdpqwxOFdf5Dg2VXtPtB1qxLcSGh5C1HdzklIt18xL504Wiy9nNUKwDMRTVCbKGoK69g=="],
"@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-b0EFQkq35K5NHUYxU72JuoheM6+pytEVUGlTwiFxWFpmddA+Bpz3LgsPRIpBk8lnPE47yT7AF2Egc3jVnKLuPg=="],
@@ -1658,6 +1664,8 @@
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="],
"gel": ["gel@2.1.1", "", { "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", "env-paths": "^3.0.0", "semver": "^7.6.2", "shell-quote": "^1.8.1", "which": "^4.0.0" }, "bin": { "gel": "dist/cli.mjs" } }, "sha512-Newg9X7mRYskoBjSw70l1YnJ/ZGbq64VPyR821H5WVkTGpHG2O0mQILxCeUhxdYERLFY9B4tUyKLyf3uMTjtKw=="],
"generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
@@ -1986,7 +1994,7 @@
"lru.min": ["lru.min@1.1.2", "", {}, "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg=="],
"luxon": ["luxon@3.7.1", "", {}, "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg=="],
"luxon": ["luxon@3.6.1", "", {}, "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ=="],
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
@@ -2736,6 +2744,20 @@
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"turbo": ["turbo@2.5.6", "", { "optionalDependencies": { "turbo-darwin-64": "2.5.6", "turbo-darwin-arm64": "2.5.6", "turbo-linux-64": "2.5.6", "turbo-linux-arm64": "2.5.6", "turbo-windows-64": "2.5.6", "turbo-windows-arm64": "2.5.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-gxToHmi9oTBNB05UjUsrWf0OyN5ZXtD0apOarC1KIx232Vp3WimRNy3810QzeNSgyD5rsaIDXlxlbnOzlouo+w=="],
"turbo-darwin-64": ["turbo-darwin-64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-3C1xEdo4aFwMJAPvtlPqz1Sw/+cddWIOmsalHFMrsqqydcptwBfu26WW2cDm3u93bUzMbBJ8k3zNKFqxJ9ei2A=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LyiG+rD7JhMfYwLqB6k3LZQtYn8CQQUePbpA8mF/hMLPAekXdJo1g0bUPw8RZLwQXUIU/3BU7tXENvhSGz5DPA=="],
"turbo-linux-64": ["turbo-linux-64@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-GOcUTT0xiT/pSnHL4YD6Yr3HreUhU8pUcGqcI2ksIF9b2/r/kRHwGFcsHgpG3+vtZF/kwsP0MV8FTlTObxsYIA=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-10Tm15bruJEA3m0V7iZcnQBpObGBcOgUcO+sY7/2vk1bweW34LMhkWi8svjV9iDF68+KJDThnYDlYE/bc7/zzQ=="],
"turbo-windows-64": ["turbo-windows-64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-FyRsVpgaj76It0ludwZsNN40ytHN+17E4PFJyeliBEbxrGTc5BexlXVpufB7XlAaoaZVxbS6KT8RofLfDRyEPg=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-j/tWu8cMeQ7HPpKri6jvKtyXg9K1gRyhdK4tKrrchH8GNHscPX/F71zax58yYtLRWTiK04zNzPcUJuoS0+v/+Q=="],
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
@@ -3012,14 +3034,12 @@
"@opencode-ai/sdk/@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.81.0", "", { "dependencies": { "@hey-api/json-schema-ref-parser": "1.0.6", "ansi-colors": "4.1.3", "c12": "2.0.1", "color-support": "1.1.3", "commander": "13.0.0", "handlebars": "4.7.8", "js-yaml": "4.1.0", "open": "10.1.2", "semver": "7.7.2" }, "peerDependencies": { "typescript": "^5.5.3" }, "bin": { "openapi-ts": "bin/index.cjs" } }, "sha512-PoJukNBkUfHOoMDpN33bBETX49TUhy7Hu8Sa0jslOvFndvZ5VjQr4Nl/Dzjb9LG1Lp5HjybyTJMA6a1zYk/q6A=="],
"@opencode/cloud-resource/@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250913.0", "", {}, "sha512-JjrYEvRn7cyALxwoFTw3XChaQneHSJOXqz2t5iKEpNzAnC2iPQU75rtTK/gw03Jjy4SHY5aEBh/uqQePtonZlA=="],
"@opencode/console-resource/@cloudflare/workers-types": ["@cloudflare/workers-types@4.20250913.0", "", {}, "sha512-JjrYEvRn7cyALxwoFTw3XChaQneHSJOXqz2t5iKEpNzAnC2iPQU75rtTK/gw03Jjy4SHY5aEBh/uqQePtonZlA=="],
"@opencode/web/@shikijs/transformers": ["@shikijs/transformers@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/types": "3.4.2" } }, "sha512-I5baLVi/ynLEOZoWSAMlACHNnG+yw5HDmse0oe+GW6U1u+ULdEB3UHiVWaHoJSSONV7tlcVxuaMy74sREDkSvg=="],
"@opencode/web/@types/luxon": ["@types/luxon@3.6.2", "", {}, "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw=="],
"@opencode/web/luxon": ["luxon@3.6.1", "", {}, "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ=="],
"@opencode/web/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"@opencode/web/shiki": ["shiki@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/engine-javascript": "3.4.2", "@shikijs/engine-oniguruma": "3.4.2", "@shikijs/langs": "3.4.2", "@shikijs/themes": "3.4.2", "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-wuxzZzQG8kvZndD7nustrNFIKYJ1jJoWIPaBpVe2+KHSvtzMi4SBjOxrigs8qeqce/l3U0cwiC+VAkLKSunHQQ=="],
@@ -3184,7 +3204,7 @@
"named-placeholders/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"nitropack/c12": ["c12@3.2.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.5.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-ixkEtbYafL56E6HiFuonMm1ZjoKtIo7TH68/uiEq4DAwv9NcUX2nJ95F8TrbMeNjqIkZpruo3ojXQJ+MGG5gcQ=="],
"nitropack/c12": ["c12@3.3.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.2", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.5.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-K9ZkuyeJQeqLEyqldbYLG3wjqwpw4BVaAqvmxq3GYKK0b1A/yYQdIcJxkzAOWcNVWhJpRXAPfZFueekiY/L8Dw=="],
"nitropack/confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="],
@@ -3568,8 +3588,6 @@
"nitropack/c12/giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"nitropack/c12/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"nitropack/h3/cookie-es": ["cookie-es@1.2.2", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
"nitropack/serve-static/send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="],
@@ -3734,7 +3752,7 @@
"giget/tar/minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="],
"nitropack/c12/giget/nypm": ["nypm@0.6.1", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.2.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-hlacBiRiv1k9hZFiphPUkfSQ/ZfQzZDzC+8z0wL3lvDAOUu/2NnChkKuMoMjNur/9OpKuz2QsIeiPVN0xM5Q0w=="],
"nitropack/c12/giget/nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="],
"node-gyp/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
-787
View File
@@ -1,787 +0,0 @@
[data-page="workspace-[id]"] {
max-width: 64rem;
padding: var(--space-10) var(--space-4);
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-10);
@media (max-width: 30rem) {
padding-top: var(--space-4);
padding-bottom: var(--space-4);
gap: var(--space-8);
}
[data-slot="sections"] {
display: flex;
flex-direction: column;
gap: var(--space-16);
@media (max-width: 30rem) {
gap: var(--space-8);
}
section {
display: flex;
flex-direction: column;
gap: var(--space-6);
/* Section titles */
[data-slot="section-title"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
h2 {
font-size: var(--font-size-md);
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.03125rem;
margin: 0;
color: var(--color-text-secondary);
text-transform: uppercase;
@media (max-width: 30rem) {
font-size: var(--font-size-md);
}
}
}
}
section:not(:last-child) {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--space-16);
@media (max-width: 30rem) {
padding-bottom: var(--space-8);
}
}
}
[data-component="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
display: flex;
flex-direction: column;
gap: var(--space-2);
p {
line-height: 1.5;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
/* Title section */
[data-component="title-section"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-bottom: var(--space-8);
border-bottom: 1px solid var(--color-border);
@media (max-width: 30rem) {
padding-bottom: var(--space-6);
}
h1 {
font-size: var(--font-size-2xl);
font-weight: 500;
line-height: 1.2;
letter-spacing: -0.03125rem;
margin: 0;
text-transform: uppercase;
@media (max-width: 30rem) {
font-size: var(--font-size-xl);
}
}
p {
line-height: 1.5;
font-size: var(--font-size-md);
color: var(--color-text-muted);
a {
color: var(--color-text-muted);
}
}
}
/* API Keys Section */
[data-component="api-keys-section"] {
[data-slot="create-form"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
[data-slot="input-container"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
@media (max-width: 30rem) {
gap: var(--space-2);
}
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
margin-top: var(--space-1);
line-height: 1.4;
}
}
[data-slot="api-keys-table"] {
overflow-x: auto;
}
[data-slot="api-keys-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="key-name"] {
color: var(--color-text);
font-family: var(--font-sans);
font-weight: 500;
}
&[data-slot="key-value"] {
font-family: var(--font-mono);
button {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-sm);
font-weight: 400;
border: none;
background-color: transparent;
color: var(--color-text-muted);
font-family: var(--font-mono);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.15s ease;
text-transform: none;
&:hover:not(:disabled) {
background-color: var(--color-bg-surface);
color: var(--color-text);
}
&:disabled {
cursor: default;
color: var(--color-text);
}
span {
font-family: inherit;
}
}
}
&[data-slot="key-date"] {
color: var(--color-text);
}
&[data-slot="key-actions"] {
font-family: var(--font-sans);
}
}
tbody tr {
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(3) /* Date */ {
display: none;
}
}
td {
&:nth-child(3) /* Date */ {
display: none;
}
}
}
}
}
/* Billing Section */
[data-component="billing-section"] {
[data-slot="section-content"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
[data-slot="reload-error"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
p {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
margin: 0;
flex: 1;
}
[data-slot="create-form"] {
display: flex;
gap: var(--space-2);
margin: 0;
flex-shrink: 0;
}
}
[data-slot="payment"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
min-width: 14.5rem;
width: fit-content;
[data-slot="credit-card"] {
padding: var(--space-3-5) var(--space-4);
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
display: flex;
align-items: center;
justify-content: space-between;
[data-slot="card-icon"] {
display: flex;
align-items: center;
}
[data-slot="card-details"] {
display: flex;
align-items: baseline;
gap: var(--space-1);
[data-slot="secret"] {
position: relative;
bottom: 2px;
font-size: var(--font-size-lg);
color: var(--color-text-muted);
font-weight: 400;
}
[data-slot="number"] {
font-size: var(--font-size-3xl);
font-weight: 500;
color: var(--color-text);
}
}
}
[data-slot="button-row"] {
display: flex;
gap: var(--space-2);
align-items: center;
[data-slot="create-form"] {
margin: 0;
}
/* Make Enable Billing button full width when it's the only button */
> button {
flex: 1;
}
}
}
[data-slot="usage"] {
p {
font-size: var(--font-size-sm);
line-height: 1.5;
color: var(--color-text-secondary);
b {
font-weight: 600;
}
}
}
}
/* Monthly Limit Section */
[data-component="monthly-limit-section"] {
[data-slot="section-content"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
[data-slot="balance"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
min-width: 15rem;
width: fit-content;
[data-slot="amount"] {
padding: var(--space-3-5) var(--space-4);
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
display: flex;
align-items: baseline;
gap: var(--space-1);
justify-content: flex-end;
[data-slot="currency"] {
position: relative;
bottom: 2px;
font-size: var(--font-size-lg);
color: var(--color-text-muted);
font-weight: 400;
}
[data-slot="value"] {
font-size: var(--font-size-3xl);
font-weight: 500;
color: var(--color-text);
}
}
[data-slot="create-form"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-1);
[data-slot="input-container"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
@media (max-width: 30rem) {
gap: var(--space-2);
}
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
margin-top: var(--space-1);
line-height: 1.4;
}
}
}
[data-slot="usage-status"] {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin: 0;
line-height: 1.4;
}
}
/* Payments Section */
[data-component="payments-section"] {
[data-slot="payments-table"] {
overflow-x: auto;
}
[data-slot="payments-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="payment-date"] {
color: var(--color-text);
}
&[data-slot="payment-id"] {
font-family: var(--font-mono);
font-weight: 400;
color: var(--color-text-muted);
max-width: 200px;
word-break: break-word;
}
&[data-slot="payment-amount"] {
color: var(--color-text);
}
}
tbody tr {
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(2) /* Payment ID */ {
display: none;
}
}
td {
&:nth-child(2) /* Payment ID */ {
display: none;
}
}
}
}
}
/* Usage Section */
[data-component="usage-section"] {
[data-slot="usage-table"] {
overflow-x: auto;
}
[data-slot="usage-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="usage-date"] {
color: var(--color-text);
}
&[data-slot="usage-model"] {
font-family: var(--font-sans);
font-weight: 400;
color: var(--color-text-secondary);
max-width: 200px;
word-break: break-word;
}
&[data-slot="usage-cost"] {
color: var(--color-text);
}
}
tbody tr {
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(2) /* Model */ {
display: none;
}
}
td {
&:nth-child(2) /* Model */ {
display: none;
}
}
}
}
}
[data-slot="new-user-sections"] {
display: flex;
flex-direction: column;
gap: var(--space-8);
padding: var(--space-6);
background-color: var(--color-bg-surface);
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
@media (max-width: 30rem) {
gap: var(--space-8);
padding: var(--space-4);
}
[data-component="feature-grid"] {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-6);
@media (max-width: 30rem) {
grid-template-columns: 1fr;
gap: var(--space-4);
}
[data-slot="feature"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
h3 {
font-size: var(--font-size-sm);
font-weight: 600;
margin: 0;
color: var(--color-text);
text-transform: uppercase;
letter-spacing: -0.025rem;
}
p {
font-size: var(--font-size-sm);
line-height: 1.5;
margin: 0;
color: var(--color-text-muted);
}
}
}
[data-component="api-key-highlight"] {
display: flex;
flex-direction: column;
gap: var(--space-6);
[data-slot="section-title"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
h2 {
font-size: var(--font-size-md);
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.03125rem;
margin: 0;
color: var(--color-text-secondary);
text-transform: uppercase;
@media (max-width: 30rem) {
font-size: var(--font-size-md);
}
}
}
[data-slot="key-display"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
[data-slot="key-container"] {
display: flex;
gap: var(--space-3);
padding: var(--space-4);
border: 2px solid var(--color-accent);
border-radius: var(--border-radius-sm);
align-items: center;
@media (max-width: 40rem) {
flex-direction: column;
gap: var(--space-3);
align-items: stretch;
}
[data-slot="key-value"] {
flex: 1;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
color: var(--color-text);
background-color: var(--color-bg);
padding: var(--space-3);
border-radius: var(--border-radius-sm);
border: 1px solid var(--color-border);
word-break: break-all;
line-height: 1.4;
@media (max-width: 40rem) {
font-size: var(--font-size-xs);
padding: var(--space-2-5);
}
}
button {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
font-size: var(--font-size-sm);
font-weight: 500;
white-space: nowrap;
min-width: 130px;
@media (max-width: 40rem) {
justify-content: center;
padding: var(--space-2-5) var(--space-3);
font-size: var(--font-size-xs);
min-width: 96px;
}
}
}
}
}
[data-component="next-steps"] {
display: flex;
flex-direction: column;
gap: var(--space-6);
ol {
margin: 0;
padding-left: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
list-style-position: inside;
li {
font-size: var(--font-size-md);
line-height: 1.5;
color: var(--color-text-secondary);
code {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
color: var(--color-text);
}
}
}
}
}
}
-702
View File
@@ -1,702 +0,0 @@
import "./[id].css"
import { Billing } from "@opencode/cloud-core/billing.js"
import { Key } from "@opencode/cloud-core/key.js"
import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { IconCopy, IconCheck, IconCreditCard } from "~/component/icon"
import { createStore } from "solid-js/store"
function formatDateForTable(date: Date) {
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "short",
hour: "numeric",
minute: "2-digit",
hour12: true,
}
return date.toLocaleDateString("en-GB", options).replace(",", ",")
}
function formatDateUTC(date: Date) {
const options: Intl.DateTimeFormatOptions = {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
timeZone: "UTC",
}
return date.toLocaleDateString("en-US", options)
}
/////////////////////////////////////
// Keys related queries and actions
/////////////////////////////////////
const listKeys = query(async (workspaceID: string) => {
"use server"
return withActor(() => Key.list(), workspaceID)
}, "key.list")
const createKey = action(async (form: FormData) => {
"use server"
const name = form.get("name")?.toString().trim()
if (!name) return { error: "Name is required" }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(
await withActor(
() =>
Key.create({ name })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: listKeys.key },
)
}, "key.create")
const removeKey = action(async (form: FormData) => {
"use server"
const id = form.get("id")?.toString()
if (!id) return { error: "ID is required" }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
}, "key.remove")
/////////////////////////////////////
// Billing related queries and actions
/////////////////////////////////////
const getBillingInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.get()
}, workspaceID)
}, "billing.get")
const getUsageInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.usages()
}, workspaceID)
}, "usage.list")
const getPaymentsInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.payments()
}, workspaceID)
}, "payment.list")
const setMonthlyLimit = action(async (form: FormData) => {
"use server"
const limit = form.get("limit")?.toString()
if (!limit) return { error: "Limit is required" }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(
await withActor(
() =>
Billing.setMonthlyLimit(parseInt(limit))
.then((data) => ({ error: undefined, data }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: getBillingInfo.key },
)
}, "billing.setMonthlyLimit")
const reload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(await withActor(() => Billing.reload(), workspaceID), { revalidate: getBillingInfo.key })
}, "billing.reload")
const disableReload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(await withActor(() => Billing.disableReload(), workspaceID), { revalidate: getBillingInfo.key })
}, "billing.disableReload")
const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
"use server"
return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
}, "checkoutUrl")
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID)
}, "sessionUrl")
function KeySection() {
const params = useParams()
const keys = createAsync(() => listKeys(params.id))
function formatKey(key: string) {
if (key.length <= 11) return key
return `${key.slice(0, 7)}...${key.slice(-4)}`
}
return (
<section data-component="api-keys-section">
<div data-slot="section-title">
<h2>API Keys</h2>
<p>Manage your API keys for accessing opencode services.</p>
</div>
<KeyCreateForm />
<div data-slot="api-keys-table">
<Show
when={keys()?.length}
fallback={
<div data-component="empty-state">
<p>Create an opencode Gateway API key</p>
</div>
}
>
<table data-slot="api-keys-table-element">
<thead>
<tr>
<th>Name</th>
<th>Key</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
<For each={keys()!}>
{(key) => {
const [copied, setCopied] = createSignal(false)
// const submission = useSubmission(removeKey, ([fd]) => fd.get("id")?.toString() === key.id)
return (
<tr>
<td data-slot="key-name">{key.name}</td>
<td data-slot="key-value">
<button
data-color="ghost"
disabled={copied()}
onClick={async () => {
await navigator.clipboard.writeText(key.key)
setCopied(true)
setTimeout(() => setCopied(false), 1000)
}}
title="Copy API key"
>
<span>{formatKey(key.key)}</span>
<Show when={copied()} fallback={<IconCopy style={{ width: "14px", height: "14px" }} />}>
<IconCheck style={{ width: "14px", height: "14px" }} />
</Show>
</button>
</td>
<td data-slot="key-date" title={formatDateUTC(key.timeCreated)}>
{formatDateForTable(key.timeCreated)}
</td>
<td data-slot="key-actions">
<form action={removeKey} method="post">
<input type="hidden" name="id" value={key.id} />
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="ghost">Delete</button>
</form>
</td>
</tr>
)
}}
</For>
</tbody>
</table>
</Show>
</div>
</section>
)
}
function KeyCreateForm() {
const params = useParams()
const submission = useSubmission(createKey)
const [store, setStore] = createStore({ show: false })
let input: HTMLInputElement
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
hide()
}
})
function show() {
// submission.clear() does not clear the result in some cases, ie.
// 1. Create key with empty name => error shows
// 2. Put in a key name and creates the key => form hides
// 3. Click add key button again => form shows with the same error if
// submission.clear() is called only once
while (true) {
submission.clear()
if (!submission.result) break
}
setStore("show", true)
input.focus()
}
function hide() {
setStore("show", false)
}
return (
<Show
when={store.show}
fallback={
<button data-color="primary" onClick={() => show()}>
Create API Key
</button>
}
>
<form action={createKey} method="post" data-slot="create-form">
<div data-slot="input-container">
<input ref={(r) => (input = r)} data-component="input" name="name" type="text" placeholder="Enter key name" />
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{err()}</div>}
</Show>
</div>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
Cancel
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? "Creating..." : "Create"}
</button>
</div>
</form>
</Show>
)
}
function BillingSection() {
const params = useParams()
const balanceInfo = createAsync(() => getBillingInfo(params.id))
const createCheckoutUrlAction = useAction(createCheckoutUrl)
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
const createSessionUrlAction = useAction(createSessionUrl)
const createSessionUrlSubmission = useSubmission(createSessionUrl)
const disableReloadSubmission = useSubmission(disableReload)
const reloadSubmission = useSubmission(reload)
const balanceAmount = createMemo(() => {
return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
})
return (
<section data-component="billing-section">
<div data-slot="section-title">
<h2>Billing</h2>
<p>
Manage payments methods. <a href="mailto:contact@anoma.ly">Contact us</a> if you have any questions.
</p>
</div>
<div data-slot="section-content">
<Show when={balanceInfo()?.reloadError}>
<div data-slot="reload-error">
<p>
Reload failed at{" "}
{balanceInfo()?.timeReloadError!.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
})}
. Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try
again.
</p>
<form action={reload} method="post" data-slot="create-form">
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="primary" type="submit" disabled={reloadSubmission.pending}>
{reloadSubmission.pending ? "Reloading..." : "Reload"}
</button>
</form>
</div>
</Show>
<div data-slot="payment">
<div data-slot="credit-card">
<div data-slot="card-icon">
<IconCreditCard style={{ width: "32px", height: "32px" }} />
</div>
<div data-slot="card-details">
<Show when={balanceInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}>
<span data-slot="secret"></span>
<span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
</Show>
</div>
</div>
<div data-slot="button-row">
<Show
when={balanceInfo()?.reload}
fallback={
<button
data-color="primary"
disabled={createCheckoutUrlSubmission.pending}
onClick={async () => {
const baseUrl = window.location.href
const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
if (checkoutUrl) {
window.location.href = checkoutUrl
}
}}
>
{createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
</button>
}
>
<button
data-color="primary"
disabled={createSessionUrlSubmission.pending}
onClick={async () => {
const baseUrl = window.location.href
const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
if (sessionUrl) {
window.location.href = sessionUrl
}
}}
>
{createSessionUrlSubmission.pending ? "Loading..." : "Manage Payment Methods"}
</button>
<form action={disableReload} method="post" data-slot="create-form">
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="ghost" type="submit" disabled={disableReloadSubmission.pending}>
{disableReloadSubmission.pending ? "Disabling..." : "Disable"}
</button>
</form>
</Show>
</div>
</div>
<div data-slot="usage">
<Show when={!balanceInfo()?.reload && !(balanceAmount() === "0.00" || balanceAmount() === "-0.00")}>
<p>
You have <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b> remaining in
your account. You can continue using the API with your remaining balance.
</p>
</Show>
<Show when={balanceInfo()?.reload && !balanceInfo()?.reloadError}>
<p>
Your current balance is <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
. We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches <b>$5</b>.
</p>
</Show>
</div>
</div>
</section>
)
}
function MonthlyLimitSection() {
const params = useParams()
const submission = useSubmission(setMonthlyLimit)
const [store, setStore] = createStore({ show: false })
const balanceInfo = createAsync(() => getBillingInfo(params.id))
let input: HTMLInputElement
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
hide()
}
})
function show() {
// submission.clear() does not clear the result in some cases, ie.
// 1. Create key with empty name => error shows
// 2. Put in a key name and creates the key => form hides
// 3. Click add key button again => form shows with the same error if
// submission.clear() is called only once
while (true) {
submission.clear()
if (!submission.result) break
}
setStore("show", true)
input.focus()
}
function hide() {
setStore("show", false)
}
return (
<section data-component="monthly-limit-section">
<div data-slot="section-title">
<h2>Monthly Limit</h2>
<p>Set a monthly spending limit for your account.</p>
</div>
<div data-slot="section-content">
<div data-slot="balance">
<div data-slot="amount">
{balanceInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
<span data-slot="value">{balanceInfo()?.monthlyLimit ?? "-"}</span>
</div>
<Show
when={!store.show}
fallback={
<form action={setMonthlyLimit} method="post" data-slot="create-form">
<div data-slot="input-container">
<input ref={(r) => (input = r)} data-component="input" name="limit" type="number" placeholder="50" />
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{err()}</div>}
</Show>
</div>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
Cancel
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? "Setting..." : "Set"}
</button>
</div>
</form>
}
>
<button data-color="primary" onClick={() => show()}>
{balanceInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
</button>
</Show>
</div>
<Show when={balanceInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No spending limit set.</p>}>
<p data-slot="usage-status">
Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $
{(() => {
const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated
if (!dateLastUsed) return "0"
const current = new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
timeZone: "UTC",
})
const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
timeZone: "UTC",
})
if (current !== lastUsed) return "0"
return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
})()}
.
</p>
</Show>
</div>
</section>
)
}
function UsageSection() {
const params = useParams()
const usage = createAsync(() => getUsageInfo(params.id))
return (
<section data-component="usage-section">
<div data-slot="section-title">
<h2>Usage History</h2>
<p>Recent API usage and costs.</p>
</div>
<div data-slot="usage-table">
<Show
when={usage() && usage()!.length > 0}
fallback={
<div data-component="empty-state">
<p>Make your first API call to get started.</p>
</div>
}
>
<table data-slot="usage-table-element">
<thead>
<tr>
<th>Date</th>
<th>Model</th>
<th>Input</th>
<th>Output</th>
<th>Cost</th>
</tr>
</thead>
<tbody>
<For each={usage()!}>
{(usage) => {
const date = createMemo(() => new Date(usage.timeCreated))
return (
<tr>
<td data-slot="usage-date" title={formatDateUTC(date())}>
{formatDateForTable(date())}
</td>
<td data-slot="usage-model">{usage.model}</td>
<td data-slot="usage-tokens">{usage.inputTokens}</td>
<td data-slot="usage-tokens">{usage.outputTokens}</td>
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
</tr>
)
}}
</For>
</tbody>
</table>
</Show>
</div>
</section>
)
}
function PaymentSection() {
const params = useParams()
const payments = createAsync(() => getPaymentsInfo(params.id))
return (
payments() &&
payments()!.length > 0 && (
<section data-component="payments-section">
<div data-slot="section-title">
<h2>Payments History</h2>
<p>Recent payment transactions.</p>
</div>
<div data-slot="payments-table">
<table data-slot="payments-table-element">
<thead>
<tr>
<th>Date</th>
<th>Payment ID</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<For each={payments()!}>
{(payment) => {
const date = new Date(payment.timeCreated)
return (
<tr>
<td data-slot="payment-date" title={formatDateUTC(date)}>
{formatDateForTable(date)}
</td>
<td data-slot="payment-id">{payment.id}</td>
<td data-slot="payment-amount">${((payment.amount ?? 0) / 100000000).toFixed(2)}</td>
</tr>
)
}}
</For>
</tbody>
</table>
</div>
</section>
)
)
}
function NewUserSection() {
const params = useParams()
const [copiedKey, setCopiedKey] = createSignal(false)
const keys = createAsync(() => listKeys(params.id))
const usage = createAsync(() => getUsageInfo(params.id))
const isNew = createMemo(() => {
const keysList = keys()
const usageList = usage()
return keysList?.length === 1 && (!usageList || usageList.length === 0)
})
const defaultKey = createMemo(() => keys()?.at(-1)?.key)
return (
<Show when={isNew()}>
<div data-slot="new-user-sections">
<div data-component="feature-grid">
<div data-slot="feature">
<h3>Tested & Verified Models</h3>
<p>We've benchmarked and tested models specifically for coding agents to ensure the best performance.</p>
</div>
<div data-slot="feature">
<h3>Highest Quality</h3>
<p>Access models configured for optimal performance - no downgrades or routing to cheaper providers.</p>
</div>
<div data-slot="feature">
<h3>No Lock-in</h3>
<p>Use Zen with any coding agent, and continue using other providers with opencode whenever you want.</p>
</div>
</div>
<div data-component="api-key-highlight">
<Show when={defaultKey()}>
<div data-slot="key-display">
<div data-slot="key-container">
<code data-slot="key-value">{defaultKey()}</code>
<button
data-color="primary"
disabled={copiedKey()}
onClick={async () => {
await navigator.clipboard.writeText(defaultKey() ?? "")
setCopiedKey(true)
setTimeout(() => setCopiedKey(false), 2000)
}}
title="Copy API key"
>
<Show
when={copiedKey()}
fallback={
<>
<IconCopy style={{ width: "16px", height: "16px" }} /> Copy Key
</>
}
>
<IconCheck style={{ width: "16px", height: "16px" }} /> Copied!
</Show>
</button>
</div>
</div>
</Show>
</div>
<div data-component="next-steps">
<ol>
<li>Enable billing</li>
<li>
Run <code>opencode auth login</code> and select opencode
</li>
<li>Paste your API key</li>
<li>
Start opencode and run <code>/models</code> to select a model
</li>
</ol>
</div>
</div>
</Show>
)
}
export default function () {
const params = useParams()
const balanceInfo = createAsync(() => getBillingInfo(params.id))
return (
<div data-page="workspace-[id]">
<section data-component="title-section">
<h1>Zen</h1>
<p>
Curated list of models provided by opencode.{" "}
<a target="_blank" href="/docs/zen">
Learn more
</a>
.
</p>
</section>
<div data-slot="sections">
<NewUserSection />
<KeySection />
<BillingSection />
<Show when={balanceInfo()?.reload}>
<MonthlyLimitSection />
</Show>
<UsageSection />
<PaymentSection />
</div>
</div>
)
}
-594
View File
@@ -1,594 +0,0 @@
import type { APIEvent } from "@solidjs/start/server"
import path from "node:path"
import { and, Database, eq, isNull, lt, or, sql } from "@opencode/cloud-core/drizzle/index.js"
import { KeyTable } from "@opencode/cloud-core/schema/key.sql.js"
import { BillingTable, PaymentTable, UsageTable } from "@opencode/cloud-core/schema/billing.sql.js"
import { centsToMicroCents } from "@opencode/cloud-core/util/price.js"
import { Identifier } from "@opencode/cloud-core/identifier.js"
import { Resource } from "@opencode/cloud-resource"
import { Billing } from "../../../../core/src/billing"
import { Actor } from "@opencode/cloud-core/actor.js"
type ModelCost = {
input: number
output: number
cacheRead?: number
cacheWrite5m?: number
cacheWrite1h?: number
}
type Model = {
id: string
auth: boolean
cost: ModelCost | ((usage: any) => ModelCost)
headerMappings: Record<string, string>
providers: Record<
string,
{
api: string
apiKey: string
model: string
weight?: number
}
>
}
export async function handler(
input: APIEvent,
opts: {
modifyBody?: (body: any) => any
setAuthHeader: (headers: Headers, apiKey: string) => void
parseApiKey: (headers: Headers) => string | undefined
onStreamPart: (chunk: string) => void
getStreamUsage: () => any
normalizeUsage: (body: any) => {
inputTokens: number
outputTokens: number
reasoningTokens?: number
cacheReadTokens?: number
cacheWrite5mTokens?: number
cacheWrite1hTokens?: number
}
},
) {
class AuthError extends Error {}
class CreditsError extends Error {}
class MonthlyLimitError extends Error {}
class ModelError extends Error {}
const MODELS: Record<string, Model> = {
"claude-opus-4-1": {
id: "claude-opus-4-1" as const,
auth: true,
cost: {
input: 0.000015,
output: 0.000075,
cacheRead: 0.0000015,
cacheWrite5m: 0.00001875,
cacheWrite1h: 0.00003,
},
headerMappings: {},
providers: {
anthropic: {
api: "https://api.anthropic.com",
apiKey: Resource.ANTHROPIC_API_KEY.value,
model: "claude-opus-4-1-20250805",
},
},
},
"claude-sonnet-4": {
id: "claude-sonnet-4" as const,
auth: true,
cost: (usage: any) => {
const totalInputTokens =
usage.inputTokens + usage.cacheReadTokens + usage.cacheWrite5mTokens + usage.cacheWrite1hTokens
return totalInputTokens <= 200_000
? {
input: 0.000003,
output: 0.000015,
cacheRead: 0.0000003,
cacheWrite5m: 0.00000375,
cacheWrite1h: 0.000006,
}
: {
input: 0.000006,
output: 0.0000225,
cacheRead: 0.0000006,
cacheWrite5m: 0.0000075,
cacheWrite1h: 0.000012,
}
},
headerMappings: {},
providers: {
anthropic: {
api: "https://api.anthropic.com",
apiKey: Resource.ANTHROPIC_API_KEY.value,
model: "claude-sonnet-4-20250514",
},
},
},
"claude-3-5-haiku": {
id: "claude-3-5-haiku" as const,
auth: true,
cost: {
input: 0.0000008,
output: 0.000004,
cacheRead: 0.00000008,
cacheWrite5m: 0.000001,
cacheWrite1h: 0.0000016,
},
headerMappings: {},
providers: {
anthropic: {
api: "https://api.anthropic.com",
apiKey: Resource.ANTHROPIC_API_KEY.value,
model: "claude-3-5-haiku-20241022",
},
},
},
"gpt-5": {
id: "gpt-5" as const,
auth: true,
cost: {
input: 0.00000125,
output: 0.00001,
cacheRead: 0.000000125,
},
headerMappings: {},
providers: {
openai: {
api: "https://api.openai.com",
apiKey: Resource.OPENAI_API_KEY.value,
model: "gpt-5",
},
},
},
"qwen3-coder": {
id: "qwen3-coder" as const,
auth: true,
cost: {
input: 0.00000045,
output: 0.0000018,
},
headerMappings: {},
providers: {
baseten: {
api: "https://inference.baseten.co",
apiKey: Resource.BASETEN_API_KEY.value,
model: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
weight: 4,
},
fireworks: {
api: "https://api.fireworks.ai/inference",
apiKey: Resource.FIREWORKS_API_KEY.value,
model: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
weight: 1,
},
},
},
"kimi-k2": {
id: "kimi-k2" as const,
auth: true,
cost: {
input: 0.0000006,
output: 0.0000025,
},
headerMappings: {},
providers: {
baseten: {
api: "https://inference.baseten.co",
apiKey: Resource.BASETEN_API_KEY.value,
model: "moonshotai/Kimi-K2-Instruct-0905",
weight: 4,
},
fireworks: {
api: "https://api.fireworks.ai/inference",
apiKey: Resource.FIREWORKS_API_KEY.value,
model: "accounts/fireworks/models/kimi-k2-instruct-0905",
weight: 1,
},
},
},
"grok-code": {
id: "grok-code" as const,
auth: false,
cost: {
input: 0,
output: 0,
cacheRead: 0,
},
headerMappings: {
"x-grok-conv-id": "x-opencode-session",
"x-grok-req-id": "x-opencode-request",
},
providers: {
xai: {
api: "https://api.x.ai",
apiKey: Resource.XAI_API_KEY.value,
model: "grok-code",
},
},
},
// deprecated
"qwen/qwen3-coder": {
id: "qwen/qwen3-coder" as const,
auth: true,
cost: {
input: 0.00000038,
output: 0.00000153,
},
headerMappings: {},
providers: {
baseten: {
api: "https://inference.baseten.co",
apiKey: Resource.BASETEN_API_KEY.value,
model: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
weight: 5,
},
fireworks: {
api: "https://api.fireworks.ai/inference",
apiKey: Resource.FIREWORKS_API_KEY.value,
model: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
weight: 1,
},
},
},
}
const FREE_WORKSPACES = [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
]
const logger = {
metric: (values: Record<string, any>) => {
console.log(`_metric:${JSON.stringify(values)}`)
},
log: console.log,
debug: (message: string) => {
if (Resource.App.stage === "production") return
console.debug(message)
},
}
try {
const url = new URL(input.request.url)
const body = await input.request.json()
logger.debug(JSON.stringify(body))
logger.metric({
is_tream: !!body.stream,
session: input.request.headers.get("x-opencode-session"),
request: input.request.headers.get("x-opencode-request"),
})
const MODEL = validateModel()
const apiKey = await authenticate()
const isFree = FREE_WORKSPACES.includes(apiKey?.workspaceID ?? "")
await checkCreditsAndLimit()
const providerName = selectProvider()
const providerData = MODEL.providers[providerName]
logger.metric({ provider: providerName })
// Request to model provider
const startTimestamp = Date.now()
const res = await fetch(path.posix.join(providerData.api, url.pathname.replace(/^\/zen/, "") + url.search), {
method: "POST",
headers: (() => {
const headers = input.request.headers
headers.delete("host")
headers.delete("content-length")
opts.setAuthHeader(headers, providerData.apiKey)
Object.entries(MODEL.headerMappings ?? {}).forEach(([k, v]) => {
headers.set(k, headers.get(v)!)
})
return headers
})(),
body: JSON.stringify({
...(opts.modifyBody?.(body) ?? body),
model: providerData.model,
}),
})
// Scrub response headers
const resHeaders = new Headers()
const keepHeaders = ["content-type", "cache-control"]
for (const [k, v] of res.headers.entries()) {
if (keepHeaders.includes(k.toLowerCase())) {
resHeaders.set(k, v)
}
}
// Handle non-streaming response
if (!body.stream) {
const json = await res.json()
const body = JSON.stringify(json)
logger.metric({ response_length: body.length })
logger.debug(body)
await trackUsage(json.usage)
await reload()
return new Response(body, {
status: res.status,
statusText: res.statusText,
headers: resHeaders,
})
}
// Handle streaming response
const stream = new ReadableStream({
start(c) {
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let buffer = ""
let responseLength = 0
function pump(): Promise<void> {
return (
reader?.read().then(async ({ done, value }) => {
if (done) {
logger.metric({ response_length: responseLength })
const usage = opts.getStreamUsage()
if (usage) {
await trackUsage(usage)
await reload()
}
c.close()
return
}
if (responseLength === 0) {
logger.metric({ time_to_first_byte: Date.now() - startTimestamp })
}
responseLength += value.length
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split("\n\n")
buffer = parts.pop() ?? ""
for (const part of parts) {
logger.debug(part)
opts.onStreamPart(part.trim())
}
c.enqueue(value)
return pump()
}) || Promise.resolve()
)
}
return pump()
},
})
return new Response(stream, {
status: res.status,
statusText: res.statusText,
headers: resHeaders,
})
function validateModel() {
if (!(body.model in MODELS)) {
throw new ModelError(`Model ${body.model} not supported`)
}
const model = MODELS[body.model as keyof typeof MODELS]
logger.metric({ model: model.id })
return model
}
async function authenticate() {
try {
const apiKey = opts.parseApiKey(input.request.headers)
if (!apiKey) throw new AuthError("Missing API key.")
const key = await Database.use((tx) =>
tx
.select({
id: KeyTable.id,
workspaceID: KeyTable.workspaceID,
})
.from(KeyTable)
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows[0]),
)
if (!key) throw new AuthError("Invalid API key.")
logger.metric({
api_key: key.id,
workspace: key.workspaceID,
})
return key
} catch (e) {
// ignore error if model does not require authentication
if (!MODEL.auth) return
throw e
}
}
async function checkCreditsAndLimit() {
if (!apiKey || !MODEL.auth || isFree) return
const billing = await Database.use((tx) =>
tx
.select({
balance: BillingTable.balance,
paymentMethodID: BillingTable.paymentMethodID,
monthlyLimit: BillingTable.monthlyLimit,
monthlyUsage: BillingTable.monthlyUsage,
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
})
.from(BillingTable)
.where(eq(BillingTable.workspaceID, apiKey.workspaceID))
.then((rows) => rows[0]),
)
if (!billing.paymentMethodID) throw new CreditsError("No payment method")
if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
if (
billing.monthlyLimit &&
billing.monthlyUsage &&
billing.timeMonthlyUsageUpdated &&
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
) {
const now = new Date()
const currentYear = now.getUTCFullYear()
const currentMonth = now.getUTCMonth()
const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
if (currentYear === dateYear && currentMonth === dateMonth)
throw new MonthlyLimitError(`You have reached your monthly spending limit of $${billing.monthlyLimit}.`)
}
}
function selectProvider() {
const picks = Object.entries(MODEL.providers).flatMap(([name, provider]) =>
Array<string>(provider.weight ?? 1).fill(name),
)
return picks[Math.floor(Math.random() * picks.length)]
}
async function trackUsage(usage: any) {
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
opts.normalizeUsage(usage)
const modelCost = typeof MODEL.cost === "function" ? MODEL.cost(usage) : MODEL.cost
const inputCost = modelCost.input * inputTokens * 100
const outputCost = modelCost.output * outputTokens * 100
const reasoningCost = (() => {
if (!reasoningTokens) return undefined
return modelCost.output * reasoningTokens * 100
})()
const cacheReadCost = (() => {
if (!cacheReadTokens) return undefined
if (!modelCost.cacheRead) return undefined
return modelCost.cacheRead * cacheReadTokens * 100
})()
const cacheWrite5mCost = (() => {
if (!cacheWrite5mTokens) return undefined
if (!modelCost.cacheWrite5m) return undefined
return modelCost.cacheWrite5m * cacheWrite5mTokens * 100
})()
const cacheWrite1hCost = (() => {
if (!cacheWrite1hTokens) return undefined
if (!modelCost.cacheWrite1h) return undefined
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
})()
const totalCostInCent =
inputCost +
outputCost +
(reasoningCost ?? 0) +
(cacheReadCost ?? 0) +
(cacheWrite5mCost ?? 0) +
(cacheWrite1hCost ?? 0)
logger.metric({
"tokens.input": inputTokens,
"tokens.output": outputTokens,
"tokens.reasoning": reasoningTokens,
"tokens.cache_read": cacheReadTokens,
"tokens.cache_write_5m": cacheWrite5mTokens,
"tokens.cache_write_1h": cacheWrite1hTokens,
"cost.input": Math.round(inputCost),
"cost.output": Math.round(outputCost),
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
"cost.total": Math.round(totalCostInCent),
})
if (!apiKey) return
const cost = isFree ? 0 : centsToMicroCents(totalCostInCent)
await Database.transaction(async (tx) => {
await tx.insert(UsageTable).values({
workspaceID: apiKey.workspaceID,
id: Identifier.create("usage"),
model: MODEL.id,
provider: providerName,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens,
cacheWrite1hTokens,
cost,
})
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${cost}`,
monthlyUsage: sql`
CASE
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeMonthlyUsageUpdated: sql`now()`,
})
.where(eq(BillingTable.workspaceID, apiKey.workspaceID))
})
await Database.use((tx) =>
tx
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(eq(KeyTable.id, apiKey.id)),
)
}
async function reload() {
if (!apiKey) return
// acquire reload lock
const lock = await Database.use((tx) =>
tx
.update(BillingTable)
.set({
timeReloadLockedTill: sql`now() + interval 1 minute`,
})
.where(
and(
eq(BillingTable.workspaceID, apiKey.workspaceID),
lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
),
),
)
if (lock.rowsAffected === 0) return
await Actor.provide("system", { workspaceID: apiKey.workspaceID }, async () => {
await Billing.reload()
})
}
} catch (error: any) {
logger.metric({
"error.type": error.constructor.name,
"error.message": error.message,
})
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
if (
error instanceof AuthError ||
error instanceof CreditsError ||
error instanceof MonthlyLimitError ||
error instanceof ModelError
)
return new Response(
JSON.stringify({
type: "error",
error: { type: error.constructor.name, message: error.message },
}),
{ status: 401 },
)
return new Response(
JSON.stringify({
type: "error",
error: {
type: "error",
message: error.message,
},
}),
{ status: 500 },
)
}
}
+6 -20
View File
@@ -44,7 +44,7 @@ new sst.x.DevCommand("Studio", {
link: [database],
dev: {
command: "bun db studio",
directory: "cloud/core",
directory: "packages/console/core",
autostart: true,
},
})
@@ -59,7 +59,7 @@ const GOOGLE_CLIENT_ID = new sst.Secret("GOOGLE_CLIENT_ID")
const authStorage = new sst.cloudflare.Kv("AuthStorage")
export const auth = new sst.cloudflare.Worker("AuthApi", {
domain: `auth.${domain}`,
handler: "cloud/function/src/auth.ts",
handler: "packages/console/function/src/auth.ts",
url: true,
link: [database, authStorage, GITHUB_CLIENT_ID_CONSOLE, GITHUB_CLIENT_SECRET_CONSOLE, GOOGLE_CLIENT_ID],
})
@@ -99,11 +99,7 @@ export const stripeWebhook = new WebhookEndpoint("StripeWebhookEndpoint", {
],
})
const ANTHROPIC_API_KEY = new sst.Secret("ANTHROPIC_API_KEY")
const OPENAI_API_KEY = new sst.Secret("OPENAI_API_KEY")
const XAI_API_KEY = new sst.Secret("XAI_API_KEY")
const BASETEN_API_KEY = new sst.Secret("BASETEN_API_KEY")
const FIREWORKS_API_KEY = new sst.Secret("FIREWORKS_API_KEY")
const ZEN_MODELS = new sst.Secret("ZEN_MODELS")
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) },
@@ -120,25 +116,15 @@ let logProcessor
if ($app.stage === "production" || $app.stage === "frank") {
const HONEYCOMB_API_KEY = new sst.Secret("HONEYCOMB_API_KEY")
logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "cloud/function/src/log-processor.ts",
handler: "packages/console/function/src/log-processor.ts",
link: [HONEYCOMB_API_KEY],
})
}
new sst.cloudflare.x.SolidStart("Console", {
domain,
path: "cloud/app",
link: [
database,
AUTH_API_URL,
STRIPE_WEBHOOK_SECRET,
STRIPE_SECRET_KEY,
ANTHROPIC_API_KEY,
OPENAI_API_KEY,
XAI_API_KEY,
BASETEN_API_KEY,
FIREWORKS_API_KEY,
],
path: "packages/console/app",
link: [database, AUTH_API_URL, STRIPE_WEBHOOK_SECRET, STRIPE_SECRET_KEY, ZEN_MODELS],
environment: {
//VITE_DOCS_URL: web.url.apply((url) => url!),
//VITE_API_URL: gateway.url.apply((url) => url!),
+1 -1
View File
@@ -4,7 +4,7 @@ new sst.cloudflare.StaticSite("Desktop", {
domain: "desktop." + domain,
path: "packages/app",
build: {
command: "bun run build",
command: "bun turbo build",
output: "./dist",
},
})
+6 -3
View File
@@ -6,14 +6,14 @@
"packageManager": "bun@1.2.21",
"scripts": {
"dev": "bun run --conditions=development packages/opencode/src/index.ts",
"typecheck": "bun run --filter='*' typecheck",
"typecheck": "bun turbo typecheck",
"generate": "(cd packages/sdk && ./js/script/generate.ts) && (cd packages/sdk/stainless && ./generate.ts)",
"postinstall": "./script/hooks"
},
"workspaces": {
"packages": [
"cloud/*",
"packages/*",
"packages/console/*",
"packages/sdk/js"
],
"catalog": {
@@ -23,6 +23,8 @@
"@tsconfig/node22": "22.0.2",
"ai": "5.0.8",
"hono": "4.7.10",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"typescript": "5.8.2",
"zod": "4.1.8",
"remeda": "2.26.0",
@@ -34,7 +36,8 @@
},
"devDependencies": {
"prettier": "3.6.2",
"sst": "3.17.13"
"sst": "3.17.13",
"turbo": "2.5.6"
},
"repository": {
"type": "git",
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.ico" />
<link rel="shortcut icon" type="image/ico" href="/src/assets/favicon.svg" />
<link rel="stylesheet" href="/src/assets/theme.css" />
<title>opencode</title>
</head>
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "0.9.3",
"version": "0.11.0",
"description": "",
"type": "module",
"scripts": {
@@ -29,7 +29,8 @@
"@solidjs/router": "0.15.3",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "8.0.2",
"luxon": "3.7.1",
"fuzzysort": "catalog:",
"luxon": "catalog:",
"marked": "16.2.0",
"marked-shiki": "1.2.1",
"remeda": "catalog:",
+11 -8
View File
@@ -35,18 +35,19 @@ class ColorResolver {
try {
if (typeof value === "string") {
if (value.startsWith("#") || value === "none") {
return { dark: value, light: value }
if (value === "none") return { dark: value, light: value }
if (value.startsWith("#")) {
return { dark: value.toUpperCase(), light: value.toUpperCase() }
}
const resolved = this.resolveReference(value)
return { dark: resolved, light: resolved }
}
if (typeof value === "object" && value !== null) {
const dark = this.resolveColorValue(value.dark || value.light || "#000000")
const light = this.resolveColorValue(value.light || value.dark || "#ffffff")
const light = this.resolveColorValue(value.light || value.dark || "#FFFFFF")
return { dark, light }
}
return { dark: "#000000", light: "#ffffff" }
return { dark: "#000000", light: "#FFFFFF" }
} finally {
this.visited.delete(key)
}
@@ -54,8 +55,9 @@ class ColorResolver {
private resolveColorValue(value: any): string {
if (typeof value === "string") {
if (value.startsWith("#") || value === "none") {
return value
if (value === "none") return value
if (value.startsWith("#")) {
return value.toUpperCase()
}
return this.resolveReference(value)
}
@@ -68,8 +70,9 @@ class ColorResolver {
throw new Error(`Color reference '${ref}' not found`)
}
if (typeof colorValue === "string") {
if (colorValue.startsWith("#") || colorValue === "none") {
return colorValue
if (colorValue === "none") return colorValue
if (colorValue.startsWith("#")) {
return colorValue.toUpperCase()
}
return this.resolveReference(colorValue)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 377 B

After

Width:  |  Height:  |  Size: 377 B

+188
View File
@@ -0,0 +1,188 @@
import { Select as KobalteSelect } from "@kobalte/core/select"
import { createEffect, createMemo, Show } from "solid-js"
import type { ComponentProps } from "solid-js"
import { Icon } from "@/ui/icon"
import fuzzysort from "fuzzysort"
import { pipe, groupBy, entries, map } from "remeda"
import { createStore } from "solid-js/store"
export interface SelectProps<T> {
variant?: "default" | "outline"
size?: "sm" | "md" | "lg"
placeholder?: string
filter?:
| false
| {
placeholder?: string
keys: string[]
}
options: T[]
current?: T
value?: (x: T) => string
label?: (x: T) => string
groupBy?: (x: T) => string
onFilter?: (query: string) => void
onSelect?: (value: T | undefined) => void
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
}
export function Select<T>(props: SelectProps<T>) {
let inputRef: HTMLInputElement | undefined = undefined
let listboxRef: HTMLUListElement | undefined = undefined
const [store, setStore] = createStore({
filter: "",
})
const grouped = createMemo(() => {
const needle = store.filter.toLowerCase()
const result = pipe(
props.options,
(x) =>
!needle || !props.filter
? x
: fuzzysort.go(needle, x, { keys: props.filter && props.filter.keys }).map((x) => x.obj),
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
entries(),
map(([k, v]) => ({ category: k, options: v })),
)
return result
})
// const flat = createMemo(() => {
// return pipe(
// grouped(),
// flatMap(({ options }) => options),
// )
// })
createEffect(() => {
store.filter
listboxRef?.scrollTo(0, 0)
// setStore("selected", 0)
// scroll.scrollTo(0)
})
return (
<KobalteSelect<T, { category: string; options: T[] }>
value={props.current}
options={grouped()}
optionValue={(x) => (props.value ? props.value(x) : (x as string))}
optionTextValue={(x) => (props.label ? props.label(x) : (x as string))}
optionGroupChildren="options"
placeholder={props.placeholder}
sectionComponent={(props) => (
<KobalteSelect.Section class="text-xs uppercase text-text-muted/60 font-light mt-3 first:mt-0 ml-2">
{props.section.rawValue.category}
</KobalteSelect.Section>
)}
itemComponent={(itemProps) => (
<KobalteSelect.Item
classList={{
"relative flex cursor-pointer select-none items-center": true,
"rounded-sm px-2 py-0.5 text-xs outline-none text-text": true,
"transition-colors data-[disabled]:pointer-events-none": true,
"data-[highlighted]:bg-background-element data-[disabled]:opacity-50": true,
[props.class ?? ""]: !!props.class,
}}
{...itemProps}
>
<KobalteSelect.ItemLabel>
{props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
</KobalteSelect.ItemLabel>
<KobalteSelect.ItemIndicator
classList={{
"ml-auto": true,
}}
>
<Icon name="checkmark" size={16} />
</KobalteSelect.ItemIndicator>
</KobalteSelect.Item>
)}
onChange={(v) => {
if (props.onSelect) props.onSelect(v ?? undefined)
if (v !== null) {
// close the select
}
}}
onOpenChange={(v) => v || setStore("filter", "")}
>
<KobalteSelect.Trigger
classList={{
...(props.classList ?? {}),
"flex w-full items-center justify-between rounded-md transition-colors": true,
"focus-visible:outline-none focus-visible:ring focus-visible:ring-border-active/30": true,
"disabled:cursor-not-allowed disabled:opacity-50": true,
"data-[placeholder-shown]:text-text-muted cursor-pointer": true,
"hover:bg-background-element focus-visible:ring-border-active": true,
"bg-background-element text-text": props.variant === "default" || !props.variant,
"border-2 border-border bg-transparent text-text": props.variant === "outline",
"h-6 pl-2 text-xs": props.size === "sm",
"h-8 pl-3 text-sm": props.size === "md" || !props.size,
"h-10 pl-4 text-base": props.size === "lg",
[props.class ?? ""]: !!props.class,
}}
>
<KobalteSelect.Value<T>>
{(state) => {
const selected = state.selectedOption() ?? props.current
if (!selected) return props.placeholder || ""
if (props.label) return props.label(selected)
return selected as string
}}
</KobalteSelect.Value>
<KobalteSelect.Icon
classList={{
"size-fit shrink-0 text-text-muted transition-transform duration-100 data-[expanded]:rotate-180": true,
}}
>
<Icon name="chevron-down" size={24} />
</KobalteSelect.Icon>
</KobalteSelect.Trigger>
<KobalteSelect.Portal>
<KobalteSelect.Content
onKeyDown={(e) => {
if (!props.filter) return
if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") {
return
}
inputRef?.focus()
}}
classList={{
"min-w-32 overflow-hidden rounded-md border border-border-subtle/40": true,
"bg-background-panel p-1 shadow-md z-50": true,
"data-[closed]:animate-out data-[closed]:fade-out-0 data-[closed]:zoom-out-95": true,
"data-[expanded]:animate-in data-[expanded]:fade-in-0 data-[expanded]:zoom-in-95": true,
}}
>
<Show when={props.filter}>
<input
ref={(el) => (inputRef = el)}
id="select-filter"
type="text"
placeholder={props.filter ? props.filter.placeholder : "Filter items"}
value={store.filter}
onInput={(e) => setStore("filter", e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
listboxRef?.focus()
}
}}
classList={{
"w-full": true,
"px-2 pb-2 text-text font-light placeholder-text-muted/70 text-xs focus:outline-none": true,
}}
/>
</Show>
<KobalteSelect.Listbox
ref={(el) => (listboxRef = el)}
classList={{
"overflow-y-auto max-h-48 no-scrollbar": true,
}}
/>
</KobalteSelect.Content>
</KobalteSelect.Portal>
</KobalteSelect>
)
}
+19 -10
View File
@@ -24,22 +24,26 @@ function init() {
const sdk = useSDK()
const sync = useSync()
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
const agent = (() => {
const [store, setStore] = createStore<{
current: string
}>({
current: agents()[0].name,
current: list()[0].name,
})
return {
list,
current() {
return agents().find((x) => x.name === store.current)!
return list().find((x) => x.name === store.current)!
},
set(name: string | undefined) {
setStore("current", name ?? list()[0].name)
},
move(direction: 1 | -1) {
let next = agents().findIndex((x) => x.name === store.current) + direction
if (next < 0) next = agents().length - 1
if (next >= agents().length) next = 0
const value = agents()[next]
let next = list().findIndex((x) => x.name === store.current) + direction
if (next < 0) next = list().length - 1
if (next >= list().length) next = 0
const value = list()[next]
setStore("current", value.name)
if (value.model)
model.set({
@@ -89,7 +93,12 @@ function init() {
return store.model[agent.current().name] ?? (a.model ? a.model : fallback())
})
const list = createMemo(() =>
sync.data.provider.flatMap((x) => Object.values(x.models).map((m) => ({ providerID: x.id, modelID: m.id }))),
)
return {
list,
current,
recent() {
return store.recent
@@ -103,10 +112,10 @@ function init() {
model: model.name ?? value.modelID,
}
}),
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
set(model: { providerID: string; modelID: string } | undefined, options?: { recent?: boolean }) {
batch(() => {
setStore("model", agent.current().name, model)
if (options?.recent) {
setStore("model", agent.current().name, model ?? fallback())
if (options?.recent && model) {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
setStore("recent", uniq)
+83 -74
View File
@@ -1,7 +1,8 @@
import { FileIcon, Icon, IconButton, Tooltip } from "@/ui"
import { FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui"
import { Tabs } from "@/ui/tabs"
import { Select } from "@/components/select"
import FileTree from "@/components/file-tree"
import { createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { useLocal, useSDK } from "@/context"
import { Code } from "@/components/code"
import {
@@ -17,18 +18,17 @@ import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import type { LocalFile } from "@/context/local"
import SessionList from "@/components/session-list"
import SessionTimeline from "@/components/session-timeline"
import { createStore } from "solid-js/store"
export default function Page() {
const sdk = useSDK()
const local = useLocal()
const [clickTimer, setClickTimer] = createSignal<number | undefined>()
const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
const [inputValue, setInputValue] = createSignal("")
const [isDragging, setIsDragging] = createSignal<"left" | "right" | undefined>(undefined)
const [leftScrolled, setLeftScrolled] = createSignal(false)
// TODO: remove
local.model.set({ providerID: "opencode", modelID: "grok-code" })
const [store, setStore] = createStore({
clickTimer: undefined as number | undefined,
activeItem: undefined as string | undefined,
prompt: "",
dragging: undefined as "left" | "right" | undefined,
})
let inputRef: HTMLInputElement | undefined = undefined
@@ -51,6 +51,10 @@ export default function Page() {
return
}
if (document.activeElement?.id === "select-filter") {
return
}
if (local.file.active()) {
if (e.getModifierState(MOD)) {
if (e.key.toLowerCase() === "a") {
@@ -76,20 +80,20 @@ export default function Page() {
}
const resetClickTimer = () => {
if (!clickTimer()) return
clearTimeout(clickTimer())
setClickTimer(undefined)
if (!store.clickTimer) return
clearTimeout(store.clickTimer)
setStore("clickTimer", undefined)
}
const startClickTimer = () => {
const newClickTimer = setTimeout(() => {
setClickTimer(undefined)
setStore("clickTimer", undefined)
}, 300)
setClickTimer(newClickTimer as unknown as number)
setStore("clickTimer", newClickTimer as unknown as number)
}
const handleFileClick = async (file: LocalFile) => {
if (clickTimer()) {
if (store.clickTimer) {
resetClickTimer()
local.file.update(file.path, { ...file, pinned: true })
} else {
@@ -107,7 +111,7 @@ export default function Page() {
}
const onDragStart = (event: any) => {
setActiveItem(event.draggable.id as string)
setStore("activeItem", event.draggable.id as string)
}
const onDragOver = (event: DragEvent) => {
@@ -123,12 +127,12 @@ export default function Page() {
}
const onDragEnd = () => {
setActiveItem(undefined)
setStore("activeItem", undefined)
}
const handleLeftDragStart = (e: MouseEvent) => {
e.preventDefault()
setIsDragging("left")
setStore("dragging", "left")
const startX = e.clientX
const startWidth = local.layout.leftWidth()
@@ -139,7 +143,7 @@ export default function Page() {
}
const handleMouseUp = () => {
setIsDragging(undefined)
setStore("dragging", undefined)
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
@@ -150,7 +154,7 @@ export default function Page() {
const handleRightDragStart = (e: MouseEvent) => {
e.preventDefault()
setIsDragging("right")
setStore("dragging", "right")
const startX = e.clientX
const startWidth = local.layout.rightWidth()
@@ -161,7 +165,7 @@ export default function Page() {
}
const handleMouseUp = () => {
setIsDragging(undefined)
setStore("dragging", undefined)
document.removeEventListener("mousemove", handleMouseMove)
document.removeEventListener("mouseup", handleMouseUp)
}
@@ -172,8 +176,8 @@ export default function Page() {
const handleSubmit = async (e: SubmitEvent) => {
e.preventDefault()
const prompt = inputValue()
setInputValue("")
const prompt = store.prompt
setStore("prompt", "")
inputRef?.blur()
const session =
@@ -228,35 +232,19 @@ export default function Page() {
<Tabs class="relative flex flex-col h-full" defaultValue="files">
<div class="sticky top-0 shrink-0 flex">
<Tabs.List class="grow w-full after:hidden">
<Tabs.Trigger value="files" class="flex-1 justify-center">
<Tabs.Trigger value="files" class="flex-1 justify-center text-xs">
Files
</Tabs.Trigger>
<Tabs.Trigger value="changes" class="flex-1 justify-center">
<Tabs.Trigger value="changes" class="flex-1 justify-center text-xs">
Changes
</Tabs.Trigger>
</Tabs.List>
</div>
<Tabs.Content
value="files"
class="grow min-h-0 py-2 bg-background"
onScroll={(e: Event & { currentTarget: HTMLDivElement }) => setLeftScrolled(e.currentTarget.scrollTop > 0)}
>
<Tabs.Content value="files" class="grow min-h-0 py-2 bg-background">
<FileTree path="" onFileClick={handleFileClick} />
<Show when={leftScrolled()}>
<div
class="pointer-events-none sticky top-20 left-px h-4
bg-gradient-to-t from-transparent to-background"
style={`width: ${local.layout.leftWidth() - 2}px`}
/>
</Show>
<div
class="pointer-events-none fixed bottom-0 left-px h-4
bg-gradient-to-b from-transparent to-background"
style={`width: ${local.layout.leftWidth() - 2}px`}
/>
</Tabs.Content>
<Tabs.Content value="changes" class="grow min-h-0 py-2 bg-background">
<div class="px-2 text-sm text-text-muted">No changes yet</div>
<div class="px-2 text-xs text-text-muted">No changes yet</div>
</Tabs.Content>
</Tabs>
</div>
@@ -266,9 +254,9 @@ export default function Page() {
onMouseDown={(e) => handleLeftDragStart(e)}
>
<div
class="w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors"
classList={{
"bg-border-active": isDragging() === "left",
"w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors": true,
"bg-border-active!": store.dragging === "left",
}}
/>
</div>
@@ -281,8 +269,8 @@ export default function Page() {
<Show when={local.session.active()} fallback={<SessionList />}>
{(activeSession) => (
<div class="relative">
<div class="sticky top-0 bg-background z-50 p-2 h-9 border-b border-border-subtle/30">
<div class="flex items-center gap-2">
<div class="sticky top-0 bg-background z-50 px-2 h-8 border-b border-border-subtle/30">
<div class="h-full flex items-center gap-2">
<IconButton
size="xs"
variant="ghost"
@@ -300,16 +288,6 @@ export default function Page() {
</div>
)}
</Show>
<div
class="pointer-events-none fixed top-0 right-px h-4
bg-gradient-to-t from-transparent to-background"
style={`width: ${local.layout.rightWidth() - 2}px`}
/>
<div
class="pointer-events-none fixed bottom-0 right-px h-4
bg-gradient-to-b from-transparent to-background"
style={`width: ${local.layout.rightWidth() - 2}px`}
/>
</div>
</div>
<div
@@ -318,14 +296,22 @@ export default function Page() {
onMouseDown={(e) => handleRightDragStart(e)}
>
<div
class="w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors"
classList={{ "bg-border-active": isDragging() === "right" }}
classList={{
"w-0.5 h-full bg-transparent group-hover:bg-border-active transition-colors": true,
"bg-border-active!": store.dragging === "right",
}}
/>
</div>
</Show>
<div
class="relative"
style={`margin-left: ${local.layout.leftWidth()}px; margin-right: ${local.layout.rightPane() ? local.layout.rightWidth() : 0}px`}
>
<Logo
size={64}
variant="ornate"
class="absolute top-2/5 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
/>
<DragDropProvider
onDragStart={onDragStart}
onDragEnd={onDragEnd}
@@ -435,12 +421,12 @@ export default function Page() {
</For>
</Tabs>
<DragOverlay>
{activeItem() &&
{store.activeItem &&
(() => {
const draggedFile = local.file.node(activeItem()!)
const draggedFile = local.file.node(store.activeItem!)
return (
<div
class="relative px-3 h-9 flex items-center
class="relative px-3 h-8 flex items-center
text-sm font-medium text-text whitespace-nowrap
shrink-0 bg-background-panel
border-x border-border-subtle/40 border-b border-b-transparent"
@@ -453,11 +439,14 @@ export default function Page() {
</DragDropProvider>
<form
onSubmit={handleSubmit}
class="peer/editor absolute bottom-8 z-50 flex items-center justify-center"
style={`left: ${local.layout.leftWidth() + 40}px; right: ${local.layout.rightPane() ? local.layout.rightWidth() + 40 : 40}px`}
class="peer/editor absolute inset-x-4 z-50 flex items-center justify-center"
classList={{
"bottom-8": !!local.file.active(),
"bottom-2/5": local.file.active() === undefined,
}}
>
<div
class="w-full max-w-2xl min-w-1/2 p-2 mx-auto rounded-lg isolate backdrop-blur-xs
class="w-full max-w-xl min-w-0 p-2 mx-auto rounded-lg isolate backdrop-blur-xs
flex flex-col gap-1
bg-gradient-to-b from-background-panel/90 to-background/90
ring-1 ring-border-active/50 border border-transparent
@@ -479,16 +468,36 @@ export default function Page() {
<input
ref={(el) => (inputRef = el)}
type="text"
value={inputValue()}
onInput={(e) => setInputValue(e.currentTarget.value)}
value={store.prompt}
onInput={(e) => setStore("prompt", e.currentTarget.value)}
placeholder="It all starts with a prompt..."
class="w-full p-1 pb-4 text-text font-light placeholder-text-muted/70 text-sm focus:outline-none"
/>
<div class="px-1 flex justify-between items-center text-xs text-text-muted">
<span>
<span class="text-primary uppercase">{local.agent.current()?.name ?? "unknown"}</span> /{" "}
{local.model.parsed().provider} / {local.model.parsed().model}
</span>
<div class="flex justify-between items-center text-xs text-text-muted">
<div class="flex gap-2 items-center">
<Select
options={local.agent.list().map((a) => a.name)}
current={local.agent.current().name}
onSelect={local.agent.set}
size="sm"
class="uppercase"
/>
<Select
options={local.model.list()}
current={local.model.current()}
onSelect={local.model.set}
label={(x) => x.modelID}
value={(x) => `${x.providerID}.${x.modelID}`}
filter={{
keys: ["providerID", "modelID"],
placeholder: "Filter models",
}}
groupBy={(x) => x.providerID}
size="sm"
class="uppercase"
/>
<span class="text-text-muted/70">{local.model.parsed().provider}</span>
</div>
<div class="flex gap-1 items-center">
<IconButton class="text-text-muted" size="xs" variant="ghost">
<Icon name="photo" size={16} />
@@ -548,7 +557,7 @@ const SortableTab = (props: {
<TabVisual file={props.file} />
</Tabs.Trigger>
<IconButton
class="absolute right-1 top-2 opacity-0 text-text-muted/60
class="absolute right-1 top-1.5 opacity-0 text-text-muted/60
peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text
peer-data-[selected]/tab:hover:bg-border-subtle
hover:opacity-100 peer-hover/tab:opacity-100"
+2 -2
View File
@@ -18,7 +18,7 @@ function TabsList(props: TabsListProps) {
classList={{
"relative flex items-center bg-background overflow-x-auto no-scrollbar": true,
"divide-x divide-border-subtle/40": true,
"after:content-[''] after:block after:grow after:h-9": true,
"after:content-[''] after:block after:grow after:h-8": true,
"after:border-l empty:after:border-l-0! after:border-b after:border-border-subtle/40": true,
[local.class ?? ""]: !!local.class,
}}
@@ -32,7 +32,7 @@ function TabsTrigger(props: ParentProps<TabsTriggerProps>) {
return (
<KobalteTabs.Trigger
classList={{
"relative px-3 h-9 flex items-center": true,
"relative px-3 h-8 flex items-center": true,
"text-sm font-medium text-text-muted/60 cursor-pointer": true,
"whitespace-nowrap shrink-0 border-b border-border-subtle/40": true,
"disabled:pointer-events-none disabled:opacity-50": true,
@@ -1,23 +1,24 @@
{
"name": "@opencode/cloud-app",
"name": "@opencode/console-app",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"dev": "vinxi dev --host 0.0.0.0",
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "vinxi build && ../../packages/opencode/script/schema.ts ./.output/public/config.json",
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "0.9.3"
"version": "0.11.0"
},
"dependencies": {
"@ibm/plex": "6.4.1",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.1.0",
"solid-js": "catalog:",
"vinxi": "^0.5.7",
"@opencode/cloud-core": "workspace:*"
"zod": "catalog:"
},
"engines": {
"node": ">=22"
+5
View File
@@ -0,0 +1,5 @@
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="600" height="600" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M115 180H300V420H115V180ZM253.75 229.044H161.25V370.405H253.75V229.044Z" fill="white"/>
<path d="M346.25 180H485V229.044H392.5V370.405H485V419.449H346.25V180Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 377 B

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before

Width:  |  Height:  |  Size: 212 B

After

Width:  |  Height:  |  Size: 212 B

Before

Width:  |  Height:  |  Size: 443 B

After

Width:  |  Height:  |  Size: 443 B

Before

Width:  |  Height:  |  Size: 902 KiB

After

Width:  |  Height:  |  Size: 902 KiB

Before

Width:  |  Height:  |  Size: 456 KiB

After

Width:  |  Height:  |  Size: 456 KiB

Before

Width:  |  Height:  |  Size: 998 KiB

After

Width:  |  Height:  |  Size: 998 KiB

Before

Width:  |  Height:  |  Size: 592 KiB

After

Width:  |  Height:  |  Size: 592 KiB

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 981 B

After

Width:  |  Height:  |  Size: 981 B

@@ -0,0 +1,114 @@
.root {
[data-slot="section-content"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
[data-slot="reload-error"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
p {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
margin: 0;
flex: 1;
}
[data-slot="create-form"] {
display: flex;
gap: var(--space-2);
margin: 0;
flex-shrink: 0;
}
}
[data-slot="payment"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
min-width: 14.5rem;
width: fit-content;
@media (max-width: 30rem) {
width: 100%;
}
[data-slot="credit-card"] {
padding: var(--space-3-5) var(--space-4);
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
display: flex;
align-items: center;
justify-content: space-between;
[data-slot="card-icon"] {
display: flex;
align-items: center;
color: var(--color-text-muted);
}
[data-slot="card-details"] {
display: flex;
align-items: baseline;
gap: var(--space-1);
[data-slot="secret"] {
position: relative;
bottom: 2px;
font-size: var(--font-size-lg);
color: var(--color-text-muted);
font-weight: 400;
}
[data-slot="number"] {
font-size: var(--font-size-3xl);
font-weight: 500;
color: var(--color-text);
}
}
}
[data-slot="button-row"] {
display: flex;
gap: var(--space-2);
align-items: center;
@media (max-width: 30rem) {
flex-direction: column;
> button {
width: 100%;
}
}
[data-slot="create-form"] {
margin: 0;
}
/* Make Enable Billing button full width when it's the only button */
> button {
flex: 1;
}
}
}
[data-slot="usage"] {
p {
font-size: var(--font-size-sm);
line-height: 1.5;
color: var(--color-text-secondary);
b {
font-weight: 600;
}
}
}
}
@@ -0,0 +1,202 @@
import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
import { createMemo, Show } from "solid-js"
import { Billing } from "@opencode/console-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import { IconCreditCard } from "~/component/icon"
import styles from "./billing-section.module.css"
const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
"use server"
return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
}, "checkoutUrl")
const reload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(await withActor(() => Billing.reload(), workspaceID), { revalidate: getBillingInfo.key })
}, "billing.reload")
const disableReload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(await withActor(() => Billing.disableReload(), workspaceID), { revalidate: getBillingInfo.key })
}, "billing.disableReload")
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID)
}, "sessionUrl")
const getBillingInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.get()
}, workspaceID)
}, "billing.get")
export function BillingSection() {
const params = useParams()
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
const balanceInfo = createAsync(() => getBillingInfo(params.id))
const createCheckoutUrlAction = useAction(createCheckoutUrl)
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
const createSessionUrlAction = useAction(createSessionUrl)
const createSessionUrlSubmission = useSubmission(createSessionUrl)
const disableReloadSubmission = useSubmission(disableReload)
const reloadSubmission = useSubmission(reload)
// DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW
// Scenario 1: User has not added billing details and has no balance
// const balanceInfo = () => ({
// balance: 0,
// paymentMethodLast4: null as string | null,
// reload: false,
// reloadError: null as string | null,
// timeReloadError: null as Date | null,
// })
// Scenario 2: User has not added billing details but has a balance
// const balanceInfo = () => ({
// balance: 1500000000, // $15.00
// paymentMethodLast4: null as string | null,
// reload: false,
// reloadError: null as string | null,
// timeReloadError: null as Date | null
// })
// Scenario 3: User has added billing details (reload enabled)
// const balanceInfo = () => ({
// balance: 750000000, // $7.50
// paymentMethodLast4: "4242",
// reload: true,
// reloadError: null as string | null,
// timeReloadError: null as Date | null
// })
// Scenario 4: User has billing details but reload failed
// const balanceInfo = () => ({
// balance: 250000000, // $2.50
// paymentMethodLast4: "4242",
// reload: true,
// reloadError: "Your card was declined." as string,
// timeReloadError: new Date(Date.now() - 3600000) as Date // 1 hour ago
// })
const balanceAmount = createMemo(() => {
return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
})
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Billing</h2>
<p>
Manage payments methods. <a href="mailto:contact@anoma.ly">Contact us</a> if you have any questions.
</p>
</div>
<div data-slot="section-content">
<Show when={balanceInfo()?.reloadError}>
<div data-slot="reload-error">
<p>
Reload failed at{" "}
{balanceInfo()?.timeReloadError!.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
})}
. Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try
again.
</p>
<form action={reload} method="post" data-slot="create-form">
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="primary" type="submit" disabled={reloadSubmission.pending}>
{reloadSubmission.pending ? "Reloading..." : "Reload"}
</button>
</form>
</div>
</Show>
<div data-slot="payment">
<div data-slot="credit-card">
<div data-slot="card-icon">
<IconCreditCard style={{ width: "32px", height: "32px" }} />
</div>
<div data-slot="card-details">
<Show when={balanceInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}>
<span data-slot="secret"></span>
<span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
</Show>
</div>
</div>
<div data-slot="button-row">
<Show
when={balanceInfo()?.reload}
fallback={
<button
data-color="primary"
disabled={createCheckoutUrlSubmission.pending}
onClick={async () => {
const baseUrl = window.location.href
const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
if (checkoutUrl) {
window.location.href = checkoutUrl
}
}}
>
{createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
</button>
}
>
<button
data-color="primary"
disabled={createSessionUrlSubmission.pending}
onClick={async () => {
const baseUrl = window.location.href
const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
if (sessionUrl) {
window.location.href = sessionUrl
}
}}
>
{createSessionUrlSubmission.pending ? "Loading..." : "Manage Payment Methods"}
</button>
<form action={disableReload} method="post" data-slot="create-form">
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="ghost" type="submit" disabled={disableReloadSubmission.pending}>
{disableReloadSubmission.pending ? "Disabling..." : "Disable"}
</button>
</form>
</Show>
</div>
</div>
<div data-slot="usage">
<Show when={!balanceInfo()?.reload}>
<Show
when={!(balanceAmount() === "0.00" || balanceAmount() === "-0.00")}
fallback={
<p>
We'll load <b>$20</b> (+$1.23 processing fee) and reload it when it reaches <b>$5</b>.
</p>
}
>
<p>
You have <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b> remaining in
your account. You can continue using the API with your remaining balance.
</p>
</Show>
</Show>
<Show when={balanceInfo()?.reload && !balanceInfo()?.reloadError}>
<p>
Your current balance is <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
. We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches <b>$5</b>.
</p>
</Show>
</div>
</div>
</section>
)
}
@@ -0,0 +1,25 @@
export function formatDateForTable(date: Date) {
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "short",
hour: "numeric",
minute: "2-digit",
hour12: true,
}
return date.toLocaleDateString("en-GB", options).replace(",", ",")
}
export function formatDateUTC(date: Date) {
const options: Intl.DateTimeFormatOptions = {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
timeZone: "UTC",
}
return date.toLocaleDateString("en-US", options)
}
@@ -0,0 +1,172 @@
.root {
[data-component="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
display: flex;
flex-direction: column;
gap: var(--space-2);
p {
line-height: 1.5;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
[data-slot="create-form"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
[data-slot="input-container"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
@media (max-width: 30rem) {
gap: var(--space-2);
}
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
margin-top: var(--space-1);
line-height: 1.4;
}
}
[data-slot="api-keys-table"] {
overflow-x: auto;
}
[data-slot="api-keys-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="key-name"] {
color: var(--color-text);
font-family: var(--font-sans);
font-weight: 500;
}
&[data-slot="key-value"] {
font-family: var(--font-mono);
button {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-sm);
font-weight: 400;
border: none;
background-color: transparent;
color: var(--color-text-muted);
font-family: var(--font-mono);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.15s ease;
text-transform: none;
&:hover:not(:disabled) {
background-color: var(--color-bg-surface);
color: var(--color-text);
}
&:disabled {
cursor: default;
color: var(--color-text);
}
span {
font-family: inherit;
}
}
}
&[data-slot="key-date"] {
color: var(--color-text);
}
&[data-slot="key-actions"] {
font-family: var(--font-sans);
}
}
tbody tr {
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(3) /* Date */ {
display: none;
}
}
td {
&:nth-child(3) /* Date */ {
display: none;
}
}
}
}
}
@@ -0,0 +1,182 @@
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, createSignal, For, Show } from "solid-js"
import { IconCopy, IconCheck } from "~/component/icon"
import { Key } from "@opencode/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"
const removeKey = action(async (form: FormData) => {
"use server"
const id = form.get("id")?.toString()
if (!id) return { error: "ID is required" }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
}, "key.remove")
const createKey = action(async (form: FormData) => {
"use server"
const name = form.get("name")?.toString().trim()
if (!name) return { error: "Name is required" }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(
await withActor(
() =>
Key.create({ name })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: listKeys.key },
)
}, "key.create")
const listKeys = query(async (workspaceID: string) => {
"use server"
return withActor(() => Key.list(), workspaceID)
}, "key.list")
export function KeyCreateForm() {
const params = useParams()
const submission = useSubmission(createKey)
const [store, setStore] = createStore({ show: false })
let input: HTMLInputElement
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
hide()
}
})
function show() {
// submission.clear() does not clear the result in some cases, ie.
// 1. Create key with empty name => error shows
// 2. Put in a key name and creates the key => form hides
// 3. Click add key button again => form shows with the same error if
// submission.clear() is called only once
while (true) {
submission.clear()
if (!submission.result) break
}
setStore("show", true)
input.focus()
}
function hide() {
setStore("show", false)
}
return (
<Show
when={store.show}
fallback={
<button data-color="primary" onClick={() => show()}>
Create API Key
</button>
}
>
<form action={createKey} method="post" data-slot="create-form">
<div data-slot="input-container">
<input ref={(r) => (input = r)} data-component="input" name="name" type="text" placeholder="Enter key name" />
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{err()}</div>}
</Show>
</div>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
Cancel
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? "Creating..." : "Create"}
</button>
</div>
</form>
</Show>
)
}
export function KeySection() {
const params = useParams()
const keys = createAsync(() => listKeys(params.id))
function formatKey(key: string) {
if (key.length <= 11) return key
return `${key.slice(0, 7)}...${key.slice(-4)}`
}
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>API Keys</h2>
<p>Manage your API keys for accessing opencode services.</p>
</div>
<KeyCreateForm />
<div data-slot="api-keys-table">
<Show
when={keys()?.length}
fallback={
<div data-component="empty-state">
<p>Create an opencode Gateway API key</p>
</div>
}
>
<table data-slot="api-keys-table-element">
<thead>
<tr>
<th>Name</th>
<th>Key</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
<For each={keys()!}>
{(key) => {
const [copied, setCopied] = createSignal(false)
// const submission = useSubmission(removeKey, ([fd]) => fd.get("id")?.toString() === key.id)
return (
<tr>
<td data-slot="key-name">{key.name}</td>
<td data-slot="key-value">
<button
data-color="ghost"
disabled={copied()}
onClick={async () => {
await navigator.clipboard.writeText(key.key)
setCopied(true)
setTimeout(() => setCopied(false), 1000)
}}
title="Copy API key"
>
<span>{formatKey(key.key)}</span>
<Show when={copied()} fallback={<IconCopy style={{ width: "14px", height: "14px" }} />}>
<IconCheck style={{ width: "14px", height: "14px" }} />
</Show>
</button>
</td>
<td data-slot="key-date" title={formatDateUTC(key.timeCreated)}>
{formatDateForTable(key.timeCreated)}
</td>
<td data-slot="key-actions">
<form action={removeKey} method="post">
<input type="hidden" name="id" value={key.id} />
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="ghost">Delete</button>
</form>
</td>
</tr>
)
}}
</For>
</tbody>
</table>
</Show>
</div>
</section>
)
}
@@ -0,0 +1,102 @@
.root {
[data-slot="section-content"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
[data-slot="balance"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
min-width: 15rem;
width: fit-content;
@media (max-width: 30rem) {
width: 100%;
}
[data-slot="amount"] {
padding: var(--space-3-5) var(--space-4);
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
display: flex;
align-items: baseline;
gap: var(--space-1);
justify-content: flex-end;
[data-slot="currency"] {
position: relative;
bottom: 2px;
font-size: var(--font-size-lg);
color: var(--color-text-muted);
font-weight: 400;
}
[data-slot="value"] {
font-size: var(--font-size-3xl);
font-weight: 500;
color: var(--color-text);
}
}
[data-slot="create-form"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-1);
[data-slot="input-container"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
@media (max-width: 30rem) {
gap: var(--space-2);
}
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
}
}
}
[data-slot="usage-status"] {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin: 0;
line-height: 1.4;
}
}
@@ -0,0 +1,139 @@
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode/console-core/billing.js"
import styles from "./monthly-limit-section.module.css"
const getBillingInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.get()
}, workspaceID)
}, "billing.get")
const setMonthlyLimit = action(async (form: FormData) => {
"use server"
const limit = form.get("limit")?.toString()
if (!limit) return { error: "Limit is required." }
const numericLimit = parseInt(limit)
if (numericLimit < 0) return { error: "Set a valid monthly limit." }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required." }
return json(
await withActor(
() =>
Billing.setMonthlyLimit(numericLimit)
.then((data) => ({ error: undefined, data }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: getBillingInfo.key },
)
}, "billing.setMonthlyLimit")
export function MonthlyLimitSection() {
const params = useParams()
const submission = useSubmission(setMonthlyLimit)
const [store, setStore] = createStore({ show: false })
const balanceInfo = createAsync(() => getBillingInfo(params.id))
let input: HTMLInputElement
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
hide()
}
})
function show() {
// submission.clear() does not clear the result in some cases, ie.
// 1. Create key with empty name => error shows
// 2. Put in a key name and creates the key => form hides
// 3. Click add key button again => form shows with the same error if
// submission.clear() is called only once
while (true) {
submission.clear()
if (!submission.result) break
}
setStore("show", true)
input.focus()
}
function hide() {
setStore("show", false)
}
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Monthly Limit</h2>
<p>Set a monthly spending limit for your account.</p>
</div>
<div data-slot="section-content">
<div data-slot="balance">
<div data-slot="amount">
{balanceInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
<span data-slot="value">{balanceInfo()?.monthlyLimit ?? "-"}</span>
</div>
<Show
when={!store.show}
fallback={
<form action={setMonthlyLimit} method="post" data-slot="create-form">
<div data-slot="input-container">
<input
required
ref={(r) => (input = r)}
data-component="input"
name="limit"
type="number"
placeholder="50"
/>
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{err()}</div>}
</Show>
</div>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
Cancel
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? "Setting..." : "Set"}
</button>
</div>
</form>
}
>
<button data-color="primary" onClick={() => show()}>
{balanceInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
</button>
</Show>
</div>
<Show when={balanceInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No spending limit set.</p>}>
<p data-slot="usage-status">
Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $
{(() => {
const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated
if (!dateLastUsed) return "0"
const current = new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
timeZone: "UTC",
})
const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
timeZone: "UTC",
})
if (current !== lastUsed) return "0"
return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
})()}
.
</p>
</Show>
</div>
</section>
)
}
@@ -0,0 +1,163 @@
.root {
display: flex;
flex-direction: column;
gap: var(--space-8);
padding: var(--space-6);
background-color: var(--color-bg-surface);
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
@media (max-width: 30rem) {
gap: var(--space-8);
padding: var(--space-4);
}
[data-component="feature-grid"] {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-6);
@media (max-width: 30rem) {
grid-template-columns: 1fr;
gap: var(--space-4);
}
[data-slot="feature"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
h3 {
font-size: var(--font-size-sm);
font-weight: 600;
margin: 0;
color: var(--color-text);
text-transform: uppercase;
letter-spacing: -0.025rem;
}
p {
font-size: var(--font-size-sm);
line-height: 1.5;
margin: 0;
color: var(--color-text-muted);
}
}
}
[data-component="api-key-highlight"] {
display: flex;
flex-direction: column;
gap: var(--space-6);
[data-slot="section-title"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
h2 {
font-size: var(--font-size-md);
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.03125rem;
margin: 0;
color: var(--color-text-secondary);
text-transform: uppercase;
@media (max-width: 30rem) {
font-size: var(--font-size-md);
}
}
}
[data-slot="key-display"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
[data-slot="key-container"] {
display: flex;
gap: var(--space-3);
padding: var(--space-4);
border: 2px solid var(--color-accent);
border-radius: var(--border-radius-sm);
align-items: center;
@media (max-width: 40rem) {
flex-direction: column;
gap: var(--space-3);
align-items: stretch;
}
[data-slot="key-value"] {
flex: 1;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
color: var(--color-text);
background-color: var(--color-bg);
padding: var(--space-3);
border-radius: var(--border-radius-sm);
border: 1px solid var(--color-border);
word-break: break-all;
line-height: 1.4;
@media (max-width: 40rem) {
font-size: var(--font-size-xs);
padding: var(--space-2-5);
}
}
button {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
font-size: var(--font-size-sm);
font-weight: 500;
white-space: nowrap;
min-width: 130px;
@media (max-width: 40rem) {
justify-content: center;
padding: var(--space-2-5) var(--space-3);
font-size: var(--font-size-xs);
min-width: 96px;
}
}
}
}
}
[data-component="next-steps"] {
display: flex;
flex-direction: column;
gap: var(--space-6);
ol {
margin: 0;
padding-left: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
list-style-position: inside;
li {
font-size: var(--font-size-md);
line-height: 1.5;
color: var(--color-text-secondary);
code {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
color: var(--color-text);
}
}
}
}
}
@@ -0,0 +1,97 @@
import { query, useParams, createAsync } from "@solidjs/router"
import { createMemo, createSignal, Show } from "solid-js"
import { IconCopy, IconCheck } from "~/component/icon"
import { Key } from "@opencode/console-core/key.js"
import { Billing } from "@opencode/console-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import styles from "./new-user-section.module.css"
const getUsageInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.usages()
}, workspaceID)
}, "usage.list")
const listKeys = query(async (workspaceID: string) => {
"use server"
return withActor(() => Key.list(), workspaceID)
}, "key.list")
export function NewUserSection() {
const params = useParams()
const [copiedKey, setCopiedKey] = createSignal(false)
const keys = createAsync(() => listKeys(params.id))
const usage = createAsync(() => getUsageInfo(params.id))
const isNew = createMemo(() => {
const keysList = keys()
const usageList = usage()
return keysList?.length === 1 && (!usageList || usageList.length === 0)
})
const defaultKey = createMemo(() => keys()?.at(-1)?.key)
return (
<Show when={isNew()}>
<div class={styles.root}>
<div data-component="feature-grid">
<div data-slot="feature">
<h3>Tested & Verified Models</h3>
<p>We've benchmarked and tested models specifically for coding agents to ensure the best performance.</p>
</div>
<div data-slot="feature">
<h3>Highest Quality</h3>
<p>Access models configured for optimal performance - no downgrades or routing to cheaper providers.</p>
</div>
<div data-slot="feature">
<h3>No Lock-in</h3>
<p>Use Zen with any coding agent, and continue using other providers with opencode whenever you want.</p>
</div>
</div>
<div data-component="api-key-highlight">
<Show when={defaultKey()}>
<div data-slot="key-display">
<div data-slot="key-container">
<code data-slot="key-value">{defaultKey()}</code>
<button
data-color="primary"
disabled={copiedKey()}
onClick={async () => {
await navigator.clipboard.writeText(defaultKey() ?? "")
setCopiedKey(true)
setTimeout(() => setCopiedKey(false), 2000)
}}
title="Copy API key"
>
<Show
when={copiedKey()}
fallback={
<>
<IconCopy style={{ width: "16px", height: "16px" }} /> Copy Key
</>
}
>
<IconCheck style={{ width: "16px", height: "16px" }} /> Copied!
</Show>
</button>
</div>
</div>
</Show>
</div>
<div data-component="next-steps">
<ol>
<li>Enable billing</li>
<li>
Run <code>opencode auth login</code> and select opencode
</li>
<li>Paste your API key</li>
<li>
Start opencode and run <code>/models</code> to select a model
</li>
</ol>
</div>
</div>
</Show>
)
}
@@ -0,0 +1,72 @@
.root {
[data-slot="payments-table"] {
overflow-x: auto;
}
[data-slot="payments-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="payment-date"] {
color: var(--color-text);
}
&[data-slot="payment-id"] {
font-family: var(--font-mono);
font-weight: 400;
color: var(--color-text-muted);
max-width: 200px;
word-break: break-word;
}
&[data-slot="payment-amount"] {
color: var(--color-text);
}
}
tbody tr {
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(2) /* Payment ID */ {
display: none;
}
}
td {
&:nth-child(2) /* Payment ID */ {
display: none;
}
}
}
}
}
@@ -0,0 +1,112 @@
import { Billing } from "@opencode/console-core/billing.js"
import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
import { For } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { formatDateUTC, formatDateForTable } from "./common"
import styles from "./payment-section.module.css"
const getPaymentsInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.payments()
}, workspaceID)
}, "payment.list")
const downloadReceipt = action(async (workspaceID: string, paymentID: string) => {
"use server"
return withActor(() => Billing.generateReceiptUrl({ paymentID }), workspaceID)
}, "receipt.download")
export function PaymentSection() {
const params = useParams()
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
const payments = createAsync(() => getPaymentsInfo(params.id))
const downloadReceiptAction = useAction(downloadReceipt)
// DUMMY DATA FOR TESTING
// const payments = () => [
// {
// id: "pi_3QK1x2FT9vXn4A6r1234567890",
// paymentID: "pi_3QK1x2FT9vXn4A6r1234567890",
// timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // 1 day ago
// amount: 2100000000, // $21.00 ($20 + $1 fee)
// },
// {
// id: "pi_3QJ8k7FT9vXn4A6r0987654321",
// paymentID: "pi_3QJ8k7FT9vXn4A6r0987654321",
// timeCreated: new Date(Date.now() - 86400000 * 15).toISOString(), // 15 days ago
// amount: 2100000000, // $21.00
// },
// {
// id: "pi_3QI5m1FT9vXn4A6r5678901234",
// paymentID: "pi_3QI5m1FT9vXn4A6r5678901234",
// timeCreated: new Date(Date.now() - 86400000 * 32).toISOString(), // 32 days ago
// amount: 2100000000, // $21.00
// },
// {
// id: "pi_3QH2n9FT9vXn4A6r3456789012",
// paymentID: "pi_3QH2n9FT9vXn4A6r3456789012",
// timeCreated: new Date(Date.now() - 86400000 * 47).toISOString(), // 47 days ago
// amount: 2100000000, // $21.00
// },
// {
// id: "pi_3QG7p4FT9vXn4A6r7890123456",
// paymentID: "pi_3QG7p4FT9vXn4A6r7890123456",
// timeCreated: new Date(Date.now() - 86400000 * 63).toISOString(), // 63 days ago
// amount: 2100000000, // $21.00
// },
// ]
return (
payments() &&
payments()!.length > 0 && (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Payments History</h2>
<p>Recent payment transactions.</p>
</div>
<div data-slot="payments-table">
<table data-slot="payments-table-element">
<thead>
<tr>
<th>Date</th>
<th>Payment ID</th>
<th>Amount</th>
<th>Receipt</th>
</tr>
</thead>
<tbody>
<For each={payments()!}>
{(payment) => {
const date = new Date(payment.timeCreated)
return (
<tr>
<td data-slot="payment-date" title={formatDateUTC(date)}>
{formatDateForTable(date)}
</td>
<td data-slot="payment-id">{payment.id}</td>
<td data-slot="payment-amount">${((payment.amount ?? 0) / 100000000).toFixed(2)}</td>
<td data-slot="payment-receipt">
<button
onClick={async () => {
const receiptUrl = await downloadReceiptAction(params.id, payment.paymentID!)
if (receiptUrl) {
window.open(receiptUrl, "_blank")
}
}}
data-slot="receipt-button"
>
view
</button>
</td>
</tr>
)
}}
</For>
</tbody>
</table>
</div>
</section>
)
)
}
@@ -0,0 +1,88 @@
.root {
[data-component="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
display: flex;
flex-direction: column;
gap: var(--space-2);
p {
line-height: 1.5;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
[data-slot="usage-table"] {
overflow-x: auto;
}
[data-slot="usage-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="usage-date"] {
color: var(--color-text);
}
&[data-slot="usage-model"] {
font-family: var(--font-sans);
font-weight: 400;
color: var(--color-text-secondary);
max-width: 200px;
word-break: break-word;
}
&[data-slot="usage-cost"] {
color: var(--color-text);
}
}
tbody tr {
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(2) /* Model */ {
display: none;
}
}
td {
&:nth-child(2) /* Model */ {
display: none;
}
}
}
}
}
@@ -0,0 +1,128 @@
import { Billing } from "@opencode/console-core/billing.js"
import { query, useParams, createAsync } from "@solidjs/router"
import { createMemo, For, Show } from "solid-js"
import { formatDateUTC, formatDateForTable } from "./common"
import { withActor } from "~/context/auth.withActor"
import styles from "./usage-section.module.css"
const getUsageInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.usages()
}, workspaceID)
}, "usage.list")
export function UsageSection() {
const params = useParams()
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
const usage = createAsync(() => getUsageInfo(params.id))
// DUMMY DATA FOR TESTING
// const usage = () => [
// {
// timeCreated: new Date(Date.now() - 86400000 * 0).toISOString(), // Today
// model: "claude-3-5-sonnet-20241022",
// inputTokens: 1247,
// outputTokens: 423,
// cost: 125400000, // $1.254
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 0.5).toISOString(), // 12 hours ago
// model: "claude-3-haiku-20240307",
// inputTokens: 892,
// outputTokens: 156,
// cost: 23500000, // $0.235
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // Yesterday
// model: "claude-3-5-sonnet-20241022",
// inputTokens: 2134,
// outputTokens: 687,
// cost: 234700000, // $2.347
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 1.3).toISOString(), // 1.3 days ago
// model: "gpt-4o-mini",
// inputTokens: 567,
// outputTokens: 234,
// cost: 8900000, // $0.089
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 2).toISOString(), // 2 days ago
// model: "claude-3-opus-20240229",
// inputTokens: 1893,
// outputTokens: 945,
// cost: 445600000, // $4.456
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 2.7).toISOString(), // 2.7 days ago
// model: "gpt-4o",
// inputTokens: 1456,
// outputTokens: 532,
// cost: 156800000, // $1.568
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 3).toISOString(), // 3 days ago
// model: "claude-3-haiku-20240307",
// inputTokens: 634,
// outputTokens: 89,
// cost: 12300000, // $0.123
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 4).toISOString(), // 4 days ago
// model: "claude-3-5-sonnet-20241022",
// inputTokens: 3245,
// outputTokens: 1123,
// cost: 387200000, // $3.872
// },
// ]
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Usage History</h2>
<p>Recent API usage and costs.</p>
</div>
<div data-slot="usage-table">
<Show
when={usage() && usage()!.length > 0}
fallback={
<div data-component="empty-state">
<p>Make your first API call to get started.</p>
</div>
}
>
<table data-slot="usage-table-element">
<thead>
<tr>
<th>Date</th>
<th>Model</th>
<th>Input</th>
<th>Output</th>
<th>Cost</th>
</tr>
</thead>
<tbody>
<For each={usage()!}>
{(usage) => {
const date = createMemo(() => new Date(usage.timeCreated))
return (
<tr>
<td data-slot="usage-date" title={formatDateUTC(date())}>
{formatDateForTable(date())}
</td>
<td data-slot="usage-model">{usage.model}</td>
<td data-slot="usage-tokens">{usage.inputTokens}</td>
<td data-slot="usage-tokens">{usage.outputTokens}</td>
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
</tr>
)
}}
</For>
</tbody>
</table>
</Show>
</div>
</section>
)
}
@@ -1,10 +1,10 @@
import { getRequestEvent } from "solid-js/web"
import { and, Database, eq, inArray } from "@opencode/cloud-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode/cloud-core/schema/workspace.sql.js"
import { UserTable } from "@opencode/cloud-core/schema/user.sql.js"
import { and, Database, eq, inArray } from "@opencode/console-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js"
import { UserTable } from "@opencode/console-core/schema/user.sql.js"
import { redirect } from "@solidjs/router"
import { AccountTable } from "@opencode/cloud-core/schema/account.sql.js"
import { Actor } from "@opencode/cloud-core/actor.js"
import { AccountTable } from "@opencode/console-core/schema/account.sql.js"
import { Actor } from "@opencode/console-core/actor.js"
import { createClient } from "@openauthjs/openauth/client"
import { useAuthSession } from "./auth.session"
@@ -1,4 +1,4 @@
import { Actor } from "@opencode/cloud-core/actor.js"
import { Actor } from "@opencode/console-core/actor.js"
import { getActor } from "./auth"
export async function withActor<T>(fn: () => T, workspace?: string) {
@@ -1,4 +1,4 @@
import { Account } from "@opencode/cloud-core/account.js"
import { Account } from "@opencode/console-core/account.js"
import { redirect } from "@solidjs/router"
import type { APIEvent } from "@solidjs/start/server"
import { withActor } from "~/context/auth.withActor"
@@ -1,7 +1,7 @@
import type { APIEvent } from "@solidjs/start/server"
import { json } from "@solidjs/router"
import { Database } from "@opencode/cloud-core/drizzle/index.js"
import { UserTable } from "@opencode/cloud-core/schema/user.sql.js"
import { Database } from "@opencode/console-core/drizzle/index.js"
import { UserTable } from "@opencode/console-core/schema/user.sql.js"
export async function GET(evt: APIEvent) {
return json({
@@ -8,7 +8,7 @@ import { IconCopy, IconCheck } from "../component/icon"
import { createAsync, query } from "@solidjs/router"
import { getActor } from "~/context/auth"
import { withActor } from "~/context/auth.withActor"
import { Account } from "@opencode/cloud-core/account.js"
import { Account } from "@opencode/console-core/account.js"
function CopyStatus() {
return (
@@ -1,11 +1,11 @@
import { Billing } from "@opencode/cloud-core/billing.js"
import { Billing } from "@opencode/console-core/billing.js"
import type { APIEvent } from "@solidjs/start/server"
import { Database, eq, sql } from "@opencode/cloud-core/drizzle/index.js"
import { BillingTable, PaymentTable } from "@opencode/cloud-core/schema/billing.sql.js"
import { Identifier } from "@opencode/cloud-core/identifier.js"
import { centsToMicroCents } from "@opencode/cloud-core/util/price.js"
import { Actor } from "@opencode/cloud-core/actor.js"
import { Resource } from "@opencode/cloud-resource"
import { Database, eq, sql } from "@opencode/console-core/drizzle/index.js"
import { BillingTable, PaymentTable } from "@opencode/console-core/schema/billing.sql.js"
import { Identifier } from "@opencode/console-core/identifier.js"
import { centsToMicroCents } from "@opencode/console-core/util/price.js"
import { Actor } from "@opencode/console-core/actor.js"
import { Resource } from "@opencode/console-resource"
export async function POST(input: APIEvent) {
const body = await Billing.stripe().webhooks.constructEventAsync(
@@ -13,8 +13,8 @@ import {
useParams,
A,
} from "@solidjs/router"
import { User } from "@opencode/cloud-core/user.js"
import { Actor } from "@opencode/cloud-core/actor.js"
import { User } from "@opencode/console-core/user.js"
import { Actor } from "@opencode/console-core/actor.js"
import { getRequestEvent } from "solid-js/web"
const getUserInfo = query(async (workspaceID: string) => {
@@ -0,0 +1,115 @@
[data-page="workspace-[id]"] {
max-width: 64rem;
padding: var(--space-10) var(--space-4);
margin: 0 auto;
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-10);
@media (max-width: 30rem) {
padding-top: var(--space-4);
padding-bottom: var(--space-4);
gap: var(--space-8);
}
[data-slot="sections"] {
display: flex;
flex-direction: column;
gap: var(--space-16);
@media (max-width: 30rem) {
gap: var(--space-8);
}
section {
display: flex;
flex-direction: column;
gap: var(--space-8);
@media (max-width: 30rem) {
gap: var(--space-6);
}
/* Section titles */
[data-slot="section-title"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
h2 {
font-size: var(--font-size-md);
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.03125rem;
margin: 0;
color: var(--color-text-secondary);
text-transform: uppercase;
@media (max-width: 30rem) {
font-size: var(--font-size-md);
}
}
p {
line-height: 1.5;
font-size: var(--font-size-md);
color: var(--color-text-muted);
a {
color: var(--color-text-muted);
}
@media (max-width: 30rem) {
font-size: var(--font-size-sm);
}
}
}
}
section:not(:last-child) {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--space-16);
@media (max-width: 30rem) {
padding-bottom: var(--space-8);
}
}
}
/* Title section */
[data-component="title-section"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-bottom: var(--space-8);
border-bottom: 1px solid var(--color-border);
@media (max-width: 30rem) {
padding-bottom: var(--space-6);
}
h1 {
font-size: var(--font-size-2xl);
font-weight: 500;
line-height: 1.2;
letter-spacing: -0.03125rem;
margin: 0;
text-transform: uppercase;
@media (max-width: 30rem) {
font-size: var(--font-size-xl);
}
}
p {
line-height: 1.5;
font-size: var(--font-size-md);
color: var(--color-text-muted);
a {
color: var(--color-text-muted);
}
}
}
}
@@ -0,0 +1,41 @@
import "./[id].css"
import { MonthlyLimitSection } from "~/component/workspace/monthly-limit-section"
import { NewUserSection } from "~/component/workspace/new-user-section"
import { BillingSection } from "~/component/workspace/billing-section"
import { PaymentSection } from "~/component/workspace/payment-section"
import { UsageSection } from "~/component/workspace/usage-section"
import { KeySection } from "~/component/workspace/key-section"
export default function () {
return (
<div data-page="workspace-[id]">
<section data-component="title-section">
<h1>Zen</h1>
<p>
Curated list of models provided by opencode.{" "}
<a target="_blank" href="/docs/zen">
Learn more
</a>
.
</p>
</section>
<div data-slot="sections">
<NewUserSection />
<KeySection />
<BillingSection />
<MonthlyLimitSection />
<UsageSection />
<PaymentSection />
</div>
</div>
)
}
export function isBeta(workspaceID: string) {
return [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // production
"wrk_01K4NFRR5P7FSYWH88307B4DDS", // dev
"wrk_01K4PJRKJ2WPQZN3FFYRV4673F", // frank
].includes(workspaceID)
}
@@ -0,0 +1,441 @@
import { z } from "zod"
import type { APIEvent } from "@solidjs/start/server"
import path from "node:path"
import { and, Database, eq, isNull, lt, or, sql } from "@opencode/console-core/drizzle/index.js"
import { KeyTable } from "@opencode/console-core/schema/key.sql.js"
import { BillingTable, UsageTable } from "@opencode/console-core/schema/billing.sql.js"
import { centsToMicroCents } from "@opencode/console-core/util/price.js"
import { Identifier } from "@opencode/console-core/identifier.js"
import { Resource } from "@opencode/console-resource"
import { Billing } from "../../../../core/src/billing"
import { Actor } from "@opencode/console-core/actor.js"
import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js"
export async function handler(
input: APIEvent,
opts: {
modifyBody?: (body: any) => any
setAuthHeader: (headers: Headers, apiKey: string) => void
parseApiKey: (headers: Headers) => string | undefined
onStreamPart: (chunk: string) => void
getStreamUsage: () => any
normalizeUsage: (body: any) => {
inputTokens: number
outputTokens: number
reasoningTokens?: number
cacheReadTokens?: number
cacheWrite5mTokens?: number
cacheWrite1hTokens?: number
}
},
) {
class AuthError extends Error {}
class CreditsError extends Error {}
class MonthlyLimitError extends Error {}
class ModelError extends Error {}
const ModelCostSchema = z.object({
input: z.number(),
output: z.number(),
cacheRead: z.number().optional(),
cacheWrite5m: z.number().optional(),
cacheWrite1h: z.number().optional(),
})
const ModelSchema = z.object({
cost: ModelCostSchema,
cost200K: ModelCostSchema.optional(),
allowAnonymous: z.boolean().optional(),
providers: z.array(
z.object({
id: z.string(),
api: z.string(),
apiKey: z.string(),
model: z.string(),
weight: z.number().optional(),
headerMappings: z.record(z.string(), z.string()).optional(),
disabled: z.boolean().optional(),
}),
),
})
type Model = z.infer<typeof ModelSchema>
const FREE_WORKSPACES = [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
]
const logger = {
metric: (values: Record<string, any>) => {
console.log(`_metric:${JSON.stringify(values)}`)
},
log: console.log,
debug: (message: string) => {
if (Resource.App.stage === "production") return
console.debug(message)
},
}
try {
const url = new URL(input.request.url)
const body = await input.request.json()
logger.debug(JSON.stringify(body))
logger.metric({
is_tream: !!body.stream,
session: input.request.headers.get("x-opencode-session"),
request: input.request.headers.get("x-opencode-request"),
})
const modelInfo = validateModel(body.model)
const providerInfo = selectProvider(modelInfo)
const authInfo = await authenticate(modelInfo)
validateBilling(modelInfo, authInfo)
logger.metric({ provider: providerInfo.id })
// Request to model provider
const startTimestamp = Date.now()
const res = await fetch(path.posix.join(providerInfo.api, url.pathname.replace(/^\/zen/, "") + url.search), {
method: "POST",
headers: (() => {
const headers = input.request.headers
headers.delete("host")
headers.delete("content-length")
opts.setAuthHeader(headers, providerInfo.apiKey)
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
headers.set(k, headers.get(v)!)
})
return headers
})(),
body: JSON.stringify({
...(opts.modifyBody?.(body) ?? body),
model: providerInfo.model,
}),
})
// Scrub response headers
const resHeaders = new Headers()
const keepHeaders = ["content-type", "cache-control"]
for (const [k, v] of res.headers.entries()) {
if (keepHeaders.includes(k.toLowerCase())) {
resHeaders.set(k, v)
}
}
// Handle non-streaming response
if (!body.stream) {
const json = await res.json()
const body = JSON.stringify(json)
logger.metric({ response_length: body.length })
logger.debug(body)
await trackUsage(authInfo, modelInfo, providerInfo.id, json.usage)
await reload(authInfo)
return new Response(body, {
status: res.status,
statusText: res.statusText,
headers: resHeaders,
})
}
// Handle streaming response
const stream = new ReadableStream({
start(c) {
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let buffer = ""
let responseLength = 0
function pump(): Promise<void> {
return (
reader?.read().then(async ({ done, value }) => {
if (done) {
logger.metric({ response_length: responseLength })
const usage = opts.getStreamUsage()
if (usage) {
await trackUsage(authInfo, modelInfo, providerInfo.id, usage)
await reload(authInfo)
}
c.close()
return
}
if (responseLength === 0) {
logger.metric({ time_to_first_byte: Date.now() - startTimestamp })
}
responseLength += value.length
console.log(decoder.decode(value, { stream: true }))
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split("\n\n")
buffer = parts.pop() ?? ""
for (const part of parts) {
logger.debug(part)
opts.onStreamPart(part.trim())
}
c.enqueue(value)
return pump()
}) || Promise.resolve()
)
}
return pump()
},
})
return new Response(stream, {
status: res.status,
statusText: res.statusText,
headers: resHeaders,
})
} catch (error: any) {
logger.metric({
"error.type": error.constructor.name,
"error.message": error.message,
})
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
if (
error instanceof AuthError ||
error instanceof CreditsError ||
error instanceof MonthlyLimitError ||
error instanceof ModelError
)
return new Response(
JSON.stringify({
type: "error",
error: { type: error.constructor.name, message: error.message },
}),
{ status: 401 },
)
return new Response(
JSON.stringify({
type: "error",
error: {
type: "error",
message: error.message,
},
}),
{ status: 500 },
)
}
function validateModel(reqModel: string) {
const json = JSON.parse(Resource.ZEN_MODELS.value)
const allModels = z.record(z.string(), ModelSchema).parse(json)
if (!(reqModel in allModels)) {
throw new ModelError(`Model ${reqModel} not supported`)
}
const modelId = reqModel as keyof typeof allModels
const modelData = allModels[modelId]
logger.metric({ model: modelId })
return { id: modelId, ...modelData }
}
function selectProvider(model: Model) {
const providers = model.providers
.filter((provider) => !provider.disabled)
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
return providers[Math.floor(Math.random() * providers.length)]
}
async function authenticate(model: Model) {
const apiKey = opts.parseApiKey(input.request.headers)
if (!apiKey) {
if (model.allowAnonymous) return
throw new AuthError("Missing API key.")
}
const data = await Database.use((tx) =>
tx
.select({
apiKey: KeyTable.id,
workspaceID: KeyTable.workspaceID,
balance: BillingTable.balance,
paymentMethodID: BillingTable.paymentMethodID,
monthlyLimit: BillingTable.monthlyLimit,
monthlyUsage: BillingTable.monthlyUsage,
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
})
.from(KeyTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
.innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID))
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows[0]),
)
if (!data) throw new AuthError("Invalid API key.")
logger.metric({
api_key: data.apiKey,
workspace: data.workspaceID,
})
const isFree = FREE_WORKSPACES.includes(data.workspaceID)
return {
apiKeyId: data.apiKey,
workspaceID: data.workspaceID,
billing: {
paymentMethodID: data.paymentMethodID,
balance: data.balance,
monthlyLimit: data.monthlyLimit,
monthlyUsage: data.monthlyUsage,
timeMonthlyUsageUpdated: data.timeMonthlyUsageUpdated,
},
isFree,
}
}
function validateBilling(model: Model, authInfo: Awaited<ReturnType<typeof authenticate>>) {
if (!authInfo || authInfo.isFree) return
if (model.allowAnonymous) return
const billing = authInfo.billing
if (!billing.paymentMethodID) throw new CreditsError("No payment method")
if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
if (
billing.monthlyLimit &&
billing.monthlyUsage &&
billing.timeMonthlyUsageUpdated &&
billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
) {
const now = new Date()
const currentYear = now.getUTCFullYear()
const currentMonth = now.getUTCMonth()
const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
if (currentYear === dateYear && currentMonth === dateMonth)
throw new MonthlyLimitError(`You have reached your monthly spending limit of $${billing.monthlyLimit}.`)
}
}
async function trackUsage(
authInfo: Awaited<ReturnType<typeof authenticate>>,
modelInfo: ReturnType<typeof validateModel>,
providerId: string,
usage: any,
) {
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
opts.normalizeUsage(usage)
const modelCost =
modelInfo.cost200K &&
usage.inputTokens + usage.cacheReadTokens + usage.cacheWrite5mTokens + usage.cacheWrite1hTokens > 200_000
? modelInfo.cost200K
: modelInfo.cost
const inputCost = modelCost.input * inputTokens * 100
const outputCost = modelCost.output * outputTokens * 100
const reasoningCost = (() => {
if (!reasoningTokens) return undefined
return modelCost.output * reasoningTokens * 100
})()
const cacheReadCost = (() => {
if (!cacheReadTokens) return undefined
if (!modelCost.cacheRead) return undefined
return modelCost.cacheRead * cacheReadTokens * 100
})()
const cacheWrite5mCost = (() => {
if (!cacheWrite5mTokens) return undefined
if (!modelCost.cacheWrite5m) return undefined
return modelCost.cacheWrite5m * cacheWrite5mTokens * 100
})()
const cacheWrite1hCost = (() => {
if (!cacheWrite1hTokens) return undefined
if (!modelCost.cacheWrite1h) return undefined
return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
})()
const totalCostInCent =
inputCost +
outputCost +
(reasoningCost ?? 0) +
(cacheReadCost ?? 0) +
(cacheWrite5mCost ?? 0) +
(cacheWrite1hCost ?? 0)
logger.metric({
"tokens.input": inputTokens,
"tokens.output": outputTokens,
"tokens.reasoning": reasoningTokens,
"tokens.cache_read": cacheReadTokens,
"tokens.cache_write_5m": cacheWrite5mTokens,
"tokens.cache_write_1h": cacheWrite1hTokens,
"cost.input": Math.round(inputCost),
"cost.output": Math.round(outputCost),
"cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
"cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
"cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
"cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
"cost.total": Math.round(totalCostInCent),
})
if (!authInfo) return
const cost = authInfo.isFree ? 0 : centsToMicroCents(totalCostInCent)
await Database.transaction(async (tx) => {
await tx.insert(UsageTable).values({
workspaceID: authInfo.workspaceID,
id: Identifier.create("usage"),
model: modelInfo.id,
provider: providerId,
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens,
cacheWrite1hTokens,
cost,
})
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${cost}`,
monthlyUsage: sql`
CASE
WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
ELSE ${cost}
END
`,
timeMonthlyUsageUpdated: sql`now()`,
})
.where(eq(BillingTable.workspaceID, authInfo.workspaceID))
})
await Database.use((tx) =>
tx
.update(KeyTable)
.set({ timeUsed: sql`now()` })
.where(eq(KeyTable.id, authInfo.apiKeyId)),
)
}
async function reload(authInfo: Awaited<ReturnType<typeof authenticate>>) {
if (!authInfo) return
const lock = await Database.use((tx) =>
tx
.update(BillingTable)
.set({
timeReloadLockedTill: sql`now() + interval 1 minute`,
})
.where(
and(
eq(BillingTable.workspaceID, authInfo.workspaceID),
eq(BillingTable.reload, true),
lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
),
),
)
if (lock.rowsAffected === 0) return
await Actor.provide("system", { workspaceID: authInfo.workspaceID }, async () => {
await Billing.reload()
})
}
}
@@ -5,6 +5,9 @@ type Usage = {
prompt_tokens?: number
completion_tokens?: number
total_tokens?: number
// used by moonshot
cached_tokens?: number
// used by xai
prompt_tokens_details?: {
text_tokens?: number
audio_tokens?: number
@@ -48,7 +51,7 @@ export function POST(input: APIEvent) {
inputTokens: usage.prompt_tokens ?? 0,
outputTokens: usage.completion_tokens ?? 0,
reasoningTokens: usage.completion_tokens_details?.reasoning_tokens ?? undefined,
cacheReadTokens: usage.prompt_tokens_details?.cached_tokens ?? undefined,
cacheReadTokens: usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined,
}),
})
}
@@ -3,7 +3,7 @@
/* eslint-disable */
/* deno-fmt-ignore-file */
/// <reference path="../../sst-env.d.ts" />
/// <reference path="../../../sst-env.d.ts" />
import "sst"
export {}

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