summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app/src/routes
diff options
context:
space:
mode:
Diffstat (limited to 'packages/console/app/src/routes')
-rw-r--r--packages/console/app/src/routes/[...404].css130
-rw-r--r--packages/console/app/src/routes/[...404].tsx38
-rw-r--r--packages/console/app/src/routes/auth/authorize.ts7
-rw-r--r--packages/console/app/src/routes/auth/callback.ts31
-rw-r--r--packages/console/app/src/routes/auth/index.ts13
-rw-r--r--packages/console/app/src/routes/debug/index.ts13
-rw-r--r--packages/console/app/src/routes/discord.ts5
-rw-r--r--packages/console/app/src/routes/docs/[...path].ts20
-rw-r--r--packages/console/app/src/routes/docs/index.ts20
-rw-r--r--packages/console/app/src/routes/index.css504
-rw-r--r--packages/console/app/src/routes/index.tsx183
-rw-r--r--packages/console/app/src/routes/s/[id].ts20
-rw-r--r--packages/console/app/src/routes/stripe/webhook.ts98
-rw-r--r--packages/console/app/src/routes/workspace.css127
-rw-r--r--packages/console/app/src/routes/workspace.tsx67
-rw-r--r--packages/console/app/src/routes/workspace/[id].css115
-rw-r--r--packages/console/app/src/routes/workspace/[id].tsx50
-rw-r--r--packages/console/app/src/routes/workspace/index.tsx0
-rw-r--r--packages/console/app/src/routes/zen/handler.ts594
-rw-r--r--packages/console/app/src/routes/zen/v1/chat/completions.ts54
-rw-r--r--packages/console/app/src/routes/zen/v1/messages.ts61
-rw-r--r--packages/console/app/src/routes/zen/v1/responses.ts52
22 files changed, 2202 insertions, 0 deletions
diff --git a/packages/console/app/src/routes/[...404].css b/packages/console/app/src/routes/[...404].css
new file mode 100644
index 000000000..1edbd0a5a
--- /dev/null
+++ b/packages/console/app/src/routes/[...404].css
@@ -0,0 +1,130 @@
+[data-page="not-found"] {
+ --color-text: hsl(224, 10%, 10%);
+ --color-text-secondary: hsl(224, 7%, 46%);
+ --color-text-dimmed: hsl(224, 6%, 63%);
+ --color-text-inverted: hsl(0, 0%, 100%);
+
+ --color-border: hsl(224, 6%, 77%);
+}
+
+[data-page="not-found"] {
+ @media (prefers-color-scheme: dark) {
+ --color-text: hsl(0, 0%, 100%);
+ --color-text-secondary: hsl(224, 6%, 66%);
+ --color-text-dimmed: hsl(224, 7%, 46%);
+ --color-text-inverted: hsl(224, 10%, 10%);
+
+ --color-border: hsl(224, 6%, 36%);
+ }
+}
+
+[data-page="not-found"] {
+ --padding: 3rem;
+ --vertical-padding: 1.5rem;
+ --heading-font-size: 1.375rem;
+
+ @media (max-width: 30rem) {
+ --padding: 1rem;
+ --vertical-padding: 0.75rem;
+ --heading-font-size: 1rem;
+ }
+
+ font-family: var(--font-mono);
+ color: var(--color-text);
+ padding: calc(var(--padding) + 1rem);
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ a {
+ color: var(--color-text);
+ text-decoration: underline;
+ text-underline-offset: var(--space-0-75);
+ text-decoration-thickness: 1px;
+ }
+
+ [data-component="content"] {
+ max-width: 40rem;
+ width: 100%;
+ border: 1px solid var(--color-border);
+ }
+
+ [data-component="top"] {
+ padding: var(--padding);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: calc(var(--vertical-padding) / 2);
+ text-align: center;
+
+ [data-slot="logo-link"] {
+ text-decoration: none;
+ }
+
+ img {
+ height: auto;
+ width: clamp(200px, 85vw, 400px);
+ }
+
+ [data-slot="logo dark"] {
+ display: none;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ [data-slot="logo light"] {
+ display: none;
+ }
+ [data-slot="logo dark"] {
+ display: block;
+ }
+ }
+
+ [data-slot="title"] {
+ line-height: 1.25;
+ font-weight: 500;
+ text-align: center;
+ font-size: var(--heading-font-size);
+ color: var(--color-text);
+ text-transform: uppercase;
+ margin: 0;
+ }
+ }
+
+ [data-component="actions"] {
+ border-top: 1px solid var(--color-border);
+ display: flex;
+
+ [data-slot="action"] {
+ flex: 1;
+ text-align: center;
+ line-height: 1.4;
+ padding: var(--vertical-padding) 1rem;
+ text-transform: uppercase;
+ font-size: 1rem;
+
+ a {
+ display: block;
+ width: 100%;
+ height: 100%;
+ color: var(--color-text);
+ text-decoration: underline;
+ text-underline-offset: var(--space-0-75);
+ text-decoration-thickness: 1px;
+ }
+ }
+
+ [data-slot="action"] + [data-slot="action"] {
+ border-left: 1px solid var(--color-border);
+ }
+
+ @media (max-width: 40rem) {
+ flex-direction: column;
+
+ [data-slot="action"] + [data-slot="action"] {
+ border-left: none;
+ border-top: 1px solid var(--color-border);
+ }
+ }
+ }
+}
diff --git a/packages/console/app/src/routes/[...404].tsx b/packages/console/app/src/routes/[...404].tsx
new file mode 100644
index 000000000..ba2842b5a
--- /dev/null
+++ b/packages/console/app/src/routes/[...404].tsx
@@ -0,0 +1,38 @@
+import "./[...404].css"
+import { Title } from "@solidjs/meta"
+import { HttpStatusCode } from "@solidjs/start"
+import logoLight from "../asset/logo-ornate-light.svg"
+import logoDark from "../asset/logo-ornate-dark.svg"
+
+export default function NotFound() {
+ return (
+ <main data-page="not-found">
+ <Title>Not Found | opencode</Title>
+ <HttpStatusCode code={404} />
+ <div data-component="content">
+ <section data-component="top">
+ <a href="/" data-slot="logo-link">
+ <img data-slot="logo light" src={logoLight} alt="opencode logo light" />
+ <img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
+ </a>
+ <h1 data-slot="title">404 - Page Not Found</h1>
+ </section>
+
+ <section data-component="actions">
+ <div data-slot="action">
+ <a href="/">Home</a>
+ </div>
+ <div data-slot="action">
+ <a href="/docs">Docs</a>
+ </div>
+ <div data-slot="action">
+ <a href="https://github.com/sst/opencode">GitHub</a>
+ </div>
+ <div data-slot="action">
+ <a href="/discord">Discord</a>
+ </div>
+ </section>
+ </div>
+ </main>
+ )
+}
diff --git a/packages/console/app/src/routes/auth/authorize.ts b/packages/console/app/src/routes/auth/authorize.ts
new file mode 100644
index 000000000..166466ef8
--- /dev/null
+++ b/packages/console/app/src/routes/auth/authorize.ts
@@ -0,0 +1,7 @@
+import type { APIEvent } from "@solidjs/start/server"
+import { AuthClient } from "~/context/auth"
+
+export async function GET(input: APIEvent) {
+ const result = await AuthClient.authorize(new URL("./callback", input.request.url).toString(), "code")
+ return Response.redirect(result.url, 302)
+}
diff --git a/packages/console/app/src/routes/auth/callback.ts b/packages/console/app/src/routes/auth/callback.ts
new file mode 100644
index 000000000..23025b54d
--- /dev/null
+++ b/packages/console/app/src/routes/auth/callback.ts
@@ -0,0 +1,31 @@
+import { redirect } from "@solidjs/router"
+import type { APIEvent } from "@solidjs/start/server"
+import { AuthClient } from "~/context/auth"
+import { useAuthSession } from "~/context/auth.session"
+
+export async function GET(input: APIEvent) {
+ const url = new URL(input.request.url)
+ const code = url.searchParams.get("code")
+ if (!code) throw new Error("No code found")
+ const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
+ if (result.err) {
+ throw new Error(result.err.message)
+ }
+ const decoded = AuthClient.decode(result.tokens.access, {} as any)
+ if (decoded.err) throw new Error(decoded.err.message)
+ const session = await useAuthSession()
+ const id = decoded.subject.properties.accountID
+ await session.update((value) => {
+ return {
+ ...value,
+ account: {
+ [id]: {
+ id,
+ email: decoded.subject.properties.email,
+ },
+ },
+ current: id,
+ }
+ })
+ return redirect("/auth")
+}
diff --git a/packages/console/app/src/routes/auth/index.ts b/packages/console/app/src/routes/auth/index.ts
new file mode 100644
index 000000000..2c893185f
--- /dev/null
+++ b/packages/console/app/src/routes/auth/index.ts
@@ -0,0 +1,13 @@
+import { Account } from "@opencode/console-core/account.js"
+import { redirect } from "@solidjs/router"
+import type { APIEvent } from "@solidjs/start/server"
+import { withActor } from "~/context/auth.withActor"
+
+export async function GET(input: APIEvent) {
+ try {
+ const workspaces = await withActor(async () => Account.workspaces())
+ return redirect(`/workspace/${workspaces[0].id}`)
+ } catch {
+ return redirect("/auth/authorize")
+ }
+}
diff --git a/packages/console/app/src/routes/debug/index.ts b/packages/console/app/src/routes/debug/index.ts
new file mode 100644
index 000000000..39fa33d90
--- /dev/null
+++ b/packages/console/app/src/routes/debug/index.ts
@@ -0,0 +1,13 @@
+import type { APIEvent } from "@solidjs/start/server"
+import { json } from "@solidjs/router"
+import { Database } from "@opencode/console-core/drizzle/index.js"
+import { UserTable } from "@opencode/console-core/schema/user.sql.js"
+
+export async function GET(evt: APIEvent) {
+ return json({
+ data: await Database.use(async (tx) => {
+ const result = await tx.$count(UserTable)
+ return result
+ }),
+ })
+}
diff --git a/packages/console/app/src/routes/discord.ts b/packages/console/app/src/routes/discord.ts
new file mode 100644
index 000000000..7088295da
--- /dev/null
+++ b/packages/console/app/src/routes/discord.ts
@@ -0,0 +1,5 @@
+import { redirect } from "@solidjs/router"
+
+export async function GET() {
+ return redirect("https://discord.gg/opencode")
+}
diff --git a/packages/console/app/src/routes/docs/[...path].ts b/packages/console/app/src/routes/docs/[...path].ts
new file mode 100644
index 000000000..f07781583
--- /dev/null
+++ b/packages/console/app/src/routes/docs/[...path].ts
@@ -0,0 +1,20 @@
+import type { APIEvent } from "@solidjs/start/server"
+
+async function handler(evt: APIEvent) {
+ const req = evt.request.clone()
+ const url = new URL(req.url)
+ const targetUrl = `https://docs.opencode.ai${url.pathname}${url.search}`
+ const response = await fetch(targetUrl, {
+ method: req.method,
+ headers: req.headers,
+ body: req.body,
+ })
+ return response
+}
+
+export const GET = handler
+export const POST = handler
+export const PUT = handler
+export const DELETE = handler
+export const OPTIONS = handler
+export const PATCH = handler
diff --git a/packages/console/app/src/routes/docs/index.ts b/packages/console/app/src/routes/docs/index.ts
new file mode 100644
index 000000000..f07781583
--- /dev/null
+++ b/packages/console/app/src/routes/docs/index.ts
@@ -0,0 +1,20 @@
+import type { APIEvent } from "@solidjs/start/server"
+
+async function handler(evt: APIEvent) {
+ const req = evt.request.clone()
+ const url = new URL(req.url)
+ const targetUrl = `https://docs.opencode.ai${url.pathname}${url.search}`
+ const response = await fetch(targetUrl, {
+ method: req.method,
+ headers: req.headers,
+ body: req.body,
+ })
+ return response
+}
+
+export const GET = handler
+export const POST = handler
+export const PUT = handler
+export const DELETE = handler
+export const OPTIONS = handler
+export const PATCH = handler
diff --git a/packages/console/app/src/routes/index.css b/packages/console/app/src/routes/index.css
new file mode 100644
index 000000000..fe95bb7ea
--- /dev/null
+++ b/packages/console/app/src/routes/index.css
@@ -0,0 +1,504 @@
+[data-page="home"] {
+ --color-text: hsl(224, 10%, 10%);
+ --color-text-secondary: hsl(224, 7%, 46%);
+ --color-text-dimmed: hsl(224, 6%, 63%);
+ --color-text-inverted: hsl(0, 0%, 100%);
+
+ --color-border: hsl(224, 6%, 77%);
+}
+
+[data-page="home"] {
+ @media (prefers-color-scheme: dark) {
+ --color-text: hsl(0, 0%, 100%);
+ --color-text-secondary: hsl(224, 6%, 66%);
+ --color-text-dimmed: hsl(224, 7%, 46%);
+ --color-text-inverted: hsl(224, 10%, 10%);
+
+ --color-border: hsl(224, 6%, 36%);
+ }
+}
+
+[data-page="home"] {
+ --padding: 3rem;
+ --vertical-padding: 1.5rem;
+ --heading-font-size: 1.375rem;
+
+ @media (max-width: 30rem) {
+ --padding: 1rem;
+ --vertical-padding: 0.75rem;
+ --heading-font-size: 1rem;
+ }
+
+ display: flex;
+ gap: var(--vertical-padding);
+ flex-direction: column;
+ font-family: var(--font-mono);
+ color: var(--color-text);
+ padding: calc(var(--padding) + 1rem);
+
+ a {
+ color: var(--color-text);
+ text-decoration: underline;
+ text-underline-offset: var(--space-0-75);
+ text-decoration-thickness: 1px;
+ }
+
+ [data-component="content"] {
+ max-width: 67.5rem;
+ margin: 0 auto;
+ border: 1px solid var(--color-border);
+ }
+
+ [data-component="top"] {
+ padding: calc(var(--padding) * 1.5) var(--padding) var(--padding);
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: calc(var(--vertical-padding) / 2);
+
+ img {
+ height: auto;
+ width: clamp(200px, 85vw, 552px);
+ }
+
+ [data-slot="logo dark"] {
+ display: none;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ [data-slot="logo light"] {
+ display: none;
+ }
+ [data-slot="logo dark"] {
+ display: block;
+ }
+ }
+
+ [data-slot="title"] {
+ line-height: 1.25;
+ font-weight: 500;
+ text-align: center;
+ font-size: var(--heading-font-size);
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+ }
+
+ [data-slot="login"] {
+ position: absolute;
+ top: 0;
+ right: 0;
+ border-width: 0 0 1px 1px;
+ border-style: solid;
+ border-color: var(--color-border);
+ background-color: var(--color-bg);
+
+ @media (max-width: 30rem) {
+ display: none;
+ }
+
+ a {
+ display: block;
+ padding: 0.5rem 1rem calc(0.5rem + 4px);
+ }
+ }
+ }
+
+ [data-component="cta"] {
+ border-top: 1px solid var(--color-border);
+ display: flex;
+
+ & > div + div {
+ border-left: 1px solid var(--color-border);
+ }
+
+ [data-slot="left"] {
+ flex: 0 0 auto;
+ text-align: center;
+ line-height: 1.4;
+ padding: var(--vertical-padding) 2rem;
+ text-transform: uppercase;
+ font-size: 1.125rem;
+
+ @media (max-width: 30rem) {
+ font-size: 1rem;
+ padding-bottom: calc(var(--vertical-padding) + 4px);
+ }
+
+ @media (max-width: 30rem) {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+ }
+ }
+
+ [data-slot="center"] {
+ display: none;
+
+ @media (max-width: 30rem) {
+ display: block;
+ flex: 1;
+ text-align: center;
+ padding: var(--vertical-padding) 0.5rem;
+ border-top: 1px solid var(--color-border);
+ border-left: none;
+ }
+ }
+
+ [data-slot="right"] {
+ flex: 1;
+ padding: var(--vertical-padding) 1rem;
+ }
+
+ @media (max-width: 50rem) {
+ flex-direction: column;
+
+ [data-slot="right"] {
+ border-left: none;
+ border-top: 1px solid var(--color-border);
+ }
+ }
+
+ [data-slot="command"] {
+ all: unset;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ color: var(--color-text-secondary);
+ font-size: 1.125rem;
+ font-family: var(--font-mono);
+ gap: var(--space-2);
+ width: 100%;
+
+ & > span {
+ @media (max-width: 24rem) {
+ font-size: 0.875rem;
+ }
+ @media (max-width: 56rem) {
+ [data-slot="protocol"] {
+ display: none;
+ }
+ }
+ @media (max-width: 38rem) {
+ text-align: center;
+ span:first-child {
+ display: block;
+ }
+ }
+ }
+ }
+
+ [data-slot="highlight"] {
+ color: var(--color-text);
+ font-weight: 500;
+ }
+ }
+
+ [data-component="features"] {
+ border-top: 1px solid var(--color-border);
+ padding: var(--padding);
+
+ [data-slot="list"] {
+ padding-left: var(--space-4);
+ margin: 0;
+ list-style: disc;
+
+ li {
+ margin-bottom: var(--space-4);
+ line-height: 1.6;
+
+ strong {
+ text-transform: uppercase;
+ font-weight: 600;
+ }
+
+ label {
+ line-height: 1;
+ text-transform: uppercase;
+ font-size: 0.75rem;
+ letter-spacing: 0.03125rem;
+ background: var(--color-border);
+ padding: 0.125rem 0.375rem;
+ color: var(--color-text-inverted);
+ }
+ }
+
+ li:last-child {
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ [data-component="install"] {
+ border-top: 1px solid var(--color-border);
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: 1fr 1fr;
+
+ @media (max-width: 40rem) {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto;
+ }
+ }
+
+ [data-component="method"] {
+ display: flex;
+ padding: calc(var(--vertical-padding) / 2) calc(var(--padding) / 2) calc(var(--vertical-padding) / 2 + 0.125rem);
+ flex-direction: column;
+ text-align: left;
+ gap: var(--space-2-5);
+
+ @media (max-width: 30rem) {
+ gap: 0.3125rem;
+ }
+
+ @media (max-width: 40rem) {
+ text-align: left;
+ }
+
+ &:nth-child(2) {
+ border-left: 1px solid var(--color-border);
+
+ @media (max-width: 40rem) {
+ border-left: none;
+ border-top: 1px solid var(--color-border);
+ }
+ }
+
+ &:nth-child(3) {
+ border-top: 1px solid var(--color-border);
+ }
+
+ &:nth-child(4) {
+ border-top: 1px solid var(--color-border);
+ border-left: 1px solid var(--color-border);
+
+ @media (max-width: 40rem) {
+ border-left: none;
+ }
+ }
+
+ [data-component="title"] {
+ letter-spacing: -0.03125rem;
+ text-transform: uppercase;
+ font-weight: normal;
+ font-size: 1rem;
+ flex-shrink: 0;
+ color: var(--color-text-dimmed);
+
+ @media (max-width: 30rem) {
+ font-size: 0.75rem;
+ }
+ }
+
+ [data-slot="button"] {
+ all: unset;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ color: var(--color-text-secondary);
+ gap: var(--space-2-5);
+ font-size: 1rem;
+
+ @media (max-width: 24rem) {
+ font-size: 0.875rem;
+ }
+
+ strong {
+ color: var(--color-text);
+ font-weight: 500;
+ }
+
+ @media (max-width: 40rem) {
+ justify-content: flex-start;
+ }
+
+ @media (max-width: 30rem) {
+ justify-content: center;
+ }
+ }
+ }
+
+ [data-component="screenshots"] {
+ border-top: 1px solid var(--color-border);
+
+ figure {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--padding) / 4);
+ padding: calc(var(--padding) / 2);
+ border-width: 0;
+ border-style: solid;
+ border-color: var(--color-border);
+ min-height: 0;
+ overflow: hidden;
+
+ & > div,
+ figcaption {
+ display: flex;
+ align-items: center;
+ }
+
+ & > div {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ a {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ }
+
+ figcaption {
+ letter-spacing: -0.03125rem;
+ text-transform: uppercase;
+ color: var(--color-text-dimmed);
+ flex-shrink: 0;
+
+ @media (max-width: 30rem) {
+ font-size: 0.75rem;
+ }
+ }
+ }
+
+ & > [data-slot="left"] figure {
+ height: var(--images-height);
+ box-sizing: border-box;
+ }
+
+ & > [data-slot="right"] figure {
+ height: calc(var(--images-height) / 2);
+ box-sizing: border-box;
+ }
+
+ & > [data-slot="left"] img {
+ width: 100%;
+ height: 100%;
+ min-width: 0;
+ object-fit: contain;
+ }
+
+ & > [data-slot="right"] img {
+ width: 100%;
+ height: calc(100% - 2rem);
+ object-fit: contain;
+ display: block;
+ }
+
+ @media (max-width: 30rem) {
+ & {
+ --images-height: auto;
+ grid-template-columns: 1fr;
+ grid-template-rows: auto auto;
+ }
+
+ & > [data-slot="left"] {
+ grid-row: 1;
+ grid-column: 1;
+ }
+
+ & > [data-slot="right"] {
+ grid-row: 2;
+ grid-column: 1;
+ border-left: none;
+ border-top: 1px solid var(--color-border);
+
+ & > [data-slot="row1"],
+ & > [data-slot="row2"] {
+ height: auto;
+ }
+ }
+
+ & > [data-slot="left"] figure,
+ & > [data-slot="right"] figure {
+ height: auto;
+ }
+
+ & > [data-slot="left"] img,
+ & > [data-slot="right"] img {
+ width: 100%;
+ height: auto;
+ max-height: none;
+ }
+ }
+ }
+
+ [data-component="copy-status"] {
+ @media (max-width: 38rem) {
+ display: none;
+ }
+
+ [data-slot="copy"] {
+ display: block;
+ width: var(--space-4);
+ height: var(--space-4);
+ color: var(--color-text-dimmed);
+
+ [data-copied] & {
+ display: none;
+ }
+ }
+
+ [data-slot="check"] {
+ display: none;
+ width: var(--space-4);
+ height: var(--space-4);
+ color: var(--color-text);
+
+ [data-copied] & {
+ display: block;
+ }
+ }
+ }
+
+ [data-component="footer"] {
+ border-top: 1px solid var(--color-border);
+ display: flex;
+ flex-direction: row;
+
+ [data-slot="cell"] {
+ flex: 1;
+ text-align: center;
+ text-transform: uppercase;
+ padding: var(--vertical-padding) 0.5rem;
+ }
+
+ [data-slot="cell"] + [data-slot="cell"] {
+ border-left: 1px solid var(--color-border);
+ }
+
+ /* Mobile: third column on its own row */
+ @media (max-width: 30rem) {
+ flex-wrap: wrap;
+
+ [data-slot="cell"]:nth-child(1),
+ [data-slot="cell"]:nth-child(2) {
+ flex: 1;
+ }
+
+ [data-slot="cell"]:nth-child(3) {
+ flex: 1 0 100%;
+ border-left: none;
+ border-top: 1px solid var(--color-border);
+ }
+ }
+ }
+
+ [data-component="legal"] {
+ color: var(--color-text-dimmed);
+ text-align: center;
+
+ a {
+ color: var(--color-text-dimmed);
+ }
+ }
+}
diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx
new file mode 100644
index 000000000..e8c1998ae
--- /dev/null
+++ b/packages/console/app/src/routes/index.tsx
@@ -0,0 +1,183 @@
+import "./index.css"
+import { Title } from "@solidjs/meta"
+import { onCleanup, onMount } from "solid-js"
+import logoLight from "../asset/logo-ornate-light.svg"
+import logoDark from "../asset/logo-ornate-dark.svg"
+import IMG_SPLASH from "../asset/lander/screenshot-splash.png"
+import { IconCopy, IconCheck } from "../component/icon"
+import { createAsync, query } from "@solidjs/router"
+import { getActor } from "~/context/auth"
+import { withActor } from "~/context/auth.withActor"
+import { Account } from "@opencode/console-core/account.js"
+
+function CopyStatus() {
+ return (
+ <div data-component="copy-status">
+ <IconCopy data-slot="copy" />
+ <IconCheck data-slot="check" />
+ </div>
+ )
+}
+
+const defaultWorkspace = query(async () => {
+ "use server"
+ const actor = await getActor()
+ if (actor.type === "account") {
+ const workspaces = await withActor(() => Account.workspaces())
+ return workspaces[0].id
+ }
+}, "defaultWorkspace")
+
+export default function Home() {
+ const workspace = createAsync(() => defaultWorkspace())
+ onMount(() => {
+ const commands = document.querySelectorAll("[data-copy]")
+ for (const button of commands) {
+ const callback = () => {
+ const text = button.textContent
+ if (text) {
+ navigator.clipboard.writeText(text)
+ button.setAttribute("data-copied", "")
+ setTimeout(() => {
+ button.removeAttribute("data-copied")
+ }, 1500)
+ }
+ }
+ button.addEventListener("click", callback)
+ onCleanup(() => {
+ button.removeEventListener("click", callback)
+ })
+ }
+ })
+
+ return (
+ <main data-page="home">
+ <Title>opencode | AI coding agent built for the terminal</Title>
+
+ <div data-component="content">
+ <section data-component="top">
+ <img data-slot="logo light" src={logoLight} alt="opencode logo light" />
+ <img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
+ <h1 data-slot="title">The AI coding agent built for the terminal</h1>
+ <div data-slot="login">
+ <a href="/auth">opencode zen</a>
+ </div>
+ </section>
+
+ <section data-component="cta">
+ <div data-slot="left">
+ <a href="/docs">Get Started</a>
+ </div>
+ <div data-slot="center">
+ <a href="/auth">opencode zen</a>
+ </div>
+ <div data-slot="right">
+ <button data-copy data-slot="command">
+ <span>
+ <span>curl -fsSL </span>
+ <span data-slot="protocol">https://</span>
+ <span data-slot="highlight">opencode.ai/install</span>
+ <span> | bash</span>
+ </span>
+ <CopyStatus />
+ </button>
+ </div>
+ </section>
+
+ <section data-component="features">
+ <ul data-slot="list">
+ <li>
+ <strong>Native TUI</strong> A responsive, native, themeable terminal UI
+ </li>
+ <li>
+ <strong>LSP enabled</strong> Automatically loads the right LSPs for the LLM
+ </li>
+ <li>
+ <strong>opencode zen</strong> A <a href="/docs/zen">curated list of models</a> provided by opencode{" "}
+ <label>New</label>
+ </li>
+ <li>
+ <strong>Multi-session</strong> Start multiple agents in parallel on the same project
+ </li>
+ <li>
+ <strong>Shareable links</strong> Share a link to any sessions for reference or to debug
+ </li>
+ <li>
+ <strong>Claude Pro</strong> Log in with Anthropic to use your Claude Pro or Max account
+ </li>
+ <li>
+ <strong>Use any model</strong> Supports 75+ LLM providers through{" "}
+ <a href="https://models.dev">Models.dev</a>, including local models
+ </li>
+ </ul>
+ </section>
+
+ <section data-component="install">
+ <div data-component="method">
+ <h3 data-component="title">npm</h3>
+ <button data-copy data-slot="button">
+ <span>
+ npm install -g <strong>opencode-ai</strong>
+ </span>
+ <CopyStatus />
+ </button>
+ </div>
+ <div data-component="method">
+ <h3 data-component="title">bun</h3>
+ <button data-copy data-slot="button">
+ <span>
+ bun install -g <strong>opencode-ai</strong>
+ </span>
+ <CopyStatus />
+ </button>
+ </div>
+ <div data-component="method">
+ <h3 data-component="title">homebrew</h3>
+ <button data-copy data-slot="button">
+ <span>
+ brew install <strong>sst/tap/opencode</strong>
+ </span>
+ <CopyStatus />
+ </button>
+ </div>
+ <div data-component="method">
+ <h3 data-component="title">paru</h3>
+ <button data-copy data-slot="button">
+ <span>
+ paru -S <strong>opencode-bin</strong>
+ </span>
+ <CopyStatus />
+ </button>
+ </div>
+ </section>
+
+ <section data-component="screenshots">
+ <figure>
+ <figcaption>opencode TUI with the tokyonight theme</figcaption>
+ <a href="/docs/cli">
+ <img src={IMG_SPLASH} alt="opencode TUI with tokyonight theme" />
+ </a>
+ </figure>
+ </section>
+
+ <footer data-component="footer">
+ <div data-slot="cell">
+ <a href="https://x.com/opencode">X.com</a>
+ </div>
+ <div data-slot="cell">
+ <a href="https://github.com/sst/opencode">GitHub</a>
+ </div>
+ <div data-slot="cell">
+ <a href="https://opencode.ai/discord">Discord</a>
+ </div>
+ </footer>
+ </div>
+
+ <div data-component="legal">
+ <span>
+ ©2025 <a href="https://anoma.ly">Anomaly Innovations</a>
+ </span>
+ </div>
+ </main>
+ )
+}
diff --git a/packages/console/app/src/routes/s/[id].ts b/packages/console/app/src/routes/s/[id].ts
new file mode 100644
index 000000000..3fd1305a0
--- /dev/null
+++ b/packages/console/app/src/routes/s/[id].ts
@@ -0,0 +1,20 @@
+import type { APIEvent } from "@solidjs/start/server"
+
+async function handler(evt: APIEvent) {
+ const req = evt.request.clone()
+ const url = new URL(req.url)
+ const targetUrl = `https://docs.opencode.ai/docs${url.pathname}${url.search}`
+ const response = await fetch(targetUrl, {
+ method: req.method,
+ headers: req.headers,
+ body: req.body,
+ })
+ return response
+}
+
+export const GET = handler
+export const POST = handler
+export const PUT = handler
+export const DELETE = handler
+export const OPTIONS = handler
+export const PATCH = handler
diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts
new file mode 100644
index 000000000..920966286
--- /dev/null
+++ b/packages/console/app/src/routes/stripe/webhook.ts
@@ -0,0 +1,98 @@
+import { Billing } from "@opencode/console-core/billing.js"
+import type { APIEvent } from "@solidjs/start/server"
+import { Database, eq, sql } from "@opencode/console-core/drizzle/index.js"
+import { BillingTable, PaymentTable } from "@opencode/console-core/schema/billing.sql.js"
+import { Identifier } from "@opencode/console-core/identifier.js"
+import { centsToMicroCents } from "@opencode/console-core/util/price.js"
+import { Actor } from "@opencode/console-core/actor.js"
+import { Resource } from "@opencode/console-resource"
+
+export async function POST(input: APIEvent) {
+ const body = await Billing.stripe().webhooks.constructEventAsync(
+ await input.request.text(),
+ input.request.headers.get("stripe-signature")!,
+ Resource.STRIPE_WEBHOOK_SECRET.value,
+ )
+
+ console.log(body.type, JSON.stringify(body, null, 2))
+ if (body.type === "customer.updated") {
+ // check default payment method changed
+ const prevInvoiceSettings = body.data.previous_attributes?.invoice_settings ?? {}
+ if (!("default_payment_method" in prevInvoiceSettings)) return
+
+ const customerID = body.data.object.id
+ const paymentMethodID = body.data.object.invoice_settings.default_payment_method as string
+
+ if (!customerID) throw new Error("Customer ID not found")
+ if (!paymentMethodID) throw new Error("Payment method ID not found")
+
+ const paymentMethod = await Billing.stripe().paymentMethods.retrieve(paymentMethodID)
+ await Database.use(async (tx) => {
+ await tx
+ .update(BillingTable)
+ .set({
+ paymentMethodID,
+ paymentMethodLast4: paymentMethod.card!.last4,
+ })
+ .where(eq(BillingTable.customerID, customerID))
+ })
+ }
+ if (body.type === "checkout.session.completed") {
+ const workspaceID = body.data.object.metadata?.workspaceID
+ const customerID = body.data.object.customer as string
+ const paymentID = body.data.object.payment_intent as string
+ const amount = body.data.object.amount_total
+
+ if (!workspaceID) throw new Error("Workspace ID not found")
+ if (!customerID) throw new Error("Customer ID not found")
+ if (!amount) throw new Error("Amount not found")
+ if (!paymentID) throw new Error("Payment ID not found")
+
+ await Actor.provide("system", { workspaceID }, async () => {
+ const customer = await Billing.get()
+ if (customer?.customerID && customer.customerID !== customerID) throw new Error("Customer ID mismatch")
+
+ // set customer metadata
+ if (!customer?.customerID) {
+ await Billing.stripe().customers.update(customerID, {
+ metadata: {
+ workspaceID,
+ },
+ })
+ }
+
+ // get payment method for the payment intent
+ const paymentIntent = await Billing.stripe().paymentIntents.retrieve(paymentID, {
+ expand: ["payment_method"],
+ })
+ const paymentMethod = paymentIntent.payment_method
+ if (!paymentMethod || typeof paymentMethod === "string") throw new Error("Payment method not expanded")
+
+ await Database.transaction(async (tx) => {
+ await tx
+ .update(BillingTable)
+ .set({
+ balance: sql`${BillingTable.balance} + ${centsToMicroCents(Billing.CHARGE_AMOUNT)}`,
+ customerID,
+ paymentMethodID: paymentMethod.id,
+ paymentMethodLast4: paymentMethod.card!.last4,
+ reload: true,
+ reloadError: null,
+ timeReloadError: null,
+ })
+ .where(eq(BillingTable.workspaceID, workspaceID))
+ await tx.insert(PaymentTable).values({
+ workspaceID,
+ id: Identifier.create("payment"),
+ amount: centsToMicroCents(Billing.CHARGE_AMOUNT),
+ paymentID,
+ customerID,
+ })
+ })
+ })
+ }
+
+ console.log("finished handling")
+
+ return Response.json("ok", { status: 200 })
+}
diff --git a/packages/console/app/src/routes/workspace.css b/packages/console/app/src/routes/workspace.css
new file mode 100644
index 000000000..ed94365f0
--- /dev/null
+++ b/packages/console/app/src/routes/workspace.css
@@ -0,0 +1,127 @@
+[data-page="workspace"] {
+ line-height: 1;
+
+ /* Common elements */
+ button {
+ padding: var(--space-3) var(--space-4);
+ 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-sans);
+ font-weight: 500;
+ text-transform: uppercase;
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ &:hover:not(:disabled) {
+ background-color: var(--color-surface-hover);
+ border-color: var(--color-accent);
+ }
+
+ &:active {
+ transform: translateY(1px);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ transform: none;
+ }
+
+ &[data-color="primary"] {
+ background-color: var(--color-primary);
+ border-color: var(--color-primary);
+ color: var(--color-primary-text);
+
+ &:hover:not(:disabled) {
+ background-color: var(--color-primary-hover);
+ border-color: var(--color-primary-hover);
+ }
+ }
+
+ &[data-color="ghost"] {
+ background-color: transparent;
+ border-color: transparent;
+ color: var(--color-text-muted);
+
+ &:hover:not(:disabled) {
+ background-color: var(--color-surface-hover);
+ border-color: var(--color-border);
+ color: var(--color-text);
+ }
+ }
+ }
+
+ a {
+ color: var(--color-text);
+ text-decoration: underline;
+ text-underline-offset: var(--space-0-75);
+ text-decoration-thickness: 1px;
+ }
+
+ /* Workspace Header */
+ [data-component="workspace-header"] {
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--space-4) var(--space-4);
+ border-bottom: 1px solid var(--color-border);
+ background-color: var(--color-bg);
+
+ @media (max-width: 30rem) {
+ padding: var(--space-4) var(--space-4);
+ }
+ }
+
+ [data-slot="header-brand"] {
+ flex: 0 0 auto;
+ padding-top: 4px;
+
+ svg {
+ width: 138px;
+ }
+
+ [data-component="site-title"] {
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+ color: var(--color-text);
+ text-decoration: none;
+ letter-spacing: -0.02em;
+ }
+ }
+
+ [data-slot="header-actions"] {
+ display: flex;
+ gap: var(--space-4);
+ align-items: center;
+ font-size: var(--font-size-sm);
+
+ [data-slot="user"] {
+ color: var(--color-text-muted);
+ }
+
+ @media (max-width: 30rem) {
+ [data-slot="user"] {
+ display: none;
+ }
+ }
+
+ a,
+ button {
+ appearance: none;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ color: var(--color-text);
+ text-decoration: underline;
+ text-underline-offset: var(--space-0-75);
+ text-decoration-thickness: 1px;
+ text-transform: uppercase;
+ }
+ }
+}
diff --git a/packages/console/app/src/routes/workspace.tsx b/packages/console/app/src/routes/workspace.tsx
new file mode 100644
index 000000000..3aa3f20d3
--- /dev/null
+++ b/packages/console/app/src/routes/workspace.tsx
@@ -0,0 +1,67 @@
+import "./workspace.css"
+import { useAuthSession } from "~/context/auth.session"
+import { IconLogo } from "../component/icon"
+import { withActor } from "~/context/auth.withActor"
+import {
+ query,
+ action,
+ redirect,
+ createAsync,
+ RouteSectionProps,
+ Navigate,
+ useNavigate,
+ useParams,
+ A,
+} from "@solidjs/router"
+import { User } from "@opencode/console-core/user.js"
+import { Actor } from "@opencode/console-core/actor.js"
+import { getRequestEvent } from "solid-js/web"
+
+const getUserInfo = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ const actor = Actor.assert("user")
+ return await User.fromID(actor.properties.userID)
+ }, workspaceID)
+}, "userInfo")
+
+const logout = action(async () => {
+ "use server"
+ const auth = await useAuthSession()
+ const event = getRequestEvent()
+ const current = auth.data.current
+ if (current)
+ await auth.update((val) => {
+ delete val.account?.[current]
+ const first = Object.keys(val.account ?? {})[0]
+ val.current = first
+ event!.locals.actor = undefined
+ return val
+ })
+ throw redirect("/")
+})
+
+export default function WorkspaceLayout(props: RouteSectionProps) {
+ const params = useParams()
+ const userInfo = createAsync(() => getUserInfo(params.id))
+ return (
+ <main data-page="workspace">
+ <header data-component="workspace-header">
+ <div data-slot="header-brand">
+ <A href="/" data-component="site-title">
+ <IconLogo />
+ </A>
+ </div>
+ <div data-slot="header-actions">
+ <span data-slot="user">{userInfo()?.email}</span>
+ <form action={logout} method="post">
+ <button type="submit" formaction={logout}>
+ Logout
+ </button>
+ </form>
+ </div>
+ </header>
+ <div>{props.children}</div>
+ </main>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/[id].css b/packages/console/app/src/routes/workspace/[id].css
new file mode 100644
index 000000000..8b318a19f
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id].css
@@ -0,0 +1,115 @@
+[data-page="workspace-[id]"] {
+ max-width: 64rem;
+ padding: var(--space-10) var(--space-4);
+ margin: 0 auto;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-10);
+
+ @media (max-width: 30rem) {
+ padding-top: var(--space-4);
+ padding-bottom: var(--space-4);
+
+ gap: var(--space-8);
+ }
+
+ [data-slot="sections"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-16);
+
+ @media (max-width: 30rem) {
+ gap: var(--space-8);
+ }
+
+ section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-8);
+
+ @media (max-width: 30rem) {
+ gap: var(--space-6);
+ }
+
+ /* Section titles */
+ [data-slot="section-title"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+
+ h2 {
+ font-size: var(--font-size-md);
+ font-weight: 600;
+ line-height: 1.2;
+ letter-spacing: -0.03125rem;
+ margin: 0;
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+
+ @media (max-width: 30rem) {
+ font-size: var(--font-size-md);
+ }
+ }
+
+ p {
+ line-height: 1.5;
+ font-size: var(--font-size-md);
+ color: var(--color-text-muted);
+
+ a {
+ color: var(--color-text-muted);
+ }
+
+ @media (max-width: 30rem) {
+ font-size: var(--font-size-sm);
+ }
+ }
+ }
+ }
+ section:not(:last-child) {
+ border-bottom: 1px solid var(--color-border);
+ padding-bottom: var(--space-16);
+
+ @media (max-width: 30rem) {
+ padding-bottom: var(--space-8);
+ }
+ }
+ }
+
+ /* Title section */
+ [data-component="title-section"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ padding-bottom: var(--space-8);
+ border-bottom: 1px solid var(--color-border);
+
+ @media (max-width: 30rem) {
+ padding-bottom: var(--space-6);
+ }
+
+ h1 {
+ font-size: var(--font-size-2xl);
+ font-weight: 500;
+ line-height: 1.2;
+ letter-spacing: -0.03125rem;
+ margin: 0;
+ text-transform: uppercase;
+
+ @media (max-width: 30rem) {
+ font-size: var(--font-size-xl);
+ }
+ }
+
+ p {
+ line-height: 1.5;
+ font-size: var(--font-size-md);
+ color: var(--color-text-muted);
+
+ a {
+ color: var(--color-text-muted);
+ }
+ }
+ }
+}
diff --git a/packages/console/app/src/routes/workspace/[id].tsx b/packages/console/app/src/routes/workspace/[id].tsx
new file mode 100644
index 000000000..68a706d5d
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/[id].tsx
@@ -0,0 +1,50 @@
+import "./[id].css"
+import { Billing } from "@opencode/console-core/billing.js"
+import { query, useParams, createAsync } from "@solidjs/router"
+import { Show } from "solid-js"
+import { withActor } from "~/context/auth.withActor"
+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"
+ return withActor(async () => {
+ return await Billing.get()
+ }, workspaceID)
+}, "billing.get")
+
+export default function () {
+ const params = useParams()
+ const balanceInfo = createAsync(() => getBillingInfo(params.id))
+
+ return (
+ <div data-page="workspace-[id]">
+ <section data-component="title-section">
+ <h1>Zen</h1>
+ <p>
+ Curated list of models provided by opencode.{" "}
+ <a target="_blank" href="/docs/zen">
+ Learn more
+ </a>
+ .
+ </p>
+ </section>
+
+ <div data-slot="sections">
+ <NewUserSection />
+ <KeySection />
+ <BillingSection />
+ <Show when={true}>
+ {/*<Show when={balanceInfo()?.reload}>*/}
+ <MonthlyLimitSection />
+ </Show>
+ <UsageSection />
+ <PaymentSection />
+ </div>
+ </div>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace/index.tsx b/packages/console/app/src/routes/workspace/index.tsx
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/packages/console/app/src/routes/workspace/index.tsx
diff --git a/packages/console/app/src/routes/zen/handler.ts b/packages/console/app/src/routes/zen/handler.ts
new file mode 100644
index 000000000..6065e2f76
--- /dev/null
+++ b/packages/console/app/src/routes/zen/handler.ts
@@ -0,0 +1,594 @@
+import type { APIEvent } from "@solidjs/start/server"
+import path from "node:path"
+import { and, Database, eq, isNull, lt, or, sql } from "@opencode/console-core/drizzle/index.js"
+import { KeyTable } from "@opencode/console-core/schema/key.sql.js"
+import { BillingTable, PaymentTable, UsageTable } from "@opencode/console-core/schema/billing.sql.js"
+import { centsToMicroCents } from "@opencode/console-core/util/price.js"
+import { Identifier } from "@opencode/console-core/identifier.js"
+import { Resource } from "@opencode/console-resource"
+import { Billing } from "../../../../core/src/billing"
+import { Actor } from "@opencode/console-core/actor.js"
+
+type ModelCost = {
+ input: number
+ output: number
+ cacheRead?: number
+ cacheWrite5m?: number
+ cacheWrite1h?: number
+}
+
+type Model = {
+ id: string
+ auth: boolean
+ cost: ModelCost | ((usage: any) => ModelCost)
+ headerMappings: Record<string, string>
+ providers: Record<
+ string,
+ {
+ api: string
+ apiKey: string
+ model: string
+ weight?: number
+ }
+ >
+}
+
+export async function handler(
+ input: APIEvent,
+ opts: {
+ modifyBody?: (body: any) => any
+ setAuthHeader: (headers: Headers, apiKey: string) => void
+ parseApiKey: (headers: Headers) => string | undefined
+ onStreamPart: (chunk: string) => void
+ getStreamUsage: () => any
+ normalizeUsage: (body: any) => {
+ inputTokens: number
+ outputTokens: number
+ reasoningTokens?: number
+ cacheReadTokens?: number
+ cacheWrite5mTokens?: number
+ cacheWrite1hTokens?: number
+ }
+ },
+) {
+ class AuthError extends Error {}
+ class CreditsError extends Error {}
+ class MonthlyLimitError extends Error {}
+ class ModelError extends Error {}
+
+ const MODELS: Record<string, Model> = {
+ "claude-opus-4-1": {
+ id: "claude-opus-4-1" as const,
+ auth: true,
+ cost: {
+ input: 0.000015,
+ output: 0.000075,
+ cacheRead: 0.0000015,
+ cacheWrite5m: 0.00001875,
+ cacheWrite1h: 0.00003,
+ },
+ headerMappings: {},
+ providers: {
+ anthropic: {
+ api: "https://api.anthropic.com",
+ apiKey: Resource.ANTHROPIC_API_KEY.value,
+ model: "claude-opus-4-1-20250805",
+ },
+ },
+ },
+ "claude-sonnet-4": {
+ id: "claude-sonnet-4" as const,
+ auth: true,
+ cost: (usage: any) => {
+ const totalInputTokens =
+ usage.inputTokens + usage.cacheReadTokens + usage.cacheWrite5mTokens + usage.cacheWrite1hTokens
+ return totalInputTokens <= 200_000
+ ? {
+ input: 0.000003,
+ output: 0.000015,
+ cacheRead: 0.0000003,
+ cacheWrite5m: 0.00000375,
+ cacheWrite1h: 0.000006,
+ }
+ : {
+ input: 0.000006,
+ output: 0.0000225,
+ cacheRead: 0.0000006,
+ cacheWrite5m: 0.0000075,
+ cacheWrite1h: 0.000012,
+ }
+ },
+ headerMappings: {},
+ providers: {
+ anthropic: {
+ api: "https://api.anthropic.com",
+ apiKey: Resource.ANTHROPIC_API_KEY.value,
+ model: "claude-sonnet-4-20250514",
+ },
+ },
+ },
+ "claude-3-5-haiku": {
+ id: "claude-3-5-haiku" as const,
+ auth: true,
+ cost: {
+ input: 0.0000008,
+ output: 0.000004,
+ cacheRead: 0.00000008,
+ cacheWrite5m: 0.000001,
+ cacheWrite1h: 0.0000016,
+ },
+ headerMappings: {},
+ providers: {
+ anthropic: {
+ api: "https://api.anthropic.com",
+ apiKey: Resource.ANTHROPIC_API_KEY.value,
+ model: "claude-3-5-haiku-20241022",
+ },
+ },
+ },
+ "gpt-5": {
+ id: "gpt-5" as const,
+ auth: true,
+ cost: {
+ input: 0.00000125,
+ output: 0.00001,
+ cacheRead: 0.000000125,
+ },
+ headerMappings: {},
+ providers: {
+ openai: {
+ api: "https://api.openai.com",
+ apiKey: Resource.OPENAI_API_KEY.value,
+ model: "gpt-5",
+ },
+ },
+ },
+ "qwen3-coder": {
+ id: "qwen3-coder" as const,
+ auth: true,
+ cost: {
+ input: 0.00000045,
+ output: 0.0000018,
+ },
+ headerMappings: {},
+ providers: {
+ baseten: {
+ api: "https://inference.baseten.co",
+ apiKey: Resource.BASETEN_API_KEY.value,
+ model: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
+ weight: 4,
+ },
+ fireworks: {
+ api: "https://api.fireworks.ai/inference",
+ apiKey: Resource.FIREWORKS_API_KEY.value,
+ model: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
+ weight: 1,
+ },
+ },
+ },
+ "kimi-k2": {
+ id: "kimi-k2" as const,
+ auth: true,
+ cost: {
+ input: 0.0000006,
+ output: 0.0000025,
+ },
+ headerMappings: {},
+ providers: {
+ baseten: {
+ api: "https://inference.baseten.co",
+ apiKey: Resource.BASETEN_API_KEY.value,
+ model: "moonshotai/Kimi-K2-Instruct-0905",
+ //weight: 4,
+ },
+ //fireworks: {
+ // api: "https://api.fireworks.ai/inference",
+ // apiKey: Resource.FIREWORKS_API_KEY.value,
+ // model: "accounts/fireworks/models/kimi-k2-instruct-0905",
+ // weight: 1,
+ //},
+ },
+ },
+ "grok-code": {
+ id: "grok-code" as const,
+ auth: false,
+ cost: {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ },
+ headerMappings: {
+ "x-grok-conv-id": "x-opencode-session",
+ "x-grok-req-id": "x-opencode-request",
+ },
+ providers: {
+ xai: {
+ api: "https://api.x.ai",
+ apiKey: Resource.XAI_API_KEY.value,
+ model: "grok-code",
+ },
+ },
+ },
+ // deprecated
+ "qwen/qwen3-coder": {
+ id: "qwen/qwen3-coder" as const,
+ auth: true,
+ cost: {
+ input: 0.00000038,
+ output: 0.00000153,
+ },
+ headerMappings: {},
+ providers: {
+ baseten: {
+ api: "https://inference.baseten.co",
+ apiKey: Resource.BASETEN_API_KEY.value,
+ model: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
+ weight: 5,
+ },
+ fireworks: {
+ api: "https://api.fireworks.ai/inference",
+ apiKey: Resource.FIREWORKS_API_KEY.value,
+ model: "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct",
+ weight: 1,
+ },
+ },
+ },
+ }
+
+ const FREE_WORKSPACES = [
+ "wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
+ ]
+
+ const logger = {
+ metric: (values: Record<string, any>) => {
+ console.log(`_metric:${JSON.stringify(values)}`)
+ },
+ log: console.log,
+ debug: (message: string) => {
+ if (Resource.App.stage === "production") return
+ console.debug(message)
+ },
+ }
+
+ try {
+ const url = new URL(input.request.url)
+ const body = await input.request.json()
+ logger.debug(JSON.stringify(body))
+ logger.metric({
+ is_tream: !!body.stream,
+ session: input.request.headers.get("x-opencode-session"),
+ request: input.request.headers.get("x-opencode-request"),
+ })
+ const MODEL = validateModel()
+ const apiKey = await authenticate()
+ const isFree = FREE_WORKSPACES.includes(apiKey?.workspaceID ?? "")
+ await checkCreditsAndLimit()
+ const providerName = selectProvider()
+ const providerData = MODEL.providers[providerName]
+ logger.metric({ provider: providerName })
+
+ // Request to model provider
+ const startTimestamp = Date.now()
+ const res = await fetch(path.posix.join(providerData.api, url.pathname.replace(/^\/zen/, "") + url.search), {
+ method: "POST",
+ headers: (() => {
+ const headers = input.request.headers
+ headers.delete("host")
+ headers.delete("content-length")
+ opts.setAuthHeader(headers, providerData.apiKey)
+ Object.entries(MODEL.headerMappings ?? {}).forEach(([k, v]) => {
+ headers.set(k, headers.get(v)!)
+ })
+ return headers
+ })(),
+ body: JSON.stringify({
+ ...(opts.modifyBody?.(body) ?? body),
+ model: providerData.model,
+ }),
+ })
+
+ // Scrub response headers
+ const resHeaders = new Headers()
+ const keepHeaders = ["content-type", "cache-control"]
+ for (const [k, v] of res.headers.entries()) {
+ if (keepHeaders.includes(k.toLowerCase())) {
+ resHeaders.set(k, v)
+ }
+ }
+
+ // Handle non-streaming response
+ if (!body.stream) {
+ const json = await res.json()
+ const body = JSON.stringify(json)
+ logger.metric({ response_length: body.length })
+ logger.debug(body)
+ await trackUsage(json.usage)
+ await reload()
+ return new Response(body, {
+ status: res.status,
+ statusText: res.statusText,
+ headers: resHeaders,
+ })
+ }
+
+ // Handle streaming response
+ const stream = new ReadableStream({
+ start(c) {
+ const reader = res.body?.getReader()
+ const decoder = new TextDecoder()
+ let buffer = ""
+ let responseLength = 0
+
+ function pump(): Promise<void> {
+ return (
+ reader?.read().then(async ({ done, value }) => {
+ if (done) {
+ logger.metric({ response_length: responseLength })
+ const usage = opts.getStreamUsage()
+ if (usage) {
+ await trackUsage(usage)
+ await reload()
+ }
+ c.close()
+ return
+ }
+
+ if (responseLength === 0) {
+ logger.metric({ time_to_first_byte: Date.now() - startTimestamp })
+ }
+ responseLength += value.length
+ buffer += decoder.decode(value, { stream: true })
+
+ const parts = buffer.split("\n\n")
+ buffer = parts.pop() ?? ""
+
+ for (const part of parts) {
+ logger.debug(part)
+ opts.onStreamPart(part.trim())
+ }
+
+ c.enqueue(value)
+
+ return pump()
+ }) || Promise.resolve()
+ )
+ }
+
+ return pump()
+ },
+ })
+
+ return new Response(stream, {
+ status: res.status,
+ statusText: res.statusText,
+ headers: resHeaders,
+ })
+
+ function validateModel() {
+ if (!(body.model in MODELS)) {
+ throw new ModelError(`Model ${body.model} not supported`)
+ }
+ const model = MODELS[body.model as keyof typeof MODELS]
+ logger.metric({ model: model.id })
+ return model
+ }
+
+ async function authenticate() {
+ try {
+ const apiKey = opts.parseApiKey(input.request.headers)
+ if (!apiKey) throw new AuthError("Missing API key.")
+
+ const key = await Database.use((tx) =>
+ tx
+ .select({
+ id: KeyTable.id,
+ workspaceID: KeyTable.workspaceID,
+ })
+ .from(KeyTable)
+ .where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
+ .then((rows) => rows[0]),
+ )
+
+ if (!key) throw new AuthError("Invalid API key.")
+ logger.metric({
+ api_key: key.id,
+ workspace: key.workspaceID,
+ })
+ return key
+ } catch (e) {
+ // ignore error if model does not require authentication
+ if (!MODEL.auth) return
+ throw e
+ }
+ }
+
+ async function checkCreditsAndLimit() {
+ if (!apiKey || !MODEL.auth || isFree) return
+
+ const billing = await Database.use((tx) =>
+ tx
+ .select({
+ balance: BillingTable.balance,
+ paymentMethodID: BillingTable.paymentMethodID,
+ monthlyLimit: BillingTable.monthlyLimit,
+ monthlyUsage: BillingTable.monthlyUsage,
+ timeMonthlyUsageUpdated: BillingTable.timeMonthlyUsageUpdated,
+ })
+ .from(BillingTable)
+ .where(eq(BillingTable.workspaceID, apiKey.workspaceID))
+ .then((rows) => rows[0]),
+ )
+
+ if (!billing.paymentMethodID) throw new CreditsError("No payment method")
+ if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
+ if (
+ billing.monthlyLimit &&
+ billing.monthlyUsage &&
+ billing.timeMonthlyUsageUpdated &&
+ billing.monthlyUsage >= centsToMicroCents(billing.monthlyLimit * 100)
+ ) {
+ const now = new Date()
+ const currentYear = now.getUTCFullYear()
+ const currentMonth = now.getUTCMonth()
+ const dateYear = billing.timeMonthlyUsageUpdated.getUTCFullYear()
+ const dateMonth = billing.timeMonthlyUsageUpdated.getUTCMonth()
+ if (currentYear === dateYear && currentMonth === dateMonth)
+ throw new MonthlyLimitError(`You have reached your monthly spending limit of $${billing.monthlyLimit}.`)
+ }
+ }
+
+ function selectProvider() {
+ const picks = Object.entries(MODEL.providers).flatMap(([name, provider]) =>
+ Array<string>(provider.weight ?? 1).fill(name),
+ )
+ return picks[Math.floor(Math.random() * picks.length)]
+ }
+
+ async function trackUsage(usage: any) {
+ const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
+ opts.normalizeUsage(usage)
+
+ const modelCost = typeof MODEL.cost === "function" ? MODEL.cost(usage) : MODEL.cost
+
+ const inputCost = modelCost.input * inputTokens * 100
+ const outputCost = modelCost.output * outputTokens * 100
+ const reasoningCost = (() => {
+ if (!reasoningTokens) return undefined
+ return modelCost.output * reasoningTokens * 100
+ })()
+ const cacheReadCost = (() => {
+ if (!cacheReadTokens) return undefined
+ if (!modelCost.cacheRead) return undefined
+ return modelCost.cacheRead * cacheReadTokens * 100
+ })()
+ const cacheWrite5mCost = (() => {
+ if (!cacheWrite5mTokens) return undefined
+ if (!modelCost.cacheWrite5m) return undefined
+ return modelCost.cacheWrite5m * cacheWrite5mTokens * 100
+ })()
+ const cacheWrite1hCost = (() => {
+ if (!cacheWrite1hTokens) return undefined
+ if (!modelCost.cacheWrite1h) return undefined
+ return modelCost.cacheWrite1h * cacheWrite1hTokens * 100
+ })()
+ const totalCostInCent =
+ inputCost +
+ outputCost +
+ (reasoningCost ?? 0) +
+ (cacheReadCost ?? 0) +
+ (cacheWrite5mCost ?? 0) +
+ (cacheWrite1hCost ?? 0)
+
+ logger.metric({
+ "tokens.input": inputTokens,
+ "tokens.output": outputTokens,
+ "tokens.reasoning": reasoningTokens,
+ "tokens.cache_read": cacheReadTokens,
+ "tokens.cache_write_5m": cacheWrite5mTokens,
+ "tokens.cache_write_1h": cacheWrite1hTokens,
+ "cost.input": Math.round(inputCost),
+ "cost.output": Math.round(outputCost),
+ "cost.reasoning": reasoningCost ? Math.round(reasoningCost) : undefined,
+ "cost.cache_read": cacheReadCost ? Math.round(cacheReadCost) : undefined,
+ "cost.cache_write_5m": cacheWrite5mCost ? Math.round(cacheWrite5mCost) : undefined,
+ "cost.cache_write_1h": cacheWrite1hCost ? Math.round(cacheWrite1hCost) : undefined,
+ "cost.total": Math.round(totalCostInCent),
+ })
+
+ if (!apiKey) return
+
+ const cost = isFree ? 0 : centsToMicroCents(totalCostInCent)
+ await Database.transaction(async (tx) => {
+ await tx.insert(UsageTable).values({
+ workspaceID: apiKey.workspaceID,
+ id: Identifier.create("usage"),
+ model: MODEL.id,
+ provider: providerName,
+ inputTokens,
+ outputTokens,
+ reasoningTokens,
+ cacheReadTokens,
+ cacheWrite5mTokens,
+ cacheWrite1hTokens,
+ cost,
+ })
+ await tx
+ .update(BillingTable)
+ .set({
+ balance: sql`${BillingTable.balance} - ${cost}`,
+ monthlyUsage: sql`
+ CASE
+ WHEN MONTH(${BillingTable.timeMonthlyUsageUpdated}) = MONTH(now()) AND YEAR(${BillingTable.timeMonthlyUsageUpdated}) = YEAR(now()) THEN ${BillingTable.monthlyUsage} + ${cost}
+ ELSE ${cost}
+ END
+ `,
+ timeMonthlyUsageUpdated: sql`now()`,
+ })
+ .where(eq(BillingTable.workspaceID, apiKey.workspaceID))
+ })
+
+ await Database.use((tx) =>
+ tx
+ .update(KeyTable)
+ .set({ timeUsed: sql`now()` })
+ .where(eq(KeyTable.id, apiKey.id)),
+ )
+ }
+
+ async function reload() {
+ if (!apiKey) return
+
+ const lock = await Database.use((tx) =>
+ tx
+ .update(BillingTable)
+ .set({
+ timeReloadLockedTill: sql`now() + interval 1 minute`,
+ })
+ .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()`)),
+ ),
+ ),
+ )
+ if (lock.rowsAffected === 0) return
+
+ await Actor.provide("system", { workspaceID: apiKey.workspaceID }, async () => {
+ await Billing.reload()
+ })
+ }
+ } catch (error: any) {
+ logger.metric({
+ "error.type": error.constructor.name,
+ "error.message": error.message,
+ })
+
+ // Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
+ if (
+ error instanceof AuthError ||
+ error instanceof CreditsError ||
+ error instanceof MonthlyLimitError ||
+ error instanceof ModelError
+ )
+ return new Response(
+ JSON.stringify({
+ type: "error",
+ error: { type: error.constructor.name, message: error.message },
+ }),
+ { status: 401 },
+ )
+
+ return new Response(
+ JSON.stringify({
+ type: "error",
+ error: {
+ type: "error",
+ message: error.message,
+ },
+ }),
+ { status: 500 },
+ )
+ }
+}
diff --git a/packages/console/app/src/routes/zen/v1/chat/completions.ts b/packages/console/app/src/routes/zen/v1/chat/completions.ts
new file mode 100644
index 000000000..801557324
--- /dev/null
+++ b/packages/console/app/src/routes/zen/v1/chat/completions.ts
@@ -0,0 +1,54 @@
+import type { APIEvent } from "@solidjs/start/server"
+import { handler } from "~/routes/zen/handler"
+
+type Usage = {
+ prompt_tokens?: number
+ completion_tokens?: number
+ total_tokens?: number
+ prompt_tokens_details?: {
+ text_tokens?: number
+ audio_tokens?: number
+ image_tokens?: number
+ cached_tokens?: number
+ }
+ completion_tokens_details?: {
+ reasoning_tokens?: number
+ audio_tokens?: number
+ accepted_prediction_tokens?: number
+ rejected_prediction_tokens?: number
+ }
+}
+
+export function POST(input: APIEvent) {
+ let usage: Usage
+ return handler(input, {
+ modifyBody: (body: any) => ({
+ ...body,
+ ...(body.stream ? { stream_options: { include_usage: true } } : {}),
+ }),
+ setAuthHeader: (headers: Headers, apiKey: string) => {
+ headers.set("authorization", `Bearer ${apiKey}`)
+ },
+ parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
+ onStreamPart: (chunk: string) => {
+ if (!chunk.startsWith("data: ")) return
+
+ let json
+ try {
+ json = JSON.parse(chunk.slice(6)) as { usage?: Usage }
+ } catch (e) {
+ return
+ }
+
+ if (!json.usage) return
+ usage = json.usage
+ },
+ getStreamUsage: () => usage,
+ normalizeUsage: (usage: Usage) => ({
+ inputTokens: usage.prompt_tokens ?? 0,
+ outputTokens: usage.completion_tokens ?? 0,
+ reasoningTokens: usage.completion_tokens_details?.reasoning_tokens ?? undefined,
+ cacheReadTokens: usage.prompt_tokens_details?.cached_tokens ?? undefined,
+ }),
+ })
+}
diff --git a/packages/console/app/src/routes/zen/v1/messages.ts b/packages/console/app/src/routes/zen/v1/messages.ts
new file mode 100644
index 000000000..1fd85d5c7
--- /dev/null
+++ b/packages/console/app/src/routes/zen/v1/messages.ts
@@ -0,0 +1,61 @@
+import type { APIEvent } from "@solidjs/start/server"
+import { handler } from "~/routes/zen/handler"
+
+type Usage = {
+ cache_creation?: {
+ ephemeral_5m_input_tokens?: number
+ ephemeral_1h_input_tokens?: number
+ }
+ cache_creation_input_tokens?: number
+ cache_read_input_tokens?: number
+ input_tokens?: number
+ output_tokens?: number
+ server_tool_use?: {
+ web_search_requests?: number
+ }
+}
+
+export function POST(input: APIEvent) {
+ let usage: Usage
+ return handler(input, {
+ modifyBody: (body: any) => ({
+ ...body,
+ service_tier: "standard_only",
+ }),
+ setAuthHeader: (headers: Headers, apiKey: string) => headers.set("x-api-key", apiKey),
+ parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined,
+ onStreamPart: (chunk: string) => {
+ const data = chunk.split("\n")[1]
+ if (!data.startsWith("data: ")) return
+
+ let json
+ try {
+ json = JSON.parse(data.slice(6)) as { usage?: Usage }
+ } catch (e) {
+ return
+ }
+
+ if (!json.usage) return
+ usage = {
+ ...usage,
+ ...json.usage,
+ cache_creation: {
+ ...usage?.cache_creation,
+ ...json.usage.cache_creation,
+ },
+ server_tool_use: {
+ ...usage?.server_tool_use,
+ ...json.usage.server_tool_use,
+ },
+ }
+ },
+ getStreamUsage: () => usage,
+ normalizeUsage: (usage: Usage) => ({
+ inputTokens: usage.input_tokens ?? 0,
+ outputTokens: usage.output_tokens ?? 0,
+ cacheReadTokens: usage.cache_read_input_tokens ?? undefined,
+ cacheWrite5mTokens: usage.cache_creation?.ephemeral_5m_input_tokens ?? undefined,
+ cacheWrite1hTokens: usage.cache_creation?.ephemeral_1h_input_tokens ?? undefined,
+ }),
+ })
+}
diff --git a/packages/console/app/src/routes/zen/v1/responses.ts b/packages/console/app/src/routes/zen/v1/responses.ts
new file mode 100644
index 000000000..486c129b9
--- /dev/null
+++ b/packages/console/app/src/routes/zen/v1/responses.ts
@@ -0,0 +1,52 @@
+import type { APIEvent } from "@solidjs/start/server"
+import { handler } from "~/routes/zen/handler"
+
+type Usage = {
+ input_tokens?: number
+ input_tokens_details?: {
+ cached_tokens?: number
+ }
+ output_tokens?: number
+ output_tokens_details?: {
+ reasoning_tokens?: number
+ }
+ total_tokens?: number
+}
+
+export function POST(input: APIEvent) {
+ let usage: Usage
+ return handler(input, {
+ setAuthHeader: (headers: Headers, apiKey: string) => {
+ headers.set("authorization", `Bearer ${apiKey}`)
+ },
+ parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
+ onStreamPart: (chunk: string) => {
+ const [event, data] = chunk.split("\n")
+ if (event !== "event: response.completed") return
+ if (!data.startsWith("data: ")) return
+
+ let json
+ try {
+ json = JSON.parse(data.slice(6)) as { response?: { usage?: Usage } }
+ } catch (e) {
+ return
+ }
+
+ if (!json.response?.usage) return
+ usage = json.response.usage
+ },
+ getStreamUsage: () => usage,
+ normalizeUsage: (usage: Usage) => {
+ const inputTokens = usage.input_tokens ?? 0
+ const outputTokens = usage.output_tokens ?? 0
+ const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined
+ const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined
+ return {
+ inputTokens: inputTokens - (cacheReadTokens ?? 0),
+ outputTokens: outputTokens - (reasoningTokens ?? 0),
+ reasoningTokens,
+ cacheReadTokens,
+ }
+ },
+ })
+}