Compare commits

..
33 Commits
Author SHA1 Message Date
opencode 0e0460f6c0 release: v0.9.9 2025-09-17 07:40:40 +00:00
Dax Raad 8acd537d1d ci: turborepo typecheck 2025-09-17 03:33:54 -04:00
Dax Raad 40c206c2f9 add opencode attach command to connect to a remote opencode server 2025-09-17 03:30:25 -04:00
Dax Raad 259c722208 only prune messages from more than 2 turns ago 2025-09-17 03:30:09 -04:00
opencode e618cbc447 release: v0.9.8 2025-09-17 07:14:33 +00:00
Dax Raad abd99aeb7d ignore: fix event type gen 2025-09-17 01:17:56 -04:00
opencode ad5fc76b11 release: v0.9.7 2025-09-17 05:09:08 +00:00
Dax Raad ff1f4d6bf9 disable reading .env file automatically 2025-09-17 01:02:23 -04:00
GitHub Action 170ea9c32b chore: format code 2025-09-16 23:53:59 +00:00
Jay V 65ced67432 ignore: zen 2025-09-16 19:53:18 -04:00
Jay V 9f46068c57 ignore: mobile styles zen 2025-09-16 19:39:36 -04:00
Jay V 479cf2fa4f ignore: zen 2025-09-16 19:39:36 -04:00
Frank 39c54f367f wip: zen 2025-09-16 18:13:05 -04:00
Frank 8c71107a93 wip: zen 2025-09-16 17:49:39 -04:00
GitHub Action ef10097329 chore: format code 2025-09-16 21:17:56 +00:00
Jay V 36ee4b5ede ignore: zen 2025-09-16 17:17:17 -04:00
Jay V ae84d5a734 ignore: zen 2025-09-16 17:17:17 -04:00
GitHub Action cd53770734 chore: format code 2025-09-16 20:17:16 +00:00
Jay V 4b1eca73eb ignore: zen 2025-09-16 16:16:30 -04:00
opencode fffcf69cd4 release: v0.9.6 2025-09-16 17:56:45 +00:00
Dax Raad d4c01f858b disable thinking for opencode zen and gpt-5 temporarily 2025-09-16 13:49:37 -04:00
GitHub Action 8e17570c53 chore: format code 2025-09-16 17:35:32 +00:00
Jay V 7f9d08b556 docs: zen 2025-09-16 13:26:49 -04:00
Frank 32a045f60b wip: zen 2025-09-16 09:01:13 -04:00
Aiden ClineandGitHub 91adc3cd41 docs: remove dup section (#2629) 2025-09-16 07:30:29 -05:00
GitHub Action 2bb9b4212f ignore: update download stats 2025-09-16 2025-09-16 12:04:17 +00:00
opencode 3472a50928 release: v0.9.5 2025-09-16 08:58:35 +00:00
Dax Raad 3aeac02bf1 enable session pruning and allow disabling with OPENCODE_DISABLE_PRUNE 2025-09-16 04:53:17 -04:00
opencode 52fcdcc37b release: v0.9.4 2025-09-16 08:35:55 +00:00
Dax Raad 78d6b3a963 fix crash when todo content is empty fixes #2622 2025-09-16 04:28:35 -04:00
15df2710fa fix(windows): force npm cmd shim generation and update install docs (#2558)
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: GitHub Action <action@github.com>
2025-09-16 03:40:19 -04:00
opencode 02e492f6eb release: v0.9.3 2025-09-16 07:16:25 +00:00
Dax Raad 2d5bd26a59 feat: enhance provider model mapping and reasoning capabilities
- Add npm package tracking to provider model mapping
- Implement special handling for opencode provider with reasoning
- Update provider options mapping to use npm package names
2025-09-16 03:10:17 -04:00
65 changed files with 4398 additions and 3242 deletions
+1
View File
@@ -9,3 +9,4 @@ openapi.json
playground
tmp
dist
.turbo
+1
View File
@@ -80,3 +80,4 @@
| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) |
| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) |
| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) |
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
+24 -9
View File
@@ -9,6 +9,7 @@
"devDependencies": {
"prettier": "3.6.2",
"sst": "3.17.13",
"turbo": "2.5.6",
},
},
"cloud/app": {
@@ -26,7 +27,7 @@
},
"cloud/core": {
"name": "@opencode/cloud-core",
"version": "0.9.2",
"version": "0.9.9",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@opencode/cloud-resource": "workspace:*",
@@ -43,7 +44,7 @@
},
"cloud/function": {
"name": "@opencode/cloud-function",
"version": "0.9.2",
"version": "0.9.9",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -69,7 +70,7 @@
},
"cloud/scripts": {
"name": "@opencode/cloud-scripts",
"version": "0.9.2",
"version": "0.9.9",
"dependencies": {
"@opencode/cloud-core": "workspace:*",
"tsx": "4.20.5",
@@ -81,7 +82,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "0.9.2",
"version": "0.9.9",
"dependencies": {
"@kobalte/core": "0.13.11",
"@opencode-ai/sdk": "workspace:*",
@@ -113,7 +114,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "0.9.2",
"version": "0.9.9",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
@@ -128,7 +129,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "0.9.2",
"version": "0.9.9",
"bin": {
"opencode": "./bin/opencode",
},
@@ -179,7 +180,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "0.9.2",
"version": "0.9.9",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
},
@@ -190,7 +191,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "0.9.2",
"version": "0.9.9",
"dependencies": {
"@hey-api/openapi-ts": "0.81.0",
},
@@ -202,7 +203,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "0.9.2",
"version": "0.9.9",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -2736,6 +2737,20 @@
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"turbo": ["turbo@2.5.6", "", { "optionalDependencies": { "turbo-darwin-64": "2.5.6", "turbo-darwin-arm64": "2.5.6", "turbo-linux-64": "2.5.6", "turbo-linux-arm64": "2.5.6", "turbo-windows-64": "2.5.6", "turbo-windows-arm64": "2.5.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-gxToHmi9oTBNB05UjUsrWf0OyN5ZXtD0apOarC1KIx232Vp3WimRNy3810QzeNSgyD5rsaIDXlxlbnOzlouo+w=="],
"turbo-darwin-64": ["turbo-darwin-64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-3C1xEdo4aFwMJAPvtlPqz1Sw/+cddWIOmsalHFMrsqqydcptwBfu26WW2cDm3u93bUzMbBJ8k3zNKFqxJ9ei2A=="],
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LyiG+rD7JhMfYwLqB6k3LZQtYn8CQQUePbpA8mF/hMLPAekXdJo1g0bUPw8RZLwQXUIU/3BU7tXENvhSGz5DPA=="],
"turbo-linux-64": ["turbo-linux-64@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-GOcUTT0xiT/pSnHL4YD6Yr3HreUhU8pUcGqcI2ksIF9b2/r/kRHwGFcsHgpG3+vtZF/kwsP0MV8FTlTObxsYIA=="],
"turbo-linux-arm64": ["turbo-linux-arm64@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-10Tm15bruJEA3m0V7iZcnQBpObGBcOgUcO+sY7/2vk1bweW34LMhkWi8svjV9iDF68+KJDThnYDlYE/bc7/zzQ=="],
"turbo-windows-64": ["turbo-windows-64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-FyRsVpgaj76It0ludwZsNN40ytHN+17E4PFJyeliBEbxrGTc5BexlXVpufB7XlAaoaZVxbS6KT8RofLfDRyEPg=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-j/tWu8cMeQ7HPpKri6jvKtyXg9K1gRyhdK4tKrrchH8GNHscPX/F71zax58yYtLRWTiK04zNzPcUJuoS0+v/+Q=="],
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],
+1 -1
View File
@@ -7,7 +7,7 @@
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "vinxi build && ../../packages/opencode/script/schema.ts ./.output/public/config.json",
"start": "vinxi start",
"version": "0.9.2"
"version": "0.9.9"
},
"dependencies": {
"@ibm/plex": "6.4.1",
@@ -0,0 +1,114 @@
.root {
[data-slot="section-content"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
[data-slot="reload-error"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
p {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
margin: 0;
flex: 1;
}
[data-slot="create-form"] {
display: flex;
gap: var(--space-2);
margin: 0;
flex-shrink: 0;
}
}
[data-slot="payment"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
min-width: 14.5rem;
width: fit-content;
@media (max-width: 30rem) {
width: 100%;
}
[data-slot="credit-card"] {
padding: var(--space-3-5) var(--space-4);
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
display: flex;
align-items: center;
justify-content: space-between;
[data-slot="card-icon"] {
display: flex;
align-items: center;
color: var(--color-text-muted);
}
[data-slot="card-details"] {
display: flex;
align-items: baseline;
gap: var(--space-1);
[data-slot="secret"] {
position: relative;
bottom: 2px;
font-size: var(--font-size-lg);
color: var(--color-text-muted);
font-weight: 400;
}
[data-slot="number"] {
font-size: var(--font-size-3xl);
font-weight: 500;
color: var(--color-text);
}
}
}
[data-slot="button-row"] {
display: flex;
gap: var(--space-2);
align-items: center;
@media (max-width: 30rem) {
flex-direction: column;
> button {
width: 100%;
}
}
[data-slot="create-form"] {
margin: 0;
}
/* Make Enable Billing button full width when it's the only button */
> button {
flex: 1;
}
}
}
[data-slot="usage"] {
p {
font-size: var(--font-size-sm);
line-height: 1.5;
color: var(--color-text-secondary);
b {
font-weight: 600;
}
}
}
}
@@ -0,0 +1,193 @@
import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
import { createMemo, Show } from "solid-js"
import { Billing } from "@opencode/cloud-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import { IconCreditCard } from "~/component/icon"
import styles from "./billing-section.module.css"
const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
"use server"
return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
}, "checkoutUrl")
const reload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(await withActor(() => Billing.reload(), workspaceID), { revalidate: getBillingInfo.key })
}, "billing.reload")
const disableReload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(await withActor(() => Billing.disableReload(), workspaceID), { revalidate: getBillingInfo.key })
}, "billing.disableReload")
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID)
}, "sessionUrl")
const getBillingInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.get()
}, workspaceID)
}, "billing.get")
export function BillingSection() {
const params = useParams()
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
const balanceInfo = createAsync(() => getBillingInfo(params.id))
const createCheckoutUrlAction = useAction(createCheckoutUrl)
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
const createSessionUrlAction = useAction(createSessionUrl)
const createSessionUrlSubmission = useSubmission(createSessionUrl)
const disableReloadSubmission = useSubmission(disableReload)
const reloadSubmission = useSubmission(reload)
// DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW
// Scenario 1: User has not added billing details and has no balance
// const balanceInfo = () => ({
// balance: 0,
// paymentMethodLast4: null as string | null,
// reload: false,
// reloadError: null as string | null,
// timeReloadError: null as Date | null,
// })
// Scenario 2: User has not added billing details but has a balance
// const balanceInfo = () => ({
// balance: 1500000000, // $15.00
// paymentMethodLast4: null as string | null,
// reload: false,
// reloadError: null as string | null,
// timeReloadError: null as Date | null
// })
// Scenario 3: User has added billing details (reload enabled)
// const balanceInfo = () => ({
// balance: 750000000, // $7.50
// paymentMethodLast4: "4242",
// reload: true,
// reloadError: null as string | null,
// timeReloadError: null as Date | null
// })
// Scenario 4: User has billing details but reload failed
// const balanceInfo = () => ({
// balance: 250000000, // $2.50
// paymentMethodLast4: "4242",
// reload: true,
// reloadError: "Your card was declined." as string,
// timeReloadError: new Date(Date.now() - 3600000) as Date // 1 hour ago
// })
const balanceAmount = createMemo(() => {
return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
})
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Billing</h2>
<p>
Manage payments methods. <a href="mailto:contact@anoma.ly">Contact us</a> if you have any questions.
</p>
</div>
<div data-slot="section-content">
<Show when={balanceInfo()?.reloadError}>
<div data-slot="reload-error">
<p>
Reload failed at{" "}
{balanceInfo()?.timeReloadError!.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
})}
. Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try
again.
</p>
<form action={reload} method="post" data-slot="create-form">
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="primary" type="submit" disabled={reloadSubmission.pending}>
{reloadSubmission.pending ? "Reloading..." : "Reload"}
</button>
</form>
</div>
</Show>
<div data-slot="payment">
<div data-slot="credit-card">
<div data-slot="card-icon">
<IconCreditCard style={{ width: "32px", height: "32px" }} />
</div>
<div data-slot="card-details">
<Show when={balanceInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}>
<span data-slot="secret"></span>
<span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
</Show>
</div>
</div>
<div data-slot="button-row">
<Show
when={balanceInfo()?.reload}
fallback={
<button
data-color="primary"
disabled={createCheckoutUrlSubmission.pending}
onClick={async () => {
const baseUrl = window.location.href
const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
if (checkoutUrl) {
window.location.href = checkoutUrl
}
}}
>
{createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
</button>
}
>
<button
data-color="primary"
disabled={createSessionUrlSubmission.pending}
onClick={async () => {
const baseUrl = window.location.href
const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
if (sessionUrl) {
window.location.href = sessionUrl
}
}}
>
{createSessionUrlSubmission.pending ? "Loading..." : "Manage Payment Methods"}
</button>
<form action={disableReload} method="post" data-slot="create-form">
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="ghost" type="submit" disabled={disableReloadSubmission.pending}>
{disableReloadSubmission.pending ? "Disabling..." : "Disable"}
</button>
</form>
</Show>
</div>
</div>
<div data-slot="usage">
<Show when={!balanceInfo()?.reload && !(balanceAmount() === "0.00" || balanceAmount() === "-0.00")}>
<p>
You have <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b> remaining in
your account. You can continue using the API with your remaining balance.
</p>
</Show>
<Show when={balanceInfo()?.reload && !balanceInfo()?.reloadError}>
<p>
Your current balance is <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
. We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches <b>$5</b>.
</p>
</Show>
</div>
</div>
</section>
)
}
@@ -0,0 +1,25 @@
export function formatDateForTable(date: Date) {
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "short",
hour: "numeric",
minute: "2-digit",
hour12: true,
}
return date.toLocaleDateString("en-GB", options).replace(",", ",")
}
export function formatDateUTC(date: Date) {
const options: Intl.DateTimeFormatOptions = {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
timeZone: "UTC",
}
return date.toLocaleDateString("en-US", options)
}
@@ -0,0 +1,172 @@
.root {
[data-component="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
display: flex;
flex-direction: column;
gap: var(--space-2);
p {
line-height: 1.5;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
[data-slot="create-form"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
[data-slot="input-container"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
@media (max-width: 30rem) {
gap: var(--space-2);
}
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
margin-top: var(--space-1);
line-height: 1.4;
}
}
[data-slot="api-keys-table"] {
overflow-x: auto;
}
[data-slot="api-keys-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="key-name"] {
color: var(--color-text);
font-family: var(--font-sans);
font-weight: 500;
}
&[data-slot="key-value"] {
font-family: var(--font-mono);
button {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-sm);
font-weight: 400;
border: none;
background-color: transparent;
color: var(--color-text-muted);
font-family: var(--font-mono);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.15s ease;
text-transform: none;
&:hover:not(:disabled) {
background-color: var(--color-bg-surface);
color: var(--color-text);
}
&:disabled {
cursor: default;
color: var(--color-text);
}
span {
font-family: inherit;
}
}
}
&[data-slot="key-date"] {
color: var(--color-text);
}
&[data-slot="key-actions"] {
font-family: var(--font-sans);
}
}
tbody tr {
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(3) /* Date */ {
display: none;
}
}
td {
&:nth-child(3) /* Date */ {
display: none;
}
}
}
}
}
@@ -0,0 +1,182 @@
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, createSignal, For, Show } from "solid-js"
import { IconCopy, IconCheck } from "~/component/icon"
import { Key } from "@opencode/cloud-core/key.js"
import { withActor } from "~/context/auth.withActor"
import { createStore } from "solid-js/store"
import { formatDateUTC, formatDateForTable } from "./common"
import styles from "./key-section.module.css"
const removeKey = action(async (form: FormData) => {
"use server"
const id = form.get("id")?.toString()
if (!id) return { error: "ID is required" }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
}, "key.remove")
const createKey = action(async (form: FormData) => {
"use server"
const name = form.get("name")?.toString().trim()
if (!name) return { error: "Name is required" }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(
await withActor(
() =>
Key.create({ name })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: listKeys.key },
)
}, "key.create")
const listKeys = query(async (workspaceID: string) => {
"use server"
return withActor(() => Key.list(), workspaceID)
}, "key.list")
export function KeyCreateForm() {
const params = useParams()
const submission = useSubmission(createKey)
const [store, setStore] = createStore({ show: false })
let input: HTMLInputElement
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
hide()
}
})
function show() {
// submission.clear() does not clear the result in some cases, ie.
// 1. Create key with empty name => error shows
// 2. Put in a key name and creates the key => form hides
// 3. Click add key button again => form shows with the same error if
// submission.clear() is called only once
while (true) {
submission.clear()
if (!submission.result) break
}
setStore("show", true)
input.focus()
}
function hide() {
setStore("show", false)
}
return (
<Show
when={store.show}
fallback={
<button data-color="primary" onClick={() => show()}>
Create API Key
</button>
}
>
<form action={createKey} method="post" data-slot="create-form">
<div data-slot="input-container">
<input ref={(r) => (input = r)} data-component="input" name="name" type="text" placeholder="Enter key name" />
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{err()}</div>}
</Show>
</div>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
Cancel
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? "Creating..." : "Create"}
</button>
</div>
</form>
</Show>
)
}
export function KeySection() {
const params = useParams()
const keys = createAsync(() => listKeys(params.id))
function formatKey(key: string) {
if (key.length <= 11) return key
return `${key.slice(0, 7)}...${key.slice(-4)}`
}
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>API Keys</h2>
<p>Manage your API keys for accessing opencode services.</p>
</div>
<KeyCreateForm />
<div data-slot="api-keys-table">
<Show
when={keys()?.length}
fallback={
<div data-component="empty-state">
<p>Create an opencode Gateway API key</p>
</div>
}
>
<table data-slot="api-keys-table-element">
<thead>
<tr>
<th>Name</th>
<th>Key</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
<For each={keys()!}>
{(key) => {
const [copied, setCopied] = createSignal(false)
// const submission = useSubmission(removeKey, ([fd]) => fd.get("id")?.toString() === key.id)
return (
<tr>
<td data-slot="key-name">{key.name}</td>
<td data-slot="key-value">
<button
data-color="ghost"
disabled={copied()}
onClick={async () => {
await navigator.clipboard.writeText(key.key)
setCopied(true)
setTimeout(() => setCopied(false), 1000)
}}
title="Copy API key"
>
<span>{formatKey(key.key)}</span>
<Show when={copied()} fallback={<IconCopy style={{ width: "14px", height: "14px" }} />}>
<IconCheck style={{ width: "14px", height: "14px" }} />
</Show>
</button>
</td>
<td data-slot="key-date" title={formatDateUTC(key.timeCreated)}>
{formatDateForTable(key.timeCreated)}
</td>
<td data-slot="key-actions">
<form action={removeKey} method="post">
<input type="hidden" name="id" value={key.id} />
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="ghost">Delete</button>
</form>
</td>
</tr>
)
}}
</For>
</tbody>
</table>
</Show>
</div>
</section>
)
}
@@ -0,0 +1,102 @@
.root {
[data-slot="section-content"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
[data-slot="balance"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
min-width: 15rem;
width: fit-content;
@media (max-width: 30rem) {
width: 100%;
}
[data-slot="amount"] {
padding: var(--space-3-5) var(--space-4);
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
display: flex;
align-items: baseline;
gap: var(--space-1);
justify-content: flex-end;
[data-slot="currency"] {
position: relative;
bottom: 2px;
font-size: var(--font-size-lg);
color: var(--color-text-muted);
font-weight: 400;
}
[data-slot="value"] {
font-size: var(--font-size-3xl);
font-weight: 500;
color: var(--color-text);
}
}
[data-slot="create-form"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-1);
[data-slot="input-container"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
@media (max-width: 30rem) {
gap: var(--space-2);
}
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
}
}
}
[data-slot="usage-status"] {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin: 0;
line-height: 1.4;
}
}
@@ -0,0 +1,139 @@
import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode/cloud-core/billing.js"
import styles from "./monthly-limit-section.module.css"
const getBillingInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.get()
}, workspaceID)
}, "billing.get")
const setMonthlyLimit = action(async (form: FormData) => {
"use server"
const limit = form.get("limit")?.toString()
if (!limit) return { error: "Limit is required." }
const numericLimit = parseInt(limit)
if (numericLimit < 0) return { error: "Set a valid monthly limit." }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required." }
return json(
await withActor(
() =>
Billing.setMonthlyLimit(numericLimit)
.then((data) => ({ error: undefined, data }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: getBillingInfo.key },
)
}, "billing.setMonthlyLimit")
export function MonthlyLimitSection() {
const params = useParams()
const submission = useSubmission(setMonthlyLimit)
const [store, setStore] = createStore({ show: false })
const balanceInfo = createAsync(() => getBillingInfo(params.id))
let input: HTMLInputElement
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
hide()
}
})
function show() {
// submission.clear() does not clear the result in some cases, ie.
// 1. Create key with empty name => error shows
// 2. Put in a key name and creates the key => form hides
// 3. Click add key button again => form shows with the same error if
// submission.clear() is called only once
while (true) {
submission.clear()
if (!submission.result) break
}
setStore("show", true)
input.focus()
}
function hide() {
setStore("show", false)
}
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Monthly Limit</h2>
<p>Set a monthly spending limit for your account.</p>
</div>
<div data-slot="section-content">
<div data-slot="balance">
<div data-slot="amount">
{balanceInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
<span data-slot="value">{balanceInfo()?.monthlyLimit ?? "-"}</span>
</div>
<Show
when={!store.show}
fallback={
<form action={setMonthlyLimit} method="post" data-slot="create-form">
<div data-slot="input-container">
<input
required
ref={(r) => (input = r)}
data-component="input"
name="limit"
type="number"
placeholder="50"
/>
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{err()}</div>}
</Show>
</div>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
Cancel
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? "Setting..." : "Set"}
</button>
</div>
</form>
}
>
<button data-color="primary" onClick={() => show()}>
{balanceInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
</button>
</Show>
</div>
<Show when={balanceInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No spending limit set.</p>}>
<p data-slot="usage-status">
Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $
{(() => {
const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated
if (!dateLastUsed) return "0"
const current = new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
timeZone: "UTC",
})
const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
timeZone: "UTC",
})
if (current !== lastUsed) return "0"
return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
})()}
.
</p>
</Show>
</div>
</section>
)
}
@@ -0,0 +1,163 @@
.root {
display: flex;
flex-direction: column;
gap: var(--space-8);
padding: var(--space-6);
background-color: var(--color-bg-surface);
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
@media (max-width: 30rem) {
gap: var(--space-8);
padding: var(--space-4);
}
[data-component="feature-grid"] {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-6);
@media (max-width: 30rem) {
grid-template-columns: 1fr;
gap: var(--space-4);
}
[data-slot="feature"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
h3 {
font-size: var(--font-size-sm);
font-weight: 600;
margin: 0;
color: var(--color-text);
text-transform: uppercase;
letter-spacing: -0.025rem;
}
p {
font-size: var(--font-size-sm);
line-height: 1.5;
margin: 0;
color: var(--color-text-muted);
}
}
}
[data-component="api-key-highlight"] {
display: flex;
flex-direction: column;
gap: var(--space-6);
[data-slot="section-title"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
h2 {
font-size: var(--font-size-md);
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.03125rem;
margin: 0;
color: var(--color-text-secondary);
text-transform: uppercase;
@media (max-width: 30rem) {
font-size: var(--font-size-md);
}
}
}
[data-slot="key-display"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
[data-slot="key-container"] {
display: flex;
gap: var(--space-3);
padding: var(--space-4);
border: 2px solid var(--color-accent);
border-radius: var(--border-radius-sm);
align-items: center;
@media (max-width: 40rem) {
flex-direction: column;
gap: var(--space-3);
align-items: stretch;
}
[data-slot="key-value"] {
flex: 1;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
color: var(--color-text);
background-color: var(--color-bg);
padding: var(--space-3);
border-radius: var(--border-radius-sm);
border: 1px solid var(--color-border);
word-break: break-all;
line-height: 1.4;
@media (max-width: 40rem) {
font-size: var(--font-size-xs);
padding: var(--space-2-5);
}
}
button {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
font-size: var(--font-size-sm);
font-weight: 500;
white-space: nowrap;
min-width: 130px;
@media (max-width: 40rem) {
justify-content: center;
padding: var(--space-2-5) var(--space-3);
font-size: var(--font-size-xs);
min-width: 96px;
}
}
}
}
}
[data-component="next-steps"] {
display: flex;
flex-direction: column;
gap: var(--space-6);
ol {
margin: 0;
padding-left: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
list-style-position: inside;
li {
font-size: var(--font-size-md);
line-height: 1.5;
color: var(--color-text-secondary);
code {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
color: var(--color-text);
}
}
}
}
}
@@ -0,0 +1,97 @@
import { query, useParams, createAsync } from "@solidjs/router"
import { createMemo, createSignal, Show } from "solid-js"
import { IconCopy, IconCheck } from "~/component/icon"
import { Key } from "@opencode/cloud-core/key.js"
import { Billing } from "@opencode/cloud-core/billing.js"
import { withActor } from "~/context/auth.withActor"
import styles from "./new-user-section.module.css"
const getUsageInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.usages()
}, workspaceID)
}, "usage.list")
const listKeys = query(async (workspaceID: string) => {
"use server"
return withActor(() => Key.list(), workspaceID)
}, "key.list")
export function NewUserSection() {
const params = useParams()
const [copiedKey, setCopiedKey] = createSignal(false)
const keys = createAsync(() => listKeys(params.id))
const usage = createAsync(() => getUsageInfo(params.id))
const isNew = createMemo(() => {
const keysList = keys()
const usageList = usage()
return keysList?.length === 1 && (!usageList || usageList.length === 0)
})
const defaultKey = createMemo(() => keys()?.at(-1)?.key)
return (
<Show when={isNew()}>
<div class={styles.root}>
<div data-component="feature-grid">
<div data-slot="feature">
<h3>Tested & Verified Models</h3>
<p>We've benchmarked and tested models specifically for coding agents to ensure the best performance.</p>
</div>
<div data-slot="feature">
<h3>Highest Quality</h3>
<p>Access models configured for optimal performance - no downgrades or routing to cheaper providers.</p>
</div>
<div data-slot="feature">
<h3>No Lock-in</h3>
<p>Use Zen with any coding agent, and continue using other providers with opencode whenever you want.</p>
</div>
</div>
<div data-component="api-key-highlight">
<Show when={defaultKey()}>
<div data-slot="key-display">
<div data-slot="key-container">
<code data-slot="key-value">{defaultKey()}</code>
<button
data-color="primary"
disabled={copiedKey()}
onClick={async () => {
await navigator.clipboard.writeText(defaultKey() ?? "")
setCopiedKey(true)
setTimeout(() => setCopiedKey(false), 2000)
}}
title="Copy API key"
>
<Show
when={copiedKey()}
fallback={
<>
<IconCopy style={{ width: "16px", height: "16px" }} /> Copy Key
</>
}
>
<IconCheck style={{ width: "16px", height: "16px" }} /> Copied!
</Show>
</button>
</div>
</div>
</Show>
</div>
<div data-component="next-steps">
<ol>
<li>Enable billing</li>
<li>
Run <code>opencode auth login</code> and select opencode
</li>
<li>Paste your API key</li>
<li>
Start opencode and run <code>/models</code> to select a model
</li>
</ol>
</div>
</div>
</Show>
)
}
@@ -0,0 +1,72 @@
.root {
[data-slot="payments-table"] {
overflow-x: auto;
}
[data-slot="payments-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="payment-date"] {
color: var(--color-text);
}
&[data-slot="payment-id"] {
font-family: var(--font-mono);
font-weight: 400;
color: var(--color-text-muted);
max-width: 200px;
word-break: break-word;
}
&[data-slot="payment-amount"] {
color: var(--color-text);
}
}
tbody tr {
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(2) /* Payment ID */ {
display: none;
}
}
td {
&:nth-child(2) /* Payment ID */ {
display: none;
}
}
}
}
}
@@ -0,0 +1,113 @@
import { Billing } from "@opencode/cloud-core/billing.js"
import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
import { For } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { formatDateUTC, formatDateForTable } from "./common"
import styles from "./payment-section.module.css"
const getPaymentsInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.payments()
}, workspaceID)
}, "payment.list")
const downloadReceipt = action(async (workspaceID: string, paymentID: string) => {
"use server"
return withActor(() => Billing.generateReceiptUrl({ paymentID }), workspaceID)
}, "receipt.download")
export function PaymentSection() {
const params = useParams()
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
const payments = createAsync(() => getPaymentsInfo(params.id))
const downloadReceiptAction = useAction(downloadReceipt)
// DUMMY DATA FOR TESTING
// const payments = () => [
// {
// id: "pi_3QK1x2FT9vXn4A6r1234567890",
// paymentID: "pi_3QK1x2FT9vXn4A6r1234567890",
// timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // 1 day ago
// amount: 2100000000, // $21.00 ($20 + $1 fee)
// },
// {
// id: "pi_3QJ8k7FT9vXn4A6r0987654321",
// paymentID: "pi_3QJ8k7FT9vXn4A6r0987654321",
// timeCreated: new Date(Date.now() - 86400000 * 15).toISOString(), // 15 days ago
// amount: 2100000000, // $21.00
// },
// {
// id: "pi_3QI5m1FT9vXn4A6r5678901234",
// paymentID: "pi_3QI5m1FT9vXn4A6r5678901234",
// timeCreated: new Date(Date.now() - 86400000 * 32).toISOString(), // 32 days ago
// amount: 2100000000, // $21.00
// },
// {
// id: "pi_3QH2n9FT9vXn4A6r3456789012",
// paymentID: "pi_3QH2n9FT9vXn4A6r3456789012",
// timeCreated: new Date(Date.now() - 86400000 * 47).toISOString(), // 47 days ago
// amount: 2100000000, // $21.00
// },
// {
// id: "pi_3QG7p4FT9vXn4A6r7890123456",
// paymentID: "pi_3QG7p4FT9vXn4A6r7890123456",
// timeCreated: new Date(Date.now() - 86400000 * 63).toISOString(), // 63 days ago
// amount: 2100000000, // $21.00
// },
// ]
return (
payments() &&
payments()!.length > 0 && (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Payments History</h2>
<p>Recent payment transactions.</p>
</div>
<div data-slot="payments-table">
<table data-slot="payments-table-element">
<thead>
<tr>
<th>Date</th>
<th>Payment ID</th>
<th>Amount</th>
<th>Receipt</th>
</tr>
</thead>
<tbody>
<For each={payments()!}>
{(payment) => {
const date = new Date(payment.timeCreated)
return (
<tr>
<td data-slot="payment-date" title={formatDateUTC(date)}>
{formatDateForTable(date)}
</td>
<td data-slot="payment-id">{payment.id}</td>
<td data-slot="payment-amount">${((payment.amount ?? 0) / 100000000).toFixed(2)}</td>
<td data-slot="payment-receipt">
<button
onClick={async () => {
const receiptUrl = await downloadReceiptAction(params.id, payment.paymentID!)
if (receiptUrl) {
window.open(receiptUrl, "_blank")
}
}}
data-slot="receipt-button"
style="cursor: pointer;"
>
view
</button>
</td>
</tr>
)
}}
</For>
</tbody>
</table>
</div>
</section>
)
)
}
@@ -0,0 +1,88 @@
.root {
[data-component="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
display: flex;
flex-direction: column;
gap: var(--space-2);
p {
line-height: 1.5;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
[data-slot="usage-table"] {
overflow-x: auto;
}
[data-slot="usage-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="usage-date"] {
color: var(--color-text);
}
&[data-slot="usage-model"] {
font-family: var(--font-sans);
font-weight: 400;
color: var(--color-text-secondary);
max-width: 200px;
word-break: break-word;
}
&[data-slot="usage-cost"] {
color: var(--color-text);
}
}
tbody tr {
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(2) /* Model */ {
display: none;
}
}
td {
&:nth-child(2) /* Model */ {
display: none;
}
}
}
}
}
@@ -0,0 +1,128 @@
import { Billing } from "@opencode/cloud-core/billing.js"
import { query, useParams, createAsync } from "@solidjs/router"
import { createMemo, For, Show } from "solid-js"
import { formatDateUTC, formatDateForTable } from "./common"
import { withActor } from "~/context/auth.withActor"
import styles from "./usage-section.module.css"
const getUsageInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.usages()
}, workspaceID)
}, "usage.list")
export function UsageSection() {
const params = useParams()
// ORIGINAL CODE - COMMENTED OUT FOR TESTING
const usage = createAsync(() => getUsageInfo(params.id))
// DUMMY DATA FOR TESTING
// const usage = () => [
// {
// timeCreated: new Date(Date.now() - 86400000 * 0).toISOString(), // Today
// model: "claude-3-5-sonnet-20241022",
// inputTokens: 1247,
// outputTokens: 423,
// cost: 125400000, // $1.254
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 0.5).toISOString(), // 12 hours ago
// model: "claude-3-haiku-20240307",
// inputTokens: 892,
// outputTokens: 156,
// cost: 23500000, // $0.235
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // Yesterday
// model: "claude-3-5-sonnet-20241022",
// inputTokens: 2134,
// outputTokens: 687,
// cost: 234700000, // $2.347
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 1.3).toISOString(), // 1.3 days ago
// model: "gpt-4o-mini",
// inputTokens: 567,
// outputTokens: 234,
// cost: 8900000, // $0.089
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 2).toISOString(), // 2 days ago
// model: "claude-3-opus-20240229",
// inputTokens: 1893,
// outputTokens: 945,
// cost: 445600000, // $4.456
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 2.7).toISOString(), // 2.7 days ago
// model: "gpt-4o",
// inputTokens: 1456,
// outputTokens: 532,
// cost: 156800000, // $1.568
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 3).toISOString(), // 3 days ago
// model: "claude-3-haiku-20240307",
// inputTokens: 634,
// outputTokens: 89,
// cost: 12300000, // $0.123
// },
// {
// timeCreated: new Date(Date.now() - 86400000 * 4).toISOString(), // 4 days ago
// model: "claude-3-5-sonnet-20241022",
// inputTokens: 3245,
// outputTokens: 1123,
// cost: 387200000, // $3.872
// },
// ]
return (
<section class={styles.root}>
<div data-slot="section-title">
<h2>Usage History</h2>
<p>Recent API usage and costs.</p>
</div>
<div data-slot="usage-table">
<Show
when={usage() && usage()!.length > 0}
fallback={
<div data-component="empty-state">
<p>Make your first API call to get started.</p>
</div>
}
>
<table data-slot="usage-table-element">
<thead>
<tr>
<th>Date</th>
<th>Model</th>
<th>Input</th>
<th>Output</th>
<th>Cost</th>
</tr>
</thead>
<tbody>
<For each={usage()!}>
{(usage) => {
const date = createMemo(() => new Date(usage.timeCreated))
return (
<tr>
<td data-slot="usage-date" title={formatDateUTC(date())}>
{formatDateForTable(date())}
</td>
<td data-slot="usage-model">{usage.model}</td>
<td data-slot="usage-tokens">{usage.inputTokens}</td>
<td data-slot="usage-tokens">{usage.outputTokens}</td>
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
</tr>
)
}}
</For>
</tbody>
</table>
</Show>
</div>
</section>
)
}
+19 -691
View File
@@ -26,7 +26,11 @@
section {
display: flex;
flex-direction: column;
gap: var(--space-6);
gap: var(--space-8);
@media (max-width: 30rem) {
gap: var(--space-6);
}
/* Section titles */
[data-slot="section-title"] {
@@ -47,6 +51,20 @@
font-size: var(--font-size-md);
}
}
p {
line-height: 1.5;
font-size: var(--font-size-md);
color: var(--color-text-muted);
a {
color: var(--color-text-muted);
}
@media (max-width: 30rem) {
font-size: var(--font-size-sm);
}
}
}
}
section:not(:last-child) {
@@ -59,22 +77,6 @@
}
}
[data-component="empty-state"] {
padding: var(--space-20) var(--space-6);
text-align: center;
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
display: flex;
flex-direction: column;
gap: var(--space-2);
p {
line-height: 1.5;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
/* Title section */
[data-component="title-section"] {
display: flex;
@@ -110,678 +112,4 @@
}
}
}
/* API Keys Section */
[data-component="api-keys-section"] {
[data-slot="create-form"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
[data-slot="input-container"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
@media (max-width: 30rem) {
gap: var(--space-2);
}
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
margin-top: var(--space-1);
line-height: 1.4;
}
}
[data-slot="api-keys-table"] {
overflow-x: auto;
}
[data-slot="api-keys-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="key-name"] {
color: var(--color-text);
font-family: var(--font-sans);
font-weight: 500;
}
&[data-slot="key-value"] {
font-family: var(--font-mono);
button {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-sm);
font-weight: 400;
border: none;
background-color: transparent;
color: var(--color-text-muted);
font-family: var(--font-mono);
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.15s ease;
text-transform: none;
&:hover:not(:disabled) {
background-color: var(--color-bg-surface);
color: var(--color-text);
}
&:disabled {
cursor: default;
color: var(--color-text);
}
span {
font-family: inherit;
}
}
}
&[data-slot="key-date"] {
color: var(--color-text);
}
&[data-slot="key-actions"] {
font-family: var(--font-sans);
}
}
tbody tr {
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(3) /* Date */ {
display: none;
}
}
td {
&:nth-child(3) /* Date */ {
display: none;
}
}
}
}
}
/* Billing Section */
[data-component="billing-section"] {
[data-slot="section-content"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
[data-slot="reload-error"] {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
p {
color: var(--color-danger);
font-size: var(--font-size-sm);
line-height: 1.4;
margin: 0;
flex: 1;
}
[data-slot="create-form"] {
display: flex;
gap: var(--space-2);
margin: 0;
flex-shrink: 0;
}
}
[data-slot="payment"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
min-width: 14.5rem;
width: fit-content;
[data-slot="credit-card"] {
padding: var(--space-3-5) var(--space-4);
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
display: flex;
align-items: center;
justify-content: space-between;
[data-slot="card-icon"] {
display: flex;
align-items: center;
}
[data-slot="card-details"] {
display: flex;
align-items: baseline;
gap: var(--space-1);
[data-slot="secret"] {
position: relative;
bottom: 2px;
font-size: var(--font-size-lg);
color: var(--color-text-muted);
font-weight: 400;
}
[data-slot="number"] {
font-size: var(--font-size-3xl);
font-weight: 500;
color: var(--color-text);
}
}
}
[data-slot="button-row"] {
display: flex;
gap: var(--space-2);
align-items: center;
[data-slot="create-form"] {
margin: 0;
}
/* Make Enable Billing button full width when it's the only button */
> button {
flex: 1;
}
}
}
[data-slot="usage"] {
p {
font-size: var(--font-size-sm);
line-height: 1.5;
color: var(--color-text-secondary);
b {
font-weight: 600;
}
}
}
}
/* Monthly Limit Section */
[data-component="monthly-limit-section"] {
[data-slot="section-content"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
[data-slot="balance"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
min-width: 15rem;
width: fit-content;
[data-slot="amount"] {
padding: var(--space-3-5) var(--space-4);
background-color: var(--color-bg-surface);
border-radius: var(--border-radius-sm);
display: flex;
align-items: baseline;
gap: var(--space-1);
justify-content: flex-end;
[data-slot="currency"] {
position: relative;
bottom: 2px;
font-size: var(--font-size-lg);
color: var(--color-text-muted);
font-weight: 400;
}
[data-slot="value"] {
font-size: var(--font-size-3xl);
font-weight: 500;
color: var(--color-text);
}
}
[data-slot="create-form"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-1);
[data-slot="input-container"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
@media (max-width: 30rem) {
gap: var(--space-2);
}
input {
flex: 1;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
background-color: var(--color-bg);
color: var(--color-text);
font-size: var(--font-size-sm);
font-family: var(--font-mono);
&:focus {
outline: none;
border-color: var(--color-accent);
}
&::placeholder {
color: var(--color-text-disabled);
}
}
[data-slot="form-actions"] {
display: flex;
gap: var(--space-2);
justify-content: flex-end;
}
[data-slot="form-error"] {
color: var(--color-danger);
font-size: var(--font-size-sm);
margin-top: var(--space-1);
line-height: 1.4;
}
}
}
[data-slot="usage-status"] {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin: 0;
line-height: 1.4;
}
}
/* Payments Section */
[data-component="payments-section"] {
[data-slot="payments-table"] {
overflow-x: auto;
}
[data-slot="payments-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="payment-date"] {
color: var(--color-text);
}
&[data-slot="payment-id"] {
font-family: var(--font-mono);
font-weight: 400;
color: var(--color-text-muted);
max-width: 200px;
word-break: break-word;
}
&[data-slot="payment-amount"] {
color: var(--color-text);
}
}
tbody tr {
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(2) /* Payment ID */ {
display: none;
}
}
td {
&:nth-child(2) /* Payment ID */ {
display: none;
}
}
}
}
}
/* Usage Section */
[data-component="usage-section"] {
[data-slot="usage-table"] {
overflow-x: auto;
}
[data-slot="usage-table-element"] {
width: 100%;
border-collapse: collapse;
font-size: var(--font-size-sm);
thead {
border-bottom: 1px solid var(--color-border);
}
th {
padding: var(--space-3) var(--space-4);
text-align: left;
font-weight: normal;
color: var(--color-text-muted);
text-transform: uppercase;
}
td {
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-muted);
color: var(--color-text-muted);
font-family: var(--font-mono);
&[data-slot="usage-date"] {
color: var(--color-text);
}
&[data-slot="usage-model"] {
font-family: var(--font-sans);
font-weight: 400;
color: var(--color-text-secondary);
max-width: 200px;
word-break: break-word;
}
&[data-slot="usage-cost"] {
color: var(--color-text);
}
}
tbody tr {
&:last-child td {
border-bottom: none;
}
}
@media (max-width: 40rem) {
th,
td {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
}
th {
&:nth-child(2) /* Model */ {
display: none;
}
}
td {
&:nth-child(2) /* Model */ {
display: none;
}
}
}
}
}
[data-slot="new-user-sections"] {
display: flex;
flex-direction: column;
gap: var(--space-8);
padding: var(--space-6);
background-color: var(--color-bg-surface);
border: 1px dashed var(--color-border);
border-radius: var(--border-radius-sm);
@media (max-width: 30rem) {
gap: var(--space-8);
padding: var(--space-4);
}
[data-component="feature-grid"] {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-6);
@media (max-width: 30rem) {
grid-template-columns: 1fr;
gap: var(--space-4);
}
[data-slot="feature"] {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
h3 {
font-size: var(--font-size-sm);
font-weight: 600;
margin: 0;
color: var(--color-text);
text-transform: uppercase;
letter-spacing: -0.025rem;
}
p {
font-size: var(--font-size-sm);
line-height: 1.5;
margin: 0;
color: var(--color-text-muted);
}
}
}
[data-component="api-key-highlight"] {
display: flex;
flex-direction: column;
gap: var(--space-6);
[data-slot="section-title"] {
display: flex;
flex-direction: column;
gap: var(--space-1);
h2 {
font-size: var(--font-size-md);
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.03125rem;
margin: 0;
color: var(--color-text-secondary);
text-transform: uppercase;
@media (max-width: 30rem) {
font-size: var(--font-size-md);
}
}
}
[data-slot="key-display"] {
display: flex;
flex-direction: column;
gap: var(--space-3);
[data-slot="key-container"] {
display: flex;
gap: var(--space-3);
padding: var(--space-4);
border: 2px solid var(--color-accent);
border-radius: var(--border-radius-sm);
align-items: center;
@media (max-width: 40rem) {
flex-direction: column;
gap: var(--space-3);
align-items: stretch;
}
[data-slot="key-value"] {
flex: 1;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
color: var(--color-text);
background-color: var(--color-bg);
padding: var(--space-3);
border-radius: var(--border-radius-sm);
border: 1px solid var(--color-border);
word-break: break-all;
line-height: 1.4;
@media (max-width: 40rem) {
font-size: var(--font-size-xs);
padding: var(--space-2-5);
}
}
button {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
font-size: var(--font-size-sm);
font-weight: 500;
white-space: nowrap;
min-width: 130px;
@media (max-width: 40rem) {
justify-content: center;
padding: var(--space-2-5) var(--space-3);
font-size: var(--font-size-xs);
min-width: 96px;
}
}
}
}
}
[data-component="next-steps"] {
display: flex;
flex-direction: column;
gap: var(--space-6);
ol {
margin: 0;
padding-left: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
list-style-position: inside;
li {
font-size: var(--font-size-md);
line-height: 1.5;
color: var(--color-text-secondary);
code {
font-family: var(--font-mono);
font-size: var(--font-size-sm);
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-sm);
color: var(--color-text);
}
}
}
}
}
}
+10 -662
View File
@@ -1,77 +1,14 @@
import "./[id].css"
import { Billing } from "@opencode/cloud-core/billing.js"
import { Key } from "@opencode/cloud-core/key.js"
import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
import { query, useParams, createAsync } from "@solidjs/router"
import { Show } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { IconCopy, IconCheck, IconCreditCard } from "~/component/icon"
import { createStore } from "solid-js/store"
function formatDateForTable(date: Date) {
const options: Intl.DateTimeFormatOptions = {
day: "numeric",
month: "short",
hour: "numeric",
minute: "2-digit",
hour12: true,
}
return date.toLocaleDateString("en-GB", options).replace(",", ",")
}
function formatDateUTC(date: Date) {
const options: Intl.DateTimeFormatOptions = {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short",
timeZone: "UTC",
}
return date.toLocaleDateString("en-US", options)
}
/////////////////////////////////////
// Keys related queries and actions
/////////////////////////////////////
const listKeys = query(async (workspaceID: string) => {
"use server"
return withActor(() => Key.list(), workspaceID)
}, "key.list")
const createKey = action(async (form: FormData) => {
"use server"
const name = form.get("name")?.toString().trim()
if (!name) return { error: "Name is required" }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(
await withActor(
() =>
Key.create({ name })
.then((data) => ({ error: undefined, data }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: listKeys.key },
)
}, "key.create")
const removeKey = action(async (form: FormData) => {
"use server"
const id = form.get("id")?.toString()
if (!id) return { error: "ID is required" }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
}, "key.remove")
/////////////////////////////////////
// Billing related queries and actions
/////////////////////////////////////
import { MonthlyLimitSection } from "~/component/workspace/monthly-limit-section"
import { NewUserSection } from "~/component/workspace/new-user-section"
import { BillingSection } from "~/component/workspace/billing-section"
import { PaymentSection } from "~/component/workspace/payment-section"
import { UsageSection } from "~/component/workspace/usage-section"
import { KeySection } from "~/component/workspace/key-section"
const getBillingInfo = query(async (workspaceID: string) => {
"use server"
@@ -80,596 +17,6 @@ const getBillingInfo = query(async (workspaceID: string) => {
}, workspaceID)
}, "billing.get")
const getUsageInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.usages()
}, workspaceID)
}, "usage.list")
const getPaymentsInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
return await Billing.payments()
}, workspaceID)
}, "payment.list")
const setMonthlyLimit = action(async (form: FormData) => {
"use server"
const limit = form.get("limit")?.toString()
if (!limit) return { error: "Limit is required" }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(
await withActor(
() =>
Billing.setMonthlyLimit(parseInt(limit))
.then((data) => ({ error: undefined, data }))
.catch((e) => ({ error: e.message as string })),
workspaceID,
),
{ revalidate: getBillingInfo.key },
)
}, "billing.setMonthlyLimit")
const reload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(await withActor(() => Billing.reload(), workspaceID), { revalidate: getBillingInfo.key })
}, "billing.reload")
const disableReload = action(async (form: FormData) => {
"use server"
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(await withActor(() => Billing.disableReload(), workspaceID), { revalidate: getBillingInfo.key })
}, "billing.disableReload")
const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
"use server"
return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
}, "checkoutUrl")
const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
"use server"
return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID)
}, "sessionUrl")
function KeySection() {
const params = useParams()
const keys = createAsync(() => listKeys(params.id))
function formatKey(key: string) {
if (key.length <= 11) return key
return `${key.slice(0, 7)}...${key.slice(-4)}`
}
return (
<section data-component="api-keys-section">
<div data-slot="section-title">
<h2>API Keys</h2>
<p>Manage your API keys for accessing opencode services.</p>
</div>
<KeyCreateForm />
<div data-slot="api-keys-table">
<Show
when={keys()?.length}
fallback={
<div data-component="empty-state">
<p>Create an opencode Gateway API key</p>
</div>
}
>
<table data-slot="api-keys-table-element">
<thead>
<tr>
<th>Name</th>
<th>Key</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
<For each={keys()!}>
{(key) => {
const [copied, setCopied] = createSignal(false)
// const submission = useSubmission(removeKey, ([fd]) => fd.get("id")?.toString() === key.id)
return (
<tr>
<td data-slot="key-name">{key.name}</td>
<td data-slot="key-value">
<button
data-color="ghost"
disabled={copied()}
onClick={async () => {
await navigator.clipboard.writeText(key.key)
setCopied(true)
setTimeout(() => setCopied(false), 1000)
}}
title="Copy API key"
>
<span>{formatKey(key.key)}</span>
<Show when={copied()} fallback={<IconCopy style={{ width: "14px", height: "14px" }} />}>
<IconCheck style={{ width: "14px", height: "14px" }} />
</Show>
</button>
</td>
<td data-slot="key-date" title={formatDateUTC(key.timeCreated)}>
{formatDateForTable(key.timeCreated)}
</td>
<td data-slot="key-actions">
<form action={removeKey} method="post">
<input type="hidden" name="id" value={key.id} />
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="ghost">Delete</button>
</form>
</td>
</tr>
)
}}
</For>
</tbody>
</table>
</Show>
</div>
</section>
)
}
function KeyCreateForm() {
const params = useParams()
const submission = useSubmission(createKey)
const [store, setStore] = createStore({ show: false })
let input: HTMLInputElement
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
hide()
}
})
function show() {
// submission.clear() does not clear the result in some cases, ie.
// 1. Create key with empty name => error shows
// 2. Put in a key name and creates the key => form hides
// 3. Click add key button again => form shows with the same error if
// submission.clear() is called only once
while (true) {
submission.clear()
if (!submission.result) break
}
setStore("show", true)
input.focus()
}
function hide() {
setStore("show", false)
}
return (
<Show
when={store.show}
fallback={
<button data-color="primary" onClick={() => show()}>
Create API Key
</button>
}
>
<form action={createKey} method="post" data-slot="create-form">
<div data-slot="input-container">
<input ref={(r) => (input = r)} data-component="input" name="name" type="text" placeholder="Enter key name" />
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{err()}</div>}
</Show>
</div>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
Cancel
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? "Creating..." : "Create"}
</button>
</div>
</form>
</Show>
)
}
function BillingSection() {
const params = useParams()
const balanceInfo = createAsync(() => getBillingInfo(params.id))
const createCheckoutUrlAction = useAction(createCheckoutUrl)
const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
const createSessionUrlAction = useAction(createSessionUrl)
const createSessionUrlSubmission = useSubmission(createSessionUrl)
const disableReloadSubmission = useSubmission(disableReload)
const reloadSubmission = useSubmission(reload)
const balanceAmount = createMemo(() => {
return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
})
return (
<section data-component="billing-section">
<div data-slot="section-title">
<h2>Billing</h2>
<p>
Manage payments methods. <a href="mailto:contact@anoma.ly">Contact us</a> if you have any questions.
</p>
</div>
<div data-slot="section-content">
<Show when={balanceInfo()?.reloadError}>
<div data-slot="reload-error">
<p>
Reload failed at{" "}
{balanceInfo()?.timeReloadError!.toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
})}
. Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try
again.
</p>
<form action={reload} method="post" data-slot="create-form">
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="primary" type="submit" disabled={reloadSubmission.pending}>
{reloadSubmission.pending ? "Reloading..." : "Reload"}
</button>
</form>
</div>
</Show>
<div data-slot="payment">
<div data-slot="credit-card">
<div data-slot="card-icon">
<IconCreditCard style={{ width: "32px", height: "32px" }} />
</div>
<div data-slot="card-details">
<Show when={balanceInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}>
<span data-slot="secret"></span>
<span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
</Show>
</div>
</div>
<div data-slot="button-row">
<Show
when={balanceInfo()?.reload}
fallback={
<button
data-color="primary"
disabled={createCheckoutUrlSubmission.pending}
onClick={async () => {
const baseUrl = window.location.href
const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
if (checkoutUrl) {
window.location.href = checkoutUrl
}
}}
>
{createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
</button>
}
>
<button
data-color="primary"
disabled={createSessionUrlSubmission.pending}
onClick={async () => {
const baseUrl = window.location.href
const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
if (sessionUrl) {
window.location.href = sessionUrl
}
}}
>
{createSessionUrlSubmission.pending ? "Loading..." : "Manage Payment Methods"}
</button>
<form action={disableReload} method="post" data-slot="create-form">
<input type="hidden" name="workspaceID" value={params.id} />
<button data-color="ghost" type="submit" disabled={disableReloadSubmission.pending}>
{disableReloadSubmission.pending ? "Disabling..." : "Disable"}
</button>
</form>
</Show>
</div>
</div>
<div data-slot="usage">
<Show when={!balanceInfo()?.reload && !(balanceAmount() === "0.00" || balanceAmount() === "-0.00")}>
<p>
You have <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b> remaining in
your account. You can continue using the API with your remaining balance.
</p>
</Show>
<Show when={balanceInfo()?.reload && !balanceInfo()?.reloadError}>
<p>
Your current balance is <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
. We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches <b>$5</b>.
</p>
</Show>
</div>
</div>
</section>
)
}
function MonthlyLimitSection() {
const params = useParams()
const submission = useSubmission(setMonthlyLimit)
const [store, setStore] = createStore({ show: false })
const balanceInfo = createAsync(() => getBillingInfo(params.id))
let input: HTMLInputElement
createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
hide()
}
})
function show() {
// submission.clear() does not clear the result in some cases, ie.
// 1. Create key with empty name => error shows
// 2. Put in a key name and creates the key => form hides
// 3. Click add key button again => form shows with the same error if
// submission.clear() is called only once
while (true) {
submission.clear()
if (!submission.result) break
}
setStore("show", true)
input.focus()
}
function hide() {
setStore("show", false)
}
return (
<section data-component="monthly-limit-section">
<div data-slot="section-title">
<h2>Monthly Limit</h2>
<p>Set a monthly spending limit for your account.</p>
</div>
<div data-slot="section-content">
<div data-slot="balance">
<div data-slot="amount">
{balanceInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
<span data-slot="value">{balanceInfo()?.monthlyLimit ?? "-"}</span>
</div>
<Show
when={!store.show}
fallback={
<form action={setMonthlyLimit} method="post" data-slot="create-form">
<div data-slot="input-container">
<input ref={(r) => (input = r)} data-component="input" name="limit" type="number" placeholder="50" />
<Show when={submission.result && submission.result.error}>
{(err) => <div data-slot="form-error">{err()}</div>}
</Show>
</div>
<input type="hidden" name="workspaceID" value={params.id} />
<div data-slot="form-actions">
<button type="reset" data-color="ghost" onClick={() => hide()}>
Cancel
</button>
<button type="submit" data-color="primary" disabled={submission.pending}>
{submission.pending ? "Setting..." : "Set"}
</button>
</div>
</form>
}
>
<button data-color="primary" onClick={() => show()}>
{balanceInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
</button>
</Show>
</div>
<Show when={balanceInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No spending limit set.</p>}>
<p data-slot="usage-status">
Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $
{(() => {
const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated
if (!dateLastUsed) return "0"
const current = new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
timeZone: "UTC",
})
const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
timeZone: "UTC",
})
if (current !== lastUsed) return "0"
return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
})()}
.
</p>
</Show>
</div>
</section>
)
}
function UsageSection() {
const params = useParams()
const usage = createAsync(() => getUsageInfo(params.id))
return (
<section data-component="usage-section">
<div data-slot="section-title">
<h2>Usage History</h2>
<p>Recent API usage and costs.</p>
</div>
<div data-slot="usage-table">
<Show
when={usage() && usage()!.length > 0}
fallback={
<div data-component="empty-state">
<p>Make your first API call to get started.</p>
</div>
}
>
<table data-slot="usage-table-element">
<thead>
<tr>
<th>Date</th>
<th>Model</th>
<th>Input</th>
<th>Output</th>
<th>Cost</th>
</tr>
</thead>
<tbody>
<For each={usage()!}>
{(usage) => {
const date = createMemo(() => new Date(usage.timeCreated))
return (
<tr>
<td data-slot="usage-date" title={formatDateUTC(date())}>
{formatDateForTable(date())}
</td>
<td data-slot="usage-model">{usage.model}</td>
<td data-slot="usage-tokens">{usage.inputTokens}</td>
<td data-slot="usage-tokens">{usage.outputTokens}</td>
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
</tr>
)
}}
</For>
</tbody>
</table>
</Show>
</div>
</section>
)
}
function PaymentSection() {
const params = useParams()
const payments = createAsync(() => getPaymentsInfo(params.id))
return (
payments() &&
payments()!.length > 0 && (
<section data-component="payments-section">
<div data-slot="section-title">
<h2>Payments History</h2>
<p>Recent payment transactions.</p>
</div>
<div data-slot="payments-table">
<table data-slot="payments-table-element">
<thead>
<tr>
<th>Date</th>
<th>Payment ID</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
<For each={payments()!}>
{(payment) => {
const date = new Date(payment.timeCreated)
return (
<tr>
<td data-slot="payment-date" title={formatDateUTC(date)}>
{formatDateForTable(date)}
</td>
<td data-slot="payment-id">{payment.id}</td>
<td data-slot="payment-amount">${((payment.amount ?? 0) / 100000000).toFixed(2)}</td>
</tr>
)
}}
</For>
</tbody>
</table>
</div>
</section>
)
)
}
function NewUserSection() {
const params = useParams()
const [copiedKey, setCopiedKey] = createSignal(false)
const keys = createAsync(() => listKeys(params.id))
const usage = createAsync(() => getUsageInfo(params.id))
const isNew = createMemo(() => {
const keysList = keys()
const usageList = usage()
return keysList?.length === 1 && (!usageList || usageList.length === 0)
})
const defaultKey = createMemo(() => keys()?.at(-1)?.key)
return (
<Show when={isNew()}>
<div data-slot="new-user-sections">
<div data-component="feature-grid">
<div data-slot="feature">
<h3>Tested & Verified Models</h3>
<p>We've benchmarked and tested models specifically for coding agents to ensure the best performance.</p>
</div>
<div data-slot="feature">
<h3>Highest Quality</h3>
<p>Access models configured for optimal performance - no downgrades or routing to cheaper providers.</p>
</div>
<div data-slot="feature">
<h3>No Lock-in</h3>
<p>Use Zen with any coding agent, and continue using other providers with opencode whenever you want.</p>
</div>
</div>
<div data-component="api-key-highlight">
<Show when={defaultKey()}>
<div data-slot="key-display">
<div data-slot="key-container">
<code data-slot="key-value">{defaultKey()}</code>
<button
data-color="primary"
disabled={copiedKey()}
onClick={async () => {
await navigator.clipboard.writeText(defaultKey() ?? "")
setCopiedKey(true)
setTimeout(() => setCopiedKey(false), 2000)
}}
title="Copy API key"
>
<Show
when={copiedKey()}
fallback={
<>
<IconCopy style={{ width: "16px", height: "16px" }} /> Copy Key
</>
}
>
<IconCheck style={{ width: "16px", height: "16px" }} /> Copied!
</Show>
</button>
</div>
</div>
</Show>
</div>
<div data-component="next-steps">
<ol>
<li>Enable billing</li>
<li>
Run <code>opencode auth login</code> and select opencode
</li>
<li>Paste your API key</li>
<li>
Start opencode and run <code>/models</code> to select a model
</li>
</ol>
</div>
</div>
</Show>
)
}
export default function () {
const params = useParams()
const balanceInfo = createAsync(() => getBillingInfo(params.id))
@@ -691,7 +38,8 @@ export default function () {
<NewUserSection />
<KeySection />
<BillingSection />
<Show when={balanceInfo()?.reload}>
<Show when={true}>
{/*<Show when={balanceInfo()?.reload}>*/}
<MonthlyLimitSection />
</Show>
<UsageSection />
+1 -1
View File
@@ -538,7 +538,6 @@ export async function handler(
async function reload() {
if (!apiKey) return
// acquire reload lock
const lock = await Database.use((tx) =>
tx
.update(BillingTable)
@@ -548,6 +547,7 @@ export async function handler(
.where(
and(
eq(BillingTable.workspaceID, apiKey.workspaceID),
eq(BillingTable.reload, true),
lt(BillingTable.balance, centsToMicroCents(Billing.CHARGE_THRESHOLD)),
or(isNull(BillingTable.timeReloadLockedTill), lt(BillingTable.timeReloadLockedTill, sql`now()`)),
),
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cloud-core",
"version": "0.9.2",
"version": "0.9.9",
"private": true,
"type": "module",
"dependencies": {
+17
View File
@@ -224,4 +224,21 @@ export namespace Billing {
return session.url
},
)
export const generateReceiptUrl = fn(
z.object({
paymentID: z.string(),
}),
async (input) => {
const { paymentID } = input
const intent = await Billing.stripe().paymentIntents.retrieve(paymentID)
if (!intent.latest_charge) throw new Error("No charge found")
const charge = await Billing.stripe().charges.retrieve(intent.latest_charge as string)
if (!charge.receipt_url) throw new Error("No receipt URL found")
return charge.receipt_url
},
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/cloud-function",
"version": "0.9.2",
"version": "0.9.9",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/cloud-scripts",
"version": "0.9.2",
"version": "0.9.9",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+3 -2
View File
@@ -6,7 +6,7 @@
"packageManager": "bun@1.2.21",
"scripts": {
"dev": "bun run --conditions=development packages/opencode/src/index.ts",
"typecheck": "bun run --filter='*' typecheck",
"typecheck": "bun turbo typecheck",
"generate": "(cd packages/sdk && ./js/script/generate.ts) && (cd packages/sdk/stainless && ./generate.ts)",
"postinstall": "./script/hooks"
},
@@ -34,7 +34,8 @@
},
"devDependencies": {
"prettier": "3.6.2",
"sst": "3.17.13"
"sst": "3.17.13",
"turbo": "2.5.6"
},
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "0.9.2",
"version": "0.9.9",
"description": "",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "0.9.2",
"version": "0.9.9",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.9.2",
"version": "0.9.9",
"name": "opencode",
"type": "module",
"private": true,
+43 -3
View File
@@ -68,10 +68,45 @@ function findBinary() {
}
}
function main() {
async function regenerateWindowsCmdWrappers() {
console.log("Windows + npm detected: Forcing npm to rebuild bin links")
try {
const { execSync } = require("child_process")
const pkgPath = path.join(__dirname, "..")
// npm_config_global is string | undefined
// if it exists, the value is true
const isGlobal = process.env.npm_config_global === "true" || pkgPath.includes(path.join("npm", "node_modules"))
// The npm rebuild command does 2 things - Execute lifecycle scripts and rebuild bin links
// We want to skip lifecycle scripts to avoid infinite loops, so we use --ignore-scripts
const cmd = `npm rebuild opencode-ai --ignore-scripts${isGlobal ? " -g" : ""}`
const opts = {
stdio: "inherit",
shell: true,
...(isGlobal ? {} : { cwd: path.join(pkgPath, "..", "..") }), // For local, run from project root
}
console.log(`Running: ${cmd}`)
execSync(cmd, opts)
console.log("Successfully rebuilt npm bin links")
} catch (error) {
console.error("Error rebuilding npm links:", error.message)
console.error("npm rebuild failed. You may need to manually run: npm rebuild opencode-ai --ignore-scripts")
}
}
async function main() {
try {
if (os.platform() === "win32") {
console.log("Windows detected, skipping postinstall")
// NPM eg format - npm/11.4.2 node/v24.4.1 win32 x64
// Bun eg format - bun/1.2.19 npm/? node/v24.3.0 win32 x64
if (process.env.npm_config_user_agent.startsWith("npm")) {
await regenerateWindowsCmdWrappers()
} else {
console.log("Windows detected but not npm, skipping postinstall")
}
return
}
@@ -92,4 +127,9 @@ function main() {
}
}
main()
try {
main()
} catch (error) {
console.error("Postinstall script error:", error.message)
process.exit(0)
}
+16 -4
View File
@@ -13,13 +13,25 @@ function main() {
return
}
const binDir = path.join(__dirname, "bin")
const unixScript = path.join(binDir, "opencode")
console.log("Windows detected: Modifying package.json bin entry")
console.log("Windows detected: Configuring bin scripts for Windows")
// Read package.json
const packageJsonPath = path.join(__dirname, "package.json")
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"))
// Modify bin to point to .cmd file on Windows
packageJson.bin = {
opencode: "./bin/opencode.cmd",
}
// Write it back
fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2))
console.log("Updated package.json bin to use opencode.cmd")
// Now you can also remove the Unix script if you want
const unixScript = path.join(__dirname, "bin", "opencode")
if (fs.existsSync(unixScript)) {
console.log("Removing Unix shell script from bin/")
console.log("Removing Unix shell script")
fs.unlinkSync(unixScript)
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ for (const [os, arch] of targets) {
compile: {
target: `bun-${os}-${arch}` as any,
outfile: `dist/${name}/bin/opencode`,
execArgv: [`--user-agent=opencode/${version}`, `--`],
execArgv: [`--user-agent=opencode/${version}`, `--env-file=""`, `--`],
windows: {},
},
entrypoints: ["./src/index.ts"],
+4 -4
View File
@@ -33,16 +33,16 @@ export namespace Bus {
"type",
registry
.entries()
.map(([type, def]) =>
z
.map(([type, def]) => {
return z
.object({
type: z.literal(type),
properties: def.properties,
})
.meta({
ref: "Event" + "." + def.type,
}),
)
})
})
.toArray() as any,
)
}
+56
View File
@@ -0,0 +1,56 @@
import { Global } from "../../global"
import { cmd } from "./cmd"
import path from "path"
import fs from "fs/promises"
import { Log } from "../../util/log"
import { $ } from "bun"
export const AttachCommand = cmd({
command: "attach <server>",
describe: "attach to a running opencode server",
builder: (yargs) =>
yargs.positional("server", {
type: "string",
describe: "http://localhost:4096",
}),
handler: async (args) => {
let cmd = [] as string[]
const tui = Bun.embeddedFiles.find((item) => (item as File).name.includes("tui")) as File
if (tui) {
let binaryName = tui.name
if (process.platform === "win32" && !binaryName.endsWith(".exe")) {
binaryName += ".exe"
}
const binary = path.join(Global.Path.cache, "tui", binaryName)
const file = Bun.file(binary)
if (!(await file.exists())) {
await Bun.write(file, tui, { mode: 0o755 })
if (process.platform !== "win32") await fs.chmod(binary, 0o755)
}
cmd = [binary]
}
if (!tui) {
const dir = Bun.fileURLToPath(new URL("../../../../tui/cmd/opencode", import.meta.url))
let binaryName = `./dist/tui${process.platform === "win32" ? ".exe" : ""}`
await $`go build -o ${binaryName} ./main.go`.cwd(dir)
cmd = [path.join(dir, binaryName)]
}
Log.Default.info("tui", {
cmd,
})
const proc = Bun.spawn({
cmd,
stdout: "inherit",
stderr: "inherit",
stdin: "inherit",
env: {
...process.env,
CGO_ENABLED: "0",
OPENCODE_SERVER: args.server,
},
})
await proc.exited
},
})
-2
View File
@@ -15,7 +15,6 @@ import { Ide } from "../../ide"
import { Flag } from "../../flag/flag"
import { Session } from "../../session"
import { Instance } from "../../project/instance"
import { $ } from "bun"
declare global {
@@ -151,7 +150,6 @@ export const TuiCommand = cmd({
...process.env,
CGO_ENABLED: "0",
OPENCODE_SERVER: server.url.toString(),
OPENCODE_PROJECT: JSON.stringify(Instance.project),
},
onExit: () => {
server.stop()
+1
View File
@@ -3,6 +3,7 @@ export namespace Flag {
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
export const OPENCODE_DISABLE_DEFAULT_PLUGINS = truthy("OPENCODE_DISABLE_DEFAULT_PLUGINS")
export const OPENCODE_DISABLE_LSP_DOWNLOAD = truthy("OPENCODE_DISABLE_LSP_DOWNLOAD")
+2
View File
@@ -18,6 +18,7 @@ import { StatsCommand } from "./cli/cmd/stats"
import { McpCommand } from "./cli/cmd/mcp"
import { GithubCommand } from "./cli/cmd/github"
import { ExportCommand } from "./cli/cmd/export"
import { AttachCommand } from "./cli/cmd/attach"
const cancel = new AbortController()
@@ -71,6 +72,7 @@ const cli = yargs(hideBin(process.argv))
.usage("\n" + UI.logo())
.command(McpCommand)
.command(TuiCommand)
.command(AttachCommand)
.command(RunCommand)
.command(GenerateCommand)
.command(DebugCommand)
+1 -1
View File
@@ -15,7 +15,7 @@ export namespace Plugin {
const state = Instance.state(async () => {
const client = createOpencodeClient({
baseUrl: "http://localhost:4096",
fetch: async (...args) => Server.App.fetch(...args),
fetch: async (...args) => Server.App().fetch(...args),
})
const config = await Config.get()
const hooks = []
+3 -1
View File
@@ -159,7 +159,7 @@ export namespace Provider {
} = {}
const models = new Map<
string,
{ providerID: string; modelID: string; info: ModelsDev.Model; language: LanguageModel }
{ providerID: string; modelID: string; info: ModelsDev.Model; language: LanguageModel; npm?: string }
>()
const sdk = new Map<number, SDK>()
@@ -378,12 +378,14 @@ export namespace Provider {
modelID,
info,
language,
npm: info.provider?.npm ?? provider.info.npm,
})
return {
modelID,
providerID,
info,
language,
npm: info.provider?.npm ?? provider.info.npm,
}
} catch (e) {
if (e instanceof NoSuchModelError)
@@ -96,6 +96,11 @@ export namespace ProviderTransform {
if (providerID !== "azure") {
result["textVerbosity"] = "low"
}
if (providerID === "opencode") {
result["promptCacheKey"] = sessionID
// result["include"] = ["reasoning.encrypted_content"]
// result["reasoningSummary"] = "detailed"
}
}
return result
}
File diff suppressed because it is too large Load Diff
@@ -11,8 +11,12 @@ import z from "zod/v4"
import type { ModelsDev } from "../provider/models"
import { SessionPrompt } from "./prompt"
import { Flag } from "../flag/flag"
import { Token } from "../util/token"
import { Log } from "../util/log"
export namespace SessionCompaction {
const log = Log.create({ service: "session.compaction" })
export const Event = {
Compacted: Bus.event(
"session.compacted",
@@ -32,6 +36,52 @@ export namespace SessionCompaction {
return count > usable
}
export const PRUNE_MINIMUM = 20_000
export const PRUNE_PROTECT = 40_000
// goes backwards through parts until there are 40_000 tokens worth of tool
// calls. then erases output of previous tool calls. idea is to throw away old
// tool calls that are no longer relevant.
export async function prune(input: { sessionID: string }) {
if (Flag.OPENCODE_DISABLE_PRUNE) return
log.info("pruning")
const msgs = await Session.messages(input.sessionID)
let total = 0
let pruned = 0
const toPrune = []
let turns = 0
loop: for (let msgIndex = msgs.length - 1; msgIndex >= 0; msgIndex--) {
const msg = msgs[msgIndex]
if (msg.info.role === "user") turns++
if (turns < 2) continue
if (msg.info.role === "assistant" && msg.info.summary) break loop
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
const part = msg.parts[partIndex]
if (part.type === "tool")
if (part.state.status === "completed") {
if (part.state.time.compacted) break loop
const estimate = Token.estimate(part.state.output)
total += estimate
if (total > PRUNE_PROTECT) {
pruned += estimate
toPrune.push(part)
}
}
}
}
log.info("found", { pruned, total })
if (pruned > PRUNE_MINIMUM) {
for (const part of toPrune) {
if (part.state.status === "completed") {
part.state.time.compacted = Date.now()
await Session.updatePart(part)
}
}
log.info("pruned", { count: toPrune.length })
}
}
export async function run(input: { sessionID: string; providerID: string; modelID: string }) {
await Session.update(input.sessionID, (draft) => {
draft.time.compacting = Date.now()
-29
View File
@@ -16,7 +16,6 @@ import { Log } from "../util/log"
import { MessageV2 } from "./message-v2"
import { Project } from "../project/project"
import { Instance } from "../project/instance"
import { Token } from "../util/token"
import { SessionPrompt } from "./prompt"
export namespace Session {
@@ -293,34 +292,6 @@ export namespace Session {
return part
}
// goes backwards through parts until there are 40_000 tokens worth of tool
// calls. then erases output of previous tool calls. idea is to throw away old
// tool calls that are no longer relevant.
export async function prune(input: { sessionID: string }) {
const msgs = await messages(input.sessionID)
let sum = 0
for (let msgIndex = msgs.length - 2; msgIndex >= 0; msgIndex--) {
const msg = msgs[msgIndex]
if (msg.info.role === "assistant" && msg.info.summary) return
for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) {
const part = msg.parts[partIndex]
if (part.type === "tool")
if (part.state.status === "completed") {
if (part.state.time.compacted) return
sum += Token.estimate(part.state.output)
if (sum > 40_000) {
log.info("pruning", {
sum,
id: part.id,
})
part.state.time.compacted = Date.now()
await updatePart(part)
}
}
}
}
}
export function getUsage(model: ModelsDev.Model, usage: LanguageModelUsage, metadata?: ProviderMetadata) {
const tokens = {
input: usage.inputTokens ?? 0,
+2 -2
View File
@@ -268,7 +268,7 @@ export namespace SessionPrompt {
maxOutputTokens: ProviderTransform.maxOutputTokens(model.providerID, outputLimit, params.options),
abortSignal: abort.signal,
providerOptions: {
[model.providerID]: params.options,
[model.npm === "@ai-sdk/openai" ? "openai" : model.providerID]: params.options,
},
stopWhen: stepCountIs(1),
temperature: params.temperature,
@@ -321,7 +321,7 @@ export namespace SessionPrompt {
item.callback(result)
}
state().queued.delete(input.sessionID)
// Session.prune(input)
SessionCompaction.prune(input)
return result
}
}
@@ -0,0 +1,458 @@
import { test, expect } from "bun:test"
import { $ } from "bun"
import { Snapshot } from "../../src/snapshot"
import { Instance } from "../../src/project/instance"
async function bootstrap() {
const dir = await $`mktemp -d`.text().then((t) => t.trim())
// Randomize file contents to ensure unique git repos
const unique = Math.random().toString(36).slice(2)
const aContent = `A${unique}`
const bContent = `B${unique}`
await Bun.write(`${dir}/a.txt`, aContent)
await Bun.write(`${dir}/b.txt`, bContent)
await $`git init`.cwd(dir).quiet()
await $`git add .`.cwd(dir).quiet()
await $`git commit -m init`.cwd(dir).quiet()
return {
[Symbol.asyncDispose]: async () => {
await $`rm -rf ${dir}`.quiet()
},
dir,
aContent,
bContent,
}
}
test("tracks deleted files correctly", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`rm ${tmp.dir}/a.txt`.quiet()
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/a.txt`)
})
})
test("revert should remove new files", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/new.txt`, "NEW")
await Snapshot.revert([await Snapshot.patch(before!)])
expect(await Bun.file(`${tmp.dir}/new.txt`).exists()).toBe(false)
})
})
test("revert in subdirectory", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`mkdir -p ${tmp.dir}/sub`.quiet()
await Bun.write(`${tmp.dir}/sub/file.txt`, "SUB")
await Snapshot.revert([await Snapshot.patch(before!)])
expect(await Bun.file(`${tmp.dir}/sub/file.txt`).exists()).toBe(false)
// Note: revert currently only removes files, not directories
// The empty subdirectory will remain
})
})
test("multiple file operations", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`rm ${tmp.dir}/a.txt`.quiet()
await Bun.write(`${tmp.dir}/c.txt`, "C")
await $`mkdir -p ${tmp.dir}/dir`.quiet()
await Bun.write(`${tmp.dir}/dir/d.txt`, "D")
await Bun.write(`${tmp.dir}/b.txt`, "MODIFIED")
await Snapshot.revert([await Snapshot.patch(before!)])
expect(await Bun.file(`${tmp.dir}/a.txt`).text()).toBe(tmp.aContent)
expect(await Bun.file(`${tmp.dir}/c.txt`).exists()).toBe(false)
// Note: revert currently only removes files, not directories
// The empty directory will remain
expect(await Bun.file(`${tmp.dir}/b.txt`).text()).toBe(tmp.bContent)
})
})
test("empty directory handling", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`mkdir ${tmp.dir}/empty`.quiet()
expect((await Snapshot.patch(before!)).files.length).toBe(0)
})
})
test("binary file handling", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/image.png`, Buffer.from([0x89, 0x50, 0x4e, 0x47]))
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.dir}/image.png`)
await Snapshot.revert([patch])
expect(await Bun.file(`${tmp.dir}/image.png`).exists()).toBe(false)
})
})
test("symlink handling", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`ln -s ${tmp.dir}/a.txt ${tmp.dir}/link.txt`.quiet()
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/link.txt`)
})
})
test("large file handling", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/large.txt`, "x".repeat(1024 * 1024))
expect((await Snapshot.patch(before!)).files).toContain(`${tmp.dir}/large.txt`)
})
})
test("nested directory revert", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`mkdir -p ${tmp.dir}/level1/level2/level3`.quiet()
await Bun.write(`${tmp.dir}/level1/level2/level3/deep.txt`, "DEEP")
await Snapshot.revert([await Snapshot.patch(before!)])
expect(await Bun.file(`${tmp.dir}/level1/level2/level3/deep.txt`).exists()).toBe(false)
})
})
test("special characters in filenames", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/file with spaces.txt`, "SPACES")
await Bun.write(`${tmp.dir}/file-with-dashes.txt`, "DASHES")
await Bun.write(`${tmp.dir}/file_with_underscores.txt`, "UNDERSCORES")
const files = (await Snapshot.patch(before!)).files
expect(files).toContain(`${tmp.dir}/file with spaces.txt`)
expect(files).toContain(`${tmp.dir}/file-with-dashes.txt`)
expect(files).toContain(`${tmp.dir}/file_with_underscores.txt`)
})
})
test("revert with empty patches", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
// Should not crash with empty patches
expect(Snapshot.revert([])).resolves.toBeUndefined()
// Should not crash with patches that have empty file lists
expect(Snapshot.revert([{ hash: "dummy", files: [] }])).resolves.toBeUndefined()
})
})
test("patch with invalid hash", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Create a change
await Bun.write(`${tmp.dir}/test.txt`, "TEST")
// Try to patch with invalid hash - should handle gracefully
const patch = await Snapshot.patch("invalid-hash-12345")
expect(patch.files).toEqual([])
expect(patch.hash).toBe("invalid-hash-12345")
})
})
test("revert non-existent file", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Try to revert a file that doesn't exist in the snapshot
// This should not crash
expect(
Snapshot.revert([
{
hash: before!,
files: [`${tmp.dir}/nonexistent.txt`],
},
]),
).resolves.toBeUndefined()
})
})
test("unicode filenames", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
const unicodeFiles = [
`${tmp.dir}/文件.txt`,
`${tmp.dir}/🚀rocket.txt`,
`${tmp.dir}/café.txt`,
`${tmp.dir}/файл.txt`,
]
for (const file of unicodeFiles) {
await Bun.write(file, "unicode content")
}
const patch = await Snapshot.patch(before!)
// Note: git escapes unicode characters by default, so we just check that files are detected
// The actual filenames will be escaped like "caf\303\251.txt" but functionality works
expect(patch.files.length).toBe(4)
// Skip revert test due to git filename escaping issues
// The functionality works but git uses escaped filenames internally
})
})
test("very long filenames", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
const longName = "a".repeat(200) + ".txt"
const longFile = `${tmp.dir}/${longName}`
await Bun.write(longFile, "long filename content")
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(longFile)
await Snapshot.revert([patch])
expect(await Bun.file(longFile).exists()).toBe(false)
})
})
test("hidden files", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/.hidden`, "hidden content")
await Bun.write(`${tmp.dir}/.gitignore`, "*.log")
await Bun.write(`${tmp.dir}/.config`, "config content")
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.dir}/.hidden`)
expect(patch.files).toContain(`${tmp.dir}/.gitignore`)
expect(patch.files).toContain(`${tmp.dir}/.config`)
})
})
test("nested symlinks", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await $`mkdir -p ${tmp.dir}/sub/dir`.quiet()
await Bun.write(`${tmp.dir}/sub/dir/target.txt`, "target content")
await $`ln -s ${tmp.dir}/sub/dir/target.txt ${tmp.dir}/sub/dir/link.txt`.quiet()
await $`ln -s ${tmp.dir}/sub ${tmp.dir}/sub-link`.quiet()
const patch = await Snapshot.patch(before!)
expect(patch.files).toContain(`${tmp.dir}/sub/dir/link.txt`)
expect(patch.files).toContain(`${tmp.dir}/sub-link`)
})
})
test("file permissions and ownership changes", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Change permissions multiple times
await $`chmod 600 ${tmp.dir}/a.txt`.quiet()
await $`chmod 755 ${tmp.dir}/a.txt`.quiet()
await $`chmod 644 ${tmp.dir}/a.txt`.quiet()
const patch = await Snapshot.patch(before!)
// Note: git doesn't track permission changes on existing files by default
// Only tracks executable bit when files are first added
expect(patch.files.length).toBe(0)
})
})
test("circular symlinks", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Create circular symlink
await $`ln -s ${tmp.dir}/circular ${tmp.dir}/circular`.quiet().nothrow()
const patch = await Snapshot.patch(before!)
expect(patch.files.length).toBeGreaterThanOrEqual(0) // Should not crash
})
})
test("gitignore changes", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
await Bun.write(`${tmp.dir}/.gitignore`, "*.ignored")
await Bun.write(`${tmp.dir}/test.ignored`, "ignored content")
await Bun.write(`${tmp.dir}/normal.txt`, "normal content")
const patch = await Snapshot.patch(before!)
// Should track gitignore itself
expect(patch.files).toContain(`${tmp.dir}/.gitignore`)
// Should track normal files
expect(patch.files).toContain(`${tmp.dir}/normal.txt`)
// Should not track ignored files (git won't see them)
expect(patch.files).not.toContain(`${tmp.dir}/test.ignored`)
})
})
test("concurrent file operations during patch", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Start creating files
const createPromise = (async () => {
for (let i = 0; i < 10; i++) {
await Bun.write(`${tmp.dir}/concurrent${i}.txt`, `concurrent${i}`)
// Small delay to simulate concurrent operations
await new Promise((resolve) => setTimeout(resolve, 1))
}
})()
// Get patch while files are being created
const patchPromise = Snapshot.patch(before!)
await createPromise
const patch = await patchPromise
// Should capture some or all of the concurrent files
expect(patch.files.length).toBeGreaterThanOrEqual(0)
})
})
test("snapshot state isolation between projects", async () => {
// Test that different projects don't interfere with each other
await using tmp1 = await bootstrap()
await using tmp2 = await bootstrap()
await Instance.provide(tmp1.dir, async () => {
const before1 = await Snapshot.track()
await Bun.write(`${tmp1.dir}/project1.txt`, "project1 content")
const patch1 = await Snapshot.patch(before1!)
expect(patch1.files).toContain(`${tmp1.dir}/project1.txt`)
})
await Instance.provide(tmp2.dir, async () => {
const before2 = await Snapshot.track()
await Bun.write(`${tmp2.dir}/project2.txt`, "project2 content")
const patch2 = await Snapshot.patch(before2!)
expect(patch2.files).toContain(`${tmp2.dir}/project2.txt`)
// Ensure project1 files don't appear in project2
expect(patch2.files).not.toContain(`${tmp1?.dir}/project1.txt`)
})
})
test("track with no changes returns same hash", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const hash1 = await Snapshot.track()
expect(hash1).toBeTruthy()
// Track again with no changes
const hash2 = await Snapshot.track()
expect(hash2).toBe(hash1!)
// Track again
const hash3 = await Snapshot.track()
expect(hash3).toBe(hash1!)
})
})
test("diff function with various changes", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Make various changes
await $`rm ${tmp.dir}/a.txt`.quiet()
await Bun.write(`${tmp.dir}/new.txt`, "new content")
await Bun.write(`${tmp.dir}/b.txt`, "modified content")
const diff = await Snapshot.diff(before!)
expect(diff).toContain("deleted")
expect(diff).toContain("modified")
// Note: git diff only shows changes to tracked files, not untracked files like new.txt
})
})
test("restore function", async () => {
await using tmp = await bootstrap()
await Instance.provide(tmp.dir, async () => {
const before = await Snapshot.track()
expect(before).toBeTruthy()
// Make changes
await $`rm ${tmp.dir}/a.txt`.quiet()
await Bun.write(`${tmp.dir}/new.txt`, "new content")
await Bun.write(`${tmp.dir}/b.txt`, "modified")
// Restore to original state
await Snapshot.restore(before!)
expect(await Bun.file(`${tmp.dir}/a.txt`).exists()).toBe(true)
expect(await Bun.file(`${tmp.dir}/a.txt`).text()).toBe(tmp.aContent)
expect(await Bun.file(`${tmp.dir}/new.txt`).exists()).toBe(true) // New files should remain
expect(await Bun.file(`${tmp.dir}/b.txt`).text()).toBe(tmp.bContent)
})
})
+8 -8
View File
@@ -31,7 +31,7 @@ describe("HTTP tool registration API", () => {
}
// Register
const registerRes = await Server.App.fetch(
const registerRes = await Server.App().fetch(
makeRequest("POST", "http://localhost:4096/experimental/tool/register", toolSpec),
)
expect(registerRes.status).toBe(200)
@@ -39,13 +39,13 @@ describe("HTTP tool registration API", () => {
expect(ok).toBe(true)
// IDs should include the new tool
const idsRes = await Server.App.fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids"))
const idsRes = await Server.App().fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids"))
expect(idsRes.status).toBe(200)
const ids = (await idsRes.json()) as string[]
expect(ids).toContain("http-echo")
// List tools for a provider/model and check JSON Schema shape
const listRes = await Server.App.fetch(
const listRes = await Server.App().fetch(
makeRequest("GET", "http://localhost:4096/experimental/tool?provider=openai&model=gpt-4o"),
)
expect(listRes.status).toBe(200)
@@ -105,7 +105,7 @@ describe("Plugin tool.register hook", () => {
expect(allIDs).toContain("from-plugin")
// Also verify via the HTTP surface
const idsRes = await Server.App.fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids"))
const idsRes = await Server.App().fetch(makeRequest("GET", "http://localhost:4096/experimental/tool/ids"))
expect(idsRes.status).toBe(200)
const ids = (await idsRes.json()) as string[]
expect(ids).toContain("from-plugin")
@@ -168,7 +168,7 @@ test("Multiple plugins can each register tools", async () => {
expect(ids).toContain("alpha-tool")
expect(ids).toContain("beta-tool")
const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids"))
const res = await Server.App().fetch(new Request("http://localhost:4096/experimental/tool/ids"))
expect(res.status).toBe(200)
const httpIds = (await res.json()) as string[]
expect(httpIds).toContain("alpha-tool")
@@ -241,14 +241,14 @@ test("Plugin registers native/local tool with function execution", async () => {
expect(ids).toContain("http-tool-from-same-plugin")
// Verify via HTTP endpoint
const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids"))
const res = await Server.App().fetch(new Request("http://localhost:4096/experimental/tool/ids"))
expect(res.status).toBe(200)
const httpIds = (await res.json()) as string[]
expect(httpIds).toContain("my-native-tool")
expect(httpIds).toContain("http-tool-from-same-plugin")
// Get tool details to verify native tool has proper structure
const toolsRes = await Server.App.fetch(
const toolsRes = await Server.App().fetch(
new Request("http://localhost:4096/experimental/tool?provider=anthropic&model=claude"),
)
expect(toolsRes.status).toBe(200)
@@ -290,7 +290,7 @@ test("Plugin without tool.register is handled gracefully", async () => {
const ids = ToolRegistry.ids()
expect(ids).not.toContain("malformed-tool")
const res = await Server.App.fetch(new Request("http://localhost:4096/experimental/tool/ids"))
const res = await Server.App().fetch(new Request("http://localhost:4096/experimental/tool/ids"))
expect(res.status).toBe(200)
const httpIds = (await res.json()) as string[]
expect(httpIds).not.toContain("malformed-tool")
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "0.9.2",
"version": "0.9.9",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
@@ -1,3 +1,3 @@
{
".": "0.14.0"
".": "0.15.0"
}
+2 -2
View File
@@ -1,4 +1,4 @@
configured_endpoints: 43
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-0a4165f1eabf826d3092ea6b789aa527048278dcd4bd891f9e5ac890b9bcbb35.yml
openapi_spec_hash: da60e4fc813eb0f9ac3ab5f112e26bf6
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/opencode%2Fopencode-ad911ed0bdbeca62807509f364f25fcafd7a83e0b43e027ec0a85f72b7a4d963.yml
openapi_spec_hash: 15152513b4246bf4b5f8546fa6f1603f
config_hash: 026ef000d34bf2f930e7b41e77d2d3ff
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## 0.15.0 (2025-09-16)
Full Changelog: [v0.14.0...v0.15.0](https://github.com/sst/opencode-sdk-go/compare/v0.14.0...v0.15.0)
### Features
- **api:** api update ([397048f](https://github.com/sst/opencode-sdk-go/commit/397048faca7a1de7a028edd2254a0ad7797b151f))
## 0.14.0 (2025-09-14)
Full Changelog: [v0.13.0...v0.14.0](https://github.com/sst/opencode-sdk-go/compare/v0.13.0...v0.14.0)
+1 -1
View File
@@ -24,7 +24,7 @@ Or to pin the version:
<!-- x-release-please-start-version -->
```sh
go get -u 'github.com/sst/opencode-sdk-go@v0.14.0'
go get -u 'github.com/sst/opencode-sdk-go@v0.15.0'
```
<!-- x-release-please-end -->
+24 -2
View File
@@ -90,8 +90,9 @@ type Config struct {
// TUI specific settings
Tui ConfigTui `json:"tui"`
// Custom username to display in conversations instead of system username
Username string `json:"username"`
JSON configJSON `json:"-"`
Username string `json:"username"`
Watcher ConfigWatcher `json:"watcher"`
JSON configJSON `json:"-"`
}
// configJSON contains the JSON metadata for the struct [Config]
@@ -121,6 +122,7 @@ type configJSON struct {
Tools apijson.Field
Tui apijson.Field
Username apijson.Field
Watcher apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
@@ -1772,6 +1774,26 @@ func (r configTuiJSON) RawJSON() string {
return r.raw
}
type ConfigWatcher struct {
Ignore []string `json:"ignore"`
JSON configWatcherJSON `json:"-"`
}
// configWatcherJSON contains the JSON metadata for the struct [ConfigWatcher]
type configWatcherJSON struct {
Ignore apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *ConfigWatcher) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r configWatcherJSON) RawJSON() string {
return r.raw
}
// Custom keybind configurations
type KeybindsConfig struct {
// Next agent
+159 -5
View File
@@ -64,7 +64,9 @@ type EventListResponse struct {
// [EventListResponseEventSessionIdleProperties],
// [EventListResponseEventSessionUpdatedProperties],
// [EventListResponseEventSessionDeletedProperties],
// [EventListResponseEventSessionErrorProperties], [interface{}].
// [EventListResponseEventSessionErrorProperties], [interface{}],
// [EventListResponseEventFileWatcherUpdatedProperties],
// [EventListResponseEventIdeInstalledProperties].
Properties interface{} `json:"properties,required"`
Type EventListResponseType `json:"type,required"`
JSON eventListResponseJSON `json:"-"`
@@ -107,7 +109,9 @@ func (r *EventListResponse) UnmarshalJSON(data []byte) (err error) {
// [EventListResponseEventPermissionReplied], [EventListResponseEventFileEdited],
// [EventListResponseEventSessionIdle], [EventListResponseEventSessionUpdated],
// [EventListResponseEventSessionDeleted], [EventListResponseEventSessionError],
// [EventListResponseEventServerConnected].
// [EventListResponseEventServerConnected],
// [EventListResponseEventFileWatcherUpdated],
// [EventListResponseEventIdeInstalled].
func (r EventListResponse) AsUnion() EventListResponseUnion {
return r.union
}
@@ -121,8 +125,10 @@ func (r EventListResponse) AsUnion() EventListResponseUnion {
// [EventListResponseEventPermissionUpdated],
// [EventListResponseEventPermissionReplied], [EventListResponseEventFileEdited],
// [EventListResponseEventSessionIdle], [EventListResponseEventSessionUpdated],
// [EventListResponseEventSessionDeleted], [EventListResponseEventSessionError] or
// [EventListResponseEventServerConnected].
// [EventListResponseEventSessionDeleted], [EventListResponseEventSessionError],
// [EventListResponseEventServerConnected],
// [EventListResponseEventFileWatcherUpdated] or
// [EventListResponseEventIdeInstalled].
type EventListResponseUnion interface {
implementsEventListResponse()
}
@@ -191,6 +197,14 @@ func init() {
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(EventListResponseEventServerConnected{}),
},
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(EventListResponseEventFileWatcherUpdated{}),
},
apijson.UnionVariant{
TypeFilter: gjson.JSON,
Type: reflect.TypeOf(EventListResponseEventIdeInstalled{}),
},
)
}
@@ -1196,6 +1210,144 @@ func (r EventListResponseEventServerConnectedType) IsKnown() bool {
return false
}
type EventListResponseEventFileWatcherUpdated struct {
Properties EventListResponseEventFileWatcherUpdatedProperties `json:"properties,required"`
Type EventListResponseEventFileWatcherUpdatedType `json:"type,required"`
JSON eventListResponseEventFileWatcherUpdatedJSON `json:"-"`
}
// eventListResponseEventFileWatcherUpdatedJSON contains the JSON metadata for the
// struct [EventListResponseEventFileWatcherUpdated]
type eventListResponseEventFileWatcherUpdatedJSON struct {
Properties apijson.Field
Type apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *EventListResponseEventFileWatcherUpdated) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r eventListResponseEventFileWatcherUpdatedJSON) RawJSON() string {
return r.raw
}
func (r EventListResponseEventFileWatcherUpdated) implementsEventListResponse() {}
type EventListResponseEventFileWatcherUpdatedProperties struct {
Event EventListResponseEventFileWatcherUpdatedPropertiesEvent `json:"event,required"`
File string `json:"file,required"`
JSON eventListResponseEventFileWatcherUpdatedPropertiesJSON `json:"-"`
}
// eventListResponseEventFileWatcherUpdatedPropertiesJSON contains the JSON
// metadata for the struct [EventListResponseEventFileWatcherUpdatedProperties]
type eventListResponseEventFileWatcherUpdatedPropertiesJSON struct {
Event apijson.Field
File apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *EventListResponseEventFileWatcherUpdatedProperties) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r eventListResponseEventFileWatcherUpdatedPropertiesJSON) RawJSON() string {
return r.raw
}
type EventListResponseEventFileWatcherUpdatedPropertiesEvent string
const (
EventListResponseEventFileWatcherUpdatedPropertiesEventAdd EventListResponseEventFileWatcherUpdatedPropertiesEvent = "add"
EventListResponseEventFileWatcherUpdatedPropertiesEventChange EventListResponseEventFileWatcherUpdatedPropertiesEvent = "change"
EventListResponseEventFileWatcherUpdatedPropertiesEventUnlink EventListResponseEventFileWatcherUpdatedPropertiesEvent = "unlink"
)
func (r EventListResponseEventFileWatcherUpdatedPropertiesEvent) IsKnown() bool {
switch r {
case EventListResponseEventFileWatcherUpdatedPropertiesEventAdd, EventListResponseEventFileWatcherUpdatedPropertiesEventChange, EventListResponseEventFileWatcherUpdatedPropertiesEventUnlink:
return true
}
return false
}
type EventListResponseEventFileWatcherUpdatedType string
const (
EventListResponseEventFileWatcherUpdatedTypeFileWatcherUpdated EventListResponseEventFileWatcherUpdatedType = "file.watcher.updated"
)
func (r EventListResponseEventFileWatcherUpdatedType) IsKnown() bool {
switch r {
case EventListResponseEventFileWatcherUpdatedTypeFileWatcherUpdated:
return true
}
return false
}
type EventListResponseEventIdeInstalled struct {
Properties EventListResponseEventIdeInstalledProperties `json:"properties,required"`
Type EventListResponseEventIdeInstalledType `json:"type,required"`
JSON eventListResponseEventIdeInstalledJSON `json:"-"`
}
// eventListResponseEventIdeInstalledJSON contains the JSON metadata for the struct
// [EventListResponseEventIdeInstalled]
type eventListResponseEventIdeInstalledJSON struct {
Properties apijson.Field
Type apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *EventListResponseEventIdeInstalled) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r eventListResponseEventIdeInstalledJSON) RawJSON() string {
return r.raw
}
func (r EventListResponseEventIdeInstalled) implementsEventListResponse() {}
type EventListResponseEventIdeInstalledProperties struct {
Ide string `json:"ide,required"`
JSON eventListResponseEventIdeInstalledPropertiesJSON `json:"-"`
}
// eventListResponseEventIdeInstalledPropertiesJSON contains the JSON metadata for
// the struct [EventListResponseEventIdeInstalledProperties]
type eventListResponseEventIdeInstalledPropertiesJSON struct {
Ide apijson.Field
raw string
ExtraFields map[string]apijson.Field
}
func (r *EventListResponseEventIdeInstalledProperties) UnmarshalJSON(data []byte) (err error) {
return apijson.UnmarshalRoot(data, r)
}
func (r eventListResponseEventIdeInstalledPropertiesJSON) RawJSON() string {
return r.raw
}
type EventListResponseEventIdeInstalledType string
const (
EventListResponseEventIdeInstalledTypeIdeInstalled EventListResponseEventIdeInstalledType = "ide.installed"
)
func (r EventListResponseEventIdeInstalledType) IsKnown() bool {
switch r {
case EventListResponseEventIdeInstalledTypeIdeInstalled:
return true
}
return false
}
type EventListResponseType string
const (
@@ -1214,11 +1366,13 @@ const (
EventListResponseTypeSessionDeleted EventListResponseType = "session.deleted"
EventListResponseTypeSessionError EventListResponseType = "session.error"
EventListResponseTypeServerConnected EventListResponseType = "server.connected"
EventListResponseTypeFileWatcherUpdated EventListResponseType = "file.watcher.updated"
EventListResponseTypeIdeInstalled EventListResponseType = "ide.installed"
)
func (r EventListResponseType) IsKnown() bool {
switch r {
case EventListResponseTypeInstallationUpdated, EventListResponseTypeLspClientDiagnostics, EventListResponseTypeMessageUpdated, EventListResponseTypeMessageRemoved, EventListResponseTypeMessagePartUpdated, EventListResponseTypeMessagePartRemoved, EventListResponseTypeSessionCompacted, EventListResponseTypePermissionUpdated, EventListResponseTypePermissionReplied, EventListResponseTypeFileEdited, EventListResponseTypeSessionIdle, EventListResponseTypeSessionUpdated, EventListResponseTypeSessionDeleted, EventListResponseTypeSessionError, EventListResponseTypeServerConnected:
case EventListResponseTypeInstallationUpdated, EventListResponseTypeLspClientDiagnostics, EventListResponseTypeMessageUpdated, EventListResponseTypeMessageRemoved, EventListResponseTypeMessagePartUpdated, EventListResponseTypeMessagePartRemoved, EventListResponseTypeSessionCompacted, EventListResponseTypePermissionUpdated, EventListResponseTypePermissionReplied, EventListResponseTypeFileEdited, EventListResponseTypeSessionIdle, EventListResponseTypeSessionUpdated, EventListResponseTypeSessionDeleted, EventListResponseTypeSessionError, EventListResponseTypeServerConnected, EventListResponseTypeFileWatcherUpdated, EventListResponseTypeIdeInstalled:
return true
}
return false
+1 -1
View File
@@ -2,4 +2,4 @@
package internal
const PackageVersion = "0.14.0" // x-release-please-version
const PackageVersion = "0.15.0" // x-release-please-version
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "0.9.2",
"version": "0.9.9",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
+15 -15
View File
@@ -6,8 +6,6 @@ import type {
ProjectListResponses,
ProjectCurrentData,
ProjectCurrentResponses,
EventSubscribeData,
EventSubscribeResponses,
ConfigGetData,
ConfigGetResponses,
ToolRegisterData,
@@ -101,6 +99,8 @@ import type {
AuthSetData,
AuthSetResponses,
AuthSetErrors,
EventSubscribeData,
EventSubscribeResponses,
} from "./types.gen.js"
import { client as _heyApiClient } from "./client.gen.js"
@@ -153,18 +153,6 @@ class Project extends _HeyApiClient {
}
}
class Event extends _HeyApiClient {
/**
* Get events
*/
public subscribe<ThrowOnError extends boolean = false>(options?: Options<EventSubscribeData, ThrowOnError>) {
return (options?.client ?? this._client).get.sse<EventSubscribeResponses, unknown, ThrowOnError>({
url: "/event",
...options,
})
}
}
class Config extends _HeyApiClient {
/**
* Get config info
@@ -671,6 +659,18 @@ class Auth extends _HeyApiClient {
}
}
class Event extends _HeyApiClient {
/**
* Get events
*/
public subscribe<ThrowOnError extends boolean = false>(options?: Options<EventSubscribeData, ThrowOnError>) {
return (options?.client ?? this._client).get.sse<EventSubscribeResponses, unknown, ThrowOnError>({
url: "/event",
...options,
})
}
}
export class OpencodeClient extends _HeyApiClient {
/**
* Respond to a permission request
@@ -688,7 +688,6 @@ export class OpencodeClient extends _HeyApiClient {
})
}
project = new Project({ client: this._client })
event = new Event({ client: this._client })
config = new Config({ client: this._client })
tool = new Tool({ client: this._client })
path = new Path({ client: this._client })
@@ -699,4 +698,5 @@ export class OpencodeClient extends _HeyApiClient {
app = new App({ client: this._client })
tui = new Tui({ client: this._client })
auth = new Auth({ client: this._client })
event = new Event({ client: this._client })
}
+469 -452
View File
@@ -10,440 +10,6 @@ export type Project = {
}
}
export type EventInstallationUpdated = {
type: "installation.updated"
properties: {
version: string
}
}
export type EventLspClientDiagnostics = {
type: "lsp.client.diagnostics"
properties: {
serverID: string
path: string
}
}
export type UserMessage = {
id: string
sessionID: string
role: "user"
time: {
created: number
}
}
export type ProviderAuthError = {
name: "ProviderAuthError"
data: {
providerID: string
message: string
}
}
export type UnknownError = {
name: "UnknownError"
data: {
message: string
}
}
export type MessageOutputLengthError = {
name: "MessageOutputLengthError"
data: {
[key: string]: unknown
}
}
export type MessageAbortedError = {
name: "MessageAbortedError"
data: {
message: string
}
}
export type AssistantMessage = {
id: string
sessionID: string
role: "assistant"
time: {
created: number
completed?: number
}
error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError
system: Array<string>
modelID: string
providerID: string
mode: string
path: {
cwd: string
root: string
}
summary?: boolean
cost: number
tokens: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
}
}
export type Message = UserMessage | AssistantMessage
export type EventMessageUpdated = {
type: "message.updated"
properties: {
info: Message
}
}
export type EventMessageRemoved = {
type: "message.removed"
properties: {
sessionID: string
messageID: string
}
}
export type TextPart = {
id: string
sessionID: string
messageID: string
type: "text"
text: string
synthetic?: boolean
time?: {
start: number
end?: number
}
}
export type ReasoningPart = {
id: string
sessionID: string
messageID: string
type: "reasoning"
text: string
metadata?: {
[key: string]: unknown
}
time: {
start: number
end?: number
}
}
export type FilePartSourceText = {
value: string
start: number
end: number
}
export type FileSource = {
text: FilePartSourceText
type: "file"
path: string
}
export type Range = {
start: {
line: number
character: number
}
end: {
line: number
character: number
}
}
export type SymbolSource = {
text: FilePartSourceText
type: "symbol"
path: string
range: Range
name: string
kind: number
}
export type FilePartSource = FileSource | SymbolSource
export type FilePart = {
id: string
sessionID: string
messageID: string
type: "file"
mime: string
filename?: string
url: string
source?: FilePartSource
}
export type ToolStatePending = {
status: "pending"
}
export type ToolStateRunning = {
status: "running"
input: unknown
title?: string
metadata?: {
[key: string]: unknown
}
time: {
start: number
}
}
export type ToolStateCompleted = {
status: "completed"
input: {
[key: string]: unknown
}
output: string
title: string
metadata: {
[key: string]: unknown
}
time: {
start: number
end: number
compacted?: number
}
}
export type ToolStateError = {
status: "error"
input: {
[key: string]: unknown
}
error: string
metadata?: {
[key: string]: unknown
}
time: {
start: number
end: number
}
}
export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError
export type ToolPart = {
id: string
sessionID: string
messageID: string
type: "tool"
callID: string
tool: string
state: ToolState
}
export type StepStartPart = {
id: string
sessionID: string
messageID: string
type: "step-start"
}
export type StepFinishPart = {
id: string
sessionID: string
messageID: string
type: "step-finish"
cost: number
tokens: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
}
}
export type SnapshotPart = {
id: string
sessionID: string
messageID: string
type: "snapshot"
snapshot: string
}
export type PatchPart = {
id: string
sessionID: string
messageID: string
type: "patch"
hash: string
files: Array<string>
}
export type AgentPart = {
id: string
sessionID: string
messageID: string
type: "agent"
name: string
source?: {
value: string
start: number
end: number
}
}
export type Part =
| TextPart
| ReasoningPart
| FilePart
| ToolPart
| StepStartPart
| StepFinishPart
| SnapshotPart
| PatchPart
| AgentPart
export type EventMessagePartUpdated = {
type: "message.part.updated"
properties: {
part: Part
}
}
export type EventMessagePartRemoved = {
type: "message.part.removed"
properties: {
sessionID: string
messageID: string
partID: string
}
}
export type EventSessionCompacted = {
type: "session.compacted"
properties: {
sessionID: string
}
}
export type Permission = {
id: string
type: string
pattern?: string | Array<string>
sessionID: string
messageID: string
callID?: string
title: string
metadata: {
[key: string]: unknown
}
time: {
created: number
}
}
export type EventPermissionUpdated = {
type: "permission.updated"
properties: Permission
}
export type EventPermissionReplied = {
type: "permission.replied"
properties: {
sessionID: string
permissionID: string
response: string
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type EventSessionIdle = {
type: "session.idle"
properties: {
sessionID: string
}
}
export type Session = {
id: string
projectID: string
directory: string
parentID?: string
share?: {
url: string
}
title: string
version: string
time: {
created: number
updated: number
compacting?: number
}
revert?: {
messageID: string
partID?: string
snapshot?: string
diff?: string
}
}
export type EventSessionUpdated = {
type: "session.updated"
properties: {
info: Session
}
}
export type EventSessionDeleted = {
type: "session.deleted"
properties: {
info: Session
}
}
export type EventSessionError = {
type: "session.error"
properties: {
sessionID?: string
error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError
}
}
export type EventServerConnected = {
type: "server.connected"
properties: {
[key: string]: unknown
}
}
export type Event =
| EventInstallationUpdated
| EventLspClientDiagnostics
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
| EventMessagePartRemoved
| EventSessionCompacted
| EventPermissionUpdated
| EventPermissionReplied
| EventFileEdited
| EventSessionIdle
| EventSessionUpdated
| EventSessionDeleted
| EventSessionError
| EventServerConnected
/**
* Custom keybind configurations
*/
@@ -983,6 +549,297 @@ export type Path = {
directory: string
}
export type Session = {
id: string
projectID: string
directory: string
parentID?: string
share?: {
url: string
}
title: string
version: string
time: {
created: number
updated: number
compacting?: number
}
revert?: {
messageID: string
partID?: string
snapshot?: string
diff?: string
}
}
export type UserMessage = {
id: string
sessionID: string
role: "user"
time: {
created: number
}
}
export type ProviderAuthError = {
name: "ProviderAuthError"
data: {
providerID: string
message: string
}
}
export type UnknownError = {
name: "UnknownError"
data: {
message: string
}
}
export type MessageOutputLengthError = {
name: "MessageOutputLengthError"
data: {
[key: string]: unknown
}
}
export type MessageAbortedError = {
name: "MessageAbortedError"
data: {
message: string
}
}
export type AssistantMessage = {
id: string
sessionID: string
role: "assistant"
time: {
created: number
completed?: number
}
error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError
system: Array<string>
modelID: string
providerID: string
mode: string
path: {
cwd: string
root: string
}
summary?: boolean
cost: number
tokens: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
}
}
export type Message = UserMessage | AssistantMessage
export type TextPart = {
id: string
sessionID: string
messageID: string
type: "text"
text: string
synthetic?: boolean
time?: {
start: number
end?: number
}
}
export type ReasoningPart = {
id: string
sessionID: string
messageID: string
type: "reasoning"
text: string
metadata?: {
[key: string]: unknown
}
time: {
start: number
end?: number
}
}
export type FilePartSourceText = {
value: string
start: number
end: number
}
export type FileSource = {
text: FilePartSourceText
type: "file"
path: string
}
export type Range = {
start: {
line: number
character: number
}
end: {
line: number
character: number
}
}
export type SymbolSource = {
text: FilePartSourceText
type: "symbol"
path: string
range: Range
name: string
kind: number
}
export type FilePartSource = FileSource | SymbolSource
export type FilePart = {
id: string
sessionID: string
messageID: string
type: "file"
mime: string
filename?: string
url: string
source?: FilePartSource
}
export type ToolStatePending = {
status: "pending"
}
export type ToolStateRunning = {
status: "running"
input: unknown
title?: string
metadata?: {
[key: string]: unknown
}
time: {
start: number
}
}
export type ToolStateCompleted = {
status: "completed"
input: {
[key: string]: unknown
}
output: string
title: string
metadata: {
[key: string]: unknown
}
time: {
start: number
end: number
compacted?: number
}
}
export type ToolStateError = {
status: "error"
input: {
[key: string]: unknown
}
error: string
metadata?: {
[key: string]: unknown
}
time: {
start: number
end: number
}
}
export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError
export type ToolPart = {
id: string
sessionID: string
messageID: string
type: "tool"
callID: string
tool: string
state: ToolState
}
export type StepStartPart = {
id: string
sessionID: string
messageID: string
type: "step-start"
}
export type StepFinishPart = {
id: string
sessionID: string
messageID: string
type: "step-finish"
cost: number
tokens: {
input: number
output: number
reasoning: number
cache: {
read: number
write: number
}
}
}
export type SnapshotPart = {
id: string
sessionID: string
messageID: string
type: "snapshot"
snapshot: string
}
export type PatchPart = {
id: string
sessionID: string
messageID: string
type: "patch"
hash: string
files: Array<string>
}
export type AgentPart = {
id: string
sessionID: string
messageID: string
type: "agent"
name: string
source?: {
value: string
start: number
end: number
}
}
export type Part =
| TextPart
| ReasoningPart
| FilePart
| ToolPart
| StepStartPart
| StepFinishPart
| SnapshotPart
| PatchPart
| AgentPart
export type TextPartInput = {
id?: string
type: "text"
@@ -1151,6 +1008,166 @@ export type WellKnownAuth = {
export type Auth = OAuth | ApiAuth | WellKnownAuth
export type EventInstallationUpdated = {
type: "installation.updated"
properties: {
version: string
}
}
export type EventLspClientDiagnostics = {
type: "lsp.client.diagnostics"
properties: {
serverID: string
path: string
}
}
export type EventMessageUpdated = {
type: "message.updated"
properties: {
info: Message
}
}
export type EventMessageRemoved = {
type: "message.removed"
properties: {
sessionID: string
messageID: string
}
}
export type EventMessagePartUpdated = {
type: "message.part.updated"
properties: {
part: Part
}
}
export type EventMessagePartRemoved = {
type: "message.part.removed"
properties: {
sessionID: string
messageID: string
partID: string
}
}
export type EventSessionCompacted = {
type: "session.compacted"
properties: {
sessionID: string
}
}
export type Permission = {
id: string
type: string
pattern?: string | Array<string>
sessionID: string
messageID: string
callID?: string
title: string
metadata: {
[key: string]: unknown
}
time: {
created: number
}
}
export type EventPermissionUpdated = {
type: "permission.updated"
properties: Permission
}
export type EventPermissionReplied = {
type: "permission.replied"
properties: {
sessionID: string
permissionID: string
response: string
}
}
export type EventFileEdited = {
type: "file.edited"
properties: {
file: string
}
}
export type EventSessionIdle = {
type: "session.idle"
properties: {
sessionID: string
}
}
export type EventSessionUpdated = {
type: "session.updated"
properties: {
info: Session
}
}
export type EventSessionDeleted = {
type: "session.deleted"
properties: {
info: Session
}
}
export type EventSessionError = {
type: "session.error"
properties: {
sessionID?: string
error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError
}
}
export type EventServerConnected = {
type: "server.connected"
properties: {
[key: string]: unknown
}
}
export type EventFileWatcherUpdated = {
type: "file.watcher.updated"
properties: {
file: string
event: "add" | "change" | "unlink"
}
}
export type EventIdeInstalled = {
type: "ide.installed"
properties: {
ide: string
}
}
export type Event =
| EventInstallationUpdated
| EventLspClientDiagnostics
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
| EventMessagePartRemoved
| EventSessionCompacted
| EventPermissionUpdated
| EventPermissionReplied
| EventFileEdited
| EventSessionIdle
| EventSessionUpdated
| EventSessionDeleted
| EventSessionError
| EventServerConnected
| EventFileWatcherUpdated
| EventIdeInstalled
export type ProjectListData = {
body?: never
path?: never
@@ -1187,24 +1204,6 @@ export type ProjectCurrentResponses = {
export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses]
export type EventSubscribeData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/event"
}
export type EventSubscribeResponses = {
/**
* Event stream
*/
200: Event
}
export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses]
export type ConfigGetData = {
body?: never
path?: never
@@ -2210,6 +2209,24 @@ export type AuthSetResponses = {
export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses]
export type EventSubscribeData = {
body?: never
path?: never
query?: {
directory?: string
}
url: "/event"
}
export type EventSubscribeResponses = {
/**
* Event stream
*/
200: Event
}
export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses]
export type ClientOptions = {
baseUrl: `${string}://${string}` | (string & {})
}
@@ -641,7 +641,10 @@ func renderToolDetails(
if todos != nil {
for _, item := range todos.([]any) {
todo := item.(map[string]any)
content := todo["content"].(string)
content := todo["content"]
if content == nil {
continue
}
switch todo["status"] {
case "completed":
body += fmt.Sprintf("- [x] %s\n", content)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/web",
"type": "module",
"version": "0.9.2",
"version": "0.9.9",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
-2
View File
@@ -21,8 +21,6 @@ opencode supports both **JSON** and **JSONC** (JSON with Comments) formats.
}
```
With JSONC, you can use comments in your configuration files:
---
## Locations
+38 -8
View File
@@ -62,7 +62,34 @@ You can also install it with the following:
#### Windows
Currently, the automatic installation methods do not work properly on Windows. However you can grab the binary from the [Releases](https://github.com/sst/opencode/releases).
- **Using Chocolatey**
```bash
choco install opencode
```
- **Using WinGet**
```bash
winget install opencode
```
- **Using Scoop**
```bash
scoop bucket add extras
scoop install extras/opencode
```
- **Using NPM**
```bash
npm install -g opencode-ai
```
Support for installing opencode on Windows using Bun is currently in progress.
You can also grab the binary from the [Releases](https://github.com/sst/opencode/releases).
---
@@ -74,19 +101,22 @@ If you are new to using LLM providers, we recommend using [opencode zen](/docs/z
It's a curated list of models that have been tested and verified by the opencode
team.
1. Sign in to **<a href={console}>opencode zen</a>** and put in your billing
details.
2. Copy your API key.
3. Run `opencode auth login`, select opencode, and add your API key.
1. Run `opencode auth login`, select opencode, and head to [opencode.ai/auth](https://opencode.ai/auth).
2. Sign in, add your billing details, and copy your API key.
3. Paste your API key.
```bash
$ opencode auth login
┌ Add credential
Select provider
opencode
...
Select provider
│ opencode
● Create an api key at https://opencode.ai/auth
◆ Enter your API key
│ _
```
+20 -3
View File
@@ -57,9 +57,26 @@ tested and verified to work well with opencode. [Learn more](/docs/zen).
If you are new, we recommend starting with opencode zen.
:::
1. You sign in to **<a href={console}>opencode zen</a>** and get your API key.
2. You run `opencode auth login` and select opencode and add your API key.
3. Run `/models` in the TUI to see the list of models we recommend.
1. Run `opencode auth login`, select opencode, and head to [opencode.ai/auth](https://opencode.ai/auth).
2. Sign in, add your billing details, and copy your API key.
3. Paste your API key.
```bash
$ opencode auth login
┌ Add credential
◇ Select provider
│ opencode
● Create an api key at https://opencode.ai/auth
◆ Enter your API key
│ _
```
4. Run `/models` in the TUI to see the list of models we recommend.
It works like any other provider in opencode. And is completely optional to use
it.
@@ -13,7 +13,7 @@ that it stores locally.
Log files are written to:
- **macOS/Linux**: `~/.local/share/opencode/log/`
- **Windows**: `%APPDATA%\opencode\log\`
- **Windows**: `%USERPROFILE%\.local\share\opencode\log\`
Log files are named with timestamps (e.g., `2025-01-09T123456.log`) and the most recent 10 log files are kept.
+8 -4
View File
@@ -49,8 +49,9 @@ opencode zen is an AI gateway that gives you access to these models.
opencode zen works like any other provider in opencode.
1. You sign in to **<a href={console}>opencode zen</a>** and get your API key.
2. You run `opencode auth login` and select opencode and add your API key.
1. You sign in to **<a href={console}>opencode zen</a>**, add your billing
details, and copy your API key.
2. You run `opencode auth login`, select opencode, and paste your API key.
3. Run `/models` in the TUI to see the list of models we recommend.
You are charged per request and you can add credits to your account.
@@ -75,8 +76,7 @@ You can also access our models through the following API endpoints.
## Pricing
Below are the models that we currently support and their prices **per 1M
tokens**.
We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| Model | Input | Output | Cached Read | Cached Write |
| ------------------------------- | ------ | ------ | ----------- | ------------ |
@@ -95,6 +95,10 @@ Credit card fees are passed along at cost; we dont charge anything beyond tha
Grok Code Fast 1 is currently free on opencode for a limited time. The xAI team is using this time to collect feedback and improve Grok Code.
:::tip
Subscription plans and a free tier are coming soon.
:::
<a href={email}>Contact us</a> if you have any questions.
---
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "0.9.2",
"version": "0.9.9",
"publisher": "sst-dev",
"repository": {
"type": "git",
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://turborepo.com/schema.json",
"tasks": {
"typecheck": {}
}
}