diff --git a/bun.lock b/bun.lock
index bf44bd581d..a9e11c0450 100644
--- a/bun.lock
+++ b/bun.lock
@@ -192,6 +192,26 @@
"cloudflare": "5.2.0",
},
},
+ "packages/console/support": {
+ "name": "@opencode-ai/console-support",
+ "version": "0.0.0",
+ "dependencies": {
+ "@cloudflare/vite-plugin": "1.15.2",
+ "@opencode-ai/console-core": "workspace:*",
+ "@solidjs/meta": "catalog:",
+ "@solidjs/router": "catalog:",
+ "@solidjs/start": "catalog:",
+ "nitro": "3.0.1-alpha.1",
+ "solid-js": "catalog:",
+ "vite": "catalog:",
+ },
+ "devDependencies": {
+ "@types/bun": "catalog:",
+ "@typescript/native-preview": "catalog:",
+ "typescript": "catalog:",
+ "wrangler": "4.50.0",
+ },
+ },
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.15.10",
@@ -1631,6 +1651,8 @@
"@opencode-ai/console-resource": ["@opencode-ai/console-resource@workspace:packages/console/resource"],
+ "@opencode-ai/console-support": ["@opencode-ai/console-support@workspace:packages/console/support"],
+
"@opencode-ai/core": ["@opencode-ai/core@workspace:packages/core"],
"@opencode-ai/desktop": ["@opencode-ai/desktop@workspace:packages/desktop"],
diff --git a/packages/console/support/package.json b/packages/console/support/package.json
new file mode 100644
index 0000000000..8120b196e7
--- /dev/null
+++ b/packages/console/support/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "@opencode-ai/console-support",
+ "version": "0.0.0",
+ "type": "module",
+ "license": "MIT",
+ "scripts": {
+ "typecheck": "tsgo --noEmit",
+ "dev": "sst shell --stage production -- vite dev"
+ },
+ "dependencies": {
+ "@cloudflare/vite-plugin": "1.15.2",
+ "@opencode-ai/console-core": "workspace:*",
+ "@solidjs/meta": "catalog:",
+ "@solidjs/router": "catalog:",
+ "@solidjs/start": "catalog:",
+ "nitro": "3.0.1-alpha.1",
+ "solid-js": "catalog:",
+ "vite": "catalog:"
+ },
+ "devDependencies": {
+ "@types/bun": "catalog:",
+ "@typescript/native-preview": "catalog:",
+ "typescript": "catalog:",
+ "wrangler": "4.50.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+}
diff --git a/packages/console/support/src/app.css b/packages/console/support/src/app.css
new file mode 100644
index 0000000000..895d8091ab
--- /dev/null
+++ b/packages/console/support/src/app.css
@@ -0,0 +1,133 @@
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ background: #0d0d0d;
+ color: #e6e6e6;
+}
+
+main[data-page="support"] {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 2rem;
+}
+
+main[data-page="support"] h1 {
+ margin: 0 0 1rem;
+ font-size: 1.25rem;
+ font-weight: 500;
+}
+
+form[data-component="lookup"] {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 2rem;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+form[data-component="lookup"] input[type="text"] {
+ flex: 1;
+ min-width: 280px;
+ padding: 0.6rem 0.75rem;
+ border: 1px solid #2a2a2a;
+ background: #161616;
+ color: #e6e6e6;
+ border-radius: 4px;
+ font: inherit;
+}
+
+form[data-component="lookup"] label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ color: #b0b0b0;
+}
+
+form[data-component="lookup"] button {
+ padding: 0.6rem 1rem;
+ border: 1px solid #3a3a3a;
+ background: #1f1f1f;
+ color: #e6e6e6;
+ border-radius: 4px;
+ font: inherit;
+ cursor: pointer;
+}
+
+form[data-component="lookup"] button:hover {
+ background: #2a2a2a;
+}
+
+form[data-component="lookup"] button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+[data-component="error"] {
+ color: #ff6b6b;
+ background: #2a1414;
+ border: 1px solid #4a1f1f;
+ padding: 0.75rem 1rem;
+ border-radius: 4px;
+ margin-bottom: 1rem;
+}
+
+[data-component="section"] {
+ margin-bottom: 2rem;
+}
+
+[data-component="section"] h2 {
+ margin: 0 0 0.5rem;
+ font-size: 1rem;
+ font-weight: 500;
+ color: #b0b0b0;
+ border-bottom: 1px solid #2a2a2a;
+ padding-bottom: 0.4rem;
+}
+
+[data-component="section"] h3 {
+ margin: 1.75rem 0 0.5rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: #888;
+}
+
+[data-component="section"] h3:first-of-type {
+ margin-top: 0;
+}
+
+[data-component="section"] table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.8125rem;
+}
+
+[data-component="section"] th,
+[data-component="section"] td {
+ text-align: left;
+ padding: 0.4rem 0.6rem;
+ border-bottom: 1px solid #1f1f1f;
+ vertical-align: top;
+ white-space: nowrap;
+}
+
+[data-component="section"] th {
+ color: #888;
+ font-weight: 500;
+ background: #141414;
+}
+
+[data-component="section"] td a {
+ color: #6ea8fe;
+}
+
+[data-component="section"] [data-empty] {
+ color: #666;
+ font-style: italic;
+ padding: 0.4rem 0;
+}
diff --git a/packages/console/support/src/app.tsx b/packages/console/support/src/app.tsx
new file mode 100644
index 0000000000..9ae229d04b
--- /dev/null
+++ b/packages/console/support/src/app.tsx
@@ -0,0 +1,21 @@
+import { MetaProvider, Title } from "@solidjs/meta"
+import { Router } from "@solidjs/router"
+import { FileRoutes } from "@solidjs/start/router"
+import { Suspense } from "solid-js"
+import "./app.css"
+
+export default function App() {
+ return (
+ (
+
+ opencode support
+ {props.children}
+
+ )}
+ >
+
+
+ )
+}
diff --git a/packages/console/support/src/component/result.tsx b/packages/console/support/src/component/result.tsx
new file mode 100644
index 0000000000..3800a2c95e
--- /dev/null
+++ b/packages/console/support/src/component/result.tsx
@@ -0,0 +1,119 @@
+import { For, Show } from "solid-js"
+import type { LookupResult, WorkspaceSection } from "~/lib/lookup"
+
+export function Result(props: { data: LookupResult }) {
+ return (
+ <>
+
+ {(auth) => (
+
+ )}
+
+
+
+ {(workspaces) => (
+
+ )}
+
+
+ {(ws) => }
+ >
+ )
+}
+
+function WorkspaceView(props: { section: WorkspaceSection }) {
+ return (
+
+ {props.section.title}
+
+ Users
+
+
+ Billing
+
+
+ GO
+
+
+ Payments
+
+
+ 28-Day Usage
+
+
+ Disabled Models
+
+
+ )
+}
+
+function DataTable(props: { rows: Record[] }) {
+ const columns = () => {
+ const cols = new Set()
+ for (const row of props.rows) {
+ for (const key of Object.keys(row)) cols.add(key)
+ }
+ return [...cols]
+ }
+
+ return (
+ 0} fallback={(no data)
}>
+
+
+
+ {(col) => | {col} | }
+
+
+
+
+ {(row) => (
+
+ {(col) => | {renderCell(row[col])} | }
+
+ )}
+
+
+
+
+ )
+}
+
+function renderCell(value: unknown) {
+ if (value === null || value === undefined) return ""
+ if (typeof value === "string" && value.startsWith("https://")) {
+ return (
+
+ {value}
+
+ )
+ }
+ if (isLinkCell(value)) {
+ const external = value.__link.startsWith("http")
+ return (
+
+ {value.label}
+
+ )
+ }
+ if (typeof value === "object") return JSON.stringify(value)
+ return String(value)
+}
+
+function isLinkCell(value: unknown): value is { __link: string; label: string } {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ "__link" in value &&
+ typeof (value as { __link: unknown }).__link === "string"
+ )
+}
diff --git a/packages/console/support/src/entry-client.tsx b/packages/console/support/src/entry-client.tsx
new file mode 100644
index 0000000000..642deacf73
--- /dev/null
+++ b/packages/console/support/src/entry-client.tsx
@@ -0,0 +1,4 @@
+// @refresh reload
+import { mount, StartClient } from "@solidjs/start/client"
+
+mount(() => , document.getElementById("app")!)
diff --git a/packages/console/support/src/entry-server.tsx b/packages/console/support/src/entry-server.tsx
new file mode 100644
index 0000000000..752f8522f7
--- /dev/null
+++ b/packages/console/support/src/entry-server.tsx
@@ -0,0 +1,26 @@
+// @refresh reload
+import { createHandler, StartServer } from "@solidjs/start/server"
+
+export default createHandler(
+ () => (
+ (
+
+
+
+
+
+ {assets}
+
+
+ {children}
+ {scripts}
+
+
+ )}
+ />
+ ),
+ {
+ mode: "async",
+ },
+)
diff --git a/packages/console/support/src/global.d.ts b/packages/console/support/src/global.d.ts
new file mode 100644
index 0000000000..dc6f10c226
--- /dev/null
+++ b/packages/console/support/src/global.d.ts
@@ -0,0 +1 @@
+///
diff --git a/packages/console/support/src/lib/lookup.ts b/packages/console/support/src/lib/lookup.ts
new file mode 100644
index 0000000000..14c3978bf1
--- /dev/null
+++ b/packages/console/support/src/lib/lookup.ts
@@ -0,0 +1,480 @@
+"use server"
+
+import { Database, and, eq, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
+import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
+import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
+import {
+ BillingTable,
+ PaymentTable,
+ SubscriptionTable,
+ BlackPlans,
+ UsageTable,
+ LiteTable,
+} from "@opencode-ai/console-core/schema/billing.sql.js"
+import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
+import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
+import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
+import { BlackData } from "@opencode-ai/console-core/black.js"
+import { LiteData } from "@opencode-ai/console-core/lite.js"
+import { Subscription } from "@opencode-ai/console-core/subscription.js"
+import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
+import { getWeekBounds } from "@opencode-ai/console-core/util/date.js"
+
+export type LookupResult = {
+ identifier: string
+ auth?: Record[]
+ accountWorkspaces?: Record[]
+ workspaces: WorkspaceSection[]
+}
+
+export type WorkspaceSection = {
+ workspaceID: string
+ title: string
+ users: Record[]
+ billing: Record | null
+ go: Record[]
+ payments: Record[]
+ usage: Record[]
+ disabledModels: Record[]
+}
+
+export async function lookup(identifier: string): Promise {
+ if (!identifier) throw new Error("Identifier is required")
+
+ if (identifier.startsWith("wrk_")) {
+ const workspace = await loadWorkspace(identifier)
+ return { identifier, workspaces: [workspace] }
+ }
+
+ if (identifier.startsWith("key_")) {
+ const key = await Database.use((tx) =>
+ tx
+ .select()
+ .from(KeyTable)
+ .where(eq(KeyTable.id, identifier))
+ .then((rows) => rows[0]),
+ )
+ if (!key) throw new Error("API key not found")
+ const workspace = await loadWorkspace(key.workspaceID)
+ return { identifier, workspaces: [workspace] }
+ }
+
+ if (identifier.startsWith("sk-")) {
+ const key = await Database.use((tx) =>
+ tx
+ .select()
+ .from(KeyTable)
+ .where(eq(KeyTable.key, identifier))
+ .then((rows) => rows[0]),
+ )
+ if (!key) throw new Error("API key not found")
+ const workspace = await loadWorkspace(key.workspaceID)
+ return { identifier, workspaces: [workspace] }
+ }
+
+ // Treat as email
+ const authData = await Database.use((tx) =>
+ tx.select().from(AuthTable).where(eq(AuthTable.subject, identifier)),
+ )
+ if (authData.length === 0) throw new Error("Email not found")
+
+ const accountID = authData[0].accountID
+ const auth = await Database.use((tx) =>
+ tx.select().from(AuthTable).where(eq(AuthTable.accountID, accountID)),
+ )
+
+ const accountWorkspaces = await Database.use((tx) =>
+ tx
+ .select({
+ userID: UserTable.id,
+ workspaceID: UserTable.workspaceID,
+ workspaceName: WorkspaceTable.name,
+ balance: BillingTable.balance,
+ role: UserTable.role,
+ black: SubscriptionTable.timeCreated,
+ lite: LiteTable.timeCreated,
+ })
+ .from(UserTable)
+ .rightJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
+ .leftJoin(BillingTable, eq(BillingTable.workspaceID, WorkspaceTable.id))
+ .leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
+ .leftJoin(LiteTable, eq(LiteTable.userID, UserTable.id))
+ .where(eq(UserTable.accountID, accountID))
+ .then((rows) =>
+ rows.map((row) => ({
+ workspaceName: row.workspaceID
+ ? { __link: `#workspace-${row.workspaceID}`, label: row.workspaceName }
+ : row.workspaceName,
+ userID: row.userID,
+ workspaceID: row.workspaceID,
+ balance: formatMicroCents(row.balance) ?? "$0.00",
+ role: row.role,
+ black: formatDate(row.black),
+ lite: formatDate(row.lite),
+ })),
+ ),
+ )
+
+ const workspaces: WorkspaceSection[] = []
+ for (const w of accountWorkspaces) {
+ if (!w.workspaceID) continue
+ workspaces.push(await loadWorkspace(w.workspaceID))
+ }
+
+ return {
+ identifier,
+ auth: auth.map((row) => ({
+ provider: row.provider,
+ subject: row.subject,
+ accountID: row.accountID,
+ })),
+ accountWorkspaces,
+ workspaces,
+ }
+}
+
+async function loadWorkspace(workspaceID: string): Promise {
+ const workspace = await Database.use((tx) =>
+ tx
+ .select()
+ .from(WorkspaceTable)
+ .where(eq(WorkspaceTable.id, workspaceID))
+ .then((rows) => rows[0]),
+ )
+ if (!workspace) throw new Error(`Workspace ${workspaceID} not found`)
+
+ const users = await Database.use((tx) =>
+ tx
+ .select({
+ authEmail: AuthTable.subject,
+ inviteEmail: UserTable.email,
+ role: UserTable.role,
+ timeSeen: UserTable.timeSeen,
+ monthlyLimit: UserTable.monthlyLimit,
+ monthlyUsage: UserTable.monthlyUsage,
+ timeDeleted: UserTable.timeDeleted,
+ fixedUsage: SubscriptionTable.fixedUsage,
+ rollingUsage: SubscriptionTable.rollingUsage,
+ timeFixedUpdated: SubscriptionTable.timeFixedUpdated,
+ timeRollingUpdated: SubscriptionTable.timeRollingUpdated,
+ timeSubscriptionCreated: SubscriptionTable.timeCreated,
+ subscription: BillingTable.subscription,
+ })
+ .from(UserTable)
+ .innerJoin(BillingTable, eq(BillingTable.workspaceID, workspace.id))
+ .leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
+ .leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
+ .where(eq(UserTable.workspaceID, workspace.id))
+ .then((rows) =>
+ rows.map((row) => {
+ const subStatus = getSubscriptionStatus(row)
+ return {
+ email: (row.timeDeleted ? "[deleted] " : "") + (row.authEmail ?? row.inviteEmail),
+ role: row.role,
+ timeSeen: formatDate(row.timeSeen),
+ monthly: formatMonthlyUsage(row.monthlyUsage, row.monthlyLimit),
+ subscribed: formatDate(row.timeSubscriptionCreated),
+ subWeekly: subStatus.weekly,
+ subRolling: subStatus.rolling,
+ rateLimited: subStatus.rateLimited,
+ retryIn: subStatus.retryIn,
+ }
+ }),
+ ),
+ )
+
+ const billing = await Database.use((tx) =>
+ tx
+ .select({
+ balance: BillingTable.balance,
+ customerID: BillingTable.customerID,
+ reload: BillingTable.reload,
+ blackSubscriptionID: BillingTable.subscriptionID,
+ blackSubscription: {
+ plan: BillingTable.subscriptionPlan,
+ booked: BillingTable.timeSubscriptionBooked,
+ enrichment: BillingTable.subscription,
+ },
+ timeBlackSubscriptionSelected: BillingTable.timeSubscriptionSelected,
+ liteSubscriptionID: BillingTable.liteSubscriptionID,
+ })
+ .from(BillingTable)
+ .where(eq(BillingTable.workspaceID, workspace.id))
+ .then(
+ (rows) =>
+ rows.map((row) => ({
+ balance: `$${(row.balance / 100000000).toFixed(2)}`,
+ reload: row.reload ? "yes" : "no",
+ customerID: row.customerID,
+ GO: row.liteSubscriptionID,
+ Black: row.blackSubscriptionID
+ ? [
+ `Black ${row.blackSubscription.enrichment!.plan}`,
+ row.blackSubscription.enrichment!.seats > 1
+ ? `X ${row.blackSubscription.enrichment!.seats} seats`
+ : "",
+ row.blackSubscription.enrichment!.coupon
+ ? `(coupon: ${row.blackSubscription.enrichment!.coupon})`
+ : "",
+ `(ref: ${row.blackSubscriptionID})`,
+ ].join(" ")
+ : row.blackSubscription.booked
+ ? `Waitlist ${row.blackSubscription.plan} plan${row.timeBlackSubscriptionSelected ? " (selected)" : ""}`
+ : undefined,
+ }))[0] ?? null,
+ ),
+ )
+
+ const liteLimits = LiteData.getLimits()
+ const go = await Database.use((tx) =>
+ tx
+ .select({
+ userID: LiteTable.userID,
+ userEmail: UserTable.email,
+ authEmail: AuthTable.subject,
+ rollingUsage: LiteTable.rollingUsage,
+ weeklyUsage: LiteTable.weeklyUsage,
+ monthlyUsage: LiteTable.monthlyUsage,
+ timeRollingUpdated: LiteTable.timeRollingUpdated,
+ timeWeeklyUpdated: LiteTable.timeWeeklyUpdated,
+ timeMonthlyUpdated: LiteTable.timeMonthlyUpdated,
+ timeCreated: LiteTable.timeCreated,
+ useBalance: BillingTable.lite,
+ })
+ .from(LiteTable)
+ .innerJoin(BillingTable, eq(BillingTable.workspaceID, LiteTable.workspaceID))
+ .leftJoin(UserTable, eq(UserTable.id, LiteTable.userID))
+ .leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
+ .where(and(eq(LiteTable.workspaceID, workspace.id), isNull(LiteTable.timeDeleted)))
+ .then((rows) =>
+ rows.map((row) => {
+ const rolling = Subscription.analyzeRollingUsage({
+ limit: liteLimits.rollingLimit,
+ window: liteLimits.rollingWindow,
+ usage: row.rollingUsage ?? 0,
+ timeUpdated: row.timeRollingUpdated ?? new Date(),
+ })
+ const weekly = Subscription.analyzeWeeklyUsage({
+ limit: liteLimits.weeklyLimit,
+ usage: row.weeklyUsage ?? 0,
+ timeUpdated: row.timeWeeklyUpdated ?? new Date(),
+ })
+ const monthly = Subscription.analyzeMonthlyUsage({
+ limit: liteLimits.monthlyLimit,
+ usage: row.monthlyUsage ?? 0,
+ timeUpdated: row.timeMonthlyUpdated ?? new Date(),
+ timeSubscribed: row.timeCreated,
+ })
+ return {
+ email: row.authEmail ?? row.userEmail ?? row.userID,
+ subscribed: formatDate(row.timeCreated),
+ useBalance: row.useBalance?.useBalance ? "yes" : "no",
+ rolling: formatLiteUsage(rolling),
+ weekly: formatLiteUsage(weekly),
+ monthly: formatLiteUsage(monthly),
+ }
+ }),
+ ),
+ )
+
+ const payments = await Database.use((tx) =>
+ tx
+ .select({
+ amount: PaymentTable.amount,
+ paymentID: PaymentTable.paymentID,
+ invoiceID: PaymentTable.invoiceID,
+ customerID: PaymentTable.customerID,
+ timeCreated: PaymentTable.timeCreated,
+ timeRefunded: PaymentTable.timeRefunded,
+ })
+ .from(PaymentTable)
+ .where(eq(PaymentTable.workspaceID, workspace.id))
+ .orderBy(sql`${PaymentTable.timeCreated} DESC`)
+ .limit(100)
+ .then((rows) =>
+ rows.map((row) => ({
+ amount: `$${(row.amount / 100000000).toFixed(2)}`,
+ paymentID: row.paymentID
+ ? `https://dashboard.stripe.com/acct_1RszBH2StuRr0lbX/payments/${row.paymentID}`
+ : null,
+ invoiceID: row.invoiceID,
+ customerID: row.customerID,
+ timeCreated: formatDate(row.timeCreated),
+ timeRefunded: formatDate(row.timeRefunded),
+ })),
+ ),
+ )
+
+ const planExpr = sql`JSON_UNQUOTE(JSON_EXTRACT(${UsageTable.enrichment}, '$.plan'))`
+ const usage = await Database.use((tx) =>
+ tx
+ .select({
+ date: sql`DATE(${UsageTable.timeCreated})`.as("date"),
+ freeRequests: sql`SUM(CASE WHEN ${UsageTable.cost} = 0 THEN 1 ELSE 0 END)`.as("free_requests"),
+ goRequests: sql`SUM(CASE WHEN ${planExpr} = 'lite' THEN 1 ELSE 0 END)`.as("go_requests"),
+ goCost: sql`SUM(CASE WHEN ${planExpr} = 'lite' THEN ${UsageTable.cost} ELSE 0 END)`.as("go_cost"),
+ apiRequests: sql`SUM(CASE WHEN ${planExpr} IS NULL AND ${UsageTable.cost} > 0 THEN 1 ELSE 0 END)`.as("api_requests"),
+ apiCost: sql`SUM(CASE WHEN ${planExpr} IS NULL AND ${UsageTable.cost} > 0 THEN ${UsageTable.cost} ELSE 0 END)`.as("api_cost"),
+ })
+ .from(UsageTable)
+ .where(
+ and(
+ eq(UsageTable.workspaceID, workspace.id),
+ sql`${UsageTable.timeCreated} >= DATE_SUB(NOW(), INTERVAL 28 DAY)`,
+ ),
+ )
+ .groupBy(sql`DATE(${UsageTable.timeCreated})`)
+ .orderBy(sql`DATE(${UsageTable.timeCreated}) DESC`)
+ .then((rows) => {
+ const totals = rows.reduce(
+ (acc, r) => ({
+ freeRequests: acc.freeRequests + Number(r.freeRequests),
+ goRequests: acc.goRequests + Number(r.goRequests),
+ goCost: acc.goCost + Number(r.goCost),
+ apiRequests: acc.apiRequests + Number(r.apiRequests),
+ apiCost: acc.apiCost + Number(r.apiCost),
+ }),
+ { freeRequests: 0, goRequests: 0, goCost: 0, apiRequests: 0, apiCost: 0 },
+ )
+ const mapped: Record[] = rows.map((row) => ({
+ date: row.date,
+ freeRequests: Number(row.freeRequests),
+ goRequests: Number(row.goRequests),
+ goCost: formatMicroCents(Number(row.goCost)) ?? "$0.00",
+ apiRequests: Number(row.apiRequests),
+ apiCost: formatMicroCents(Number(row.apiCost)) ?? "$0.00",
+ }))
+ if (mapped.length > 0) {
+ mapped.push({
+ date: "TOTAL",
+ freeRequests: totals.freeRequests,
+ goRequests: totals.goRequests,
+ goCost: formatMicroCents(totals.goCost) ?? "$0.00",
+ apiRequests: totals.apiRequests,
+ apiCost: formatMicroCents(totals.apiCost) ?? "$0.00",
+ })
+ }
+ return mapped
+ }),
+ )
+
+ const disabledModels = await Database.use((tx) =>
+ tx
+ .select({
+ model: ModelTable.model,
+ timeCreated: ModelTable.timeCreated,
+ })
+ .from(ModelTable)
+ .where(eq(ModelTable.workspaceID, workspace.id))
+ .orderBy(sql`${ModelTable.timeCreated} DESC`)
+ .then((rows) =>
+ rows.map((row) => ({
+ model: row.model,
+ timeCreated: formatDate(row.timeCreated),
+ })),
+ ),
+ )
+
+ return {
+ workspaceID: workspace.id,
+ title: `Workspace "${workspace.name}" (${workspace.id})`,
+ users,
+ billing,
+ go,
+ payments,
+ usage,
+ disabledModels,
+ }
+}
+
+function formatLiteUsage(usage: { status: "ok" | "rate-limited"; usagePercent: number; resetInSec: number }) {
+ const reset = formatResetTime(usage.resetInSec)
+ const status = usage.status === "rate-limited" ? " [limited]" : ""
+ return `${usage.usagePercent}% (resets in ${reset})${status}`
+}
+
+function formatResetTime(seconds: number) {
+ if (seconds <= 0) return "now"
+ const days = Math.floor(seconds / 86400)
+ if (days >= 1) return `${days}d`
+ const hours = Math.floor(seconds / 3600)
+ if (hours >= 1) {
+ const minutes = Math.floor((seconds % 3600) / 60)
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`
+ }
+ const minutes = Math.max(1, Math.ceil(seconds / 60))
+ return `${minutes}m`
+}
+
+function formatMicroCents(value: number | null | undefined) {
+ if (value === null || value === undefined) return null
+ return `$${(value / 100000000).toFixed(2)}`
+}
+
+function formatDate(value: Date | null | undefined) {
+ if (!value) return null
+ return value.toISOString().split("T")[0]
+}
+
+function formatMonthlyUsage(usage: number | null | undefined, limit: number | null | undefined) {
+ const usageText = formatMicroCents(usage) ?? "$0.00"
+ if (limit === null || limit === undefined) return `${usageText} / no limit`
+ return `${usageText} / $${limit.toFixed(2)}`
+}
+
+function formatRetryTime(seconds: number) {
+ const days = Math.floor(seconds / 86400)
+ if (days >= 1) return `${days} day${days > 1 ? "s" : ""}`
+ const hours = Math.floor(seconds / 3600)
+ const minutes = Math.ceil((seconds % 3600) / 60)
+ if (hours >= 1) return `${hours}hr ${minutes}min`
+ return `${minutes}min`
+}
+
+function getSubscriptionStatus(row: {
+ subscription: {
+ plan: (typeof BlackPlans)[number]
+ } | null
+ timeSubscriptionCreated: Date | null
+ fixedUsage: number | null
+ rollingUsage: number | null
+ timeFixedUpdated: Date | null
+ timeRollingUpdated: Date | null
+}) {
+ if (!row.timeSubscriptionCreated || !row.subscription) {
+ return { weekly: null, rolling: null, rateLimited: null, retryIn: null }
+ }
+
+ const black = BlackData.getLimits({ plan: row.subscription.plan })
+ const now = new Date()
+ const week = getWeekBounds(now)
+
+ const fixedLimit = black.fixedLimit ? centsToMicroCents(black.fixedLimit * 100) : null
+ const rollingLimit = black.rollingLimit ? centsToMicroCents(black.rollingLimit * 100) : null
+ const rollingWindowMs = (black.rollingWindow ?? 5) * 3600 * 1000
+
+ const currentWeekly =
+ row.fixedUsage && row.timeFixedUpdated && row.timeFixedUpdated >= week.start ? row.fixedUsage : 0
+
+ const windowStart = new Date(now.getTime() - rollingWindowMs)
+ const currentRolling =
+ row.rollingUsage && row.timeRollingUpdated && row.timeRollingUpdated >= windowStart ? row.rollingUsage : 0
+
+ const isWeeklyLimited = fixedLimit !== null && currentWeekly >= fixedLimit
+ const isRollingLimited = rollingLimit !== null && currentRolling >= rollingLimit
+
+ const retryIn = isWeeklyLimited
+ ? formatRetryTime(Math.ceil((week.end.getTime() - now.getTime()) / 1000))
+ : isRollingLimited && row.timeRollingUpdated
+ ? formatRetryTime(
+ Math.ceil((row.timeRollingUpdated.getTime() + rollingWindowMs - now.getTime()) / 1000),
+ )
+ : null
+
+ return {
+ weekly: fixedLimit !== null ? `${formatMicroCents(currentWeekly)} / $${black.fixedLimit}` : null,
+ rolling: rollingLimit !== null ? `${formatMicroCents(currentRolling)} / $${black.rollingLimit}` : null,
+ rateLimited: isWeeklyLimited || isRollingLimited ? "yes" : "no",
+ retryIn,
+ }
+}
diff --git a/packages/console/support/src/routes/index.tsx b/packages/console/support/src/routes/index.tsx
new file mode 100644
index 0000000000..8038788ce3
--- /dev/null
+++ b/packages/console/support/src/routes/index.tsx
@@ -0,0 +1,22 @@
+import { Title } from "@solidjs/meta"
+
+export default function SupportPage() {
+ return (
+
+ opencode support — lookup user
+ Lookup user
+
+
+
+ )
+}
diff --git a/packages/console/support/src/routes/lookup.tsx b/packages/console/support/src/routes/lookup.tsx
new file mode 100644
index 0000000000..7e977b7f95
--- /dev/null
+++ b/packages/console/support/src/routes/lookup.tsx
@@ -0,0 +1,39 @@
+import { Title } from "@solidjs/meta"
+import { createAsync, query, useSearchParams, type RouteDefinition } from "@solidjs/router"
+import { Show } from "solid-js"
+import { ErrorBoundary } from "solid-js"
+import { Result } from "~/component/result"
+import { lookup } from "~/lib/lookup"
+
+const getLookup = query(async (identifier: string) => {
+ "use server"
+ return lookup(identifier)
+}, "support.lookup")
+
+export const route: RouteDefinition = {
+ preload: ({ location }) => {
+ const identifier = new URLSearchParams(location.search).get("identifier")?.trim()
+ if (identifier) void getLookup(identifier)
+ },
+}
+
+export default function LookupPage() {
+ const [params] = useSearchParams()
+ const identifier = () => String(params.identifier ?? "").trim()
+ const data = createAsync(() => (identifier() ? getLookup(identifier()) : Promise.resolve(undefined)))
+
+ return (
+
+ opencode support — {identifier() || "lookup"}
+ Lookup: {identifier() || "(no identifier)"}
+
+ Provide an `identifier` query parameter.}>
+ {(err as Error).message}
}>
+ Loading...}>
+ {(result) => }
+
+
+
+
+ )
+}
diff --git a/packages/console/support/sst-env.d.ts b/packages/console/support/sst-env.d.ts
new file mode 100644
index 0000000000..301538ccb2
--- /dev/null
+++ b/packages/console/support/sst-env.d.ts
@@ -0,0 +1,10 @@
+/* This file is auto-generated by SST. Do not edit. */
+/* tslint:disable */
+/* eslint-disable */
+/* deno-fmt-ignore-file */
+/* biome-ignore-all lint: auto-generated */
+
+///
+
+import "sst"
+export {}
\ No newline at end of file
diff --git a/packages/console/support/tsconfig.json b/packages/console/support/tsconfig.json
new file mode 100644
index 0000000000..0f96f182ce
--- /dev/null
+++ b/packages/console/support/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true,
+ "jsx": "preserve",
+ "jsxImportSource": "solid-js",
+ "allowJs": true,
+ "strict": true,
+ "noEmit": true,
+ "types": ["vite/client", "bun"],
+ "isolatedModules": true,
+ "paths": {
+ "~/*": ["./src/*"]
+ }
+ }
+}
diff --git a/packages/console/support/vite.config.ts b/packages/console/support/vite.config.ts
new file mode 100644
index 0000000000..3b013e9901
--- /dev/null
+++ b/packages/console/support/vite.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig, PluginOption } from "vite"
+import { solidStart } from "@solidjs/start/config"
+import { nitro } from "nitro/vite"
+
+export default defineConfig({
+ plugins: [
+ solidStart() as PluginOption,
+ nitro({
+ compatibilityDate: "2024-09-19",
+ preset: "cloudflare_module",
+ cloudflare: {
+ nodeCompat: true,
+ },
+ }),
+ ],
+ server: {
+ allowedHosts: true,
+ },
+ build: {
+ rollupOptions: {
+ external: ["cloudflare:workers"],
+ },
+ minify: false,
+ },
+})