summaryrefslogtreecommitdiffhomepage
path: root/cloud/app/src/routes
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-09-12 18:38:54 -0400
committerFrank <[email protected]>2025-09-15 14:48:02 -0400
commit7218a662ab08e5526d469090a565342872f04a48 (patch)
tree18b72df25ff825785b50c9a30ce88b8360624b3e /cloud/app/src/routes
parentd947df306907b8b5e75b294c68f876dc752943a9 (diff)
downloadopencode-7218a662ab08e5526d469090a565342872f04a48.tar.gz
opencode-7218a662ab08e5526d469090a565342872f04a48.zip
wip: zen
Diffstat (limited to 'cloud/app/src/routes')
-rw-r--r--cloud/app/src/routes/zen/handler.ts531
-rw-r--r--cloud/app/src/routes/zen/v1/chat/completions.ts2
-rw-r--r--cloud/app/src/routes/zen/v1/messages.ts2
-rw-r--r--cloud/app/src/routes/zen/v1/responses.ts2
4 files changed, 534 insertions, 3 deletions
diff --git a/cloud/app/src/routes/zen/handler.ts b/cloud/app/src/routes/zen/handler.ts
new file mode 100644
index 000000000..7a7643568
--- /dev/null
+++ b/cloud/app/src/routes/zen/handler.ts
@@ -0,0 +1,531 @@
+import type { APIEvent } from "@solidjs/start/server"
+import path from "node:path"
+import { and, Database, eq, isNull, sql } from "@opencode/cloud-core/drizzle/index.js"
+import { KeyTable } from "@opencode/cloud-core/schema/key.sql.js"
+import { BillingTable, UsageTable } from "@opencode/cloud-core/schema/billing.sql.js"
+import { centsToMicroCents } from "@opencode/cloud-core/util/price.js"
+import { Identifier } from "@opencode/cloud-core/identifier.js"
+import { Resource } from "@opencode/cloud-resource"
+
+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 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 checkCredits()
+ 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)
+ 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)
+ 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 checkCredits() {
+ if (!apiKey || !MODEL.auth || isFree) return
+
+ const billing = await Database.use((tx) =>
+ tx
+ .select({
+ balance: BillingTable.balance,
+ })
+ .from(BillingTable)
+ .where(eq(BillingTable.workspaceID, apiKey.workspaceID))
+ .then((rows) => rows[0]),
+ )
+
+ if (billing.balance <= 0) throw new CreditsError("Insufficient balance")
+ }
+
+ 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}`,
+ })
+ .where(eq(BillingTable.workspaceID, apiKey.workspaceID))
+ })
+
+ await Database.use((tx) =>
+ tx
+ .update(KeyTable)
+ .set({ timeUsed: sql`now()` })
+ .where(eq(KeyTable.id, apiKey.id)),
+ )
+ }
+ } 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 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/cloud/app/src/routes/zen/v1/chat/completions.ts b/cloud/app/src/routes/zen/v1/chat/completions.ts
index 13a75a4a4..801557324 100644
--- a/cloud/app/src/routes/zen/v1/chat/completions.ts
+++ b/cloud/app/src/routes/zen/v1/chat/completions.ts
@@ -1,5 +1,5 @@
import type { APIEvent } from "@solidjs/start/server"
-import { handler } from "~/util/zen"
+import { handler } from "~/routes/zen/handler"
type Usage = {
prompt_tokens?: number
diff --git a/cloud/app/src/routes/zen/v1/messages.ts b/cloud/app/src/routes/zen/v1/messages.ts
index b2e9e275a..1fd85d5c7 100644
--- a/cloud/app/src/routes/zen/v1/messages.ts
+++ b/cloud/app/src/routes/zen/v1/messages.ts
@@ -1,5 +1,5 @@
import type { APIEvent } from "@solidjs/start/server"
-import { handler } from "~/util/zen"
+import { handler } from "~/routes/zen/handler"
type Usage = {
cache_creation?: {
diff --git a/cloud/app/src/routes/zen/v1/responses.ts b/cloud/app/src/routes/zen/v1/responses.ts
index 1bca91f52..486c129b9 100644
--- a/cloud/app/src/routes/zen/v1/responses.ts
+++ b/cloud/app/src/routes/zen/v1/responses.ts
@@ -1,5 +1,5 @@
import type { APIEvent } from "@solidjs/start/server"
-import { handler } from "~/util/zen"
+import { handler } from "~/routes/zen/handler"
type Usage = {
input_tokens?: number