Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e0460f6c0 | ||
|
|
8acd537d1d | ||
|
|
40c206c2f9 | ||
|
|
259c722208 | ||
|
|
e618cbc447 | ||
|
|
abd99aeb7d | ||
|
|
ad5fc76b11 | ||
|
|
ff1f4d6bf9 | ||
|
|
170ea9c32b | ||
|
|
65ced67432 | ||
|
|
9f46068c57 | ||
|
|
479cf2fa4f | ||
|
|
39c54f367f | ||
|
|
8c71107a93 | ||
|
|
ef10097329 | ||
|
|
36ee4b5ede | ||
|
|
ae84d5a734 | ||
|
|
cd53770734 | ||
|
|
4b1eca73eb | ||
|
|
fffcf69cd4 | ||
|
|
d4c01f858b | ||
|
|
8e17570c53 | ||
|
|
7f9d08b556 | ||
|
|
32a045f60b | ||
|
|
91adc3cd41 | ||
|
|
2bb9b4212f | ||
|
|
3472a50928 | ||
|
|
3aeac02bf1 | ||
|
|
52fcdcc37b | ||
|
|
78d6b3a963 | ||
|
|
15df2710fa | ||
|
|
02e492f6eb | ||
|
|
2d5bd26a59 |
@@ -9,3 +9,4 @@ openapi.json
|
||||
playground
|
||||
tmp
|
||||
dist
|
||||
.turbo
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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,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,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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.9",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "0.9.2",
|
||||
"version": "0.9.9",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+1316
-1313
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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
│ _
|
||||
└
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 don’t 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://turborepo.com/schema.json",
|
||||
"tasks": {
|
||||
"typecheck": {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user