diff options
Diffstat (limited to 'packages/console/app/src/routes')
34 files changed, 242 insertions, 127 deletions
diff --git a/packages/console/app/src/routes/auth/status.ts b/packages/console/app/src/routes/auth/status.ts index 215cae698..ed522d740 100644 --- a/packages/console/app/src/routes/auth/status.ts +++ b/packages/console/app/src/routes/auth/status.ts @@ -1,7 +1,7 @@ import { APIEvent } from "@solidjs/start" import { useAuthSession } from "~/context/auth" -export async function GET(input: APIEvent) { +export async function GET(_input: APIEvent) { const session = await useAuthSession() return Response.json(session.data) } diff --git a/packages/console/app/src/routes/bench/[id].tsx b/packages/console/app/src/routes/bench/[id].tsx index dd96bcbbc..c6d10826b 100644 --- a/packages/console/app/src/routes/bench/[id].tsx +++ b/packages/console/app/src/routes/bench/[id].tsx @@ -1,7 +1,7 @@ import { Title } from "@solidjs/meta" import { createAsync, query, useParams } from "@solidjs/router" import { createSignal, For, Show } from "solid-js" -import { Database, desc, eq } from "@opencode-ai/console-core/drizzle/index.js" +import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js" import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js" import { useI18n } from "~/context/i18n" diff --git a/packages/console/app/src/routes/black/subscribe/[plan].tsx b/packages/console/app/src/routes/black/subscribe/[plan].tsx index 19b56eabe..52e640876 100644 --- a/packages/console/app/src/routes/black/subscribe/[plan].tsx +++ b/packages/console/app/src/routes/black/subscribe/[plan].tsx @@ -298,7 +298,7 @@ export default function BlackSubscribe() { // Resolve stripe promise once createEffect(() => { - stripePromise.then((s) => { + void stripePromise.then((s) => { if (s) setStripe(s) }) }) diff --git a/packages/console/app/src/routes/debug/index.ts b/packages/console/app/src/routes/debug/index.ts index 2bdd269e7..4bfb63394 100644 --- a/packages/console/app/src/routes/debug/index.ts +++ b/packages/console/app/src/routes/debug/index.ts @@ -3,7 +3,7 @@ import { json } from "@solidjs/router" import { Database } from "@opencode-ai/console-core/drizzle/index.js" import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" -export async function GET(evt: APIEvent) { +export async function GET(_evt: APIEvent) { return json({ data: await Database.use(async (tx) => { const result = await tx.$count(UserTable) diff --git a/packages/console/app/src/routes/download/[channel]/[platform].ts b/packages/console/app/src/routes/download/[channel]/[platform].ts index e9b3f23e7..082dfe707 100644 --- a/packages/console/app/src/routes/download/[channel]/[platform].ts +++ b/packages/console/app/src/routes/download/[channel]/[platform].ts @@ -1,7 +1,7 @@ import type { APIEvent } from "@solidjs/start" import type { DownloadPlatform } from "../types" -const assetNames: Record<string, string> = { +const prodAssetNames: Record<string, string> = { "darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg", "darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg", "windows-x64-nsis": "opencode-desktop-windows-x64.exe", @@ -10,6 +10,15 @@ const assetNames: Record<string, string> = { "linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm", } satisfies Record<DownloadPlatform, string> +const betaAssetNames: Record<string, string> = { + "darwin-aarch64-dmg": "opencode-electron-mac-arm64.dmg", + "darwin-x64-dmg": "opencode-electron-mac-x64.dmg", + "windows-x64-nsis": "opencode-electron-win-x64.exe", + "linux-x64-deb": "opencode-electron-linux-amd64.deb", + "linux-x64-appimage": "opencode-electron-linux-x86_64.AppImage", + "linux-x64-rpm": "opencode-electron-linux-x86_64.rpm", +} satisfies Record<DownloadPlatform, string> + // Doing this on the server lets us preserve the original name for platforms we don't care to rename for const downloadNames: Record<string, string> = { "darwin-aarch64-dmg": "OpenCode Desktop.dmg", @@ -18,7 +27,7 @@ const downloadNames: Record<string, string> = { } satisfies { [K in DownloadPlatform]?: string } export async function GET({ params: { platform, channel } }: APIEvent) { - const assetName = assetNames[platform] + const assetName = channel === "stable" ? prodAssetNames[platform] : betaAssetNames[platform] if (!assetName) return new Response(null, { status: 404 }) const resp = await fetch( @@ -37,5 +46,5 @@ export async function GET({ params: { platform, channel } }: APIEvent) { const headers = new Headers(resp.headers) if (downloadName) headers.set("content-disposition", `attachment; filename="${downloadName}"`) - return new Response(resp.body, { ...resp, headers }) + return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers }) } diff --git a/packages/console/app/src/routes/download/index.css b/packages/console/app/src/routes/download/index.css index 705302616..b2176c34a 100644 --- a/packages/console/app/src/routes/download/index.css +++ b/packages/console/app/src/routes/download/index.css @@ -316,7 +316,8 @@ /* Download Hero Section */ [data-component="download-hero"] { - display: grid; + /* display: grid; */ + display: none; grid-template-columns: 260px 1fr; gap: 4rem; padding-bottom: 2rem; diff --git a/packages/console/app/src/routes/download/index.tsx b/packages/console/app/src/routes/download/index.tsx index 0278d8622..b5c202a5e 100644 --- a/packages/console/app/src/routes/download/index.tsx +++ b/packages/console/app/src/routes/download/index.tsx @@ -77,7 +77,7 @@ export default function Download() { const handleCopyClick = (command: string) => (event: Event) => { const button = event.currentTarget as HTMLButtonElement - navigator.clipboard.writeText(command) + void navigator.clipboard.writeText(command) button.setAttribute("data-copied", "") setTimeout(() => { button.removeAttribute("data-copied") diff --git a/packages/console/app/src/routes/go/index.css b/packages/console/app/src/routes/go/index.css index 68f27a1bb..cd4406f25 100644 --- a/packages/console/app/src/routes/go/index.css +++ b/packages/console/app/src/routes/go/index.css @@ -1017,6 +1017,30 @@ body { [data-slot="faq-answer"] { margin-left: 40px; margin-bottom: 32px; + + [data-slot="faq-models"] { + margin-top: 16px; + overflow-x: auto; + + table { + width: 100%; + min-width: 28rem; + border-collapse: collapse; + } + + th, + td { + padding: 12px 16px; + text-align: left; + white-space: nowrap; + } + + th { + background: var(--color-background-weak); + color: var(--color-text-strong); + font-weight: 500; + } + } } } diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index e4628fe11..82b3caf66 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -1,5 +1,5 @@ import "./index.css" -import { createAsync, query, redirect } from "@solidjs/router" +import { createAsync, query } from "@solidjs/router" import { Title, Meta } from "@solidjs/meta" import { For, createMemo, createSignal, onCleanup, onMount } from "solid-js" //import { HttpHeader } from "@solidjs/start" @@ -12,7 +12,7 @@ import { Footer } from "~/component/footer" import { Header } from "~/component/header" import { config } from "~/config" import { getLastSeenWorkspaceID } from "../workspace/common" -import { IconMiniMax, IconMiMo, IconZai } from "~/component/icon" +import { IconMiniMax, IconMiMo, IconZai, IconAlibaba } from "~/component/icon" import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" import { LocaleLinks } from "~/component/locale-links" @@ -22,6 +22,18 @@ const checkLoggedIn = query(async () => { return await getLastSeenWorkspaceID().catch(() => undefined) }, "checkLoggedIn.get") +const models = [ + { name: "GLM-5.1", provider: "DeepInfra, Z.ai" }, + { name: "GLM-5", provider: "DeepInfra, Z.ai" }, + { name: "Kimi K2.5", provider: "Moonshot AI" }, + { name: "MiMo-V2-Pro", provider: "Xiaomi MiMo" }, + { name: "MiMo-V2-Omni", provider: "Xiaomi MiMo" }, + { name: "Qwen3.5 Plus", provider: "Alibaba Cloud Model Studio" }, + { name: "Qwen3.6 Plus", provider: "Alibaba Cloud Model Studio" }, + { name: "MiniMax M2.7", provider: "MiniMax" }, + { name: "MiniMax M2.5", provider: "MiniMax" }, +] + function LimitsGraph(props: { href: string }) { let root!: HTMLElement const [visible, setVisible] = createSignal(false) @@ -44,13 +56,13 @@ function LimitsGraph(props: { href: string }) { }) const free = 200 - const models = [ + const graph = [ { id: "glm-5.1", name: "GLM-5.1", req: 880, d: "100ms" }, - { id: "glm-5", name: "GLM-5", req: 1150, d: "120ms" }, { id: "mimo-v2-pro", name: "MiMo-V2-Pro", req: 1290, d: "150ms" }, { id: "kimi", name: "Kimi K2.5", req: 1850, d: "240ms" }, - { id: "minimax-m2.7", name: "MiniMax M2.7", req: 14000, d: "330ms" }, - { id: "minimax-m2.5", name: "MiniMax M2.5", req: 20000, d: "360ms" }, + { id: "qwen3.6-plus", name: "Qwen3.6 Plus", req: 3300, d: "280ms" }, + { id: "minimax-m2.7", name: "MiniMax M2.7", req: 3400, d: "300ms" }, + { id: "qwen3.5-plus", name: "Qwen3.5 Plus", req: 10200, d: "360ms" }, ] const w = 720 @@ -62,7 +74,7 @@ function LimitsGraph(props: { href: string }) { const plot = w - left - right const ratio = (n: number) => n / free - const rmax = Math.max(1, ...models.map((m) => ratio(m.req))) + const rmax = Math.max(1, ...graph.map((m) => ratio(m.req))) const log = (n: number) => Math.log10(Math.max(n, 1)) const base = 24 const p = 1.8 @@ -93,7 +105,7 @@ function LimitsGraph(props: { href: string }) { const sep = bh + 40 const fy = top + 22 const gy = (i: number) => fy + sep + step * i - const my = models.length < 2 ? gy(0) : (gy(0) + gy(models.length - 1)) / 2 + const my = graph.length < 2 ? gy(0) : (gy(0) + gy(graph.length - 1)) / 2 const px = (n: number) => `${(n / w) * 100}%` const py = (n: number) => `${(n / h) * 100}%` const lx = px(left - 16) @@ -132,7 +144,7 @@ function LimitsGraph(props: { href: string }) { <rect x={left} y={fy - bh / 2} width={Math.max(0, x(1) - left)} height={bh} data-bar data-kind="free" /> </g> - <For each={models}> + <For each={graph}> {(m, i) => ( <g style={{ "--d": m.d } as any}> <rect @@ -174,7 +186,7 @@ function LimitsGraph(props: { href: string }) { <span data-value>{free.toLocaleString()}</span> <span data-name>{i18n.t("go.graph.freePill")}</span> </span> - <For each={models}> + <For each={graph}> {(m, i) => ( <span data-item @@ -301,6 +313,9 @@ export default function Home() { <IconZai width="24" height="24" /> </div> <div> + <IconAlibaba width="24" height="24" /> + </div> + <div> <IconMiMo width="24" height="24" /> </div> {/* @@ -420,7 +435,31 @@ export default function Home() { <Faq question={i18n.t("go.faq.q1")}>{i18n.t("go.faq.a1")}</Faq> </li> <li> - <Faq question={i18n.t("go.faq.q2")}>{i18n.t("go.faq.a2")}</Faq> + <Faq question={i18n.t("go.faq.q2")}> + {i18n.t("go.faq.a2")} + {language.locale() === "en" && ( + <div data-slot="faq-models"> + <table> + <thead> + <tr> + <th>{i18n.t("workspace.models.table.model")}</th> + <th>{i18n.t("workspace.providers.table.provider")}</th> + </tr> + </thead> + <tbody> + <For each={models}> + {(m) => ( + <tr> + <td>{m.name}</td> + <td>{m.provider}</td> + </tr> + )} + </For> + </tbody> + </table> + </div> + )} + </Faq> </li> <li> <Faq question={i18n.t("go.faq.q9")}>{i18n.t("go.faq.a9")}</Faq> diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx index e47134d2b..c046a56a2 100644 --- a/packages/console/app/src/routes/index.tsx +++ b/packages/console/app/src/routes/index.tsx @@ -12,7 +12,6 @@ import { Header } from "~/component/header" import { Footer } from "~/component/footer" import { Legal } from "~/component/legal" import { github } from "~/lib/github" -import { createMemo } from "solid-js" import { config } from "~/config" import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" @@ -30,14 +29,12 @@ function CopyStatus() { export default function Home() { const i18n = useI18n() const language = useLanguage() - const githubData = createAsync(() => github()) - const release = createMemo(() => githubData()?.release) - + const _githubData = createAsync(() => github()) const handleCopyClick = (event: Event) => { const button = event.currentTarget as HTMLButtonElement const text = button.textContent if (text) { - navigator.clipboard.writeText(text) + void navigator.clipboard.writeText(text) button.setAttribute("data-copied", "") setTimeout(() => { button.removeAttribute("data-copied") diff --git a/packages/console/app/src/routes/temp.tsx b/packages/console/app/src/routes/temp.tsx index 4eed47857..6bbabc9ea 100644 --- a/packages/console/app/src/routes/temp.tsx +++ b/packages/console/app/src/routes/temp.tsx @@ -27,7 +27,7 @@ export default function Home() { const callback = () => { const text = button.textContent if (text) { - navigator.clipboard.writeText(text) + void navigator.clipboard.writeText(text) button.setAttribute("data-copied", "") setTimeout(() => { button.removeAttribute("data-copied") diff --git a/packages/console/app/src/routes/user-menu.tsx b/packages/console/app/src/routes/user-menu.tsx index fa1c1f60b..7b305d8ea 100644 --- a/packages/console/app/src/routes/user-menu.tsx +++ b/packages/console/app/src/routes/user-menu.tsx @@ -6,7 +6,7 @@ import { useI18n } from "~/context/i18n" import { useLanguage } from "~/context/language" import "./user-menu.css" -const logout = action(async () => { +const _logout = action(async () => { "use server" const auth = await useAuthSession() const event = getRequestEvent() diff --git a/packages/console/app/src/routes/workspace-picker.tsx b/packages/console/app/src/routes/workspace-picker.tsx index ffec2f3be..8778abefd 100644 --- a/packages/console/app/src/routes/workspace-picker.tsx +++ b/packages/console/app/src/routes/workspace-picker.tsx @@ -1,5 +1,5 @@ import { query, useParams, action, createAsync, redirect, useSubmission } from "@solidjs/router" -import { For, Show, createEffect } from "solid-js" +import { For, createEffect } from "solid-js" import { createStore } from "solid-js/store" import { withActor } from "~/context/auth.withActor" import { Actor } from "@opencode-ai/console-core/actor.js" diff --git a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx index b8f089864..5b4389466 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/black-section.tsx @@ -116,9 +116,9 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) = const setUseBalance = action(async (form: FormData) => { "use server" - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const useBalance = form.get("useBalance")?.toString() === "true" + const useBalance = (form.get("useBalance") as string | null) === "true" return json( await withActor(async () => { diff --git a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx index ef54b8409..7da1de238 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx @@ -10,11 +10,11 @@ import { formError, localizeError } from "~/lib/form-error" const setMonthlyLimit = action(async (form: FormData) => { "use server" - const limit = form.get("limit")?.toString() + const limit = form.get("limit") as string | null if (!limit) return { error: formError.limitRequired } const numericLimit = parseInt(limit) if (numericLimit < 0) return { error: formError.monthlyLimitInvalid } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } return json( await withActor( diff --git a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx index 90c9d7a2e..c9a72c087 100644 --- a/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx @@ -12,7 +12,7 @@ import { formError, formErrorReloadAmountMin, formErrorReloadTriggerMin, localiz const reload = action(async (form: FormData) => { "use server" - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } return json(await withActor(() => Billing.reload(), workspaceID), { revalidate: queryBillingInfo.key, @@ -21,11 +21,11 @@ const reload = action(async (form: FormData) => { const setReload = action(async (form: FormData) => { "use server" - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const reloadValue = form.get("reload")?.toString() === "true" - const amountStr = form.get("reloadAmount")?.toString() - const triggerStr = form.get("reloadTrigger")?.toString() + const reloadValue = (form.get("reload") as string | null) === "true" + const amountStr = form.get("reloadAmount") as string | null + const triggerStr = form.get("reloadTrigger") as string | null const reloadAmount = amountStr && amountStr.trim() !== "" ? parseInt(amountStr) : null const reloadTrigger = triggerStr && triggerStr.trim() !== "" ? parseInt(triggerStr) : null @@ -90,9 +90,9 @@ export function ReloadSection() { } const info = billingInfo()! setStore("show", true) - setStore("reload", info.reload ? true : true) - setStore("reloadAmount", info.reloadAmount.toString()) - setStore("reloadTrigger", info.reloadTrigger.toString()) + setStore("reload", true) + setStore("reloadAmount", String(info.reloadAmount)) + setStore("reloadTrigger", String(info.reloadTrigger)) } function hide() { @@ -152,11 +152,11 @@ export function ReloadSection() { data-component="input" name="reloadAmount" type="number" - min={billingInfo()?.reloadAmountMin.toString()} + min={String(billingInfo()?.reloadAmountMin ?? "")} step="1" value={store.reloadAmount} onInput={(e) => setStore("reloadAmount", e.currentTarget.value)} - placeholder={billingInfo()?.reloadAmount.toString()} + placeholder={String(billingInfo()?.reloadAmount ?? "")} disabled={!store.reload} /> </div> @@ -166,11 +166,11 @@ export function ReloadSection() { data-component="input" name="reloadTrigger" type="number" - min={billingInfo()?.reloadTriggerMin.toString()} + min={String(billingInfo()?.reloadTriggerMin ?? "")} step="1" value={store.reloadTrigger} onInput={(e) => setStore("reloadTrigger", e.currentTarget.value)} - placeholder={billingInfo()?.reloadTrigger.toString()} + placeholder={String(billingInfo()?.reloadTrigger ?? "")} disabled={!store.reload} /> </div> diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index 70d6aea7a..d0f812182 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -120,9 +120,9 @@ const createSessionUrl = action(async (workspaceID: string, returnUrl: string) = const setLiteUseBalance = action(async (form: FormData) => { "use server" - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const useBalance = form.get("useBalance")?.toString() === "true" + const useBalance = (form.get("useBalance") as string | null) === "true" return json( await withActor(async () => { @@ -292,6 +292,8 @@ export function LiteSection() { <li>Mimo-V2-Omni</li> <li>MiniMax M2.5</li> <li>MiniMax M2.7</li> + <li>Qwen3.5 Plus</li> + <li>Qwen3.6 Plus</li> </ul> <p data-slot="promo-description">{i18n.t("workspace.lite.promo.footer")}</p> <div data-slot="subscribe-actions"> diff --git a/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx index 837ab743a..cb273a422 100644 --- a/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx @@ -12,18 +12,18 @@ import { formError, localizeError } from "~/lib/form-error" const removeKey = action(async (form: FormData) => { "use server" - const id = form.get("id")?.toString() + const id = form.get("id") as string | null if (!id) return { error: formError.idRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } 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() + const name = (form.get("name") as string | null)?.trim() if (!name) return { error: formError.nameRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } return json( await withActor( diff --git a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx index 5a440632f..00edb400c 100644 --- a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx @@ -24,13 +24,13 @@ const listMembers = query(async (workspaceID: string) => { const inviteMember = action(async (form: FormData) => { "use server" - const email = form.get("email")?.toString().trim() + const email = (form.get("email") as string | null)?.trim() if (!email) return { error: formError.emailRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const role = form.get("role")?.toString() as (typeof UserRole)[number] + const role = form.get("role") as (typeof UserRole)[number] | null if (!role) return { error: formError.roleRequired } - const limit = form.get("limit")?.toString() + const limit = form.get("limit") as string | null const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid } return json( @@ -47,9 +47,9 @@ const inviteMember = action(async (form: FormData) => { const removeMember = action(async (form: FormData) => { "use server" - const id = form.get("id")?.toString() + const id = form.get("id") as string | null if (!id) return { error: formError.idRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } return json( await withActor( @@ -66,13 +66,13 @@ const removeMember = action(async (form: FormData) => { const updateMember = action(async (form: FormData) => { "use server" - const id = form.get("id")?.toString() + const id = form.get("id") as string | null if (!id) return { error: formError.idRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const role = form.get("role")?.toString() as (typeof UserRole)[number] + const role = form.get("role") as (typeof UserRole)[number] | null if (!role) return { error: formError.roleRequired } - const limit = form.get("limit")?.toString() + const limit = form.get("limit") as string | null const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null if (monthlyLimit !== null && monthlyLimit < 0) return { error: formError.monthlyLimitInvalid } @@ -118,7 +118,7 @@ function MemberRow(props: { } setStore("editing", true) setStore("selectedRole", props.member.role) - setStore("limit", props.member.monthlyLimit?.toString() ?? "") + setStore("limit", props.member.monthlyLimit != null ? String(props.member.monthlyLimit) : "") } function hide() { diff --git a/packages/console/app/src/routes/workspace/[id]/model-section.tsx b/packages/console/app/src/routes/workspace/[id]/model-section.tsx index bf19f81cd..b9cdf3bc3 100644 --- a/packages/console/app/src/routes/workspace/[id]/model-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/model-section.tsx @@ -67,11 +67,11 @@ const getModelsInfo = query(async (workspaceID: string) => { const updateModel = action(async (form: FormData) => { "use server" - const model = form.get("model")?.toString() + const model = form.get("model") as string | null if (!model) return { error: formError.modelRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } - const enabled = form.get("enabled")?.toString() === "true" + const enabled = (form.get("enabled") as string | null) === "true" return json( withActor(async () => { if (enabled) { @@ -163,7 +163,7 @@ export function ModelSection() { <form action={updateModel} method="post"> <input type="hidden" name="model" value={id} /> <input type="hidden" name="workspaceID" value={params.id} /> - <input type="hidden" name="enabled" value={isEnabled().toString()} /> + <input type="hidden" name="enabled" value={String(isEnabled())} /> <label data-slot="model-toggle-label"> <input type="checkbox" diff --git a/packages/console/app/src/routes/workspace/[id]/provider-section.tsx b/packages/console/app/src/routes/workspace/[id]/provider-section.tsx index fe04bf7c0..acdd89742 100644 --- a/packages/console/app/src/routes/workspace/[id]/provider-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/provider-section.tsx @@ -21,9 +21,9 @@ function maskCredentials(credentials: string) { const removeProvider = action(async (form: FormData) => { "use server" - const provider = form.get("provider")?.toString() + const provider = form.get("provider") as string | null if (!provider) return { error: formError.providerRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } return json(await withActor(() => Provider.remove({ provider }), workspaceID), { revalidate: listProviders.key, @@ -32,11 +32,11 @@ const removeProvider = action(async (form: FormData) => { const saveProvider = action(async (form: FormData) => { "use server" - const provider = form.get("provider")?.toString() - const credentials = form.get("credentials")?.toString() + const provider = form.get("provider") as string | null + const credentials = form.get("credentials") as string | null if (!provider) return { error: formError.providerRequired } if (!credentials) return { error: formError.apiKeyRequired } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } return json( await withActor( @@ -59,10 +59,13 @@ function ProviderRow(props: { provider: Provider }) { const params = useParams() const i18n = useI18n() const providers = createAsync(() => listProviders(params.id!)) - const saveSubmission = useSubmission(saveProvider, ([fd]) => fd.get("provider")?.toString() === props.provider.key) + const saveSubmission = useSubmission( + saveProvider, + ([fd]) => (fd.get("provider") as string | null) === props.provider.key, + ) const removeSubmission = useSubmission( removeProvider, - ([fd]) => fd.get("provider")?.toString() === props.provider.key, + ([fd]) => (fd.get("provider") as string | null) === props.provider.key, ) const [store, setStore] = createStore({ editing: false }) diff --git a/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx index 5e8331e26..a9950c41e 100644 --- a/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx @@ -30,10 +30,10 @@ const getWorkspaceInfo = query(async (workspaceID: string) => { const updateWorkspace = action(async (form: FormData) => { "use server" - const name = form.get("name")?.toString().trim() + const name = (form.get("name") as string | null)?.trim() if (!name) return { error: formError.workspaceNameRequired } if (name.length > 255) return { error: formError.nameTooLong } - const workspaceID = form.get("workspaceID")?.toString() + const workspaceID = form.get("workspaceID") as string | null if (!workspaceID) return { error: formError.workspaceRequired } return json( await withActor( diff --git a/packages/console/app/src/routes/zen/index.tsx b/packages/console/app/src/routes/zen/index.tsx index 62e8f5d37..6285a0bd8 100644 --- a/packages/console/app/src/routes/zen/index.tsx +++ b/packages/console/app/src/routes/zen/index.tsx @@ -1,5 +1,5 @@ import "./index.css" -import { createAsync, query, redirect } from "@solidjs/router" +import { createAsync, query } from "@solidjs/router" import { Title, Meta } from "@solidjs/meta" //import { HttpHeader } from "@solidjs/start" import zenLogoLight from "../../asset/zen-ornate-light.svg" diff --git a/packages/console/app/src/routes/zen/util/dataDumper.ts b/packages/console/app/src/routes/zen/util/dataDumper.ts index b852ca0b5..bc88c3813 100644 --- a/packages/console/app/src/routes/zen/util/dataDumper.ts +++ b/packages/console/app/src/routes/zen/util/dataDumper.ts @@ -26,14 +26,14 @@ export function createDataDumper(sessionId: string, requestId: string, projectId const minute = timestamp.substring(10, 12) const second = timestamp.substring(12, 14) - waitUntil( + void waitUntil( Resource.ZenDataNew.put( `data/${data.modelName}/${year}/${month}/${day}/${hour}/${minute}/${second}/${requestId}.json`, JSON.stringify({ timestamp, ...data }), ), ) - waitUntil( + void waitUntil( Resource.ZenDataNew.put( `meta/${data.modelName}/${sessionId}/${requestId}.json`, JSON.stringify({ timestamp, ...metadata }), diff --git a/packages/console/app/src/routes/zen/util/error.ts b/packages/console/app/src/routes/zen/util/error.ts index a3a93d2ef..b2a1d30d0 100644 --- a/packages/console/app/src/routes/zen/util/error.ts +++ b/packages/console/app/src/routes/zen/util/error.ts @@ -11,5 +11,6 @@ class LimitError extends Error { this.retryAfter = retryAfter } } +export class RateLimitError extends LimitError {} export class FreeUsageLimitError extends LimitError {} export class SubscriptionUsageLimitError extends LimitError {} diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 3e191918e..d1c5985a8 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -21,6 +21,7 @@ import { MonthlyLimitError, UserLimitError, ModelError, + RateLimitError, FreeUsageLimitError, SubscriptionUsageLimitError, } from "./error" @@ -35,7 +36,8 @@ import { anthropicHelper } from "./provider/anthropic" import { googleHelper } from "./provider/google" import { openaiHelper } from "./provider/openai" import { oaCompatHelper } from "./provider/openai-compatible" -import { createRateLimiter } from "./rateLimiter" +import { createRateLimiter as createIpRateLimiter } from "./ipRateLimiter" +import { createRateLimiter as createKeyRateLimiter } from "./keyRateLimiter" import { createDataDumper } from "./dataDumper" import { createTrialLimiter } from "./trialLimiter" import { createStickyTracker } from "./stickyProviderTracker" @@ -92,6 +94,8 @@ export async function handler( const isStream = opts.parseIsStream(url, body) const rawIp = input.request.headers.get("x-real-ip") ?? "" const ip = rawIp.includes(":") ? rawIp.split(":").slice(0, 4).join(":") : rawIp + const rawZenApiKey = opts.parseApiKey(input.request.headers) + const zenApiKey = rawZenApiKey === "public" ? undefined : rawZenApiKey const sessionId = input.request.headers.get("x-opencode-session") ?? "" const requestId = input.request.headers.get("x-opencode-request") ?? "" const projectId = input.request.headers.get("x-opencode-project") ?? "" @@ -106,19 +110,15 @@ export async function handler( const zenData = ZenData.list(opts.modelList) const modelInfo = validateModel(zenData, model) const dataDumper = createDataDumper(sessionId, requestId, projectId) - const trialLimiter = createTrialLimiter(modelInfo.trialProviders, ip) + const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip) const trialProviders = await trialLimiter?.check() - const rateLimiter = createRateLimiter( - modelInfo.id, - modelInfo.allowAnonymous, - modelInfo.rateLimit, - ip, - input.request, - ) + const rateLimiter = modelInfo.allowAnonymous + ? createIpRateLimiter(modelInfo.id, modelInfo.rateLimit, ip, input.request) + : createKeyRateLimiter(modelInfo.id, zenApiKey, input.request) await rateLimiter?.check() const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId) const stickyProvider = await stickyTracker?.get() - const authInfo = await authenticate(modelInfo) + const authInfo = await authenticate(modelInfo, zenApiKey) const billingSource = validateBilling(authInfo, modelInfo) logger.metric({ source: billingSource }) @@ -144,7 +144,7 @@ export async function handler( providerInfo.modifyBody({ ...createBodyConverter(opts.format, providerInfo.format)(body), model: providerInfo.model, - ...(providerInfo.payloadModifier ?? {}), + ...providerInfo.payloadModifier, ...Object.fromEntries( Object.entries(providerInfo.payloadMappings ?? {}) .map(([k, v]) => [k, input.request.headers.get(v)]) @@ -222,22 +222,23 @@ export async function handler( logger.debug("STATUS: " + res.status + " " + res.statusText) // Handle non-streaming response - if (!isStream) { + if (!isStream || res.status === 429) { const json = await res.json() - const usageInfo = providerInfo.normalizeUsage(json.usage) - const costInfo = calculateCost(modelInfo, usageInfo) - await trialLimiter?.track(usageInfo) await rateLimiter?.track() - await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) - await reload(billingSource, authInfo, costInfo) + if (json.usage) { + const usageInfo = providerInfo.normalizeUsage(json.usage) + const costInfo = calculateCost(modelInfo, usageInfo) + await trialLimiter?.track(usageInfo) + await trackUsage(sessionId, billingSource, authInfo, modelInfo, providerInfo, usageInfo, costInfo) + await reload(billingSource, authInfo, costInfo) + json.cost = calculateOccurredCost(billingSource, costInfo) + } + if (json.error?.message) { + json.error.message = `Error from provider${providerInfo.displayName ? ` (${providerInfo.displayName})` : ""}: ${json.error.message}` + } const responseConverter = createResponseConverter(providerInfo.format, opts.format) - const body = JSON.stringify( - responseConverter({ - ...json, - cost: calculateOccurredCost(billingSource, costInfo), - }), - ) + const body = JSON.stringify(responseConverter(json)) logger.metric({ response_length: body.length }) logger.debug("RESPONSE: " + body) dataDumper?.provideResponse(body) @@ -344,7 +345,7 @@ export async function handler( logger.metric({ "error.cause2": JSON.stringify(error.cause), }) - } catch (e) {} + } catch {} } // Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message. @@ -363,7 +364,11 @@ export async function handler( { status: 401 }, ) - if (error instanceof FreeUsageLimitError || error instanceof SubscriptionUsageLimitError) { + if ( + error instanceof RateLimitError || + error instanceof FreeUsageLimitError || + error instanceof SubscriptionUsageLimitError + ) { const headers = new Headers() if (error.retryAfter) { headers.set("retry-after", String(error.retryAfter)) @@ -392,7 +397,7 @@ export async function handler( function validateModel(zenData: ZenData, reqModel: string) { if (!(reqModel in zenData.models)) throw new ModelError(t("zen.api.error.modelNotSupported", { model: reqModel })) - const modelId = reqModel as keyof typeof zenData.models + const modelId = reqModel const modelData = Array.isArray(zenData.models[modelId]) ? zenData.models[modelId].find((model) => opts.format === model.formatFilter) : zenData.models[modelId] @@ -492,9 +497,8 @@ export async function handler( } } - async function authenticate(modelInfo: ModelInfo) { - const apiKey = opts.parseApiKey(input.request.headers) - if (!apiKey || apiKey === "public") { + async function authenticate(modelInfo: ModelInfo, zenApiKey?: string) { + if (!zenApiKey) { if (modelInfo.allowAnonymous) return throw new AuthError(t("zen.api.error.missingApiKey")) } @@ -573,7 +577,7 @@ export async function handler( isNull(LiteTable.timeDeleted), ), ) - .where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted))) + .where(and(eq(KeyTable.key, zenApiKey), isNull(KeyTable.timeDeleted))) .then((rows) => rows[0]), ) diff --git a/packages/console/app/src/routes/zen/util/rateLimiter.ts b/packages/console/app/src/routes/zen/util/ipRateLimiter.ts index 160633981..d22ab4ae2 100644 --- a/packages/console/app/src/routes/zen/util/rateLimiter.ts +++ b/packages/console/app/src/routes/zen/util/ipRateLimiter.ts @@ -6,14 +6,7 @@ import { i18n } from "~/i18n" import { localeFromRequest } from "~/lib/language" import { Subscription } from "@opencode-ai/console-core/subscription.js" -export function createRateLimiter( - modelId: string, - allowAnonymous: boolean | undefined, - rateLimit: number | undefined, - rawIp: string, - request: Request, -) { - if (!allowAnonymous) return +export function createRateLimiter(modelId: string, rateLimit: number | undefined, rawIp: string, request: Request) { const dict = i18n(localeFromRequest(request)) const limits = Subscription.getFreeLimits() diff --git a/packages/console/app/src/routes/zen/util/keyRateLimiter.ts b/packages/console/app/src/routes/zen/util/keyRateLimiter.ts new file mode 100644 index 000000000..e3e0fb18f --- /dev/null +++ b/packages/console/app/src/routes/zen/util/keyRateLimiter.ts @@ -0,0 +1,39 @@ +import { Database, eq, and, sql } from "@opencode-ai/console-core/drizzle/index.js" +import { KeyRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js" +import { RateLimitError } from "./error" +import { i18n } from "~/i18n" +import { localeFromRequest } from "~/lib/language" + +export function createRateLimiter(modelId: string, zenApiKey: string | undefined, request: Request) { + if (!zenApiKey) return + const dict = i18n(localeFromRequest(request)) + + const LIMIT = 100 + const yyyyMMddHHmm = new Date(Date.now()) + .toISOString() + .replace(/[^0-9]/g, "") + .substring(0, 12) + const interval = `${modelId.substring(0, 27)}-${yyyyMMddHHmm}` + + return { + check: async () => { + const rows = await Database.use((tx) => + tx + .select({ interval: KeyRateLimitTable.interval, count: KeyRateLimitTable.count }) + .from(KeyRateLimitTable) + .where(and(eq(KeyRateLimitTable.key, zenApiKey), eq(KeyRateLimitTable.interval, interval))), + ).then((rows) => rows[0]) + const count = rows?.count ?? 0 + + if (count >= LIMIT) throw new RateLimitError(dict["zen.api.error.rateLimitExceeded"], 60) + }, + track: async () => { + await Database.use((tx) => + tx + .insert(KeyRateLimitTable) + .values({ key: zenApiKey, interval, count: 1 }) + .onDuplicateKeyUpdate({ set: { count: sql`${KeyRateLimitTable.count} + 1` } }), + ) + }, + } +} diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts index b63be8688..0f6f11da7 100644 --- a/packages/console/app/src/routes/zen/util/provider/anthropic.ts +++ b/packages/console/app/src/routes/zen/util/provider/anthropic.ts @@ -153,7 +153,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) => let json try { json = JSON.parse(data.slice(6)) - } catch (e) { + } catch { return } diff --git a/packages/console/app/src/routes/zen/util/provider/google.ts b/packages/console/app/src/routes/zen/util/provider/google.ts index f6f7d6e19..ef7937c35 100644 --- a/packages/console/app/src/routes/zen/util/provider/google.ts +++ b/packages/console/app/src/routes/zen/util/provider/google.ts @@ -48,7 +48,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({ let json try { json = JSON.parse(chunk.slice(6)) as { usageMetadata?: Usage } - } catch (e) { + } catch { return } diff --git a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts index bdc12ba8b..97b0abc64 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts @@ -6,12 +6,14 @@ type Usage = { total_tokens?: number // used by moonshot cached_tokens?: number - // used by xai + // used by xai & alibaba prompt_tokens_details?: { text_tokens?: number audio_tokens?: number image_tokens?: number cached_tokens?: number + // used by alibaba + cache_creation_input_tokens?: number } completion_tokens_details?: { reasoning_tokens?: number @@ -28,7 +30,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif headers.set("authorization", `Bearer ${apiKey}`) headers.set("x-session-affinity", headers.get("x-opencode-session") ?? "") }, - modifyBody: (body: Record<string, any>, workspaceID?: string) => { + modifyBody: (body: Record<string, any>, _workspaceID?: string) => { return { ...body, ...(body.stream ? { stream_options: { include_usage: true } } : {}), @@ -47,7 +49,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif let json try { json = JSON.parse(chunk.slice(6)) as { usage?: Usage } - } catch (e) { + } catch { return } @@ -62,6 +64,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif const outputTokens = usage.completion_tokens ?? 0 const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined let cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined + const cacheWriteTokens = usage.prompt_tokens_details?.cache_creation_input_tokens ?? undefined if (adjustCacheUsage && !cacheReadTokens) { cacheReadTokens = Math.floor(inputTokens * 0.9) @@ -72,7 +75,7 @@ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentif outputTokens, reasoningTokens, cacheReadTokens, - cacheWrite5mTokens: undefined, + cacheWrite5mTokens: cacheWriteTokens, cacheWrite1hTokens: undefined, } }, @@ -286,7 +289,7 @@ export function fromOaCompatibleResponse(resp: any): CommonResponse { index: 0, message: { role: "assistant" as const, - ...(content.length > 0 && content.some((c) => c.type === "text") + ...(content.some((c) => c.type === "text") ? { content: content .filter((c) => c.type === "text") @@ -294,7 +297,7 @@ export function fromOaCompatibleResponse(resp: any): CommonResponse { .join(""), } : {}), - ...(content.length > 0 && content.some((c) => c.type === "tool_use") + ...(content.some((c) => c.type === "tool_use") ? { tool_calls: content .filter((c) => c.type === "tool_use") diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts index 3c5831a9a..bee1e01ec 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai.ts @@ -36,7 +36,7 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({ let json try { json = JSON.parse(data.slice(6)) as { response?: { usage?: Usage } } - } catch (e) { + } catch { return } diff --git a/packages/console/app/src/routes/zen/v1/models.ts b/packages/console/app/src/routes/zen/v1/models.ts index d2592d20b..6b4a878fc 100644 --- a/packages/console/app/src/routes/zen/v1/models.ts +++ b/packages/console/app/src/routes/zen/v1/models.ts @@ -5,7 +5,7 @@ import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.j import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js" import { ZenData } from "@opencode-ai/console-core/model.js" -export async function OPTIONS(input: APIEvent) { +export async function OPTIONS(_input: APIEvent) { return new Response(null, { status: 200, headers: { diff --git a/packages/console/app/src/routes/zen/v1/models/[model].ts b/packages/console/app/src/routes/zen/v1/models/[model].ts index a4edd5861..bc1168eb0 100644 --- a/packages/console/app/src/routes/zen/v1/models/[model].ts +++ b/packages/console/app/src/routes/zen/v1/models/[model].ts @@ -6,8 +6,8 @@ export function POST(input: APIEvent) { format: "google", modelList: "full", parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined, - parseModel: (url: string, body: any) => url.split("/").pop()?.split(":")?.[0] ?? "", - parseIsStream: (url: string, body: any) => + parseModel: (url: string, _body: any) => url.split("/").pop()?.split(":")?.[0] ?? "", + parseIsStream: (url: string, _body: any) => // ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse' url.split("/").pop()?.split(":")?.[1]?.startsWith("streamGenerateContent") ?? false, }) |
