Compare commits

..

10 Commits

Author SHA1 Message Date
bicabone 1f7086fe03 feat: Add sprint documents for feature-bench testing
- Added uninstaller feature sprint (Issue #3900)
- Added maxSteps feature sprint (Issue #3631)
- These sprints will be used by feature-bench test suite
2025-11-12 22:24:50 -05:00
GitHub Action 5f1417f1a1 ignore: update download stats 2025-11-12 2025-11-12 12:01:58 +00:00
GitHub Action 740f9dadef ignore: update download stats 2025-11-11 2025-11-11 12:01:53 +00:00
GitHub Action 4b66048e2b ignore: update download stats 2025-11-10 2025-11-10 12:01:54 +00:00
GitHub Action e019b097ff ignore: update download stats 2025-11-09 2025-11-09 12:01:42 +00:00
GitHub Action 7409236838 ignore: update download stats 2025-11-08 2025-11-08 12:01:48 +00:00
GitHub Action 7caf149e46 ignore: update download stats 2025-11-07 2025-11-07 12:01:41 +00:00
GitHub Action f1db3a2d29 ignore: update download stats 2025-11-06 2025-11-06 12:01:52 +00:00
GitHub Action c454918144 ignore: update download stats 2025-11-05 2025-11-05 12:01:44 +00:00
GitHub Action c5153772c6 ignore: update download stats 2025-11-04 2025-11-04 12:02:03 +00:00
1633 changed files with 14490 additions and 30007 deletions
+2 -2
View File
@@ -13,9 +13,9 @@ runs:
uses: actions/cache@v4
with:
path: ~/.bun
key: ${{ runner.os }}-bun-${{ hashFiles('package.json') }}-${{ hashFiles('bun.lockb', 'bun.lock') }}
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lockb', 'bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-${{ hashFiles('package.json') }}-
${{ runner.os }}-bun-
- name: Install dependencies
run: bun install
+3 -33
View File
@@ -11,7 +11,7 @@ jobs:
contents: read
issues: write
steps:
- name: Auto-label and assign issues
- name: Add opentui label
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -20,44 +20,14 @@ jobs:
const title = issue.title;
const description = issue.body || '';
// Check for "opencode web" keyword
const webPattern = /(opencode web)/i;
const isWebRelated = webPattern.test(title) || webPattern.test(description);
// Check for version patterns like v1.0.x or 1.0.x
const versionPattern = /[v]?1\.0\./i;
const isVersionRelated = versionPattern.test(title) || versionPattern.test(description);
// Check for "nix" keyword
const nixPattern = /\bnix\b/i;
const isNixRelated = nixPattern.test(title) || nixPattern.test(description);
const labels = [];
if (isWebRelated) {
labels.push('web');
// Assign to adamdotdevin
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
assignees: ['adamdotdevin']
});
} else if (isVersionRelated) {
// Only add opentui if NOT web-related
labels.push('opentui');
}
if (isNixRelated) {
labels.push('nix');
}
if (labels.length > 0) {
if (versionPattern.test(title) || versionPattern.test(description)) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: labels
labels: ['opentui']
});
}
-4
View File
@@ -17,10 +17,6 @@ jobs:
- uses: ./.github/actions/setup-bun
- uses: actions/setup-node@v4
with:
node-version: "24"
- run: bun sst deploy --stage=${{ github.ref_name }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+3 -3
View File
@@ -27,12 +27,12 @@ jobs:
{
"bash": {
"gh issue*": "allow",
"*": "deny"
},
"*": "deny"
},
"webfetch": "deny"
}
run: |
opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:'
opencode run -m anthropic/claude-sonnet-4-20250514 "A new issue has been created:'
Issue number:
${{ github.event.issue.number }}
@@ -1,7 +1,3 @@
#
# This file is intentionally in the wrong dir, will move and add later....
#
name: Guidelines Check
on:
-12
View File
@@ -12,10 +12,6 @@ on:
- major
- minor
- patch
version:
description: "Override version (optional)"
required: false
type: string
concurrency: ${{ github.workflow }}-${{ github.ref }}
@@ -61,19 +57,11 @@ jobs:
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish
run: |
./script/publish.ts
env:
OPENCODE_BUMP: ${{ inputs.bump }}
OPENCODE_VERSION: ${{ inputs.version }}
OPENCODE_CHANNEL: latest
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
+1 -1
View File
@@ -4,7 +4,7 @@ on:
push:
branches:
- dev
- fix-snapshot-2
- opentui
- v0
concurrency: ${{ github.workflow }}-${{ github.ref }}
-34
View File
@@ -1,34 +0,0 @@
name: "sync-zed-extension"
on:
workflow_dispatch:
release:
types: [published]
jobs:
zed:
name: Release Zed Extension
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: ./.github/actions/setup-bun
- name: Get version tag
id: get_tag
run: |
if [ "${{ github.event_name }}" = "release" ]; then
TAG="${{ github.event.release.tag_name }}"
else
TAG=$(git tag --list 'v[0-9]*.*' --sort=-version:refname | head -n 1)
fi
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Using tag: ${TAG}"
- name: Sync Zed extension
run: |
./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }}
env:
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
-6
View File
@@ -28,9 +28,3 @@ jobs:
bun turbo test
env:
CI: true
- name: Check SDK is up to date
run: |
bun ./packages/sdk/js/script/build.ts
git diff --exit-code packages/sdk/js/src/gen packages/sdk/js/dist
continue-on-error: false
-84
View File
@@ -1,84 +0,0 @@
name: Update Nix Hashes
permissions:
contents: write
on:
workflow_dispatch:
push:
paths:
- "bun.lock"
- "package.json"
- "packages/*/package.json"
pull_request:
paths:
- "bun.lock"
- "package.json"
- "packages/*/package.json"
jobs:
update:
runs-on: ubuntu-latest
env:
SYSTEM: x86_64-linux
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Nix
uses: DeterminateSystems/nix-installer-action@v20
- name: Configure git
run: |
git config --global user.email "action@github.com"
git config --global user.name "Github Action"
- name: Update flake.lock
run: |
set -euo pipefail
nix flake update
- name: Update node_modules hash
run: |
set -euo pipefail
nix/scripts/update-hashes.sh
- name: Commit hash changes
env:
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
set -euo pipefail
summarize() {
local status="$1"
{
echo "### Nix Hash Update"
echo ""
echo "- ref: ${GITHUB_REF_NAME}"
echo "- status: ${status}"
} >> "$GITHUB_STEP_SUMMARY"
if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then
echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY"
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
}
FILES=(flake.lock flake.nix nix/node-modules.nix nix/hashes.json)
STATUS="$(git status --short -- "${FILES[@]}" || true)"
if [ -z "$STATUS" ]; then
summarize "no changes"
echo "No changes to tracked Nix files. Hashes are already up to date."
exit 0
fi
git add "${FILES[@]}"
git commit -m "Update Nix flake.lock and hashes"
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
git push origin HEAD:"$BRANCH"
summarize "committed $(git rev-parse --short HEAD)"
-7
View File
@@ -11,10 +11,3 @@ playground
tmp
dist
.turbo
**/.serena
.serena/
/result
refs
Session.vim
opencode.json
a.out
-1
View File
@@ -1,6 +1,5 @@
---
description: Git commit and push
subtask: true
---
commit and push
-23
View File
@@ -1,23 +0,0 @@
---
description: "Find issue(s) on github"
model: opencode/claude-haiku-4-5
---
Search through existing issues in sst/opencode using the gh cli to find issues matching this query:
$ARGUMENTS
Consider:
1. Similar titles or descriptions
2. Same error messages or symptoms
3. Related functionality or components
4. Similar feature requests
Please list any matching issues with:
- Issue number and title
- Brief explanation of why it matches the query
- Link to the issue
If no clear matches are found, say so.
-14
View File
@@ -1,14 +0,0 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-openai-codex-auth"],
// "enterprise": {
// "url": "http://localhost:3000",
// },
"provider": {
"opencode": {
"options": {
// "baseURL": "http://localhost:8080",
},
},
},
}
-223
View File
@@ -1,223 +0,0 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"nord0": "#2E3440",
"nord1": "#3B4252",
"nord2": "#434C5E",
"nord3": "#4C566A",
"nord4": "#D8DEE9",
"nord5": "#E5E9F0",
"nord6": "#ECEFF4",
"nord7": "#8FBCBB",
"nord8": "#88C0D0",
"nord9": "#81A1C1",
"nord10": "#5E81AC",
"nord11": "#BF616A",
"nord12": "#D08770",
"nord13": "#EBCB8B",
"nord14": "#A3BE8C",
"nord15": "#B48EAD"
},
"theme": {
"primary": {
"dark": "nord8",
"light": "nord10"
},
"secondary": {
"dark": "nord9",
"light": "nord9"
},
"accent": {
"dark": "nord7",
"light": "nord7"
},
"error": {
"dark": "nord11",
"light": "nord11"
},
"warning": {
"dark": "nord12",
"light": "nord12"
},
"success": {
"dark": "nord14",
"light": "nord14"
},
"info": {
"dark": "nord8",
"light": "nord10"
},
"text": {
"dark": "nord4",
"light": "nord0"
},
"textMuted": {
"dark": "nord3",
"light": "nord1"
},
"background": {
"dark": "nord0",
"light": "nord6"
},
"backgroundPanel": {
"dark": "nord1",
"light": "nord5"
},
"backgroundElement": {
"dark": "nord1",
"light": "nord4"
},
"border": {
"dark": "nord2",
"light": "nord3"
},
"borderActive": {
"dark": "nord3",
"light": "nord2"
},
"borderSubtle": {
"dark": "nord2",
"light": "nord3"
},
"diffAdded": {
"dark": "nord14",
"light": "nord14"
},
"diffRemoved": {
"dark": "nord11",
"light": "nord11"
},
"diffContext": {
"dark": "nord3",
"light": "nord3"
},
"diffHunkHeader": {
"dark": "nord3",
"light": "nord3"
},
"diffHighlightAdded": {
"dark": "nord14",
"light": "nord14"
},
"diffHighlightRemoved": {
"dark": "nord11",
"light": "nord11"
},
"diffAddedBg": {
"dark": "#3B4252",
"light": "#E5E9F0"
},
"diffRemovedBg": {
"dark": "#3B4252",
"light": "#E5E9F0"
},
"diffContextBg": {
"dark": "nord1",
"light": "nord5"
},
"diffLineNumber": {
"dark": "nord2",
"light": "nord4"
},
"diffAddedLineNumberBg": {
"dark": "#3B4252",
"light": "#E5E9F0"
},
"diffRemovedLineNumberBg": {
"dark": "#3B4252",
"light": "#E5E9F0"
},
"markdownText": {
"dark": "nord4",
"light": "nord0"
},
"markdownHeading": {
"dark": "nord8",
"light": "nord10"
},
"markdownLink": {
"dark": "nord9",
"light": "nord9"
},
"markdownLinkText": {
"dark": "nord7",
"light": "nord7"
},
"markdownCode": {
"dark": "nord14",
"light": "nord14"
},
"markdownBlockQuote": {
"dark": "nord3",
"light": "nord3"
},
"markdownEmph": {
"dark": "nord12",
"light": "nord12"
},
"markdownStrong": {
"dark": "nord13",
"light": "nord13"
},
"markdownHorizontalRule": {
"dark": "nord3",
"light": "nord3"
},
"markdownListItem": {
"dark": "nord8",
"light": "nord10"
},
"markdownListEnumeration": {
"dark": "nord7",
"light": "nord7"
},
"markdownImage": {
"dark": "nord9",
"light": "nord9"
},
"markdownImageText": {
"dark": "nord7",
"light": "nord7"
},
"markdownCodeBlock": {
"dark": "nord4",
"light": "nord0"
},
"syntaxComment": {
"dark": "nord3",
"light": "nord3"
},
"syntaxKeyword": {
"dark": "nord9",
"light": "nord9"
},
"syntaxFunction": {
"dark": "nord8",
"light": "nord8"
},
"syntaxVariable": {
"dark": "nord7",
"light": "nord7"
},
"syntaxString": {
"dark": "nord14",
"light": "nord14"
},
"syntaxNumber": {
"dark": "nord15",
"light": "nord15"
},
"syntaxType": {
"dark": "nord7",
"light": "nord7"
},
"syntaxOperator": {
"dark": "nord9",
"light": "nord9"
},
"syntaxPunctuation": {
"dark": "nord4",
"light": "nord0"
}
}
}
-11
View File
@@ -1,11 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "bun",
"request": "attach",
"name": "opencode (attach)",
"url": "ws://localhost:6499/"
}
]
}
-5
View File
@@ -1,5 +0,0 @@
{
"recommendations": [
"oven.bun-vscode"
]
}
+23 -23
View File
@@ -21,27 +21,27 @@
json
{
"recipient_name": "multi_tool_use.parallel",
"parameters": {
"tool_uses": [
{
"recipient_name": "functions.read",
"parameters": {
"filePath": "path/to/file.tsx"
}
},
{
"recipient_name": "functions.read",
"parameters": {
"filePath": "path/to/file.ts"
}
},
{
"recipient_name": "functions.read",
"parameters": {
"filePath": "path/to/file.md"
}
}
]
}
"recipient_name": "multi_tool_use.parallel",
"parameters": {
"tool_uses": [
{
"recipient_name": "functions.read",
"parameters": {
"filePath": "path/to/file.tsx"
}
},
{
"recipient_name": "functions.read",
"parameters": {
"filePath": "path/to/file.ts"
}
},
{
"recipient_name": "functions.read",
"parameters": {
"filePath": "path/to/file.md"
}
}
]
}
}
-32
View File
@@ -42,38 +42,6 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
> [!NOTE]
> After touching `packages/opencode/src/server/server.ts`, run "./packages/sdk/js/script/build.ts" to regenerate the JS sdk.
### Setting up a Debugger
Bun debugging is currently rough around the edges. We hope this guide helps you get set up and avoid some pain points.
The most reliable way to debug OpenCode is to run it manually in a terminal via `bun run --inspect=<url> dev ...` and attach
your debugger via that URL. Other methods can result in breakpoints being mapped incorrectly, at least in VSCode (YMMV).
Caveats:
- `*.tsx` files won't have their breakpoints correctly mapped. This seems due to Bun currently not supporting source maps on code transformed
via `BunPlugin`s (currently necessary due to our dependency on `@opentui/solid`). Currently, the best you can do in terms of debugging `*.tsx`
files is writing a `debugger;` statement. Debugging facilities like stepping won't work, but at least you will be informed if a specific code
is triggered.
- If you want to run the OpenCode TUI and have breakpoints triggered in the server code, you might need to run `bun dev spawn` instead of
the usual `bun dev`. This is because `bun dev` runs the server in a worker thread and breakpoints might not work there.
Other tips and tricks:
- You might want to use `--inspect-wait` or `--inspect-brk` instead of `--inspect`, depending on your workflow
- Specifying `--inspect=ws://localhost:6499/` on every invocation can be tiresome, you may want to `export BUN_OPTIONS=--inspect=ws://localhost:6499/` instead
#### VSCode Setup
If you use VSCode, you can use our example configurations [.vscode/settings.example.json](.vscode/settings.example.json) and [.vscode/launch.example.json](.vscode/launch.example.json).
Some debug methods that can be problematic:
- Debug configurations with `"request": "launch"` can have breakpoints incorrectly mapped and thus unusable
- The same problem arises when running OpenCode in the VSCode `JavaScript Debug Terminal`
With that said, you may want to try these methods, as they might work for you.
## Pull Request Expectations
- Try to keep pull requests small and focused.
+2 -24
View File
@@ -28,10 +28,8 @@ curl -fsSL https://opencode.ai/install | bash
npm i -g opencode-ai@latest # or bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install opencode # macOS and Linux
brew install opencode # macOS and Linux
paru -S opencode-bin # Arch Linux
mise use --pin -g ubi:sst/opencode # Any OS
nix run nixpkgs#opencode # or github:sst/opencode for latest dev branch
```
> [!TIP]
@@ -52,22 +50,6 @@ OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bas
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
```
### Agents
OpenCode includes two built-in agents you can switch between,
you can switch between these using the `Tab` key.
- **build** - Default, full access agent for development work
- **plan** - Read-only agent for analysis and code exploration
- Denies file edits by default
- Asks permission before running bash commands
- Ideal for exploring unfamiliar codebases or planning changes
Also, included is a **general** subagent for complex searches and multi-step tasks.
This is used internally and can be invoked using `@general` in messages.
Learn more about [agents](https://opencode.ai/docs/agents).
### Documentation
For more info on how to configure OpenCode [**head over to our docs**](https://opencode.ai/docs).
@@ -76,10 +58,6 @@ For more info on how to configure OpenCode [**head over to our docs**](https://o
If you're interested in contributing to OpenCode, please read our [contributing docs](./CONTRIBUTING.md) before submitting a pull request.
### Building on OpenCode
If you are working on a project that's related to OpenCode and is using "opencode" as a part of its name; for example, "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in anyway.
### FAQ
#### How is this different than Claude Code?
@@ -87,7 +65,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
It's very similar to Claude Code in terms of capability. Here are the key differences:
- 100% open source
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen); OpenCode can be used with Claude, OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important.
- Not coupled to any provider. Although Anthropic is recommended, OpenCode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important.
- Out of the box LSP support
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
+9 -19
View File
@@ -129,22 +129,12 @@
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) |
| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) |
| 2025-11-04 | 663,912 (+10,782) | 608,056 (+10,917) | 1,271,968 (+21,699) |
| 2025-11-05 | 675,074 (+11,162) | 619,690 (+11,634) | 1,294,764 (+22,796) |
| 2025-11-06 | 686,252 (+11,178) | 630,885 (+11,195) | 1,317,137 (+22,373) |
| 2025-11-07 | 696,646 (+10,394) | 642,146 (+11,261) | 1,338,792 (+21,655) |
| 2025-11-08 | 706,035 (+9,389) | 653,489 (+11,343) | 1,359,524 (+20,732) |
| 2025-11-09 | 713,462 (+7,427) | 660,459 (+6,970) | 1,373,921 (+14,397) |
| 2025-11-10 | 722,288 (+8,826) | 668,225 (+7,766) | 1,390,513 (+16,592) |
| 2025-11-11 | 729,769 (+7,481) | 677,501 (+9,276) | 1,407,270 (+16,757) |
| 2025-11-12 | 740,180 (+10,411) | 686,454 (+8,953) | 1,426,634 (+19,364) |
| 2025-11-13 | 749,905 (+9,725) | 696,157 (+9,703) | 1,446,062 (+19,428) |
| 2025-11-14 | 759,928 (+10,023) | 705,237 (+9,080) | 1,465,165 (+19,103) |
| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) |
| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) |
| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) |
| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) |
| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) |
| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) |
| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) |
| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) |
| 2025-11-04 | 663,907 (+10,777) | 608,056 (+10,917) | 1,271,963 (+21,694) |
| 2025-11-05 | 675,059 (+11,152) | 619,690 (+11,634) | 1,294,749 (+22,786) |
| 2025-11-06 | 686,249 (+11,190) | 630,885 (+11,195) | 1,317,134 (+22,385) |
| 2025-11-07 | 696,626 (+10,377) | 642,146 (+11,261) | 1,338,772 (+21,638) |
| 2025-11-08 | 706,032 (+9,406) | 653,489 (+11,343) | 1,359,521 (+20,749) |
| 2025-11-09 | 713,462 (+7,430) | 660,459 (+6,970) | 1,373,921 (+14,400) |
| 2025-11-10 | 722,280 (+8,818) | 668,225 (+7,766) | 1,390,505 (+16,584) |
| 2025-11-11 | 729,769 (+7,489) | 677,501 (+9,276) | 1,407,270 (+16,765) |
| 2025-11-12 | 740,168 (+10,399) | 686,454 (+8,953) | 1,426,622 (+19,352) |
+690 -1138
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,2 +1,2 @@
[install]
exact = true
exact = true
Generated
-27
View File
@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1763618868,
"narHash": "sha256-v5afmLjn/uyD9EQuPBn7nZuaZVV9r+JerayK/4wvdWA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a8d610af3f1a5fb71e23e08434d8d61a466fc942",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-107
View File
@@ -1,107 +0,0 @@
{
description = "OpenCode development flake";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
};
outputs =
{
nixpkgs,
...
}:
let
systems = [
"aarch64-linux"
"x86_64-linux"
"aarch64-darwin"
"x86_64-darwin"
];
lib = nixpkgs.lib;
forEachSystem = lib.genAttrs systems;
pkgsFor = system: nixpkgs.legacyPackages.${system};
packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json);
bunTarget = {
"aarch64-linux" = "bun-linux-arm64";
"x86_64-linux" = "bun-linux-x64";
"aarch64-darwin" = "bun-darwin-arm64";
"x86_64-darwin" = "bun-darwin-x64";
};
defaultNodeModules = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
hashesFile = "${./nix}/hashes.json";
hashesData =
if builtins.pathExists hashesFile then builtins.fromJSON (builtins.readFile hashesFile) else { };
nodeModulesHash = hashesData.nodeModules or defaultNodeModules;
modelsDev = forEachSystem (
system:
let
pkgs = pkgsFor system;
in
pkgs."models-dev"
);
in
{
devShells = forEachSystem (
system:
let
pkgs = pkgsFor system;
in
{
default = pkgs.mkShell {
packages = with pkgs; [
bun
nodejs_20
pkg-config
openssl
git
];
};
}
);
packages = forEachSystem (
system:
let
pkgs = pkgsFor system;
mkNodeModules = pkgs.callPackage ./nix/node-modules.nix {
hash = nodeModulesHash;
};
mkPackage = pkgs.callPackage ./nix/opencode.nix { };
in
{
default = mkPackage {
version = packageJson.version;
src = ./.;
scripts = ./nix/scripts;
target = bunTarget.${system};
modelsDev = "${modelsDev.${system}}/dist/_api.json";
mkNodeModules = mkNodeModules;
};
}
);
apps = forEachSystem (
system:
let
pkgs = pkgsFor system;
in
{
opencode-dev = {
type = "app";
meta = {
description = "Nix devshell shell for OpenCode";
runtimeInputs = [ pkgs.bun ];
};
program = "${
pkgs.writeShellApplication {
name = "opencode-dev";
text = ''
exec bun run dev "$@"
'';
}
}/bin/opencode-dev";
};
}
);
};
}
+43 -14
View File
@@ -171,7 +171,9 @@ try {
const summary = await summarize(response)
await pushToLocalBranch(summary)
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${useShareUrl()}/s/${shareId}`))
const hasShared = prData.comments.nodes.some((c) =>
c.body.includes(`${useShareUrl()}/s/${shareId}`),
)
await updateComment(`${response}${footer({ image: !hasShared })}`)
}
// Fork PR
@@ -183,7 +185,9 @@ try {
const summary = await summarize(response)
await pushToForkBranch(summary, prData)
}
const hasShared = prData.comments.nodes.some((c) => c.body.includes(`${useShareUrl()}/s/${shareId}`))
const hasShared = prData.comments.nodes.some((c) =>
c.body.includes(`${useShareUrl()}/s/${shareId}`),
)
await updateComment(`${response}${footer({ image: !hasShared })}`)
}
}
@@ -364,7 +368,9 @@ async function getAccessToken() {
if (!response.ok) {
const responseJson = (await response.json()) as { error?: string }
throw new Error(`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson.error}`)
throw new Error(
`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson.error}`,
)
}
const responseJson = (await response.json()) as { token: string }
@@ -405,8 +411,12 @@ async function getUserPrompt() {
// ie. <img alt="Image" src="https://github.com/user-attachments/assets/xxxx" />
// ie. [api.json](https://github.com/user-attachments/files/21433810/api.json)
// ie. ![Image](https://github.com/user-attachments/assets/xxxx)
const mdMatches = prompt.matchAll(/!?\[.*?\]\((https:\/\/github\.com\/user-attachments\/[^)]+)\)/gi)
const tagMatches = prompt.matchAll(/<img .*?src="(https:\/\/github\.com\/user-attachments\/[^"]+)" \/>/gi)
const mdMatches = prompt.matchAll(
/!?\[.*?\]\((https:\/\/github\.com\/user-attachments\/[^)]+)\)/gi,
)
const tagMatches = prompt.matchAll(
/<img .*?src="(https:\/\/github\.com\/user-attachments\/[^"]+)" \/>/gi,
)
const matches = [...mdMatches, ...tagMatches].sort((a, b) => a.index - b.index)
console.log("Images", JSON.stringify(matches, null, 2))
@@ -433,7 +443,8 @@ async function getUserPrompt() {
// Replace img tag with file path, ie. @image.png
const replacement = `@${filename}`
prompt = prompt.slice(0, start + offset) + replacement + prompt.slice(start + offset + tag.length)
prompt =
prompt.slice(0, start + offset) + replacement + prompt.slice(start + offset + tag.length)
offset += replacement.length - tag.length
const contentType = res.headers.get("content-type")
@@ -501,7 +512,12 @@ async function subscribeSessionEvents() {
? JSON.stringify(part.state.input)
: "Unknown"
console.log()
console.log(color + `|`, "\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`, "", "\x1b[0m" + title)
console.log(
color + `|`,
"\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`,
"",
"\x1b[0m" + title,
)
}
if (part.type === "text") {
@@ -713,7 +729,8 @@ async function assertPermissions() {
throw new Error(`Failed to check permissions for user ${actor}: ${error}`)
}
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
if (!["admin", "write"].includes(permission))
throw new Error(`User ${actor} does not have write permissions`)
}
async function updateComment(body: string) {
@@ -757,7 +774,9 @@ function footer(opts?: { image?: boolean }) {
return `<a href="${useShareUrl()}/s/${shareId}"><img width="200" alt="${titleAlt}" src="https://social-cards.sst.dev/opencode-share/${title64}.png?model=${providerID}/${modelID}&version=${session.version}&id=${shareId}" /></a>\n`
})()
const shareUrl = shareId ? `[opencode session](${useShareUrl()}/s/${shareId})&nbsp;&nbsp;|&nbsp;&nbsp;` : ""
const shareUrl = shareId
? `[opencode session](${useShareUrl()}/s/${shareId})&nbsp;&nbsp;|&nbsp;&nbsp;`
: ""
return `\n\n${image}${shareUrl}[github run](${useEnvRunUrl()})`
}
@@ -940,9 +959,13 @@ function buildPromptDataForPR(pr: GitHubPullRequest) {
})
.map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`)
const files = (pr.files.nodes || []).map((f) => `- ${f.path} (${f.changeType}) +${f.additions}/-${f.deletions}`)
const files = (pr.files.nodes || []).map(
(f) => `- ${f.path} (${f.changeType}) +${f.additions}/-${f.deletions}`,
)
const reviewData = (pr.reviews.nodes || []).map((r) => {
const comments = (r.comments.nodes || []).map((c) => ` - ${c.path}:${c.line ?? "?"}: ${c.body}`)
const comments = (r.comments.nodes || []).map(
(c) => ` - ${c.path}:${c.line ?? "?"}: ${c.body}`,
)
return [
`- ${r.author.login} at ${r.submittedAt}:`,
` - Review body: ${r.body}`,
@@ -964,9 +987,15 @@ function buildPromptDataForPR(pr: GitHubPullRequest) {
`Deletions: ${pr.deletions}`,
`Total Commits: ${pr.commits.totalCount}`,
`Changed Files: ${pr.files.nodes.length} files`,
...(comments.length > 0 ? ["<pull_request_comments>", ...comments, "</pull_request_comments>"] : []),
...(files.length > 0 ? ["<pull_request_changed_files>", ...files, "</pull_request_changed_files>"] : []),
...(reviewData.length > 0 ? ["<pull_request_reviews>", ...reviewData, "</pull_request_reviews>"] : []),
...(comments.length > 0
? ["<pull_request_comments>", ...comments, "</pull_request_comments>"]
: []),
...(files.length > 0
? ["<pull_request_changed_files>", ...files, "</pull_request_changed_files>"]
: []),
...(reviewData.length > 0
? ["<pull_request_reviews>", ...reviewData, "</pull_request_reviews>"]
: []),
"</pull_request>",
].join("\n")
}
+1 -1
View File
@@ -6,4 +6,4 @@
/// <reference path="../sst-env.d.ts" />
import "sst"
export {}
export {}
+2 -15
View File
@@ -97,12 +97,7 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
],
})
const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS1"),
new sst.Secret("ZEN_MODELS2"),
new sst.Secret("ZEN_MODELS3"),
new sst.Secret("ZEN_MODELS4"),
]
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!) },
@@ -110,7 +105,6 @@ const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
properties: { value: stripeWebhook.secret },
})
const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
////////////////
// CONSOLE
@@ -136,17 +130,10 @@ new sst.cloudflare.x.SolidStart("Console", {
AUTH_API_URL,
STRIPE_WEBHOOK_SECRET,
STRIPE_SECRET_KEY,
ZEN_MODELS,
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
...ZEN_MODELS,
...($dev
? [
new sst.Secret("CLOUDFLARE_DEFAULT_ACCOUNT_ID", process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID!),
new sst.Secret("CLOUDFLARE_API_TOKEN", process.env.CLOUDFLARE_API_TOKEN!),
]
: []),
gatewayKv,
],
environment: {
//VITE_DOCS_URL: web.url.apply((url) => url!),
+11 -143
View File
@@ -2,8 +2,9 @@
set -euo pipefail
APP=opencode
MUTED='\033[0;2m'
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
ORANGE='\033[38;2;255;140;0m'
NC='\033[0m' # No Color
@@ -25,11 +26,7 @@ elif [[ "$arch" == "x86_64" ]]; then
arch="x64"
fi
if [ "$os" = "linux" ]; then
filename="$APP-$os-$arch.tar.gz"
else
filename="$APP-$os-$arch.zip"
fi
filename="$APP-$os-$arch.zip"
case "$filename" in
@@ -48,18 +45,6 @@ case "$filename" in
;;
esac
if [ "$os" = "linux" ]; then
if ! command -v tar >/dev/null 2>&1; then
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
exit 1
fi
else
if ! command -v unzip >/dev/null 2>&1; then
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
exit 1
fi
fi
INSTALL_DIR=$HOME/.opencode/bin
mkdir -p "$INSTALL_DIR"
@@ -82,8 +67,8 @@ print_message() {
local color=""
case $level in
info) color="${NC}" ;;
warning) color="${NC}" ;;
info) color="${GREEN}" ;;
warning) color="${YELLOW}" ;;
error) color="${RED}" ;;
esac
@@ -101,119 +86,19 @@ check_version() {
installed_version=$(echo $installed_version | awk '{print $2}')
if [[ "$installed_version" != "$specific_version" ]]; then
print_message info "${MUTED}Installed version: ${NC}$installed_version."
print_message info "Installed version: ${YELLOW}$installed_version."
else
print_message info "${MUTED}Version ${NC}$specific_version${MUTED} already installed"
print_message info "Version ${YELLOW}$specific_version${GREEN} already installed"
exit 0
fi
fi
}
unbuffered_sed() {
if echo | sed -u -e "" >/dev/null 2>&1; then
sed -nu "$@"
elif echo | sed -l -e "" >/dev/null 2>&1; then
sed -nl "$@"
else
local pad="$(printf "\n%512s" "")"
sed -ne "s/$/\\${pad}/" "$@"
fi
}
print_progress() {
local bytes="$1"
local length="$2"
[ "$length" -gt 0 ] || return 0
local width=50
local percent=$(( bytes * 100 / length ))
[ "$percent" -gt 100 ] && percent=100
local on=$(( percent * width / 100 ))
local off=$(( width - on ))
local filled=$(printf "%*s" "$on" "")
filled=${filled// /■}
local empty=$(printf "%*s" "$off" "")
empty=${empty// /・}
printf "\r${ORANGE}%s%s %3d%%${NC}" "$filled" "$empty" "$percent" >&4
}
download_with_progress() {
local url="$1"
local output="$2"
if [ -t 2 ]; then
exec 4>&2
else
exec 4>/dev/null
fi
local tmp_dir=${TMPDIR:-/tmp}
local basename="${tmp_dir}/opencode_install_$$"
local tracefile="${basename}.trace"
rm -f "$tracefile"
mkfifo "$tracefile"
# Hide cursor
printf "\033[?25l" >&4
trap "trap - RETURN; rm -f \"$tracefile\"; printf '\033[?25h' >&4; exec 4>&-" RETURN
(
curl --trace-ascii "$tracefile" -s -L -o "$output" "$url"
) &
local curl_pid=$!
unbuffered_sed \
-e 'y/ACDEGHLNORTV/acdeghlnortv/' \
-e '/^0000: content-length:/p' \
-e '/^<= recv data/p' \
"$tracefile" | \
{
local length=0
local bytes=0
while IFS=" " read -r -a line; do
[ "${#line[@]}" -lt 2 ] && continue
local tag="${line[0]} ${line[1]}"
if [ "$tag" = "0000: content-length:" ]; then
length="${line[2]}"
length=$(echo "$length" | tr -d '\r')
bytes=0
elif [ "$tag" = "<= recv" ]; then
local size="${line[3]}"
bytes=$(( bytes + size ))
if [ "$length" -gt 0 ]; then
print_progress "$bytes" "$length"
fi
fi
done
}
wait $curl_pid
local ret=$?
echo "" >&4
return $ret
}
download_and_install() {
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
mkdir -p opencodetmp && cd opencodetmp
if [[ "$os" == "windows" ]] || ! download_with_progress "$url" "$filename"; then
# Fallback to standard curl on Windows or if custom progress fails
curl -# -L -o "$filename" "$url"
fi
if [ "$os" = "linux" ]; then
tar -xzf "$filename"
else
unzip -q "$filename"
fi
curl -# -L -o "$filename" "$url"
unzip -q "$filename"
mv opencode "$INSTALL_DIR"
chmod 755 "${INSTALL_DIR}/opencode"
cd .. && rm -rf opencodetmp
@@ -232,7 +117,7 @@ add_to_path() {
elif [[ -w $config_file ]]; then
echo -e "\n# opencode" >> "$config_file"
echo "$command" >> "$config_file"
print_message info "${MUTED}Successfully added ${NC}opencode ${MUTED}to \$PATH in ${NC}$config_file"
print_message info "Successfully added ${ORANGE}opencode ${GREEN}to \$PATH in $config_file"
else
print_message warning "Manually add the directory to $config_file (or similar):"
print_message info " $command"
@@ -306,20 +191,3 @@ if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
echo "$INSTALL_DIR" >> $GITHUB_PATH
print_message info "Added $INSTALL_DIR to \$GITHUB_PATH"
fi
echo -e ""
echo -e "${MUTED}  ${NC} ▄ "
echo -e "${MUTED}█▀▀█ █▀▀█ █▀▀█ █▀▀▄ ${NC}█▀▀▀ █▀▀█ █▀▀█ █▀▀█"
echo -e "${MUTED}█░░█ █░░█ █▀▀▀ █░░█ ${NC}█░░░ █░░█ █░░█ █▀▀▀"
echo -e "${MUTED}▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ${NC}▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"
echo -e ""
echo -e ""
echo -e "${MUTED}To get started, navigate to a project and run:${NC}"
echo -e "opencode ${MUTED}Use free models${NC}"
echo -e "opencode auth login ${MUTED}Add paid provider API keys${NC}"
echo -e "opencode help ${MUTED}List commands and options${NC}"
echo -e ""
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
echo -e ""
echo -e ""
@@ -1,15 +1,15 @@
{
"keep": {
"days": true,
"amount": 14
},
"auditLog": "/home/thdxr/dev/projects/sst/opencode/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json",
"files": [
{
"date": 1759827172859,
"name": "/home/thdxr/dev/projects/sst/opencode/logs/mcp-puppeteer-2025-10-07.log",
"hash": "a3d98b26edd793411b968a0d24cfeee8332138e282023c3b83ec169d55c67f16"
}
],
"hashType": "sha256"
}
"keep": {
"days": true,
"amount": 14
},
"auditLog": "/home/thdxr/dev/projects/sst/opencode/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json",
"files": [
{
"date": 1759827172859,
"name": "/home/thdxr/dev/projects/sst/opencode/logs/mcp-puppeteer-2025-10-07.log",
"hash": "a3d98b26edd793411b968a0d24cfeee8332138e282023c3b83ec169d55c67f16"
}
],
"hashType": "sha256"
}
-3
View File
@@ -1,3 +0,0 @@
{
"nodeModules": "sha256-krj85tYW0P/YOnsXnO1cD5XkLtfWZzdXzj7CTNvsPPk="
}
-52
View File
@@ -1,52 +0,0 @@
{ hash, lib, stdenvNoCC, bun, cacert, curl }:
args:
stdenvNoCC.mkDerivation {
pname = "opencode-node_modules";
version = args.version;
src = args.src;
impureEnvVars =
lib.fetchers.proxyImpureEnvVars
++ [
"GIT_PROXY_COMMAND"
"SOCKS_SERVER"
];
nativeBuildInputs = [ bun cacert curl ];
dontConfigure = true;
buildPhase = ''
runHook preBuild
export HOME=$(mktemp -d)
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
bun install \
--cpu="*" \
--os="*" \
--frozen-lockfile \
--ignore-scripts \
--no-progress \
--linker=isolated
bun --bun ${args.canonicalizeScript}
bun --bun ${args.normalizeBinsScript}
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
while IFS= read -r dir; do
rel="''${dir#./}"
dest="$out/$rel"
mkdir -p "$(dirname "$dest")"
cp -R "$dir" "$dest"
done < <(find . -type d -name node_modules -prune | sort)
runHook postInstall
'';
dontFixup = true;
outputHashAlgo = "sha256";
outputHashMode = "recursive";
outputHash = hash;
}
-108
View File
@@ -1,108 +0,0 @@
{ lib, stdenv, stdenvNoCC, bun, fzf, ripgrep, makeBinaryWrapper }:
args:
let
scripts = args.scripts;
mkModules =
attrs:
args.mkNodeModules (
attrs
// {
canonicalizeScript = scripts + "/canonicalize-node-modules.ts";
normalizeBinsScript = scripts + "/normalize-bun-binaries.ts";
}
);
in
stdenvNoCC.mkDerivation (finalAttrs: {
pname = "opencode";
version = args.version;
src = args.src;
node_modules = mkModules {
version = finalAttrs.version;
src = finalAttrs.src;
};
nativeBuildInputs = [
bun
makeBinaryWrapper
];
configurePhase = ''
runHook preConfigure
cp -R ${finalAttrs.node_modules}/. .
runHook postConfigure
'';
env.MODELS_DEV_API_JSON = args.modelsDev;
env.OPENCODE_VERSION = args.version;
env.OPENCODE_CHANNEL = "stable";
buildPhase = ''
runHook preBuild
cp ${scripts + "/bun-build.ts"} bun-build.ts
substituteInPlace bun-build.ts \
--replace '@VERSION@' "${finalAttrs.version}"
export BUN_COMPILE_TARGET=${args.target}
bun --bun bun-build.ts
runHook postBuild
'';
dontStrip = true;
installPhase = ''
runHook preInstall
cd packages/opencode
if [ ! -f opencode ]; then
echo "ERROR: opencode binary not found in $(pwd)"
ls -la
exit 1
fi
if [ ! -f opencode-worker.js ]; then
echo "ERROR: opencode worker bundle not found in $(pwd)"
ls -la
exit 1
fi
install -Dm755 opencode $out/bin/opencode
install -Dm644 opencode-worker.js $out/bin/opencode-worker.js
if [ -f opencode-assets.manifest ]; then
while IFS= read -r asset; do
[ -z "$asset" ] && continue
if [ ! -f "$asset" ]; then
echo "ERROR: referenced asset \"$asset\" missing"
exit 1
fi
install -Dm644 "$asset" "$out/bin/$(basename "$asset")"
done < opencode-assets.manifest
fi
runHook postInstall
'';
postFixup = ''
wrapProgram "$out/bin/opencode" --prefix PATH : ${lib.makeBinPath [ fzf ripgrep ]}
'';
meta = {
description = "AI coding agent built for the terminal";
longDescription = ''
OpenCode is a terminal-based agent that can build anything.
It combines a TypeScript/JavaScript core with a Go-based TUI
to provide an interactive AI coding experience.
'';
homepage = "https://github.com/sst/opencode";
license = lib.licenses.mit;
platforms = [
"aarch64-linux"
"x86_64-linux"
"aarch64-darwin"
"x86_64-darwin"
];
mainProgram = "opencode";
};
})
-115
View File
@@ -1,115 +0,0 @@
import solidPlugin from "./packages/opencode/node_modules/@opentui/solid/scripts/solid-plugin"
import path from "path"
import fs from "fs"
const version = "@VERSION@"
const pkg = path.join(process.cwd(), "packages/opencode")
const parser = fs.realpathSync(path.join(pkg, "./node_modules/@opentui/core/parser.worker.js"))
const worker = "./src/cli/cmd/tui/worker.ts"
const target = process.env["BUN_COMPILE_TARGET"]
if (!target) {
throw new Error("BUN_COMPILE_TARGET not set")
}
process.chdir(pkg)
const manifestName = "opencode-assets.manifest"
const manifestPath = path.join(pkg, manifestName)
const readTrackedAssets = () => {
if (!fs.existsSync(manifestPath)) return []
return fs
.readFileSync(manifestPath, "utf8")
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
}
const removeTrackedAssets = () => {
for (const file of readTrackedAssets()) {
const filePath = path.join(pkg, file)
if (fs.existsSync(filePath)) {
fs.rmSync(filePath, { force: true })
}
}
}
const assets = new Set<string>()
const addAsset = async (p: string) => {
const file = path.basename(p)
const dest = path.join(pkg, file)
await Bun.write(dest, Bun.file(p))
assets.add(file)
}
removeTrackedAssets()
const result = await Bun.build({
conditions: ["browser"],
tsconfig: "./tsconfig.json",
plugins: [solidPlugin],
sourcemap: "external",
entrypoints: ["./src/index.ts", parser, worker],
define: {
OPENCODE_VERSION: `'@VERSION@'`,
OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(pkg, parser).replace(/\\/g, "/"),
OPENCODE_CHANNEL: "'latest'",
},
compile: {
target,
outfile: "opencode",
execArgv: ["--user-agent=opencode/" + version, '--env-file=""', "--"],
windows: {},
},
})
if (!result.success) {
console.error("Build failed!")
for (const log of result.logs) {
console.error(log)
}
throw new Error("Compilation failed")
}
const assetOutputs = result.outputs?.filter((x) => x.kind === "asset") ?? []
for (const x of assetOutputs) {
await addAsset(x.path)
}
const bundle = await Bun.build({
entrypoints: [worker],
tsconfig: "./tsconfig.json",
plugins: [solidPlugin],
target: "bun",
outdir: "./.opencode-worker",
sourcemap: "none",
})
if (!bundle.success) {
console.error("Worker build failed!")
for (const log of bundle.logs) {
console.error(log)
}
throw new Error("Worker compilation failed")
}
const workerAssets = bundle.outputs?.filter((x) => x.kind === "asset") ?? []
for (const x of workerAssets) {
await addAsset(x.path)
}
const output = bundle.outputs.find((x) => x.kind === "entry-point")
if (!output) {
throw new Error("Worker build produced no entry-point output")
}
const dest = path.join(pkg, "opencode-worker.js")
await Bun.write(dest, Bun.file(output.path))
fs.rmSync(path.dirname(output.path), { recursive: true, force: true })
const list = Array.from(assets)
await Bun.write(manifestPath, list.length > 0 ? list.join("\n") + "\n" : "")
console.log("Build successful!")
-96
View File
@@ -1,96 +0,0 @@
import { lstat, mkdir, readdir, rm, symlink } from "fs/promises"
import { join, relative } from "path"
type SemverLike = {
valid: (value: string) => string | null
rcompare: (left: string, right: string) => number
}
type Entry = {
dir: string
version: string
label: string
}
const root = process.cwd()
const bunRoot = join(root, "node_modules/.bun")
const linkRoot = join(bunRoot, "node_modules")
const directories = (await readdir(bunRoot)).sort()
const versions = new Map<string, Entry[]>()
for (const entry of directories) {
const full = join(bunRoot, entry)
const info = await lstat(full)
if (!info.isDirectory()) {
continue
}
const marker = entry.lastIndexOf("@")
if (marker <= 0) {
continue
}
const slug = entry.slice(0, marker).replace(/\+/g, "/")
const version = entry.slice(marker + 1)
const list = versions.get(slug) ?? []
list.push({ dir: full, version, label: entry })
versions.set(slug, list)
}
const semverModule = (await import(join(bunRoot, "node_modules/semver"))) as
| SemverLike
| {
default: SemverLike
}
const semver = "default" in semverModule ? semverModule.default : semverModule
const selections = new Map<string, Entry>()
for (const [slug, list] of versions) {
list.sort((a, b) => {
const left = semver.valid(a.version)
const right = semver.valid(b.version)
if (left && right) {
const delta = semver.rcompare(left, right)
if (delta !== 0) {
return delta
}
}
if (left && !right) {
return -1
}
if (!left && right) {
return 1
}
return b.version.localeCompare(a.version)
})
selections.set(slug, list[0])
}
await rm(linkRoot, { recursive: true, force: true })
await mkdir(linkRoot, { recursive: true })
const rewrites: string[] = []
for (const [slug, entry] of Array.from(selections.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
const parts = slug.split("/")
const leaf = parts.pop()
if (!leaf) {
continue
}
const parent = join(linkRoot, ...parts)
await mkdir(parent, { recursive: true })
const linkPath = join(parent, leaf)
const desired = join(entry.dir, "node_modules", slug)
const relativeTarget = relative(parent, desired)
const resolved = relativeTarget.length === 0 ? "." : relativeTarget
await rm(linkPath, { recursive: true, force: true })
await symlink(resolved, linkPath)
rewrites.push(slug + " -> " + resolved)
}
rewrites.sort()
console.log("[canonicalize-node-modules] rebuilt", rewrites.length, "links")
for (const line of rewrites.slice(0, 20)) {
console.log(" ", line)
}
if (rewrites.length > 20) {
console.log(" ...")
}
-138
View File
@@ -1,138 +0,0 @@
import { lstat, mkdir, readdir, rm, symlink } from "fs/promises"
import { join, relative } from "path"
type PackageManifest = {
name?: string
bin?: string | Record<string, string>
}
const root = process.cwd()
const bunRoot = join(root, "node_modules/.bun")
const bunEntries = (await safeReadDir(bunRoot)).sort()
let rewritten = 0
for (const entry of bunEntries) {
const modulesRoot = join(bunRoot, entry, "node_modules")
if (!(await exists(modulesRoot))) {
continue
}
const binRoot = join(modulesRoot, ".bin")
await rm(binRoot, { recursive: true, force: true })
await mkdir(binRoot, { recursive: true })
const packageDirs = await collectPackages(modulesRoot)
for (const packageDir of packageDirs) {
const manifest = await readManifest(packageDir)
if (!manifest) {
continue
}
const binField = manifest.bin
if (!binField) {
continue
}
const seen = new Set<string>()
if (typeof binField === "string") {
const fallback = manifest.name ?? packageDir.split("/").pop()
if (fallback) {
await linkBinary(binRoot, fallback, packageDir, binField, seen)
}
} else {
const entries = Object.entries(binField).sort((a, b) => a[0].localeCompare(b[0]))
for (const [name, target] of entries) {
await linkBinary(binRoot, name, packageDir, target, seen)
}
}
}
}
console.log(`[normalize-bun-binaries] rewrote ${rewritten} links`)
async function collectPackages(modulesRoot: string) {
const found: string[] = []
const topLevel = (await safeReadDir(modulesRoot)).sort()
for (const name of topLevel) {
if (name === ".bin" || name === ".bun") {
continue
}
const full = join(modulesRoot, name)
if (!(await isDirectory(full))) {
continue
}
if (name.startsWith("@")) {
const scoped = (await safeReadDir(full)).sort()
for (const child of scoped) {
const scopedDir = join(full, child)
if (await isDirectory(scopedDir)) {
found.push(scopedDir)
}
}
continue
}
found.push(full)
}
return found.sort()
}
async function readManifest(dir: string) {
const file = Bun.file(join(dir, "package.json"))
if (!(await file.exists())) {
return null
}
const data = (await file.json()) as PackageManifest
return data
}
async function linkBinary(binRoot: string, name: string, packageDir: string, target: string, seen: Set<string>) {
if (!name || !target) {
return
}
const normalizedName = normalizeBinName(name)
if (seen.has(normalizedName)) {
return
}
const resolved = join(packageDir, target)
const script = Bun.file(resolved)
if (!(await script.exists())) {
return
}
seen.add(normalizedName)
const destination = join(binRoot, normalizedName)
const relativeTarget = relative(binRoot, resolved) || "."
await rm(destination, { force: true })
await symlink(relativeTarget, destination)
rewritten++
}
async function exists(path: string) {
try {
await lstat(path)
return true
} catch {
return false
}
}
async function isDirectory(path: string) {
try {
const info = await lstat(path)
return info.isDirectory()
} catch {
return false
}
}
async function safeReadDir(path: string) {
try {
return await readdir(path)
} catch {
return []
}
}
function normalizeBinName(name: string) {
const slash = name.lastIndexOf("/")
if (slash >= 0) {
return name.slice(slash + 1)
}
return name
}
-112
View File
@@ -1,112 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
DUMMY="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
SYSTEM=${SYSTEM:-x86_64-linux}
DEFAULT_HASH_FILE=${MODULES_HASH_FILE:-nix/hashes.json}
HASH_FILE=${HASH_FILE:-$DEFAULT_HASH_FILE}
if [ ! -f "$HASH_FILE" ]; then
cat >"$HASH_FILE" <<EOF
{
"nodeModules": "$DUMMY"
}
EOF
fi
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
if ! git ls-files --error-unmatch "$HASH_FILE" >/dev/null 2>&1; then
git add -N "$HASH_FILE" >/dev/null 2>&1 || true
fi
fi
export DUMMY
export NIX_KEEP_OUTPUTS=1
export NIX_KEEP_DERIVATIONS=1
cleanup() {
rm -f "${JSON_OUTPUT:-}" "${BUILD_LOG:-}" "${TMP_EXPR:-}"
}
trap cleanup EXIT
write_node_modules_hash() {
local value="$1"
local temp
temp=$(mktemp)
jq --arg value "$value" '.nodeModules = $value' "$HASH_FILE" >"$temp"
mv "$temp" "$HASH_FILE"
}
TARGET="packages.${SYSTEM}.default"
MODULES_ATTR=".#packages.${SYSTEM}.default.node_modules"
CORRECT_HASH=""
DRV_PATH="$(nix eval --raw "${MODULES_ATTR}.drvPath")"
echo "Setting dummy node_modules outputHash for ${SYSTEM}..."
write_node_modules_hash "$DUMMY"
BUILD_LOG=$(mktemp)
JSON_OUTPUT=$(mktemp)
echo "Building node_modules for ${SYSTEM} to discover correct outputHash..."
echo "Attempting to realize derivation: ${DRV_PATH}"
REALISE_OUT=$(nix-store --realise "$DRV_PATH" --keep-failed 2>&1 | tee "$BUILD_LOG" || true)
BUILD_PATH=$(echo "$REALISE_OUT" | grep "^/nix/store/" | head -n1 || true)
if [ -n "$BUILD_PATH" ] && [ -d "$BUILD_PATH" ]; then
echo "Realized node_modules output: $BUILD_PATH"
CORRECT_HASH=$(nix hash path --sri "$BUILD_PATH" 2>/dev/null || true)
fi
if [ -z "$CORRECT_HASH" ]; then
CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)"
if [ -z "$CORRECT_HASH" ]; then
CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)"
fi
if [ -z "$CORRECT_HASH" ]; then
echo "Searching for kept failed build directory..."
KEPT_DIR=$(grep -oE "build directory.*'[^']+'" "$BUILD_LOG" | grep -oE "'/[^']+'" | tr -d "'" | head -n1)
if [ -z "$KEPT_DIR" ]; then
KEPT_DIR=$(grep -oE '/nix/var/nix/builds/[^ ]+' "$BUILD_LOG" | head -n1)
fi
if [ -n "$KEPT_DIR" ] && [ -d "$KEPT_DIR" ]; then
echo "Found kept build directory: $KEPT_DIR"
if [ -d "$KEPT_DIR/build" ]; then
HASH_PATH="$KEPT_DIR/build"
else
HASH_PATH="$KEPT_DIR"
fi
echo "Attempting to hash: $HASH_PATH"
ls -la "$HASH_PATH" || true
if [ -d "$HASH_PATH/node_modules" ]; then
CORRECT_HASH=$(nix hash path --sri "$HASH_PATH" 2>/dev/null || true)
echo "Computed hash from kept build: $CORRECT_HASH"
fi
fi
fi
fi
if [ -z "$CORRECT_HASH" ]; then
echo "Failed to determine correct node_modules hash for ${SYSTEM}."
echo "Build log:"
cat "$BUILD_LOG"
exit 1
fi
write_node_modules_hash "$CORRECT_HASH"
jq -e --arg hash "$CORRECT_HASH" '.nodeModules == $hash' "$HASH_FILE" >/dev/null
echo "node_modules hash updated for ${SYSTEM}: $CORRECT_HASH"
rm -f "$BUILD_LOG"
unset BUILD_LOG
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-openai-codex-auth"],
"mcp": {
"weather": {
"type": "local",
"command": ["bun", "x", "@h1deya/mcp-server-weather"]
},
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
"headers": {
"CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"
}
}
}
}
+10 -16
View File
@@ -4,13 +4,12 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.3",
"packageManager": "bun@1.3.1",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"random": "echo 'Random script'",
"hello": "echo 'Hello World!'"
"random": "echo 'Random script'"
},
"workspaces": {
"packages": [
@@ -20,48 +19,43 @@
"packages/slack"
],
"catalog": {
"@types/bun": "1.3.3",
"@types/bun": "1.3.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@tsconfig/node22": "22.0.2",
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/precision-diffs": "0.5.4",
"@pierre/precision-diffs": "0.4.1",
"@solidjs/meta": "0.29.4",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"ai": "5.0.97",
"ai": "5.0.8",
"hono": "4.7.10",
"hono-openapi": "1.1.1",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251014.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"solid-js": "1.9.9",
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
"virtua": "0.42.3",
"vite": "7.1.4",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dbff19d",
"solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10"
"vite-plugin-solid": "2.11.8"
}
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"husky": "9.1.7",
"prettier": "3.6.2",
"sst": "3.17.23",
"sst": "3.17.19",
"turbo": "2.5.6"
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*"
},
@@ -72,7 +66,7 @@
"license": "MIT",
"prettier": {
"semi": false,
"printWidth": 120
"printWidth": 100
},
"trustedDependencies": [
"esbuild",
+1 -3
View File
@@ -3,6 +3,7 @@ dist
.output
.vercel
.netlify
.vinxi
app.config.timestamp_*.js
# Environment
@@ -22,9 +23,6 @@ app.config.timestamp_*.js
# Temp
gitignore
# Generated files
public/sitemap.xml
# System Files
.DS_Store
Thumbs.db
+23
View File
@@ -0,0 +1,23 @@
import { defineConfig } from "@solidjs/start/config"
export default defineConfig({
middleware: "./src/middleware.ts",
vite: {
server: {
allowedHosts: true,
},
build: {
rollupOptions: {
external: ["cloudflare:workers"],
},
minify: false,
},
},
server: {
compatibilityDate: "2024-09-19",
preset: "cloudflare_module",
cloudflare: {
nodeCompat: true,
},
},
})
+12 -16
View File
@@ -1,36 +1,32 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.99",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
"dev": "vite dev --host 0.0.0.0",
"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": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vite start"
"build": "vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "1.0.15"
},
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
"@jsx-email/render": "1.1.1",
"@kobalte/core": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/console-core": "workspace:*",
"@opencode-ai/console-mail": "workspace:*",
"@openauthjs/openauth": "catalog:",
"@kobalte/core": "catalog:",
"@jsx-email/render": "1.1.1",
"@opencode-ai/console-resource": "workspace:*",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"chart.js": "4.5.1",
"nitro": "3.0.1-alpha.1",
"@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.1.0",
"solid-js": "catalog:",
"vite": "catalog:",
"vinxi": "^0.5.7",
"zod": "catalog:"
},
"devDependencies": {
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"wrangler": "4.50.0"
"@typescript/native-preview": "catalog:"
},
"engines": {
"node": ">=22"
@@ -1,103 +0,0 @@
#!/usr/bin/env bun
import { readdir, writeFile } from "fs/promises"
import { join, dirname } from "path"
import { fileURLToPath } from "url"
import { config } from "../src/config.js"
const __dirname = dirname(fileURLToPath(import.meta.url))
const BASE_URL = config.baseUrl
const PUBLIC_DIR = join(__dirname, "../public")
const ROUTES_DIR = join(__dirname, "../src/routes")
const DOCS_DIR = join(__dirname, "../../../web/src/content/docs")
interface SitemapEntry {
url: string
priority: number
changefreq: string
}
async function getMainRoutes(): Promise<SitemapEntry[]> {
const routes: SitemapEntry[] = []
// Add main static routes
const staticRoutes = [
{ path: "/", priority: 1.0, changefreq: "daily" },
{ path: "/enterprise", priority: 0.8, changefreq: "weekly" },
{ path: "/brand", priority: 0.6, changefreq: "monthly" },
{ path: "/zen", priority: 0.8, changefreq: "weekly" },
]
for (const route of staticRoutes) {
routes.push({
url: `${BASE_URL}${route.path}`,
priority: route.priority,
changefreq: route.changefreq,
})
}
return routes
}
async function getDocsRoutes(): Promise<SitemapEntry[]> {
const routes: SitemapEntry[] = []
try {
const files = await readdir(DOCS_DIR)
for (const file of files) {
if (!file.endsWith(".mdx")) continue
const slug = file.replace(".mdx", "")
const path = slug === "index" ? "/docs/" : `/docs/${slug}`
routes.push({
url: `${BASE_URL}${path}`,
priority: slug === "index" ? 0.9 : 0.7,
changefreq: "weekly",
})
}
} catch (error) {
console.error("Error reading docs directory:", error)
}
return routes
}
function generateSitemapXML(entries: SitemapEntry[]): string {
const urls = entries
.map(
(entry) => ` <url>
<loc>${entry.url}</loc>
<changefreq>${entry.changefreq}</changefreq>
<priority>${entry.priority}</priority>
</url>`,
)
.join("\n")
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`
}
async function main() {
console.log("Generating sitemap...")
const mainRoutes = await getMainRoutes()
const docsRoutes = await getDocsRoutes()
const allRoutes = [...mainRoutes, ...docsRoutes]
console.log(`Found ${mainRoutes.length} main routes`)
console.log(`Found ${docsRoutes.length} docs routes`)
console.log(`Total: ${allRoutes.length} routes`)
const xml = generateSitemapXML(allRoutes)
const outputPath = join(PUBLIC_DIR, "sitemap.xml")
await writeFile(outputPath, xml, "utf-8")
console.log(`✓ Sitemap generated at ${outputPath}`)
}
main()
+1 -1
View File
@@ -12,7 +12,7 @@ export default function App() {
root={(props) => (
<MetaProvider>
<Title>opencode</Title>
<Meta name="description" content="OpenCode - The AI coding agent built for the terminal." />
<Meta name="description" content="opencode - The AI coding agent built for the terminal." />
<Suspense>{props.children}</Suspense>
</MetaProvider>
)}
@@ -77,4 +77,4 @@
background-color: var(--color-accent-alpha);
}
}
}
}
@@ -1,7 +1,6 @@
import { createAsync } from "@solidjs/router"
import { createMemo } from "solid-js"
import { github } from "~/lib/github"
import { config } from "~/config"
export function Footer() {
const githubData = createAsync(() => github())
@@ -11,13 +10,13 @@ export function Footer() {
notation: "compact",
compactDisplay: "short",
}).format(githubData()!.stars!)
: config.github.starsFormatted.compact,
: "25K",
)
return (
<footer data-component="footer">
<div data-slot="cell">
<a href={config.github.repoUrl} target="_blank">
<a href="https://github.com/sst/opencode" target="_blank">
GitHub <span>[{starCount()}]</span>
</a>
</div>
@@ -28,7 +27,7 @@ export function Footer() {
<a href="/discord">Discord</a>
</div>
<div data-slot="cell">
<a href={config.social.twitter}>X</a>
<a href="https://x.com/opencode">X</a>
</div>
</footer>
)
@@ -18,7 +18,6 @@ import { createMemo, Match, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { github } from "~/lib/github"
import { createEffect, onCleanup } from "solid-js"
import { config } from "~/config"
import "./header-context-menu.css"
const isDarkMode = () => window.matchMedia("(prefers-color-scheme: dark)").matches
@@ -43,7 +42,7 @@ export function Header(props: { zen?: boolean }) {
notation: "compact",
compactDisplay: "short",
}).format(githubData()?.stars!)
: config.github.starsFormatted.compact,
: "29K",
)
const [store, setStore] = createStore({
@@ -149,7 +148,7 @@ export function Header(props: { zen?: boolean }) {
<nav data-component="nav-desktop">
<ul>
<li>
<a href={config.github.repoUrl} target="_blank">
<a href="https://github.com/sst/opencode" target="_blank">
GitHub <span>[{starCount()}]</span>
</a>
</li>
@@ -223,7 +222,7 @@ export function Header(props: { zen?: boolean }) {
<A href="/">Home</A>
</li>
<li>
<a href={config.github.repoUrl} target="_blank">
<a href="https://github.com/sst/opencode" target="_blank">
GitHub <span>[{starCount()}]</span>
</a>
</li>
@@ -202,14 +202,6 @@ export function IconZai(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
)
}
export function IconGoogle(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 50 50" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M49.04,24.001l-1.082-0.043h-0.001C36.134,23.492,26.508,13.866,26.042,2.043L25.999,0.96C25.978,0.424,25.537,0,25,0 s-0.978,0.424-0.999,0.96l-0.043,1.083C23.492,13.866,13.866,23.492,2.042,23.958L0.96,24.001C0.424,24.022,0,24.463,0,25 c0,0.537,0.424,0.978,0.961,0.999l1.082,0.042c11.823,0.467,21.449,10.093,21.915,21.916l0.043,1.083C24.022,49.576,24.463,50,25,50 s0.978-0.424,0.999-0.96l0.043-1.083c0.466-11.823,10.092-21.449,21.915-21.916l1.082-0.042C49.576,25.978,50,25.537,50,25 C50,24.463,49.576,24.022,49.04,24.001z"></path>
</svg>
)
}
export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 18" fill="none">
@@ -220,30 +212,3 @@ export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
</svg>
)
}
export function IconChevronLeft(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 20 20" fill="none">
<path d="M12 15L7 10L12 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" />
</svg>
)
}
export function IconChevronRight(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} viewBox="0 0 20 20" fill="none">
<path d="M8 5L13 10L8 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" />
</svg>
)
}
export function IconBreakdown(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg {...props} width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M2 12L2 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M6 12L6 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M10 12L10 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M14 12L14 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
)
}
+1 -1
View File
@@ -63,4 +63,4 @@
font-weight: 600;
color: var(--color-text);
}
}
}
-29
View File
@@ -1,29 +0,0 @@
/**
* Application-wide constants and configuration
*/
export const config = {
// Base URL
baseUrl: "https://opencode.ai",
// GitHub
github: {
repoUrl: "https://github.com/sst/opencode",
starsFormatted: {
compact: "30K",
full: "30,000",
},
},
// Social links
social: {
twitter: "https://x.com/opencode",
discord: "https://discord.gg/opencode",
},
// Static stats (used on landing page)
stats: {
contributors: "300",
commits: "4,000",
monthlyUsers: "300,000",
},
} as const
@@ -1,4 +1,4 @@
import { useSession } from "@solidjs/start/http"
import { useSession } from "vinxi/http"
export interface AuthSession {
account?: Record<
-4
View File
@@ -1,5 +1 @@
/// <reference types="@solidjs/start/env" />
export declare module "@solidjs/start/server" {
export type APIEvent = { request: Request }
}
+3 -5
View File
@@ -1,5 +1,4 @@
import { query } from "@solidjs/router"
import { config } from "~/config"
export const github = query(async () => {
"use server"
@@ -7,12 +6,11 @@ export const github = query(async () => {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
}
const apiBaseUrl = config.github.repoUrl.replace("https://github.com/", "https://api.github.com/repos/")
try {
const [meta, releases, contributors] = await Promise.all([
fetch(apiBaseUrl, { headers }).then((res) => res.json()),
fetch(`${apiBaseUrl}/releases`, { headers }).then((res) => res.json()),
fetch(`${apiBaseUrl}/contributors?per_page=1`, { headers }),
fetch("https://api.github.com/repos/sst/opencode", { headers }).then((res) => res.json()),
fetch("https://api.github.com/repos/sst/opencode/releases", { headers }).then((res) => res.json()),
fetch("https://api.github.com/repos/sst/opencode/contributors?per_page=1", { headers }),
])
const [release] = releases
const contributorCount = Number.parseInt(
+2 -2
View File
@@ -1,5 +1,5 @@
import { createMiddleware } from "@solidjs/start/middleware"
import { defineMiddleware } from "vinxi/http"
export default createMiddleware({
export default defineMiddleware({
onBeforeResponse() {},
})
@@ -264,7 +264,7 @@
[data-component="brand-content"] {
padding: 4rem 5rem;
h1 {
h2 {
font-size: 1.5rem;
font-weight: 500;
color: var(--color-text-strong);
@@ -299,6 +299,7 @@
transition: all 0.2s ease;
text-decoration: none;
&:hover:not(:disabled) {
background: var(--color-background-strong-hover);
}
@@ -384,21 +385,23 @@
0 1px 2px -1px rgba(19, 16, 16, 0.12);
@media (max-width: 40rem) {
box-shadow: 0 0 0 1px rgba(19, 16, 16, 0.16);
box-shadow:
0 0 0 1px rgba(19, 16, 16, 0.16)
}
&:hover {
background: var(--color-background);
}
&:active {
transform: scale(0.98);
box-shadow:
0 0 0 1px rgba(19, 16, 16, 0.08),
0 6px 8px -8px rgba(19, 16, 16, 0.5);
box-shadow: 0 0 0 1px rgba(19, 16, 16, 0.08), 0 6px 8px -8px rgba(19, 16, 16, 0.50);
}
}
@media (max-width: 60rem) {
padding: 2rem 1.5rem;
}
+125 -25
View File
@@ -1,7 +1,6 @@
import "./index.css"
import { Title, Meta, Link } from "@solidjs/meta"
import { Title, Meta } from "@solidjs/meta"
import { Header } from "~/component/header"
import { config } from "~/config"
import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
import previewLogoLight from "../../asset/brand/preview-opencode-logo-light.png"
@@ -54,21 +53,26 @@ export default function Brand() {
return (
<main data-page="enterprise">
<Title>OpenCode | Brand</Title>
<Link rel="canonical" href={`${config.baseUrl}/brand`} />
<Meta name="description" content="OpenCode brand guidelines" />
<div data-component="container">
<Header />
<div data-component="content">
<section data-component="brand-content">
<h1>Brand guidelines</h1>
<h2>Brand guidelines</h2>
<p>Resources and assets to help you work with the OpenCode brand.</p>
<button
data-component="download-button"
onClick={() => downloadFile(brandAssets, "opencode-brand-assets.zip")}
>
Download all assets
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -84,7 +88,13 @@ export default function Brand() {
<div data-component="actions">
<button onClick={() => downloadFile(logoLightPng, "opencode-logo-light.png")}>
PNG
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -95,7 +105,13 @@ export default function Brand() {
</button>
<button onClick={() => downloadFile(logoLightSvg, "opencode-logo-light.svg")}>
SVG
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -111,7 +127,13 @@ export default function Brand() {
<div data-component="actions">
<button onClick={() => downloadFile(logoDarkPng, "opencode-logo-dark.png")}>
PNG
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -122,7 +144,13 @@ export default function Brand() {
</button>
<button onClick={() => downloadFile(logoDarkSvg, "opencode-logo-dark.svg")}>
SVG
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -136,9 +164,17 @@ export default function Brand() {
<div>
<img src={previewWordmarkLight} alt="OpenCode brand guidelines" />
<div data-component="actions">
<button onClick={() => downloadFile(wordmarkLightPng, "opencode-wordmark-light.png")}>
<button
onClick={() => downloadFile(wordmarkLightPng, "opencode-wordmark-light.png")}
>
PNG
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -147,9 +183,17 @@ export default function Brand() {
/>
</svg>
</button>
<button onClick={() => downloadFile(wordmarkLightSvg, "opencode-wordmark-light.svg")}>
<button
onClick={() => downloadFile(wordmarkLightSvg, "opencode-wordmark-light.svg")}
>
SVG
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -163,9 +207,17 @@ export default function Brand() {
<div>
<img src={previewWordmarkDark} alt="OpenCode brand guidelines" />
<div data-component="actions">
<button onClick={() => downloadFile(wordmarkDarkPng, "opencode-wordmark-dark.png")}>
<button
onClick={() => downloadFile(wordmarkDarkPng, "opencode-wordmark-dark.png")}
>
PNG
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -174,9 +226,17 @@ export default function Brand() {
/>
</svg>
</button>
<button onClick={() => downloadFile(wordmarkDarkSvg, "opencode-wordmark-dark.svg")}>
<button
onClick={() => downloadFile(wordmarkDarkSvg, "opencode-wordmark-dark.svg")}
>
SVG
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -190,9 +250,19 @@ export default function Brand() {
<div>
<img src={previewWordmarkSimpleLight} alt="OpenCode brand guidelines" />
<div data-component="actions">
<button onClick={() => downloadFile(wordmarkSimpleLightPng, "opencode-wordmark-simple-light.png")}>
<button
onClick={() =>
downloadFile(wordmarkSimpleLightPng, "opencode-wordmark-simple-light.png")
}
>
PNG
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -201,9 +271,19 @@ export default function Brand() {
/>
</svg>
</button>
<button onClick={() => downloadFile(wordmarkSimpleLightSvg, "opencode-wordmark-simple-light.svg")}>
<button
onClick={() =>
downloadFile(wordmarkSimpleLightSvg, "opencode-wordmark-simple-light.svg")
}
>
SVG
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -217,9 +297,19 @@ export default function Brand() {
<div>
<img src={previewWordmarkSimpleDark} alt="OpenCode brand guidelines" />
<div data-component="actions">
<button onClick={() => downloadFile(wordmarkSimpleDarkPng, "opencode-wordmark-simple-dark.png")}>
<button
onClick={() =>
downloadFile(wordmarkSimpleDarkPng, "opencode-wordmark-simple-dark.png")
}
>
PNG
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -228,9 +318,19 @@ export default function Brand() {
/>
</svg>
</button>
<button onClick={() => downloadFile(wordmarkSimpleDarkSvg, "opencode-wordmark-simple-dark.svg")}>
<button
onClick={() =>
downloadFile(wordmarkSimpleDarkSvg, "opencode-wordmark-simple-dark.svg")
}
>
SVG
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
@@ -1,5 +0,0 @@
import { redirect } from "@solidjs/router"
export async function GET() {
return redirect("https://discord.gg/h5TNnkFVNy")
}
@@ -287,7 +287,7 @@
}
[data-component="enterprise-column-1"] {
h1 {
h2 {
font-size: 1.5rem;
font-weight: 500;
color: var(--color-text-strong);
@@ -1,7 +1,6 @@
import "./index.css"
import { Title, Meta, Link } from "@solidjs/meta"
import { Title, Meta } from "@solidjs/meta"
import { createSignal, Show } from "solid-js"
import { config } from "~/config"
import { Header } from "~/component/header"
import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
@@ -55,7 +54,6 @@ export default function Enterprise() {
return (
<main data-page="enterprise">
<Title>OpenCode | Enterprise solutions for your organisation</Title>
<Link rel="canonical" href={`${config.baseUrl}/enterprise`} />
<Meta name="description" content="Contact OpenCode for enterprise solutions" />
<div data-component="container">
<Header />
@@ -64,28 +62,41 @@ export default function Enterprise() {
<section data-component="enterprise-content">
<div data-component="enterprise-columns">
<div data-component="enterprise-column-1">
<h1>Your code is yours</h1>
<h2>Your code is yours</h2>
<p>
OpenCode operates securely inside your organization with no data or context stored and no licensing
restrictions or ownership claims. Start a trial with your team, then deploy it across your
organization by integrating it with your SSO and internal AI gateway.
OpenCode operates securely inside your organization with no data or context stored
and no licensing restrictions or ownership claims. Start a trial with your team,
then deploy it across your organization by integrating it with your SSO and
internal AI gateway.
</p>
<p>Let us know and how we can help.</p>
<Show when={false}>
<div data-component="testimonial">
<div data-component="quotation">
<svg width="20" height="17" viewBox="0 0 20 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="20"
height="17"
viewBox="0 0 20 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.4118 0L16.5882 9.20833H20V17H12.2353V10.0938L16 0H19.4118ZM7.17647 0L4.35294 9.20833H7.76471V17H0V10.0938L3.76471 0H7.17647Z"
fill="currentColor"
/>
</svg>
</div>
Thanks to OpenCode, we found a way to create software to track all our assets even the imaginary
ones.
Thanks to OpenCode, we found a way to create software to track all our assets
even the imaginary ones.
<div data-component="testimonial-logo">
<svg width="80" height="79" viewBox="0 0 80 79" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="80"
height="79"
viewBox="0 0 80 79"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
@@ -202,7 +213,11 @@ export default function Enterprise() {
</button>
</form>
{showSuccess() && <div data-component="success-message">Message sent, we'll be in touch soon.</div>}
{showSuccess() && (
<div data-component="success-message">
Message sent, we'll be in touch soon.
</div>
)}
</div>
</div>
</div>
@@ -215,29 +230,31 @@ export default function Enterprise() {
<ul>
<li>
<Faq question="What is OpenCode Enterprise?">
OpenCode Enterprise is for organizations that want to ensure that their code and data never leaves
their infrastructure. It can do this by using a centralized config that integrates with your SSO and
internal AI gateway.
OpenCode Enterprise is for organizations that want to ensure that their code and
data never leaves their infrastructure. It can do this by using a centralized
config that integrates with your SSO and internal AI gateway.
</Faq>
</li>
<li>
<Faq question="How do I get started with OpenCode Enterprise?">
Simply start with an internal trial with your team. OpenCode by default does not store your code or
context data, making it easy to get started. Then contact us to discuss pricing and implementation
options.
Simply start with an internal trial with your team. OpenCode by default does not
store your code or context data, making it easy to get started. Then contact us to
discuss pricing and implementation options.
</Faq>
</li>
<li>
<Faq question="How does enterprise pricing work?">
We offer per-seat enterprise pricing. If you have your own LLM gateway, we do not charge for tokens
used. For further details, contact us for a custom quote based on your organization's needs.
We offer per-seat enterprise pricing. If you have your own LLM gateway, we do not
charge for tokens used. For further details, contact us for a custom quote based
on your organization's needs.
</Faq>
</li>
<li>
<Faq question="Is my data secure with OpenCode Enterprise?">
Yes. OpenCode does not store your code or context data. All processing happens locally or through
direct API calls to your AI provider. With central config and SSO integration, your data remains
secure within your organization's infrastructure.
Yes. OpenCode does not store your code or context data. All processing happens
locally or through direct API calls to your AI provider. With central config and
SSO integration, your data remains secure within your organization's
infrastructure.
</Faq>
</li>
</ul>
+1 -1
View File
@@ -479,7 +479,7 @@ body {
border-bottom: 1px solid var(--color-border-weak);
}
h1 {
strong {
font-size: 28px;
color: var(--color-text-strong);
font-weight: 500;
+16 -15
View File
@@ -1,6 +1,6 @@
import "./index.css"
import { Title, Meta, Link } from "@solidjs/meta"
// import { HttpHeader } from "@solidjs/start"
import { HttpHeader } from "@solidjs/start"
import video from "../asset/lander/opencode-min.mp4"
import videoPoster from "../asset/lander/opencode-poster.png"
import { IconCopy, IconCheck } from "../component/icon"
@@ -13,7 +13,6 @@ import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
import { github } from "~/lib/github"
import { createMemo } from "solid-js"
import { config } from "~/config"
function CopyStatus() {
return (
@@ -42,9 +41,8 @@ export default function Home() {
return (
<main data-page="opencode">
{/*<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />*/}
<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />
<Title>OpenCode | The AI coding agent built for the terminal</Title>
<Link rel="canonical" href={config.baseUrl} />
<Link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<Meta property="og:image" content="/social-share.png" />
<Meta name="twitter:image" content="/social-share.png" />
@@ -54,10 +52,14 @@ export default function Home() {
<div data-component="content">
<section data-component="hero">
<div data-slot="hero-copy">
<a data-slot="releases" href={release()?.url ?? `${config.github.repoUrl}/releases`} target="_blank">
<a
data-slot="releases"
href={release()?.url ?? "https://github.com/sst/opencode/releases"}
target="_blank"
>
Whats new in {release()?.name ?? "the latest release"}
</a>
<h1>The AI coding agent built for the terminal</h1>
<strong>The AI coding agent built for the terminal</strong>
<p>
OpenCode is fully open source, giving you control and freedom to use any provider, any model, and any
editor.
@@ -217,10 +219,9 @@ export default function Home() {
<div>
<span>[*]</span>
<p>
With over <strong>{config.github.starsFormatted.full}</strong> GitHub stars,{" "}
<strong>{config.stats.contributors}</strong> contributors, and almost{" "}
<strong>{config.stats.commits}</strong> commits, OpenCode is used and trusted by over{" "}
<strong>{config.stats.monthlyUsers}</strong> developers every month.
With over <strong>29,000</strong> GitHub stars, <strong>230</strong> contributors, and almost{" "}
<strong>3,500</strong> commits, OpenCode is used and trusted by over <strong>250,000</strong>{" "}
developers every month.
</p>
</div>
@@ -273,7 +274,7 @@ export default function Home() {
</svg>
</div>
<span>
<figure>Fig 1.</figure> <strong>{config.github.starsFormatted.compact}</strong> GitHub Stars
<figure>Fig 1.</figure> <strong>29K</strong> GitHub Stars
</span>
</div>
@@ -576,7 +577,7 @@ export default function Home() {
</svg>
</div>
<span>
<figure>Fig 2.</figure> <strong>{config.stats.contributors}</strong> Contributors
<figure>Fig 2.</figure> <strong>230</strong> Contributors
</span>
</div>
@@ -618,7 +619,7 @@ export default function Home() {
</svg>
</div>
<span>
<figure>Fig 3.</figure> <strong>{config.stats.monthlyUsers}</strong> Monthly Devs
<figure>Fig 3.</figure> <strong>250K</strong> Monthly Devs
</span>
</div>
</div>
@@ -687,11 +688,11 @@ export default function Home() {
<li>
<Faq question="Is OpenCode open source?">
Yes, OpenCode is fully open source. The source code is public on{" "}
<a href={config.github.repoUrl} target="_blank">
<a href="https://github.com/sst/opencode" target="_blank">
GitHub
</a>{" "}
under the{" "}
<a href={`${config.github.repoUrl}?tab=MIT-1-ov-file#readme`} target="_blank">
<a href="https://github.com/sst/opencode?tab=MIT-1-ov-file#readme" target="_blank">
MIT License
</a>
, meaning anyone can use, modify, or contribute to its development. Anyone from the community can file
+118 -116
View File
@@ -13,144 +13,146 @@ export async function POST(input: APIEvent) {
input.request.headers.get("stripe-signature")!,
Resource.STRIPE_WEBHOOK_SECRET.value,
)
console.log(body.type, JSON.stringify(body, null, 2))
if (body.type === "customer.updated") {
// check default payment method changed
const prevInvoiceSettings = body.data.previous_attributes?.invoice_settings ?? {}
if (!("default_payment_method" in prevInvoiceSettings)) return
return (async () => {
if (body.type === "customer.updated") {
// check default payment method changed
const prevInvoiceSettings = body.data.previous_attributes?.invoice_settings ?? {}
if (!("default_payment_method" in prevInvoiceSettings)) return "ignored"
const customerID = body.data.object.id
const paymentMethodID = body.data.object.invoice_settings.default_payment_method as string
const customerID = body.data.object.id
const paymentMethodID = body.data.object.invoice_settings.default_payment_method as string
if (!customerID) throw new Error("Customer ID not found")
if (!paymentMethodID) throw new Error("Payment method ID not found")
if (!customerID) throw new Error("Customer ID not found")
if (!paymentMethodID) throw new Error("Payment method ID not found")
const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID)
await Database.use(async (tx) => {
await tx
.update(BillingTable)
.set({
paymentMethodID,
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
paymentMethodType: paymentMethod.type,
})
.where(eq(BillingTable.customerID, customerID))
})
}
if (body.type === "checkout.session.completed") {
const workspaceID = body.data.object.metadata?.workspaceID
const amountInCents = body.data.object.metadata?.amount && parseInt(body.data.object.metadata?.amount)
const customerID = body.data.object.customer as string
const paymentID = body.data.object.payment_intent as string
const invoiceID = body.data.object.invoice as string
if (!workspaceID) throw new Error("Workspace ID not found")
if (!customerID) throw new Error("Customer ID not found")
if (!amountInCents) throw new Error("Amount not found")
if (!paymentID) throw new Error("Payment ID not found")
if (!invoiceID) throw new Error("Invoice ID not found")
await Actor.provide("system", { workspaceID }, async () => {
const customer = await Billing.get()
if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch")
// set customer metadata
if (!customer?.customerID) {
await Billing.stripe().customers.update(customerID, {
metadata: {
workspaceID,
},
})
}
// get payment method for the payment intent
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
expand: ["payment_method"],
const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID)
await Database.use(async (tx) => {
await tx
.update(BillingTable)
.set({
paymentMethodID,
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
paymentMethodType: paymentMethod.type,
})
const paymentMethod = paymentIntent.payment_method
if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
.where(eq(BillingTable.customerID, customerID))
})
}
if (body.type === "checkout.session.completed") {
const workspaceID = body.data.object.metadata?.workspaceID
const customerID = body.data.object.customer as string
const paymentID = body.data.object.payment_intent as string
const invoiceID = body.data.object.invoice as string
const amount = body.data.object.amount_total
await Database.transaction(async (tx) => {
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} + ${centsToMicroCents(amountInCents)}`,
customerID,
paymentMethodID: paymentMethod.id,
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
paymentMethodType: paymentMethod.type,
// enable reload if first time enabling billing
...(customer?.customerID
? {}
: {
reload: true,
reloadError: null,
timeReloadError: null,
}),
})
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.insert(PaymentTable).values({
if (!workspaceID) throw new Error("Workspace ID not found")
if (!customerID) throw new Error("Customer ID not found")
if (!amount) throw new Error("Amount not found")
if (!paymentID) throw new Error("Payment ID not found")
if (!invoiceID) throw new Error("Invoice ID not found")
await Actor.provide("system", { workspaceID }, async () => {
const customer = await Billing.get()
if (customer?.customerID && customer.customerID !== customerID)
throw new Error("Customer ID mismatch")
// set customer metadata
if (!customer?.customerID) {
await Billing.stripe().customers.update(customerID, {
metadata: {
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(amountInCents),
paymentID,
invoiceID,
customerID,
})
},
})
})
}
if (body.type === "charge.refunded") {
const customerID = body.data.object.customer as string
const paymentIntentID = body.data.object.payment_intent as string
if (!customerID) throw new Error("Customer ID not found")
if (!paymentIntentID) throw new Error("Payment ID not found")
}
const workspaceID = await Database.use((tx) =>
// get payment method for the payment intent
const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
expand: ["payment_method"],
})
const paymentMethod = paymentIntent.payment_method
if (!paymentMethod || typeof paymentMethod === "string")
throw new Error("Payment method not expanded")
const oldBillingInfo = await Database.use((tx) =>
tx
.select({
workspaceID: BillingTable.workspaceID,
customerID: BillingTable.customerID,
})
.from(BillingTable)
.where(eq(BillingTable.customerID, customerID))
.then((rows) => rows[0]?.workspaceID),
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0]),
)
if (!workspaceID) throw new Error("Workspace ID not found")
const amount = await Database.use((tx) =>
tx
.select({
amount: PaymentTable.amount,
})
.from(PaymentTable)
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
.then((rows) => rows[0]?.amount),
)
if (!amount) throw new Error("Payment not found")
await Database.transaction(async (tx) => {
await tx
.update(PaymentTable)
.set({
timeRefunded: new Date(body.created * 1000),
})
.where(and(eq(PaymentTable.paymentID, paymentIntentID), eq(PaymentTable.workspaceID, workspaceID)))
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${amount}`,
balance: sql`${BillingTable.balance} + ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`,
customerID,
paymentMethodID: paymentMethod.id,
paymentMethodLast4: paymentMethod.card?.last4 ?? null,
paymentMethodType: paymentMethod.type,
// enable reload if first time enabling billing
...(oldBillingInfo?.customerID
? {}
: {
reload: true,
reloadError: null,
timeReloadError: null,
}),
})
.where(eq(BillingTable.workspaceID, workspaceID))
await tx.insert(PaymentTable).values({
workspaceID,
id: Identifier.create("payment"),
amount: centsToMicroCents(Billing.CHARGE_AMOUNT),
paymentID,
invoiceID,
customerID,
})
})
}
})()
.then((message) => {
return Response.json({ message: message ?? "done" }, { status: 200 })
})
.catch((error: any) => {
return Response.json({ message: error.message }, { status: 500 })
}
if (body.type === "charge.refunded") {
const customerID = body.data.object.customer as string
const paymentIntentID = body.data.object.payment_intent as string
if (!customerID) throw new Error("Customer ID not found")
if (!paymentIntentID) throw new Error("Payment ID not found")
const workspaceID = await Database.use((tx) =>
tx
.select({
workspaceID: BillingTable.workspaceID,
})
.from(BillingTable)
.where(eq(BillingTable.customerID, customerID))
.then((rows) => rows[0]?.workspaceID),
)
if (!workspaceID) throw new Error("Workspace ID not found")
await Database.transaction(async (tx) => {
await tx
.update(PaymentTable)
.set({
timeRefunded: new Date(body.created * 1000),
})
.where(
and(
eq(PaymentTable.paymentID, paymentIntentID),
eq(PaymentTable.workspaceID, workspaceID),
),
)
await tx
.update(BillingTable)
.set({
balance: sql`${BillingTable.balance} - ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`,
})
.where(eq(BillingTable.workspaceID, workspaceID))
})
}
console.log("finished handling")
return Response.json("ok", { status: 200 })
}
+1 -1
View File
@@ -121,7 +121,7 @@ export default function Home() {
<h3 data-component="title">homebrew</h3>
<button data-copy data-slot="button">
<span>
brew install <strong>opencode</strong>
brew install <strong>sst/tap/opencode</strong>
</span>
<CopyStatus />
</button>
@@ -14,4 +14,4 @@
color: var(--color-danger);
}
}
}
}
@@ -71,4 +71,4 @@
color: var(--color-text-muted);
}
}
}
}
@@ -104,4 +104,4 @@
}
}
}
}
}
@@ -19,7 +19,7 @@ const getUserEmail = query(async (workspaceID: string) => {
export default function WorkspaceLayout(props: RouteSectionProps) {
const params = useParams()
const userEmail = createAsync(() => getUserEmail(params.id!))
const userEmail = createAsync(() => getUserEmail(params.id))
return (
<main data-page="workspace">
<Link rel="icon" type="image/svg+xml" href="/favicon-zen.svg" />
@@ -5,7 +5,7 @@ import "./[id].css"
export default function WorkspaceLayout(props: RouteSectionProps) {
const params = useParams()
const userInfo = createAsync(() => querySessionInfo(params.id!))
const userInfo = createAsync(() => querySessionInfo(params.id))
return (
<main data-page="workspace">
@@ -71,57 +71,6 @@
flex: 1;
}
[data-slot="add-balance-form-container"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
[data-slot="add-balance-form"] {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--space-3);
label {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text-muted);
white-space: nowrap;
}
input[data-component="input"] {
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);
line-height: 1.5;
&:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-alpha);
}
&::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);
line-height: 1.4;
}
[data-slot="credit-card"] {
padding: var(--space-2) var(--space-4);
background-color: var(--color-bg-surface);
@@ -182,4 +131,4 @@
padding: var(--space-4);
min-width: 150px;
}
}
}
@@ -1,86 +1,24 @@
import { action, useParams, useAction, createAsync, useSubmission, json } from "@solidjs/router"
import { createMemo, Match, Show, Switch, createEffect } from "solid-js"
import { createStore } from "solid-js/store"
import { action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
import { createMemo, Match, Show, Switch } from "solid-js"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import { IconCreditCard, IconStripe } from "~/component/icon"
import styles from "./billing-section.module.css"
import { createCheckoutUrl, formatBalance, queryBillingInfo } from "../../common"
import { createCheckoutUrl, queryBillingInfo } from "../../common"
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
return json(
await withActor(
() =>
Billing.generateSessionUrl({ returnUrl })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({
error: e.message as string,
data: undefined,
})),
workspaceID,
),
{ revalidate: queryBillingInfo.key },
)
return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID)
}, "sessionUrl")
export function BillingSection() {
const params = useParams()
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const checkoutAction = useAction(createCheckoutUrl)
const checkoutSubmission = useSubmission(createCheckoutUrl)
const sessionAction = useAction(createSessionUrl)
const sessionSubmission = useSubmission(createSessionUrl)
const [store, setStore] = createStore({
showAddBalanceForm: false,
addBalanceAmount: billingInfo()?.reloadAmount.toString() ?? "",
checkoutRedirecting: false,
sessionRedirecting: false,
})
createEffect(() => {
const info = billingInfo()
if (info) {
setStore("addBalanceAmount", info.reloadAmount.toString())
}
})
const balance = createMemo(() => formatBalance(billingInfo()?.balance ?? 0))
async function onClickCheckout() {
const amount = parseInt(store.addBalanceAmount)
const baseUrl = window.location.href
const checkout = await checkoutAction(params.id!, amount, baseUrl, baseUrl)
if (checkout && checkout.data) {
setStore("checkoutRedirecting", true)
window.location.href = checkout.data
}
}
async function onClickSession() {
const baseUrl = window.location.href
const sessionUrl = await sessionAction(params.id!, baseUrl)
if (sessionUrl && sessionUrl.data) {
setStore("sessionRedirecting", true)
window.location.href = sessionUrl.data
}
}
function showAddBalanceForm() {
while (true) {
checkoutSubmission.clear()
if (!checkoutSubmission.result) break
}
setStore({
showAddBalanceForm: true,
})
}
function hideAddBalanceForm() {
setStore("showAddBalanceForm", false)
checkoutSubmission.clear()
}
const balanceInfo = createAsync(() => queryBillingInfo(params.id))
const createCheckoutUrlAction = useAction(createCheckoutUrl)
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
const createSessionUrlAction = useAction(createSessionUrl)
const createSessionUrlSubmission = useSubmission(createSessionUrl)
// DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW
@@ -134,104 +72,97 @@ export function BillingSection() {
// timeReloadError: null as Date | null
// })
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.
Manage payments methods. <a href="mailto:contact@anoma.ly">Contact us</a> if you have any
questions.
</p>
</div>
<div data-slot="section-content">
<div data-slot="balance-display">
<div data-slot="balance-amount">
<span data-slot="balance-value">${balance()}</span>
<span data-slot="balance-value">
${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}
</span>
<span data-slot="balance-label">Current Balance</span>
</div>
<Show when={billingInfo()?.customerID}>
<Show when={balanceInfo()?.customerID}>
<div data-slot="balance-right-section">
<Show
when={!store.showAddBalanceForm}
fallback={
<div data-slot="add-balance-form-container">
<div data-slot="add-balance-form">
<label>Add $</label>
<input
data-component="input"
type="number"
min={billingInfo()?.reloadAmountMin.toString()}
step="1"
value={store.addBalanceAmount}
onInput={(e) => {
setStore("addBalanceAmount", e.currentTarget.value)
checkoutSubmission.clear()
}}
placeholder="Enter amount"
/>
<div data-slot="form-actions">
<button data-color="ghost" type="button" onClick={() => hideAddBalanceForm()}>
Cancel
</button>
<button
data-color="primary"
type="button"
disabled={!store.addBalanceAmount || checkoutSubmission.pending || store.checkoutRedirecting}
onClick={onClickCheckout}
>
{checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Add"}
</button>
</div>
</div>
<Show when={checkoutSubmission.result && (checkoutSubmission.result as any).error}>
{(err: any) => <div data-slot="form-error">{err()}</div>}
</Show>
</div>
}
<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
}
}}
>
<button data-color="primary" onClick={() => showAddBalanceForm()}>
Add Balance
</button>
</Show>
{createCheckoutUrlSubmission.pending ? "Loading..." : "Add Balance"}
</button>
<div data-slot="credit-card">
<div data-slot="card-icon">
<Switch fallback={<IconCreditCard style={{ width: "24px", height: "24px" }} />}>
<Match when={billingInfo()?.paymentMethodType === "link"}>
<Match when={balanceInfo()?.paymentMethodType === "link"}>
<IconStripe style={{ width: "24px", height: "24px" }} />
</Match>
</Switch>
</div>
<div data-slot="card-details">
<Switch>
<Match when={billingInfo()?.paymentMethodType === "card"}>
<Show when={billingInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}>
<Match when={balanceInfo()?.paymentMethodType === "card"}>
<Show
when={balanceInfo()?.paymentMethodLast4}
fallback={<span data-slot="number">----</span>}
>
<span data-slot="secret"></span>
<span data-slot="number">{billingInfo()?.paymentMethodLast4}</span>
<span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
</Show>
</Match>
<Match when={billingInfo()?.paymentMethodType === "link"}>
<Match when={balanceInfo()?.paymentMethodType === "link"}>
<span data-slot="type">Linked to Stripe</span>
</Match>
</Switch>
</div>
<button
data-color="ghost"
disabled={sessionSubmission.pending || store.sessionRedirecting}
onClick={onClickSession}
disabled={createSessionUrlSubmission.pending}
onClick={async () => {
const baseUrl = window.location.href
const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
if (sessionUrl) {
window.location.href = sessionUrl
}
}}
>
{sessionSubmission.pending || store.sessionRedirecting ? "Loading..." : "Manage"}
{createSessionUrlSubmission.pending ? "Loading..." : "Manage"}
</button>
</div>
</div>
</Show>
</div>
<Show when={!billingInfo()?.customerID}>
<Show when={!balanceInfo()?.customerID}>
<button
data-slot="enable-billing-button"
data-color="primary"
disabled={checkoutSubmission.pending || store.checkoutRedirecting}
onClick={onClickCheckout}
disabled={createCheckoutUrlSubmission.pending}
onClick={async () => {
const baseUrl = window.location.href
const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
if (checkoutUrl) {
window.location.href = checkoutUrl
}
}}
>
{checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable Billing"}
{createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
</button>
</Show>
</div>
@@ -8,8 +8,8 @@ import { queryBillingInfo, querySessionInfo } from "../../common"
export default function () {
const params = useParams()
const userInfo = createAsync(() => querySessionInfo(params.id!))
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const userInfo = createAsync(() => querySessionInfo(params.id))
const billingInfo = createAsync(() => queryBillingInfo(params.id))
return (
<div data-page="workspace-[id]">
@@ -93,4 +93,4 @@
margin: 0;
line-height: 1.4;
}
}
}
@@ -1,10 +1,16 @@
import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
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-ai/console-core/billing.js"
import styles from "./monthly-limit-section.module.css"
import { queryBillingInfo } from "../../common"
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"
@@ -22,7 +28,7 @@ const setMonthlyLimit = action(async (form: FormData) => {
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: queryBillingInfo.key },
{ revalidate: getBillingInfo.key },
)
}, "billing.setMonthlyLimit")
@@ -30,7 +36,7 @@ export function MonthlyLimitSection() {
const params = useParams()
const submission = useSubmission(setMonthlyLimit)
const [store, setStore] = createStore({ show: false })
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const balanceInfo = createAsync(() => getBillingInfo(params.id))
let input: HTMLInputElement
@@ -62,13 +68,13 @@ export function MonthlyLimitSection() {
<section class={styles.root}>
<div data-slot="section-title">
<h2>Monthly Limit</h2>
<p>Set a monthly usage limit for your account.</p>
<p>Set a monthly spending limit for your account.</p>
</div>
<div data-slot="section-content">
<div data-slot="balance">
<div data-slot="amount">
{billingInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
<span data-slot="value">{billingInfo()?.monthlyLimit ?? "-"}</span>
{balanceInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
<span data-slot="value">{balanceInfo()?.monthlyLimit ?? "-"}</span>
</div>
<Show
when={!store.show}
@@ -100,15 +106,15 @@ export function MonthlyLimitSection() {
}
>
<button data-color="primary" onClick={() => show()}>
{billingInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
{balanceInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
</button>
</Show>
</div>
<Show when={billingInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No usage limit set.</p>}>
<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 = billingInfo()?.timeMonthlyUsageUpdated
const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated
if (!dateLastUsed) return "0"
const current = new Date().toLocaleDateString("en-US", {
@@ -122,7 +128,7 @@ export function MonthlyLimitSection() {
timeZone: "UTC",
})
if (current !== lastUsed) return "0"
return ((billingInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
})()}
.
</p>
@@ -19,7 +19,7 @@ const downloadReceipt = action(async (workspaceID: string, paymentID: string) =>
export function PaymentSection() {
const params = useParams()
const payments = createAsync(() => getPaymentsInfo(params.id!))
const payments = createAsync(() => getPaymentsInfo(params.id))
const downloadReceiptAction = useAction(downloadReceipt)
// DUMMY DATA FOR TESTING
@@ -89,7 +89,7 @@ export function PaymentSection() {
<td data-slot="payment-receipt">
<button
onClick={async () => {
const receiptUrl = await downloadReceiptAction(params.id!, payment.paymentID!)
const receiptUrl = await downloadReceiptAction(params.id, payment.paymentID!)
if (receiptUrl) {
window.open(receiptUrl, "_blank")
}
@@ -34,206 +34,6 @@
}
}
[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);
margin-top: var(--space-4);
[data-slot="form-field"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
label {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
[data-slot="field-label"] {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text-muted);
}
[data-slot="toggle-container"] {
display: flex;
align-items: center;
}
input[data-component="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="input-row"] {
display: flex;
flex-direction: row;
gap: var(--space-3);
@media (max-width: 40rem) {
flex-direction: column;
gap: var(--space-2);
}
}
[data-slot="input-field"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
flex: 1;
p {
line-height: 1.2;
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
input[data-component="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);
line-height: 1.5;
min-width: 0;
&:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-alpha);
}
&::placeholder {
color: var(--color-text-disabled);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: var(--color-bg-surface);
}
}
[data-slot="field-with-connector"] {
display: flex;
align-items: center;
gap: var(--space-2);
[data-slot="field-connector"] {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
white-space: nowrap;
}
input[data-component="input"] {
flex: 1;
min-width: 80px;
}
}
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
margin-top: var(--space-1);
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
margin-top: calc(var(--space-1) * -1);
}
[data-slot="model-toggle-label"] {
position: relative;
display: inline-block;
width: 2.5rem;
height: 1.5rem;
cursor: pointer;
input {
opacity: 0;
width: 0;
height: 0;
}
span {
position: absolute;
inset: 0;
background-color: #ccc;
border: 1px solid #bbb;
border-radius: 1.5rem;
transition: all 0.3s ease;
cursor: pointer;
&::before {
content: "";
position: absolute;
top: 50%;
left: 0.125rem;
width: 1.25rem;
height: 1.25rem;
background-color: white;
border: 1px solid #ddd;
border-radius: 50%;
transform: translateY(-50%);
transition: all 0.3s ease;
}
}
input:checked + span {
background-color: #21ad0e;
border-color: #148605;
&::before {
transform: translateX(1rem) translateY(-50%);
}
}
&:hover span {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
}
input:checked:hover + span {
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.3);
}
&:has(input:disabled) {
cursor: not-allowed;
}
input:disabled + span {
opacity: 0.5;
cursor: not-allowed;
}
}
}
[data-slot="reload-error"] {
display: flex;
align-items: center;
@@ -254,8 +54,7 @@
gap: var(--space-2);
margin: 0;
flex-shrink: 0;
padding: 0;
border: none;
}
}
}
@@ -1,19 +1,17 @@
import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
import { Show } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import styles from "./reload-section.module.css"
import { queryBillingInfo } from "../../common"
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: queryBillingInfo.key,
revalidate: getBillingInfo.key,
})
}, "billing.reload")
@@ -22,27 +20,12 @@ const setReload = action(async (form: FormData) => {
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
const reloadValue = form.get("reload")?.toString() === "true"
const amountStr = form.get("reloadAmount")?.toString()
const triggerStr = form.get("reloadTrigger")?.toString()
const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null
const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null
if (reloadValue) {
if (reloadAmount === null || reloadAmount < Billing.RELOAD_AMOUNT_MIN)
return { error: `Reload amount must be at least $${Billing.RELOAD_AMOUNT_MIN}` }
if (reloadTrigger === null || reloadTrigger < Billing.RELOAD_TRIGGER_MIN)
return { error: `Balance trigger must be at least $${Billing.RELOAD_TRIGGER_MIN}` }
}
return json(
await Database.use((tx) =>
tx
.update(BillingTable)
.set({
reload: reloadValue,
...(reloadAmount !== null ? { reloadAmount } : {}),
...(reloadTrigger !== null ? { reloadTrigger } : {}),
...(reloadValue
? {
reloadError: null,
@@ -52,43 +35,22 @@ const setReload = action(async (form: FormData) => {
})
.where(eq(BillingTable.workspaceID, workspaceID)),
),
{ revalidate: queryBillingInfo.key },
{ revalidate: getBillingInfo.key },
)
}, "billing.setReload")
const getBillingInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.get()
}, workspaceID)
}, "billing.get")
export function ReloadSection() {
const params = useParams()
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const balanceInfo = createAsync(() => getBillingInfo(params.id))
const setReloadSubmission = useSubmission(setReload)
const reloadSubmission = useSubmission(reload)
const [store, setStore] = createStore({
show: false,
reload: false,
reloadAmount: "",
reloadTrigger: "",
})
createEffect(() => {
if (!setReloadSubmission.pending && setReloadSubmission.result && !(setReloadSubmission.result as any).error) {
setStore("show", false)
}
})
function show() {
while (true) {
setReloadSubmission.clear()
if (!setReloadSubmission.result) break
}
const info = billingInfo()!
setStore("show", true)
setStore("reload", info.reload ? true : true)
setStore("reloadAmount", info.reloadAmount.toString())
setStore("reloadTrigger", info.reloadTrigger.toString())
}
function hide() {
setStore("show", false)
}
return (
<section class={styles.root}>
@@ -96,102 +58,44 @@ export function ReloadSection() {
<h2>Auto Reload</h2>
<div data-slot="title-row">
<Show
when={billingInfo()?.reload}
when={balanceInfo()?.reload}
fallback={
<p>
Auto reload is <b>disabled</b>. Enable to automatically reload when balance is low.
</p>
<p>Auto reload is disabled. Enable to automatically reload when balance is low.</p>
}
>
<p>
Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+$1.23 processing fee)
when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches{" "}
<b>$5</b>.
</p>
</Show>
<button data-color="primary" type="button" onClick={() => show()}>
{billingInfo()?.reload ? "Edit" : "Enable"}
</button>
<form action={setReload} method="post" data-slot="create-form">
<input type="hidden" name="workspaceID" value={params.id} />
<input type="hidden" name="reload" value={balanceInfo()?.reload ? "false" : "true"} />
<button data-color="primary" type="submit" disabled={setReloadSubmission.pending}>
<Show
when={balanceInfo()?.reload}
fallback={setReloadSubmission.pending ? "Enabling..." : "Enable"}
>
{setReloadSubmission.pending ? "Disabling..." : "Disable"}
</Show>
</button>
</form>
</div>
</div>
<Show when={store.show}>
<form action={setReload} method="post" data-slot="create-form">
<div data-slot="form-field">
<label>
<span data-slot="field-label">Enable Auto Reload</span>
<div data-slot="toggle-container">
<label data-slot="model-toggle-label">
<input
type="checkbox"
name="reload"
value="true"
checked={store.reload}
onChange={(e) => setStore("reload", e.currentTarget.checked)}
/>
<span></span>
</label>
</div>
</label>
</div>
<div data-slot="input-row">
<div data-slot="input-field">
<p>Reload $</p>
<input
data-component="input"
name="reloadAmount"
type="number"
min={billingInfo()?.reloadAmountMin.toString()}
step="1"
value={store.reloadAmount}
onInput={(e) => setStore("reloadAmount", e.currentTarget.value)}
placeholder={billingInfo()?.reloadAmount.toString()}
disabled={!store.reload}
/>
</div>
<div data-slot="input-field">
<p>When balance reaches $</p>
<input
data-component="input"
name="reloadTrigger"
type="number"
min={billingInfo()?.reloadTriggerMin.toString()}
step="1"
value={store.reloadTrigger}
onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)}
placeholder={billingInfo()?.reloadTrigger.toString()}
disabled={!store.reload}
/>
</div>
</div>
<Show when={setReloadSubmission.result && (setReloadSubmission.result as any).error}>
{(err: any) => <div data-slot="form-error">{err()}</div>}
</Show>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="button" data-color="ghost" onClick={() => hide()}>
Cancel
</button>
<button type="submit" data-color="primary" disabled={setReloadSubmission.pending}>
{setReloadSubmission.pending ? "Saving..." : "Save"}
</button>
</div>
</form>
</Show>
<Show when={billingInfo()?.reload && billingInfo()?.reloadError}>
<div data-slot="section-content">
<div data-slot="section-content">
<Show when={balanceInfo()?.reload && balanceInfo()?.reloadError}>
<div data-slot="reload-error">
<p>
Reload failed at{" "}
{billingInfo()?.timeReloadError!.toLocaleString("en-US", {
{balanceInfo()?.timeReloadError!.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
})}
. Reason: {billingInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try
again.
. 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} />
@@ -200,8 +104,8 @@ export function ReloadSection() {
</button>
</form>
</div>
</div>
</Show>
</Show>
</div>
</section>
)
}
@@ -1,145 +0,0 @@
.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);
height: 400px;
display: flex;
align-items: center;
justify-content: center;
p {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
[data-slot="filter-container"] {
margin-bottom: 0;
display: flex;
align-items: center;
gap: var(--space-3);
[data-component="dropdown"] {
[data-slot="trigger"] {
border: 1px solid var(--color-border);
background-color: var(--color-bg);
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-sm);
color: var(--color-text);
font-size: var(--font-size-sm);
line-height: 1.5;
&:hover {
border-color: var(--color-accent);
}
&:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-alpha);
}
}
[data-slot="chevron"] {
opacity: 0.6;
}
[data-slot="dropdown"] {
min-width: 200px;
max-height: 300px;
overflow-y: auto;
padding: var(--space-1);
}
}
}
[data-slot="month-picker"] {
display: flex;
align-items: center;
background-color: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
padding: 0;
}
[data-slot="month-button"] {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none !important;
color: var(--color-text);
cursor: pointer;
padding: var(--space-2) var(--space-3);
border-radius: var(--border-radius-xs);
transition: background-color 0.2s;
line-height: 1;
&:hover {
background-color: var(--color-bg-hover);
}
svg {
display: block;
width: 16px;
height: 16px;
stroke-width: 2;
}
}
[data-slot="month-label"] {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text);
line-height: 1.5;
min-width: 140px;
text-align: center;
white-space: nowrap;
}
[data-slot="model-item"] {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
cursor: pointer;
transition: background-color 0.2s;
font-size: var(--font-size-sm);
color: var(--color-text);
border: none !important;
background: none;
width: 100%;
text-align: left;
white-space: nowrap;
&:hover {
background: var(--color-bg-hover);
}
span {
flex: 1;
user-select: none;
}
}
[data-slot="chart-container"] {
padding: var(--space-6);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
height: 400px;
}
@media (max-width: 40rem) {
[data-slot="chart-container"] {
height: 300px;
padding: var(--space-4);
}
[data-component="empty-state"] {
height: 300px;
}
}
}
@@ -1,423 +0,0 @@
import { and, Database, eq, gte, inArray, isNull, lte, or, sql, sum } from "@opencode-ai/console-core/drizzle/index.js"
import { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
import { createAsync, query, useParams } from "@solidjs/router"
import { createEffect, createMemo, onCleanup, Show, For } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
import { Dropdown } from "~/component/dropdown"
import { IconChevronLeft, IconChevronRight } from "~/component/icon"
import styles from "./graph-section.module.css"
import {
Chart,
BarController,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
type ChartConfiguration,
} from "chart.js"
Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend)
async function getCosts(workspaceID: string, year: number, month: number) {
"use server"
return withActor(async () => {
const startDate = new Date(year, month, 1)
const endDate = new Date(year, month + 1, 0)
// First query: get usage data without joining keys
const usageData = await Database.use((tx) =>
tx
.select({
date: sql<string>`DATE(${UsageTable.timeCreated})`,
model: UsageTable.model,
totalCost: sum(UsageTable.cost),
keyId: UsageTable.keyID,
})
.from(UsageTable)
.where(
and(
eq(UsageTable.workspaceID, workspaceID),
gte(UsageTable.timeCreated, startDate),
lte(UsageTable.timeCreated, endDate),
),
)
.groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID)
.then((x) =>
x.map((r) => ({
...r,
totalCost: r.totalCost ? parseInt(r.totalCost) : 0,
})),
),
)
// Get unique key IDs from usage
const usageKeyIds = new Set(usageData.map((r) => r.keyId).filter((id) => id !== null))
// Second query: get all existing keys plus any keys from usage
const keysData = await Database.use((tx) =>
tx
.select({
keyId: KeyTable.id,
keyName: KeyTable.name,
userEmail: AuthTable.subject,
timeDeleted: KeyTable.timeDeleted,
})
.from(KeyTable)
.innerJoin(UserTable, and(eq(KeyTable.userID, UserTable.id), eq(KeyTable.workspaceID, UserTable.workspaceID)))
.innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
.where(
and(
eq(KeyTable.workspaceID, workspaceID),
usageKeyIds.size > 0
? or(inArray(KeyTable.id, Array.from(usageKeyIds)), isNull(KeyTable.timeDeleted))
: isNull(KeyTable.timeDeleted),
),
)
.orderBy(AuthTable.subject, KeyTable.name),
)
return {
usage: usageData,
keys: keysData.map((key) => ({
id: key.keyId,
displayName:
key.timeDeleted !== null
? `${key.userEmail} - ${key.keyName} (deleted)`
: `${key.userEmail} - ${key.keyName}`,
})),
}
}, workspaceID)
}
const queryCosts = query(getCosts, "costs.get")
const MODEL_COLORS: Record<string, string> = {
"claude-sonnet-4-5": "#D4745C",
"claude-sonnet-4": "#E8B4A4",
"claude-opus-4": "#C8A098",
"claude-haiku-4-5": "#F0D8D0",
"claude-3-5-haiku": "#F8E8E0",
"gpt-5.1": "#4A90E2",
"gpt-5.1-codex": "#6BA8F0",
"gpt-5": "#7DB8F8",
"gpt-5-codex": "#9FCAFF",
"gpt-5-nano": "#B8D8FF",
"grok-code": "#8B5CF6",
"big-pickle": "#10B981",
"kimi-k2": "#F59E0B",
"qwen3-coder": "#EC4899",
"glm-4.6": "#14B8A6",
}
function getModelColor(model: string): string {
if (MODEL_COLORS[model]) return MODEL_COLORS[model]
const hash = model.split("").reduce((acc, char) => char.charCodeAt(0) + ((acc << 5) - acc), 0)
const hue = Math.abs(hash) % 360
return `hsl(${hue}, 50%, 65%)`
}
function formatDateLabel(dateStr: string): string {
const date = new Date()
const [y, m, d] = dateStr.split("-").map(Number)
date.setFullYear(y)
date.setMonth(m - 1)
date.setDate(d)
date.setHours(0, 0, 0, 0)
const month = date.toLocaleDateString("en-US", { month: "short" })
const day = date.getUTCDate().toString().padStart(2, "0")
return `${month} ${day}`
}
function addOpacityToColor(color: string, opacity: number): string {
if (color.startsWith("#")) {
const r = parseInt(color.slice(1, 3), 16)
const g = parseInt(color.slice(3, 5), 16)
const b = parseInt(color.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${opacity})`
}
if (color.startsWith("hsl")) return color.replace(")", `, ${opacity})`).replace("hsl", "hsla")
return color
}
export function GraphSection() {
let canvasRef: HTMLCanvasElement | undefined
let chartInstance: Chart | undefined
const params = useParams()
const now = new Date()
const [store, setStore] = createStore({
data: null as Awaited<ReturnType<typeof getCosts>> | null,
year: now.getFullYear(),
month: now.getMonth(),
key: null as string | null,
model: null as string | null,
modelDropdownOpen: false,
keyDropdownOpen: false,
})
const initialData = createAsync(() => queryCosts(params.id!, store.year, store.month))
const onPreviousMonth = async () => {
const month = store.month === 0 ? 11 : store.month - 1
const year = store.month === 0 ? store.year - 1 : store.year
const data = await getCosts(params.id!, year, month)
setStore({ month, year, data })
}
const onNextMonth = async () => {
const month = store.month === 11 ? 0 : store.month + 1
const year = store.month === 11 ? store.year + 1 : store.year
setStore({ month, year, data: await getCosts(params.id!, year, month) })
}
const onSelectModel = (model: string | null) => setStore({ model, modelDropdownOpen: false })
const onSelectKey = (keyID: string | null) => setStore({ key: keyID, keyDropdownOpen: false })
const getData = createMemo(() => store.data ?? initialData())
const getModels = createMemo(() => {
const data = getData()
if (!data?.usage) return []
return Array.from(new Set(data.usage.map((row) => row.model))).sort()
})
const getDates = createMemo(() => {
const daysInMonth = new Date(store.year, store.month + 1, 0).getDate()
return Array.from({ length: daysInMonth }, (_, i) => {
const date = new Date(store.year, store.month, i + 1)
return date.toISOString().split("T")[0]
})
})
const getKeyName = (keyID: string | null): string => {
if (!keyID || !store.data?.keys) return "All Keys"
const found = store.data.keys.find((k) => k.id === keyID)
return found?.displayName ?? "All Keys"
}
const formatMonthYear = () =>
new Date(store.year, store.month, 1).toLocaleDateString("en-US", { month: "long", year: "numeric" })
const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth()
const chartConfig = createMemo((): ChartConfiguration | null => {
const data = getData()
const dates = getDates()
if (!data?.usage?.length) return null
const dailyData = new Map<string, Map<string, number>>()
for (const dateKey of dates) dailyData.set(dateKey, new Map())
data.usage
.filter((row) => (store.key ? row.keyId === store.key : true))
.forEach((row) => {
const dayMap = dailyData.get(row.date)
if (!dayMap) return
dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost)
})
const filteredModels = store.model === null ? getModels() : [store.model]
const datasets = filteredModels.map((model) => {
const color = getModelColor(model)
return {
label: model,
data: dates.map((date) => (dailyData.get(date)?.get(model) || 0) / 100_000_000),
backgroundColor: color,
hoverBackgroundColor: color,
borderWidth: 0,
}
})
return {
type: "bar",
data: {
labels: dates.map(formatDateLabel),
datasets,
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
stacked: true,
grid: {
display: false,
},
ticks: {
maxRotation: 0,
autoSkipPadding: 20,
color: "rgba(255, 255, 255, 0.5)",
font: {
family: "monospace",
size: 11,
},
},
},
y: {
stacked: true,
beginAtZero: true,
grid: {
color: "rgba(255, 255, 255, 0.1)",
},
ticks: {
color: "rgba(255, 255, 255, 0.5)",
font: {
family: "monospace",
size: 11,
},
callback: (value) => {
const num = Number(value)
return num >= 1000 ? `$${(num / 1000).toFixed(1)}k` : `$${num.toFixed(0)}`
},
},
},
},
plugins: {
tooltip: {
mode: "index",
intersect: false,
backgroundColor: "rgba(0, 0, 0, 0.9)",
titleColor: "rgba(255, 255, 255, 0.9)",
bodyColor: "rgba(255, 255, 255, 0.8)",
borderColor: "rgba(255, 255, 255, 0.1)",
borderWidth: 1,
padding: 12,
displayColors: true,
callbacks: {
label: (context) => {
const value = context.parsed.y
if (!value || value === 0) return
return `${context.dataset.label}: $${value.toFixed(2)}`
},
},
},
legend: {
display: true,
position: "bottom",
labels: {
color: "rgba(255, 255, 255, 0.7)",
font: {
size: 12,
},
padding: 16,
boxWidth: 16,
boxHeight: 16,
usePointStyle: false,
},
onHover: (event, legendItem, legend) => {
const chart = legend.chart
chart.data.datasets?.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i)
const baseColor = getModelColor(dataset.label || "")
const color = i === legendItem.datasetIndex ? baseColor : addOpacityToColor(baseColor, 0.3)
meta.data.forEach((bar: any) => {
bar.options.backgroundColor = color
})
})
chart.update("none")
},
onLeave: (event, legendItem, legend) => {
const chart = legend.chart
chart.data.datasets?.forEach((dataset, i) => {
const meta = chart.getDatasetMeta(i)
const baseColor = getModelColor(dataset.label || "")
meta.data.forEach((bar: any) => {
bar.options.backgroundColor = baseColor
})
})
chart.update("none")
},
},
},
},
}
})
createEffect(() => {
const config = chartConfig()
if (!config || !canvasRef) return
if (chartInstance) chartInstance.destroy()
chartInstance = new Chart(canvasRef, config)
})
onCleanup(() => chartInstance?.destroy())
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Cost</h2>
<p>Usage costs broken down by model.</p>
</div>
<Show when={getData()}>
<div data-slot="filter-container">
<div data-slot="month-picker">
<button data-slot="month-button" onClick={onPreviousMonth}>
<IconChevronLeft />
</button>
<span data-slot="month-label">{formatMonthYear()}</span>
<button data-slot="month-button" onClick={onNextMonth} disabled={isCurrentMonth()}>
<IconChevronRight />
</button>
</div>
<Dropdown
trigger={store.model === null ? "All Models" : store.model}
open={store.modelDropdownOpen}
onOpenChange={(open) => setStore({ modelDropdownOpen: open })}
>
<>
<button data-slot="model-item" onClick={() => onSelectModel(null)}>
<span>All Models</span>
</button>
<For each={getModels()}>
{(model) => (
<button data-slot="model-item" onClick={() => onSelectModel(model)}>
<span>{model}</span>
</button>
)}
</For>
</>
</Dropdown>
<Dropdown
trigger={getKeyName(store.key)}
open={store.keyDropdownOpen}
onOpenChange={(open) => setStore({ keyDropdownOpen: open })}
>
<>
<button data-slot="model-item" onClick={() => onSelectKey(null)}>
<span>All Keys</span>
</button>
<For each={getData()?.keys || []}>
{(key) => (
<button data-slot="model-item" onClick={() => onSelectKey(key.id)}>
<span>{key.displayName}</span>
</button>
)}
</For>
</>
</Dropdown>
</div>
</Show>
<Show
when={chartConfig()}
fallback={
<div data-component="empty-state">
<p>No usage data available for the selected period.</p>
</div>
}
>
<div data-slot="chart-container">
<canvas ref={canvasRef} />
</div>
</Show>
</section>
)
}
@@ -1,33 +1,22 @@
import { Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
import { NewUserSection } from "./new-user-section"
import { UsageSection } from "./usage-section"
import { ModelSection } from "./model-section"
import { ProviderSection } from "./provider-section"
import { GraphSection } from "./graph-section"
import { IconLogo } from "~/component/icon"
import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
import { createAsync, useParams, useAction, useSubmission } from "@solidjs/router"
import { querySessionInfo, queryBillingInfo, createCheckoutUrl } from "../common"
import { Show, createMemo } from "solid-js"
export default function () {
const params = useParams()
const userInfo = createAsync(() => querySessionInfo(params.id!))
const billingInfo = createAsync(() => queryBillingInfo(params.id!))
const checkoutAction = useAction(createCheckoutUrl)
const checkoutSubmission = useSubmission(createCheckoutUrl)
const [store, setStore] = createStore({
checkoutRedirecting: false,
})
const balance = createMemo(() => formatBalance(billingInfo()?.balance ?? 0))
const userInfo = createAsync(() => querySessionInfo(params.id))
const billingInfo = createAsync(() => queryBillingInfo(params.id))
const createCheckoutUrlAction = useAction(createCheckoutUrl)
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
async function onClickCheckout() {
const baseUrl = window.location.href
const checkout = await checkoutAction(params.id!, billingInfo()!.reloadAmount, baseUrl, baseUrl)
if (checkout && checkout.data) {
setStore("checkoutRedirecting", true)
window.location.href = checkout.data
}
}
const balanceAmount = createMemo(() => {
return ((billingInfo()?.balance ?? 0) / 100000000).toFixed(2)
})
return (
<div data-page="workspace-[id]">
@@ -49,15 +38,21 @@ export default function () {
<button
data-color="primary"
data-size="sm"
disabled={checkoutSubmission.pending || store.checkoutRedirecting}
onClick={onClickCheckout}
disabled={createCheckoutUrlSubmission.pending}
onClick={async () => {
const baseUrl = window.location.href
const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
if (checkoutUrl) {
window.location.href = checkoutUrl
}
}}
>
{checkoutSubmission.pending || store.checkoutRedirecting ? "Loading..." : "Enable billing"}
{createCheckoutUrlSubmission.pending ? "Loading..." : "Enable billing"}
</button>
}
>
<span data-slot="balance">
Current balance <b>${balance()}</b>
Current balance <b>${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
</span>
</Show>
</span>
@@ -67,9 +62,6 @@ export default function () {
<div data-slot="sections">
<NewUserSection />
<Show when={userInfo()?.isAdmin}>
<GraphSection />
</Show>
<ModelSection />
<Show when={userInfo()?.isAdmin}>
<ProviderSection />
@@ -171,6 +171,7 @@
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
@@ -180,7 +181,8 @@
th {
&:nth-child(3)
/* Date */ {
/* Date */
{
display: none;
}
}
@@ -188,10 +190,11 @@
td {
&:nth-child(3)
/* Date */ {
/* Date */
{
display: none;
}
}
}
}
}
}
@@ -45,7 +45,7 @@ const listKeys = query(async (workspaceID: string) => {
export function KeySection() {
const params = useParams()
const keys = createAsync(() => listKeys(params.id!))
const keys = createAsync(() => listKeys(params.id))
const submission = useSubmission(createKey)
const [store, setStore] = createStore({ show: false })
@@ -209,7 +209,7 @@ const roleOptions = [
export function MemberSection() {
const params = useParams()
const data = createAsync(() => listMembers(params.id!))
const data = createAsync(() => listMembers(params.id))
const submission = useSubmission(inviteMember)
const [store, setStore] = createStore({
show: false,
@@ -328,7 +328,7 @@ export function MemberSection() {
{(member) => (
<MemberRow
member={member}
workspaceID={params.id!}
workspaceID={params.id}
actorID={data()!.actorID}
actorRole={data()!.actorRole}
/>
@@ -69,4 +69,4 @@
}
}
}
}
}
@@ -5,21 +5,11 @@ import { withActor } from "~/context/auth.withActor"
import { ZenData } from "@opencode-ai/console-core/model.js"
import styles from "./model-section.module.css"
import { querySessionInfo } from "../common"
import {
IconAlibaba,
IconAnthropic,
IconGoogle,
IconMoonshotAI,
IconOpenAI,
IconStealth,
IconXai,
IconZai,
} from "~/component/icon"
import { IconAlibaba, IconAnthropic, IconMoonshotAI, IconOpenAI, IconStealth, IconXai, IconZai } from "~/component/icon"
const getModelLab = (modelId: string) => {
if (modelId.startsWith("claude")) return "Anthropic"
if (modelId.startsWith("gpt")) return "OpenAI"
if (modelId.startsWith("gemini")) return "Google"
if (modelId.startsWith("kimi")) return "Moonshot AI"
if (modelId.startsWith("glm")) return "Z.ai"
if (modelId.startsWith("qwen")) return "Alibaba"
@@ -33,18 +23,8 @@ const getModelsInfo = query(async (workspaceID: string) => {
return {
all: Object.entries(ZenData.list().models)
.filter(([id, _model]) => !["claude-3-5-haiku"].includes(id))
.filter(([id, _model]) => !id.startsWith("alpha-"))
.sort(([idA, modelA], [idB, modelB]) => {
const priority = ["big-pickle", "grok", "claude", "gpt", "gemini"]
const getPriority = (id: string) => {
const index = priority.findIndex((p) => id.startsWith(p))
return index === -1 ? Infinity : index
}
const pA = getPriority(idA)
const pB = getPriority(idB)
if (pA !== pB) return pA - pB
return modelA.name.localeCompare(modelB.name)
})
.filter(([id, _model]) => !id.startsWith("an-"))
.sort(([_idA, modelA], [_idB, modelB]) => modelA.name.localeCompare(modelB.name))
.map(([id, model]) => ({ id, name: model.name })),
disabled: await Model.listDisabled(),
}
@@ -72,8 +52,8 @@ const updateModel = action(async (form: FormData) => {
export function ModelSection() {
const params = useParams()
const modelsInfo = createAsync(() => getModelsInfo(params.id!))
const userInfo = createAsync(() => querySessionInfo(params.id!))
const modelsInfo = createAsync(() => getModelsInfo(params.id))
const userInfo = createAsync(() => querySessionInfo(params.id))
const modelsWithLab = createMemo(() => {
const info = modelsInfo()
@@ -116,8 +96,6 @@ export function ModelSection() {
return <IconOpenAI width={16} height={16} />
case "Anthropic":
return <IconAnthropic width={16} height={16} />
case "Google":
return <IconGoogle width={16} height={16} />
case "Moonshot AI":
return <IconMoonshotAI width={16} height={16} />
case "Z.ai":
@@ -140,4 +140,4 @@
}
}
}
}
}
@@ -21,8 +21,8 @@ const listKeys = query(async (workspaceID: string) => {
export function NewUserSection() {
const params = useParams()
const [copiedKey, setCopiedKey] = createSignal(false)
const keys = createAsync(() => listKeys(params.id!))
const usage = createAsync(() => getUsageInfo(params.id!))
const keys = createAsync(() => listKeys(params.id))
const usage = createAsync(() => getUsageInfo(params.id))
const isNew = createMemo(() => {
const keysList = keys()
const usageList = usage()
@@ -128,6 +128,7 @@
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
@@ -135,4 +136,4 @@
}
}
}
}
}
@@ -22,9 +22,7 @@ const removeProvider = action(async (form: FormData) => {
if (!provider) return { error: "Provider is required" }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(await withActor(() => Provider.remove({ provider }), workspaceID), {
revalidate: listProviders.key,
})
return json(await withActor(() => Provider.remove({ provider }), workspaceID), { revalidate: listProviders.key })
}, "provider.remove")
const saveProvider = action(async (form: FormData) => {
@@ -54,7 +52,7 @@ const listProviders = query(async (workspaceID: string) => {
function ProviderRow(props: { provider: Provider }) {
const params = useParams()
const providers = createAsync(() => listProviders(params.id!))
const providers = createAsync(() => listProviders(params.id))
const saveSubmission = useSubmission(saveProvider, ([fd]) => fd.get("provider")?.toString() === props.provider.key)
const removeSubmission = useSubmission(
removeProvider,
@@ -31,7 +31,7 @@
margin: 0;
}
> button {
>button {
align-self: flex-start;
}
}
@@ -80,7 +80,7 @@
}
}
> button[type="reset"] {
>button[type="reset"] {
align-self: flex-start;
}
@@ -91,4 +91,4 @@
margin-top: calc(var(--space-1) * -1);
}
}
}
}
@@ -46,7 +46,7 @@ const updateWorkspace = action(async (form: FormData) => {
export function SettingsSection() {
const params = useParams()
const workspaceInfo = createAsync(() => getWorkspaceInfo(params.id!))
const workspaceInfo = createAsync(() => getWorkspaceInfo(params.id))
const submission = useSubmission(updateWorkspace)
const [store, setStore] = createStore({ show: false })
@@ -1,23 +1,24 @@
.root {
/* Empty state */
[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);
}
}
/* Table container */
[data-slot="usage-table"] {
overflow-x: auto;
}
/* Table element */
[data-slot="usage-table-element"] {
width: 100%;
border-collapse: collapse;
@@ -47,6 +48,7 @@
&[data-slot="usage-model"] {
font-family: var(--font-sans);
font-weight: 400;
color: var(--color-text-secondary);
max-width: 200px;
word-break: break-word;
@@ -54,133 +56,33 @@
&[data-slot="usage-cost"] {
color: var(--color-text);
font-weight: 500;
}
[data-slot="tokens-with-breakdown"] {
position: relative;
display: flex;
align-items: center;
gap: var(--space-2);
}
[data-slot="breakdown-button"] {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: none;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s ease;
&:hover {
color: var(--color-text);
}
svg {
width: 16px;
height: 16px;
}
}
[data-slot="breakdown-popup"] {
position: absolute;
left: 0;
top: 100%;
margin-top: var(--space-2);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
padding: var(--space-2);
z-index: 10;
min-width: 180px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
font-size: var(--font-size-xs);
@media (prefers-color-scheme: dark) {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
}
}
tbody tr:last-child td {
border-bottom: none;
}
}
/* Pagination */
[data-slot="pagination"] {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
padding: var(--space-4) 0;
border-top: 1px solid var(--color-border-muted);
margin-top: var(--space-2);
button {
padding: var(--space-2) var(--space-4);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
color: var(--color-text);
font-size: var(--font-size-sm);
cursor: pointer;
transition: all 0.15s ease;
svg {
width: 16px;
height: 16px;
stroke-width: 2;
}
&:hover:not(:disabled) {
background: var(--color-bg-tertiary);
border-color: var(--color-border-hover);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
tbody tr {
&:last-child td {
border-bottom: none;
}
}
}
/* Mobile responsive */
@media (max-width: 40rem) {
[data-slot="usage-table-element"] {
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
/* Hide Model column on mobile */
th:nth-child(2),
td:nth-child(2) {
display: none;
th {
&:nth-child(2) /* Model */ {
display: none;
}
}
td {
&:nth-child(2) /* Model */ {
display: none;
}
}
}
}
/* Breakdown popup content */
[data-slot="breakdown-row"] {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-4);
padding: var(--space-1) 0;
}
[data-slot="breakdown-label"] {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
[data-slot="breakdown-value"] {
color: var(--color-text);
font-weight: 500;
font-size: var(--font-size-xs);
}
}
@@ -1,69 +1,81 @@
import { Billing } from "@opencode-ai/console-core/billing.js"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, For, Show, createEffect, createSignal } from "solid-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 { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon"
import styles from "./usage-section.module.css"
import { createStore } from "solid-js/store"
const PAGE_SIZE = 50
async function getUsageInfo(workspaceID: string, page: number) {
const getUsageInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.usages(page, PAGE_SIZE)
return await Billing.usages()
}, workspaceID)
}
const queryUsageInfo = query(getUsageInfo, "usage.list")
}, "usage.list")
export function UsageSection() {
const params = useParams()
const usage = createAsync(() => queryUsageInfo(params.id!, 0))
const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
const [openBreakdownId, setOpenBreakdownId] = createSignal<string | null>(null)
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
const usage = createAsync(() => getUsageInfo(params.id))
createEffect(() => {
setStore({ usage: usage() })
}, [usage])
createEffect(() => {
if (!openBreakdownId()) return
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('[data-slot="tokens-with-breakdown"]')) {
setOpenBreakdownId(null)
}
}
document.addEventListener("click", handleClickOutside)
return () => document.removeEventListener("click", handleClickOutside)
})
const hasResults = createMemo(() => store.usage && store.usage.length > 0)
const canGoPrev = createMemo(() => store.page > 0)
const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE)
const calculateTotalInputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
return u.inputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWrite5mTokens ?? 0) + (u.cacheWrite1hTokens ?? 0)
}
const goPrev = async () => {
const usage = await getUsageInfo(params.id!, store.page - 1)
setStore({
page: store.page - 1,
usage,
})
}
const goNext = async () => {
const usage = await getUsageInfo(params.id!, store.page + 1)
setStore({
page: store.page + 1,
usage,
})
}
// 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}>
@@ -73,7 +85,7 @@ export function UsageSection() {
</div>
<div data-slot="usage-table">
<Show
when={hasResults()}
when={usage() && usage()!.length > 0}
fallback={
<div data-component="empty-state">
<p>Make your first API call to get started.</p>
@@ -91,51 +103,16 @@ export function UsageSection() {
</tr>
</thead>
<tbody>
<For each={store.usage}>
{(usage, index) => {
<For each={usage()!}>
{(usage) => {
const date = createMemo(() => new Date(usage.timeCreated))
const totalInputTokens = createMemo(() => calculateTotalInputTokens(usage))
const breakdownId = `breakdown-${index()}`
const isOpen = createMemo(() => openBreakdownId() === breakdownId)
const isClaude = usage.model.toLowerCase().includes("claude")
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">
<div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}>
<button
data-slot="breakdown-button"
onClick={(e) => {
e.stopPropagation()
setOpenBreakdownId(isOpen() ? null : breakdownId)
}}
>
<IconBreakdown />
</button>
<span onClick={() => setOpenBreakdownId(null)}>{totalInputTokens()}</span>
<Show when={isOpen()}>
<div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Input</span>
<span data-slot="breakdown-value">{usage.inputTokens}</span>
</div>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Cache Read</span>
<span data-slot="breakdown-value">{usage.cacheReadTokens ?? 0}</span>
</div>
<Show when={isClaude}>
<div data-slot="breakdown-row">
<span data-slot="breakdown-label">Cache Write</span>
<span data-slot="breakdown-value">{usage.cacheWrite5mTokens ?? 0}</span>
</div>
</Show>
</div>
</Show>
</div>
</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>
@@ -144,16 +121,6 @@ export function UsageSection() {
</For>
</tbody>
</table>
<Show when={canGoPrev() || canGoNext()}>
<div data-slot="pagination">
<button disabled={!canGoPrev()} onClick={goPrev}>
<IconChevronLeft />
</button>
<button disabled={!canGoNext()} onClick={goNext}>
<IconChevronRight />
</button>
</div>
</Show>
</Show>
</div>
</section>
@@ -1,6 +1,6 @@
import { Resource } from "@opencode-ai/console-resource"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { action, json, query } from "@solidjs/router"
import { action, query } from "@solidjs/router"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
import { User } from "@opencode-ai/console-core/user.js"
@@ -34,11 +34,6 @@ export function formatDateUTC(date: Date) {
return date.toLocaleDateString("en-US", options)
}
export function formatBalance(amount: number) {
const balance = ((amount ?? 0) / 100000000).toFixed(2)
return balance === "-0.00" ? "0.00" : balance
}
export async function getLastSeenWorkspaceID() {
"use server"
return withActor(async () => {
@@ -67,40 +62,23 @@ export const querySessionInfo = query(async (workspaceID: string) => {
return withActor(() => {
return {
isAdmin: Actor.userRole() === "admin",
isBeta: Resource.App.stage === "production" ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" : true,
isBeta:
Resource.App.stage === "production"
? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y"
: true,
}
}, workspaceID)
}, "session.get")
export const createCheckoutUrl = action(
async (workspaceID: string, amount: number, successUrl: string, cancelUrl: string) => {
async (workspaceID: string, successUrl: string, cancelUrl: string) => {
"use server"
return json(
await withActor(
() =>
Billing.generateCheckoutUrl({ amount, successUrl, cancelUrl })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({
error: e.message as string,
data: undefined,
})),
workspaceID,
),
)
return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
},
"checkoutUrl",
)
export const queryBillingInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
const billing = await Billing.get()
return {
...billing,
reloadAmount: billing.reloadAmount ?? Billing.RELOAD_AMOUNT,
reloadAmountMin: Billing.RELOAD_AMOUNT_MIN,
reloadTrigger: billing.reloadTrigger ?? Billing.RELOAD_TRIGGER,
reloadTriggerMin: Billing.RELOAD_TRIGGER_MIN,
}
}, workspaceID)
return withActor(() => Billing.get(), workspaceID)
}, "billing.get")
@@ -277,7 +277,7 @@ body {
margin-bottom: 24px;
}
h1 {
strong {
font-size: 28px;
color: var(--color-text-strong);
font-weight: 500;
@@ -1,9 +1,8 @@
import "./index.css"
import { createAsync, query, redirect } from "@solidjs/router"
import { Title, Meta, Link } from "@solidjs/meta"
// import { HttpHeader } from "@solidjs/start"
import { HttpHeader } from "@solidjs/start"
import zenLogoLight from "../../asset/zen-ornate-light.svg"
import { config } from "~/config"
import zenLogoDark from "../../asset/zen-ornate-dark.svg"
import compareVideo from "../../asset/lander/opencode-comparison-min.mp4"
import compareVideoPoster from "../../asset/lander/opencode-comparison-poster.png"
@@ -29,9 +28,8 @@ export default function Home() {
createAsync(() => checkLoggedIn())
return (
<main data-page="zen">
{/*<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />*/}
<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />
<Title>OpenCode Zen | A curated set of reliable optimized models for coding agents</Title>
<Link rel="canonical" href={`${config.baseUrl}/zen`} />
<Link rel="icon" type="image/svg+xml" href="/favicon-zen.svg" />
<Meta property="og:image" content="/social-share-zen.png" />
<Meta name="twitter:image" content="/social-share-zen.png" />
@@ -44,7 +42,7 @@ export default function Home() {
<div data-slot="hero-copy">
<img data-slot="zen logo light" src={zenLogoLight} alt="zen logo light" />
<img data-slot="zen logo dark" src={zenLogoDark} alt="zen logo dark" />
<h1>Reliable optimized models for coding agents</h1>
<strong>Reliable optimized models for coding agents</strong>
<p>
Zen gives you access to a curated set of AI models that OpenCode has tested and benchmarked specifically
for coding agents. No need to worry about inconsistent performance and quality, use validated models
@@ -3,4 +3,3 @@ export class CreditsError extends Error {}
export class MonthlyLimitError extends Error {}
export class UserLimitError extends Error {}
export class ModelError extends Error {}
export class RateLimitError extends Error {}
@@ -0,0 +1 @@
export type Format = "anthropic" | "openai" | "oa-compat"
@@ -12,102 +12,77 @@ import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
import { logger } from "./logger"
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error"
import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider"
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError } from "./error"
import {
createBodyConverter,
createStreamPartConverter,
createResponseConverter,
} from "./provider/provider"
import { Format } from "./format"
import { anthropicHelper } from "./provider/anthropic"
import { googleHelper } from "./provider/google"
import { openaiHelper } from "./provider/openai"
import { oaCompatHelper } from "./provider/openai-compatible"
import { createRateLimiter } from "./rateLimiter"
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type RetryOptions = {
excludeProviders: string[]
retryCount: number
}
type Model = ZenData["models"][string]
export async function handler(
input: APIEvent,
opts: {
format: ZenData.Format
format: Format
parseApiKey: (headers: Headers) => string | undefined
parseModel: (url: string, body: any) => string
parseIsStream: (url: string, body: any) => boolean
},
) {
type AuthInfo = Awaited<ReturnType<typeof authenticate>>
type ModelInfo = Awaited<ReturnType<typeof validateModel>>
type ProviderInfo = Awaited<ReturnType<typeof selectProvider>>
const MAX_RETRIES = 3
const FREE_WORKSPACES = [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
]
try {
const url = input.request.url
const body = await input.request.json()
const ip = input.request.headers.get("x-real-ip") ?? ""
const model = opts.parseModel(url, body)
const isStream = opts.parseIsStream(url, body)
logger.metric({
is_tream: isStream,
is_tream: !!body.stream,
session: input.request.headers.get("x-opencode-session"),
request: input.request.headers.get("x-opencode-request"),
})
const zenData = ZenData.list()
const modelInfo = validateModel(zenData, model)
const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
await rateLimiter?.check()
const modelInfo = validateModel(zenData, body.model)
const providerInfo = selectProvider(
zenData,
modelInfo,
input.request.headers.get("x-real-ip") ?? "",
)
const authInfo = await authenticate(modelInfo, providerInfo)
validateBilling(modelInfo, authInfo)
validateModelSettings(authInfo)
updateProviderKey(authInfo, providerInfo)
logger.metric({ provider: providerInfo.id })
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const providerInfo = selectProvider(zenData, modelInfo, ip, retry)
const authInfo = await authenticate(modelInfo, providerInfo)
validateBilling(authInfo, modelInfo)
validateModelSettings(authInfo)
updateProviderKey(authInfo, providerInfo)
logger.metric({ provider: providerInfo.id })
const startTimestamp = Date.now()
const reqUrl = providerInfo.modifyUrl(providerInfo.api, providerInfo.model, isStream)
const reqBody = JSON.stringify(
providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
}),
)
logger.debug("REQUEST URL: " + reqUrl)
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
const res = await fetch(reqUrl, {
method: "POST",
headers: (() => {
const headers = new Headers(input.request.headers)
providerInfo.modifyHeaders(headers, body, providerInfo.apiKey)
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
headers.set(k, headers.get(v)!)
})
headers.delete("host")
headers.delete("content-length")
headers.delete("x-opencode-request")
headers.delete("x-opencode-session")
return headers
})(),
body: reqBody,
})
// Try another provider => stop retrying if using fallback provider
if (res.status !== 200 && modelInfo.fallbackProvider && providerInfo.id !== modelInfo.fallbackProvider) {
return retriableRequest({
excludeProviders: [...retry.excludeProviders, providerInfo.id],
retryCount: retry.retryCount + 1,
// Request to model provider
const startTimestamp = Date.now()
const reqUrl = providerInfo.modifyUrl(providerInfo.api)
const reqBody = JSON.stringify(
providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
}),
)
logger.debug("REQUEST URL: " + reqUrl)
logger.debug("REQUEST: " + reqBody)
const res = await fetch(reqUrl, {
method: "POST",
headers: (() => {
const headers = input.request.headers
headers.delete("host")
headers.delete("content-length")
providerInfo.modifyHeaders(headers, body, providerInfo.apiKey)
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
headers.set(k, headers.get(v)!)
})
}
return { providerInfo, authInfo, res, startTimestamp }
}
const { providerInfo, authInfo, res, startTimestamp } = await retriableRequest()
return headers
})(),
body: reqBody,
})
// Scrub response headers
const resHeaders = new Headers()
@@ -118,15 +93,17 @@ export async function handler(
}
}
logger.debug("STATUS: " + res.status + " " + res.statusText)
if (res.status === 400 || res.status === 503) {
logger.debug("RESPONSE: " + (await res.text()))
}
// Handle non-streaming response
if (!isStream) {
if (!body.stream) {
const responseConverter = createResponseConverter(providerInfo.format, opts.format)
const json = await res.json()
const body = JSON.stringify(responseConverter(json))
logger.metric({ response_length: body.length })
logger.debug("RESPONSE: " + body)
await rateLimiter?.track()
await trackUsage(authInfo, modelInfo, providerInfo, json.usage)
await reload(authInfo)
return new Response(body, {
@@ -155,7 +132,6 @@ export async function handler(
response_length: responseLength,
"timestamp.last_byte": Date.now(),
})
await rateLimiter?.track()
const usage = usageParser.retrieve()
if (usage) {
await trackUsage(authInfo, modelInfo, providerInfo, usage)
@@ -175,7 +151,7 @@ export async function handler(
responseLength += value.length
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split(providerInfo.streamSeparator)
const parts = buffer.split("\n\n")
buffer = parts.pop() ?? ""
for (let part of parts) {
@@ -230,15 +206,6 @@ export async function handler(
{ status: 401 },
)
if (error instanceof RateLimitError)
return new Response(
JSON.stringify({
type: "error",
error: { type: error.constructor.name, message: error.message },
}),
{ status: 429 },
)
return new Response(
JSON.stringify({
type: "error",
@@ -263,43 +230,42 @@ export async function handler(
return { id: modelId, ...modelData }
}
function selectProvider(zenData: ZenData, modelInfo: ModelInfo, ip: string, retry: RetryOptions) {
const provider = (() => {
if (retry.retryCount === MAX_RETRIES) {
return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
}
function selectProvider(
zenData: ZenData,
model: Awaited<ReturnType<typeof validateModel>>,
ip: string,
) {
const providers = model.providers
.filter((provider) => !provider.disabled)
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
const providers = modelInfo.providers
.filter((provider) => !provider.disabled)
.filter((provider) => !retry.excludeProviders.includes(provider.id))
.flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
// Use last character of IP address to select a provider
const lastChar = ip.charCodeAt(ip.length - 1) || 0
const index = lastChar % providers.length
const provider = providers[index]
// Use the last 2 characters of IP address to select a provider
const lastChars = ip.slice(-2)
const index = parseInt(lastChars, 16) % providers.length
return providers[index || 0]
})()
if (!provider) throw new ModelError("No provider available")
if (!(provider.id in zenData.providers)) throw new ModelError(`Provider ${provider.id} not supported`)
if (!(provider.id in zenData.providers)) {
throw new ModelError(`Provider ${provider.id} not supported`)
}
return {
...provider,
...zenData.providers[provider.id],
...(() => {
const format = zenData.providers[provider.id].format
if (format === "anthropic") return anthropicHelper
if (format === "google") return googleHelper
if (format === "openai") return openaiHelper
return oaCompatHelper
})(),
...(provider.id === "anthropic"
? anthropicHelper
: provider.id === "openai"
? openaiHelper
: oaCompatHelper),
}
}
async function authenticate(modelInfo: ModelInfo, providerInfo: ProviderInfo) {
async function authenticate(
model: Awaited<ReturnType<typeof validateModel>>,
providerInfo: Awaited<ReturnType<typeof selectProvider>>,
) {
const apiKey = opts.parseApiKey(input.request.headers)
if (!apiKey || apiKey === "public") {
if (modelInfo.allowAnonymous) return
if (!apiKey) {
if (model.allowAnonymous) return
throw new AuthError("Missing API key.")
}
@@ -314,7 +280,6 @@ export async function handler(
monthlyLimit: BillingTable.monthlyLimit,
monthlyUsage: BillingTable.monthlyUsage,
timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
reloadTrigger: BillingTable.reloadTrigger,
},
user: {
id: UserTable.id,
@@ -330,11 +295,20 @@ export async function handler(
.from(KeyTable)
.innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
.innerJoin(BillingTable, eq(BillingTable.workspaceID, KeyTable.workspaceID))
.innerJoin(UserTable, and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID)))
.leftJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, modelInfo.id)))
.innerJoin(
UserTable,
and(eq(UserTable.workspaceID, KeyTable.workspaceID), eq(UserTable.id, KeyTable.userID)),
)
.leftJoin(
ModelTable,
and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, model.id)),
)
.leftJoin(
ProviderTable,
and(eq(ProviderTable.workspaceID, KeyTable.workspaceID), eq(ProviderTable.provider, providerInfo.id)),
and(
eq(ProviderTable.workspaceID, KeyTable.workspaceID),
eq(ProviderTable.provider, providerInfo.id),
),
)
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows[0]),
@@ -357,11 +331,11 @@ export async function handler(
}
}
function validateBilling(authInfo: AuthInfo, modelInfo: ModelInfo) {
function validateBilling(model: Model, authInfo: Awaited<ReturnType<typeof authenticate>>) {
if (!authInfo) return
if (authInfo.provider?.credentials) return
if (authInfo.isFree) return
if (modelInfo.allowAnonymous) return
if (model.allowAnonymous) return
const billing = authInfo.billing
if (!billing.paymentMethodID)
@@ -405,24 +379,39 @@ export async function handler(
}
}
function validateModelSettings(authInfo: AuthInfo) {
function validateModelSettings(authInfo: Awaited<ReturnType<typeof authenticate>>) {
if (!authInfo) return
if (authInfo.isDisabled) throw new ModelError("Model is disabled")
}
function updateProviderKey(authInfo: AuthInfo, providerInfo: ProviderInfo) {
function updateProviderKey(
authInfo: Awaited<ReturnType<typeof authenticate>>,
providerInfo: Awaited<ReturnType<typeof selectProvider>>,
) {
if (!authInfo) return
if (!authInfo.provider?.credentials) return
providerInfo.apiKey = authInfo.provider.credentials
}
async function trackUsage(authInfo: AuthInfo, modelInfo: ModelInfo, providerInfo: ProviderInfo, usage: any) {
const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
providerInfo.normalizeUsage(usage)
async function trackUsage(
authInfo: Awaited<ReturnType<typeof authenticate>>,
modelInfo: ReturnType<typeof validateModel>,
providerInfo: Awaited<ReturnType<typeof selectProvider>>,
usage: any,
) {
const {
inputTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens,
cacheWrite1hTokens,
} = providerInfo.normalizeUsage(usage)
const modelCost =
modelInfo.cost200K &&
inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) > 200_000
inputTokens + (cacheReadTokens ?? 0) + (cacheWrite5mTokens ?? 0) + (cacheWrite1hTokens ?? 0) >
200_000
? modelInfo.cost200K
: modelInfo.cost
@@ -473,7 +462,8 @@ export async function handler(
if (!authInfo) return
const cost = authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
const cost =
authInfo.isFree || authInfo.provider?.credentials ? 0 : centsToMicroCents(totalCostInCent)
await Database.transaction(async (tx) => {
await tx.insert(UsageTable).values({
workspaceID: authInfo.workspaceID,
@@ -513,7 +503,9 @@ export async function handler(
`,
timeMonthlyUsageUpdated: sql`now()`,
})
.where(and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)))
.where(
and(eq(UserTable.workspaceID, authInfo.workspaceID), eq(UserTable.id, authInfo.user.id)),
)
})
await Database.use((tx) =>
@@ -524,7 +516,7 @@ export async function handler(
)
}
async function reload(authInfo: AuthInfo) {
async function reload(authInfo: Awaited<ReturnType<typeof authenticate>>) {
if (!authInfo) return
if (authInfo.isFree) return
if (authInfo.provider?.credentials) return
@@ -539,11 +531,11 @@ export async function handler(
and(
eq(BillingTable.workspaceID, authInfo.workspaceID),
eq(BillingTable.reload, true),
lt(
BillingTable.balance,
centsToMicroCents((authInfo.billing.reloadTrigger ?? Billing.RELOAD_TRIGGER) * 100),
lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
or(
isNull(BillingTable.timeReloadLockedTill),
lt(BillingTable.timeReloadLockedTill, sql`now()`),
),
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
),
),
)
@@ -30,7 +30,6 @@ export const anthropicHelper = {
service_tier: "standard_only",
}
},
streamSeparator: "\n\n",
createUsageParser: () => {
let usage: Usage
@@ -99,10 +98,7 @@ export function fromAnthropicRequest(body: any): CommonRequest {
typeof (src as any).media_type === "string" &&
typeof (src as any).data === "string"
)
return {
type: "image_url",
image_url: { url: `data:${(src as any).media_type};base64,${(src as any).data}` },
}
return { type: "image_url", image_url: { url: `data:${(src as any).media_type};base64,${(src as any).data}` } }
return undefined
}
@@ -169,11 +165,7 @@ export function fromAnthropicRequest(body: any): CommonRequest {
.filter((t: any) => t && typeof t === "object" && "input_schema" in t)
.map((t: any) => ({
type: "function",
function: {
name: (t as any).name,
description: (t as any).description,
parameters: (t as any).input_schema,
},
function: { name: (t as any).name, description: (t as any).description, parameters: (t as any).input_schema },
}))
: undefined
@@ -460,12 +452,7 @@ export function toAnthropicResponse(resp: CommonResponse) {
} catch {
input = (tc as any).function.arguments
}
content.push({
type: "tool_use",
id: (tc as any).id,
name: (tc as any).function.name,
input,
})
content.push({ type: "tool_use", id: (tc as any).id, name: (tc as any).function.name, input })
}
}
}
@@ -524,22 +511,13 @@ export function fromAnthropicChunk(chunk: string): CommonChunk | string {
if (json.type === "content_block_start") {
const cb = json.content_block
if (cb?.type === "text") {
out.choices.push({
index: json.index ?? 0,
delta: { role: "assistant", content: "" },
finish_reason: null,
})
out.choices.push({ index: json.index ?? 0, delta: { role: "assistant", content: "" }, finish_reason: null })
} else if (cb?.type === "tool_use") {
out.choices.push({
index: json.index ?? 0,
delta: {
tool_calls: [
{
index: json.index ?? 0,
id: cb.id,
type: "function",
function: { name: cb.name, arguments: "" },
},
{ index: json.index ?? 0, id: cb.id, type: "function", function: { name: cb.name, arguments: "" } },
],
},
finish_reason: null,
@@ -554,9 +532,7 @@ export function fromAnthropicChunk(chunk: string): CommonChunk | string {
} else if (d?.type === "input_json_delta") {
out.choices.push({
index: json.index ?? 0,
delta: {
tool_calls: [{ index: json.index ?? 0, function: { arguments: d.partial_json } }],
},
delta: { tool_calls: [{ index: json.index ?? 0, function: { arguments: d.partial_json } }] },
finish_reason: null,
})
}
@@ -1,74 +0,0 @@
import { ProviderHelper } from "./provider"
/*
{
promptTokenCount: 11453,
candidatesTokenCount: 71,
totalTokenCount: 11625,
cachedContentTokenCount: 8100,
promptTokensDetails: [
{modality: "TEXT",tokenCount: 11453}
],
cacheTokensDetails: [
{modality: "TEXT",tokenCount: 8100}
],
thoughtsTokenCount: 101
}
*/
type Usage = {
promptTokenCount?: number
candidatesTokenCount?: number
totalTokenCount?: number
cachedContentTokenCount?: number
promptTokensDetails?: { modality: string; tokenCount: number }[]
cacheTokensDetails?: { modality: string; tokenCount: number }[]
thoughtsTokenCount?: number
}
export const googleHelper = {
format: "google",
modifyUrl: (providerApi: string, model?: string, isStream?: boolean) =>
`${providerApi}/models/${model}:${isStream ? "streamGenerateContent?alt=sse" : "generateContent"}`,
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("x-goog-api-key", apiKey)
},
modifyBody: (body: Record<string, any>) => {
return body
},
streamSeparator: "\r\n\r\n",
createUsageParser: () => {
let usage: Usage
return {
parse: (chunk: string) => {
if (!chunk.startsWith("data: ")) return
let json
try {
json = JSON.parse(chunk.slice(6)) as { usageMetadata?: Usage }
} catch (e) {
return
}
if (!json.usageMetadata) return
usage = json.usageMetadata
},
retrieve: () => usage,
}
},
normalizeUsage: (usage: Usage) => {
const inputTokens = usage.promptTokenCount ?? 0
const outputTokens = usage.candidatesTokenCount ?? 0
const reasoningTokens = usage.thoughtsTokenCount ?? 0
const cacheReadTokens = usage.cachedContentTokenCount ?? 0
return {
inputTokens: inputTokens - cacheReadTokens,
outputTokens,
reasoningTokens,
cacheReadTokens,
cacheWrite5mTokens: undefined,
cacheWrite1hTokens: undefined,
}
},
} satisfies ProviderHelper

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