Frank 4 months ago
parent
commit
71abca9571

+ 5 - 0
packages/console/app/src/routes/zen/util/error.ts

@@ -0,0 +1,5 @@
+export class AuthError extends Error {}
+export class CreditsError extends Error {}
+export class MonthlyLimitError extends Error {}
+export class UserLimitError extends Error {}
+export class ModelError extends Error {}

+ 1 - 0
packages/console/app/src/routes/zen/util/format.ts

@@ -0,0 +1 @@
+export type Format = "anthropic" | "openai" | "oa-compat"

+ 58 - 57
packages/console/app/src/routes/zen/handler.ts → packages/console/app/src/routes/zen/util/handler.ts

@@ -1,67 +1,41 @@
-import { z } from "zod"
 import type { APIEvent } from "@solidjs/start/server"
-import path from "node:path"
 import { and, Database, eq, isNull, lt, or, sql } from "@opencode-ai/console-core/drizzle/index.js"
 import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
 import { BillingTable, UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
 import { centsToMicroCents } from "@opencode-ai/console-core/util/price.js"
 import { Identifier } from "@opencode-ai/console-core/identifier.js"
-import { Resource } from "@opencode-ai/console-resource"
-import { Billing } from "../../../../core/src/billing"
+import { Billing } from "@opencode-ai/console-core/billing.js"
 import { Actor } from "@opencode-ai/console-core/actor.js"
 import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
 import { ZenData } from "@opencode-ai/console-core/model.js"
 import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
 import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
 import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
+import { logger } from "./logger"
+import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError } from "./error"
+import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider"
+import { Format } from "./format"
+import { anthropicHelper } from "./provider/anthropic"
+import { openaiHelper } from "./provider/openai"
+import { oaCompatHelper } from "./provider/openai-compatible"
+
+type ZenData = Awaited<ReturnType<typeof ZenData.list>>
+type Model = ZenData["models"][string]
 
 export async function handler(
   input: APIEvent,
   opts: {
-    modifyBody?: (body: any) => any
-    setAuthHeader: (headers: Headers, apiKey: string) => void
+    format: Format
     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 UserLimitError extends Error {}
-  class ModelError extends Error {}
-
-  type ZenData = Awaited<ReturnType<typeof ZenData.list>>
-  type Model = ZenData["models"][string]
-
   const FREE_WORKSPACES = [
     "wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
     "wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
   ]
 
-  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"),
@@ -78,22 +52,28 @@ export async function handler(
 
     // Request to model provider
     const startTimestamp = Date.now()
-    const res = await fetch(path.posix.join(providerInfo.api, url.pathname.replace(/^\/zen\/v1/, "") + url.search), {
+    const reqUrl = providerInfo.modifyUrl(providerInfo.api)
+    const reqBody = JSON.stringify(
+      providerInfo.modifyBody({
+        ...createBodyConverter(opts.format, providerInfo.format)(body),
+        model: providerInfo.model,
+      }),
+    )
+    logger.debug("REQUEST URL: " + reqUrl)
+    logger.debug("REQUEST: " + reqBody)
+    const res = await fetch(reqUrl, {
       method: "POST",
       headers: (() => {
         const headers = input.request.headers
         headers.delete("host")
         headers.delete("content-length")
-        opts.setAuthHeader(headers, providerInfo.apiKey)
+        providerInfo.modifyHeaders(headers, providerInfo.apiKey)
         Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
           headers.set(k, headers.get(v)!)
         })
         return headers
       })(),
-      body: JSON.stringify({
-        ...(opts.modifyBody?.(body) ?? body),
-        model: providerInfo.model,
-      }),
+      body: reqBody,
     })
 
     // Scrub response headers
@@ -104,14 +84,19 @@ export async function handler(
         resHeaders.set(k, v)
       }
     }
+    logger.debug("STATUS: " + res.status + " " + res.statusText)
+    if (res.status === 400 || res.status === 503) {
+      logger.debug("RESPONSE: " + (await res.text()))
+    }
 
     // Handle non-streaming response
     if (!body.stream) {
+      const responseConverter = createResponseConverter(providerInfo.format, opts.format)
       const json = await res.json()
-      const body = JSON.stringify(json)
+      const body = JSON.stringify(responseConverter(json))
       logger.metric({ response_length: body.length })
-      logger.debug(body)
-      await trackUsage(authInfo, modelInfo, providerInfo.id, json.usage)
+      logger.debug("RESPONSE: " + body)
+      await trackUsage(authInfo, modelInfo, providerInfo, json.usage)
       await reload(authInfo)
       return new Response(body, {
         status: res.status,
@@ -121,10 +106,13 @@ export async function handler(
     }
 
     // Handle streaming response
+    const streamConverter = createStreamPartConverter(providerInfo.format, opts.format)
+    const usageParser = providerInfo.createUsageParser()
     const stream = new ReadableStream({
       start(c) {
         const reader = res.body?.getReader()
         const decoder = new TextDecoder()
+        const encoder = new TextEncoder()
         let buffer = ""
         let responseLength = 0
 
@@ -136,9 +124,9 @@ export async function handler(
                   response_length: responseLength,
                   "timestamp.last_byte": Date.now(),
                 })
-                const usage = opts.getStreamUsage()
+                const usage = usageParser.retrieve()
                 if (usage) {
-                  await trackUsage(authInfo, modelInfo, providerInfo.id, usage)
+                  await trackUsage(authInfo, modelInfo, providerInfo, usage)
                   await reload(authInfo)
                 }
                 c.close()
@@ -158,12 +146,21 @@ export async function handler(
               const parts = buffer.split("\n\n")
               buffer = parts.pop() ?? ""
 
-              for (const part of parts) {
-                logger.debug(part)
-                opts.onStreamPart(part.trim())
+              for (let part of parts) {
+                logger.debug("PART: " + part)
+
+                part = part.trim()
+                usageParser.parse(part)
+
+                if (providerInfo.format !== opts.format) {
+                  part = streamConverter(part)
+                  c.enqueue(encoder.encode(part + "\n\n"))
+                }
               }
 
-              c.enqueue(value)
+              if (providerInfo.format === opts.format) {
+                c.enqueue(value)
+              }
 
               return pump()
             }) || Promise.resolve()
@@ -235,7 +232,11 @@ export async function handler(
       throw new ModelError(`Provider ${provider.id} not supported`)
     }
 
-    return { ...provider, ...zenData.providers[provider.id] }
+    return {
+      ...provider,
+      ...zenData.providers[provider.id],
+      ...(provider.id === "anthropic" ? anthropicHelper : provider.id === "openai" ? openaiHelper : oaCompatHelper),
+    }
   }
 
   async function authenticate(
@@ -356,11 +357,11 @@ export async function handler(
   async function trackUsage(
     authInfo: Awaited<ReturnType<typeof authenticate>>,
     modelInfo: ReturnType<typeof validateModel>,
-    providerId: string,
+    providerInfo: Awaited<ReturnType<typeof selectProvider>>,
     usage: any,
   ) {
     const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
-      opts.normalizeUsage(usage)
+      providerInfo.normalizeUsage(usage)
 
     const modelCost =
       modelInfo.cost200K &&
@@ -421,7 +422,7 @@ export async function handler(
         workspaceID: authInfo.workspaceID,
         id: Identifier.create("usage"),
         model: modelInfo.id,
-        provider: providerId,
+        provider: providerInfo.id,
         inputTokens,
         outputTokens,
         reasoningTokens,

+ 12 - 0
packages/console/app/src/routes/zen/util/logger.ts

@@ -0,0 +1,12 @@
+import { Resource } from "@opencode-ai/console-resource"
+
+export 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)
+  },
+}

+ 618 - 0
packages/console/app/src/routes/zen/util/provider/anthropic.ts

@@ -0,0 +1,618 @@
+import { ProviderHelper, CommonRequest, CommonResponse, CommonChunk } from "./provider"
+
+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 const anthropicHelper = {
+  format: "anthropic",
+  modifyUrl: (providerApi: string) => providerApi + "/messages",
+  modifyHeaders: (headers: Headers, apiKey: string) => {
+    headers.set("x-api-key", apiKey)
+    headers.set("anthropic-version", headers.get("anthropic-version") ?? "2023-06-01")
+  },
+  modifyBody: (body: Record<string, any>) => {
+    return {
+      ...body,
+      service_tier: "standard_only",
+    }
+  },
+  createUsageParser: () => {
+    let usage: Usage
+
+    return {
+      parse: (chunk: string) => {
+        const data = chunk.split("\n")[1]
+        if (!data.startsWith("data: ")) return
+
+        let json
+        try {
+          json = JSON.parse(data.slice(6))
+        } catch (e) {
+          return
+        }
+
+        const usageUpdate = json.usage ?? json.message?.usage
+        if (!usageUpdate) return
+        usage = {
+          ...usage,
+          ...usageUpdate,
+          cache_creation: {
+            ...usage?.cache_creation,
+            ...usageUpdate.cache_creation,
+          },
+          server_tool_use: {
+            ...usage?.server_tool_use,
+            ...usageUpdate.server_tool_use,
+          },
+        }
+      },
+      retrieve: () => usage,
+    }
+  },
+  normalizeUsage: (usage: Usage) => ({
+    inputTokens: usage.input_tokens ?? 0,
+    outputTokens: usage.output_tokens ?? 0,
+    reasoningTokens: undefined,
+    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,
+  }),
+} satisfies ProviderHelper
+
+export function fromAnthropicRequest(body: any): CommonRequest {
+  if (!body || typeof body !== "object") return body
+
+  const msgs: any[] = []
+
+  const sys = Array.isArray(body.system) ? body.system : undefined
+  if (sys && sys.length > 0) {
+    for (const s of sys) {
+      if (!s) continue
+      if ((s as any).type !== "text") continue
+      if (typeof (s as any).text !== "string") continue
+      if ((s as any).text.length === 0) continue
+      msgs.push({ role: "system", content: (s as any).text })
+    }
+  }
+
+  const toImg = (src: any) => {
+    if (!src || typeof src !== "object") return undefined
+    if ((src as any).type === "url" && typeof (src as any).url === "string")
+      return { type: "image_url", image_url: { url: (src as any).url } }
+    if (
+      (src as any).type === "base64" &&
+      typeof (src as any).media_type === "string" &&
+      typeof (src as any).data === "string"
+    )
+      return { type: "image_url", image_url: { url: `data:${(src as any).media_type};base64,${(src as any).data}` } }
+    return undefined
+  }
+
+  const inMsgs = Array.isArray(body.messages) ? body.messages : []
+  for (const m of inMsgs) {
+    if (!m || !(m as any).role) continue
+
+    if ((m as any).role === "user") {
+      const partsIn = Array.isArray((m as any).content) ? (m as any).content : []
+      const partsOut: any[] = []
+      for (const p of partsIn) {
+        if (!p || !(p as any).type) continue
+        if ((p as any).type === "text" && typeof (p as any).text === "string")
+          partsOut.push({ type: "text", text: (p as any).text })
+        if ((p as any).type === "image") {
+          const ip = toImg((p as any).source)
+          if (ip) partsOut.push(ip)
+        }
+        if ((p as any).type === "tool_result") {
+          const id = (p as any).tool_use_id
+          const content =
+            typeof (p as any).content === "string" ? (p as any).content : JSON.stringify((p as any).content)
+          msgs.push({ role: "tool", tool_call_id: id, content })
+        }
+      }
+      if (partsOut.length > 0) {
+        if (partsOut.length === 1 && partsOut[0].type === "text") msgs.push({ role: "user", content: partsOut[0].text })
+        else msgs.push({ role: "user", content: partsOut })
+      }
+      continue
+    }
+
+    if ((m as any).role === "assistant") {
+      const partsIn = Array.isArray((m as any).content) ? (m as any).content : []
+      const texts: string[] = []
+      const tcs: any[] = []
+      for (const p of partsIn) {
+        if (!p || !(p as any).type) continue
+        if ((p as any).type === "text" && typeof (p as any).text === "string") texts.push((p as any).text)
+        if ((p as any).type === "tool_use") {
+          const name = (p as any).name
+          const id = (p as any).id
+          const inp = (p as any).input
+          const input = (() => {
+            if (typeof inp === "string") return inp
+            try {
+              return JSON.stringify(inp ?? {})
+            } catch {
+              return String(inp ?? "")
+            }
+          })()
+          tcs.push({ id, type: "function", function: { name, arguments: input } })
+        }
+      }
+      const out: any = { role: "assistant", content: texts.join("") }
+      if (tcs.length > 0) out.tool_calls = tcs
+      msgs.push(out)
+      continue
+    }
+  }
+
+  const tools = Array.isArray(body.tools)
+    ? body.tools
+        .filter((t: any) => t && typeof t === "object" && "input_schema" in t)
+        .map((t: any) => ({
+          type: "function",
+          function: { name: (t as any).name, description: (t as any).description, parameters: (t as any).input_schema },
+        }))
+    : undefined
+
+  const tcin = body.tool_choice
+  const tc = (() => {
+    if (!tcin) return undefined
+    if ((tcin as any).type === "auto") return "auto"
+    if ((tcin as any).type === "any") return "required"
+    if ((tcin as any).type === "tool" && typeof (tcin as any).name === "string")
+      return { type: "function" as const, function: { name: (tcin as any).name } }
+    return undefined
+  })()
+
+  const stop = (() => {
+    const v = body.stop_sequences
+    if (!v) return undefined
+    if (Array.isArray(v)) return v.length === 1 ? v[0] : v
+    if (typeof v === "string") return v
+    return undefined
+  })()
+
+  return {
+    max_tokens: body.max_tokens,
+    temperature: body.temperature,
+    top_p: body.top_p,
+    stop,
+    messages: msgs,
+    stream: !!body.stream,
+    tools,
+    tool_choice: tc,
+  }
+}
+
+export function toAnthropicRequest(body: CommonRequest) {
+  if (!body || typeof body !== "object") return body
+
+  const sysIn = Array.isArray(body.messages) ? body.messages.filter((m: any) => m && m.role === "system") : []
+  let ccCount = 0
+  const cc = () => {
+    ccCount++
+    return ccCount <= 4 ? { cache_control: { type: "ephemeral" } } : {}
+  }
+  const system = sysIn
+    .filter((m: any) => typeof m.content === "string" && m.content.length > 0)
+    .map((m: any) => ({ type: "text", text: m.content, ...cc() }))
+
+  const msgsIn = Array.isArray(body.messages) ? body.messages : []
+  const msgsOut: any[] = []
+
+  const toSrc = (p: any) => {
+    if (!p || typeof p !== "object") return undefined
+    if ((p as any).type === "image_url" && (p as any).image_url) {
+      const u = (p as any).image_url.url ?? (p as any).image_url
+      if (typeof u === "string" && u.startsWith("data:")) {
+        const m = u.match(/^data:([^;]+);base64,(.*)$/)
+        if (m) return { type: "base64", media_type: m[1], data: m[2] }
+      }
+      if (typeof u === "string") return { type: "url", url: u }
+    }
+    return undefined
+  }
+
+  for (const m of msgsIn) {
+    if (!m || !(m as any).role) continue
+
+    if ((m as any).role === "user") {
+      if (typeof (m as any).content === "string") {
+        msgsOut.push({
+          role: "user",
+          content: [{ type: "text", text: (m as any).content, ...cc() }],
+        })
+      } else if (Array.isArray((m as any).content)) {
+        const parts: any[] = []
+        for (const p of (m as any).content) {
+          if (!p || !(p as any).type) continue
+          if ((p as any).type === "text" && typeof (p as any).text === "string")
+            parts.push({ type: "text", text: (p as any).text, ...cc() })
+          if ((p as any).type === "image_url") {
+            const s = toSrc(p)
+            if (s) parts.push({ type: "image", source: s, ...cc() })
+          }
+        }
+        if (parts.length > 0) msgsOut.push({ role: "user", content: parts })
+      }
+      continue
+    }
+
+    if ((m as any).role === "assistant") {
+      const out: any = { role: "assistant", content: [] as any[] }
+      if (typeof (m as any).content === "string" && (m as any).content.length > 0) {
+        ;(out.content as any[]).push({ type: "text", text: (m as any).content, ...cc() })
+      }
+      if (Array.isArray((m as any).tool_calls)) {
+        for (const tc of (m as any).tool_calls) {
+          if ((tc as any).type === "function" && (tc as any).function) {
+            let input: any
+            const a = (tc as any).function.arguments
+            if (typeof a === "string") {
+              try {
+                input = JSON.parse(a)
+              } catch {
+                input = a
+              }
+            } else input = a
+            const id = (tc as any).id || `toolu_${Math.random().toString(36).slice(2)}`
+            ;(out.content as any[]).push({
+              type: "tool_use",
+              id,
+              name: (tc as any).function.name,
+              input,
+              ...cc(),
+            })
+          }
+        }
+      }
+      if ((out.content as any[]).length > 0) msgsOut.push(out)
+      continue
+    }
+
+    if ((m as any).role === "tool") {
+      msgsOut.push({
+        role: "user",
+        content: [
+          {
+            type: "tool_result",
+            tool_use_id: (m as any).tool_call_id,
+            content: (m as any).content,
+            ...cc(),
+          },
+        ],
+      })
+      continue
+    }
+  }
+
+  const tools = Array.isArray(body.tools)
+    ? body.tools
+        .filter((t: any) => t && typeof t === "object" && (t as any).type === "function")
+        .map((t: any) => ({
+          name: (t as any).function.name,
+          description: (t as any).function.description,
+          input_schema: (t as any).function.parameters,
+          ...cc(),
+        }))
+    : undefined
+
+  const tcIn = body.tool_choice
+  const tool_choice = (() => {
+    if (!tcIn) return undefined
+    if (tcIn === "auto") return { type: "auto" }
+    if (tcIn === "required") return { type: "any" }
+    if ((tcIn as any).type === "function" && (tcIn as any).function?.name)
+      return { type: "tool", name: (tcIn as any).function.name }
+    return undefined
+  })()
+
+  const stop_sequences = (() => {
+    const v = body.stop
+    if (!v) return undefined
+    if (Array.isArray(v)) return v
+    if (typeof v === "string") return [v]
+    return undefined
+  })()
+
+  return {
+    max_tokens: body.max_tokens ?? 32_000,
+    temperature: body.temperature,
+    top_p: body.top_p,
+    system: system.length > 0 ? system : undefined,
+    messages: msgsOut,
+    stream: !!body.stream,
+    tools,
+    tool_choice,
+    stop_sequences,
+  }
+}
+
+export function fromAnthropicResponse(resp: any): CommonResponse {
+  if (!resp || typeof resp !== "object") return resp
+
+  if (Array.isArray((resp as any).choices)) return resp
+
+  const isAnthropic = typeof (resp as any).type === "string" && (resp as any).type === "message"
+  if (!isAnthropic) return resp
+
+  const idIn = (resp as any).id
+  const id =
+    typeof idIn === "string" ? idIn.replace(/^msg_/, "chatcmpl_") : `chatcmpl_${Math.random().toString(36).slice(2)}`
+  const model = (resp as any).model
+
+  const blocks: any[] = Array.isArray((resp as any).content) ? (resp as any).content : []
+  const text = blocks
+    .filter((b) => b && b.type === "text" && typeof (b as any).text === "string")
+    .map((b: any) => b.text)
+    .join("")
+  const tcs = blocks
+    .filter((b) => b && b.type === "tool_use")
+    .map((b: any) => {
+      const name = (b as any).name
+      const args = (() => {
+        const inp = (b as any).input
+        if (typeof inp === "string") return inp
+        try {
+          return JSON.stringify(inp ?? {})
+        } catch {
+          return String(inp ?? "")
+        }
+      })()
+      const tid =
+        typeof (b as any).id === "string" && (b as any).id.length > 0
+          ? (b as any).id
+          : `toolu_${Math.random().toString(36).slice(2)}`
+      return { id: tid, type: "function" as const, function: { name, arguments: args } }
+    })
+
+  const finish = (r: string | null) => {
+    if (r === "end_turn") return "stop"
+    if (r === "tool_use") return "tool_calls"
+    if (r === "max_tokens") return "length"
+    if (r === "content_filter") return "content_filter"
+    return null
+  }
+
+  const u = (resp as any).usage
+  const usage = (() => {
+    if (!u) return undefined as any
+    const pt = typeof (u as any).input_tokens === "number" ? (u as any).input_tokens : undefined
+    const ct = typeof (u as any).output_tokens === "number" ? (u as any).output_tokens : undefined
+    const total = pt != null && ct != null ? pt + ct : undefined
+    const cached =
+      typeof (u as any).cache_read_input_tokens === "number" ? (u as any).cache_read_input_tokens : undefined
+    const details = cached != null ? { cached_tokens: cached } : undefined
+    return {
+      prompt_tokens: pt,
+      completion_tokens: ct,
+      total_tokens: total,
+      ...(details ? { prompt_tokens_details: details } : {}),
+    }
+  })()
+
+  return {
+    id,
+    object: "chat.completion",
+    created: Math.floor(Date.now() / 1000),
+    model,
+    choices: [
+      {
+        index: 0,
+        message: {
+          role: "assistant",
+          ...(text && text.length > 0 ? { content: text } : {}),
+          ...(tcs.length > 0 ? { tool_calls: tcs } : {}),
+        },
+        finish_reason: finish((resp as any).stop_reason ?? null),
+      },
+    ],
+    ...(usage ? { usage } : {}),
+  }
+}
+
+export function toAnthropicResponse(resp: CommonResponse) {
+  if (!resp || typeof resp !== "object") return resp
+
+  if (!Array.isArray((resp as any).choices)) return resp
+
+  const choice = (resp as any).choices[0]
+  if (!choice) return resp
+
+  const message = choice.message
+  if (!message) return resp
+
+  const content: any[] = []
+
+  if (typeof message.content === "string" && message.content.length > 0)
+    content.push({ type: "text", text: message.content })
+
+  if (Array.isArray(message.tool_calls)) {
+    for (const tc of message.tool_calls) {
+      if ((tc as any).type === "function" && (tc as any).function) {
+        let input: any
+        try {
+          input = JSON.parse((tc as any).function.arguments)
+        } catch {
+          input = (tc as any).function.arguments
+        }
+        content.push({ type: "tool_use", id: (tc as any).id, name: (tc as any).function.name, input })
+      }
+    }
+  }
+
+  const stop_reason = (() => {
+    const r = choice.finish_reason
+    if (r === "stop") return "end_turn"
+    if (r === "tool_calls") return "tool_use"
+    if (r === "length") return "max_tokens"
+    if (r === "content_filter") return "content_filter"
+    return null
+  })()
+
+  const usage = (() => {
+    const u = (resp as any).usage
+    if (!u) return undefined
+    return {
+      input_tokens: u.prompt_tokens,
+      output_tokens: u.completion_tokens,
+      cache_read_input_tokens: u.prompt_tokens_details?.cached_tokens,
+    }
+  })()
+
+  return {
+    id: (resp as any).id,
+    type: "message",
+    role: "assistant",
+    content: content.length > 0 ? content : [{ type: "text", text: "" }],
+    model: (resp as any).model,
+    stop_reason,
+    usage,
+  }
+}
+
+export function fromAnthropicChunk(chunk: string): CommonChunk | string {
+  // Anthropic sends two lines per part: "event: <type>\n" + "data: <json>"
+  const lines = chunk.split("\n")
+  const dataLine = lines.find((l) => l.startsWith("data: "))
+  if (!dataLine) return chunk
+
+  let json
+  try {
+    json = JSON.parse(dataLine.slice(6))
+  } catch {
+    return chunk
+  }
+
+  const out: CommonChunk = {
+    id: json.id ?? json.message?.id ?? "",
+    object: "chat.completion.chunk",
+    created: Math.floor(Date.now() / 1000),
+    model: json.model ?? json.message?.model ?? "",
+    choices: [],
+  }
+
+  if (json.type === "content_block_start") {
+    const cb = json.content_block
+    if (cb?.type === "text") {
+      out.choices.push({ index: json.index ?? 0, delta: { role: "assistant", content: "" }, finish_reason: null })
+    } else if (cb?.type === "tool_use") {
+      out.choices.push({
+        index: json.index ?? 0,
+        delta: {
+          tool_calls: [
+            { index: json.index ?? 0, id: cb.id, type: "function", function: { name: cb.name, arguments: "" } },
+          ],
+        },
+        finish_reason: null,
+      })
+    }
+  }
+
+  if (json.type === "content_block_delta") {
+    const d = json.delta
+    if (d?.type === "text_delta") {
+      out.choices.push({ index: json.index ?? 0, delta: { content: d.text }, finish_reason: null })
+    } else if (d?.type === "input_json_delta") {
+      out.choices.push({
+        index: json.index ?? 0,
+        delta: { tool_calls: [{ index: json.index ?? 0, function: { arguments: d.partial_json } }] },
+        finish_reason: null,
+      })
+    }
+  }
+
+  if (json.type === "message_delta") {
+    const d = json.delta
+    const finish_reason = (() => {
+      const r = d?.stop_reason
+      if (r === "end_turn") return "stop"
+      if (r === "tool_use") return "tool_calls"
+      if (r === "max_tokens") return "length"
+      if (r === "content_filter") return "content_filter"
+      return null
+    })()
+
+    out.choices.push({ index: 0, delta: {}, finish_reason })
+  }
+
+  if (json.usage) {
+    const u = json.usage
+    out.usage = {
+      prompt_tokens: u.input_tokens,
+      completion_tokens: u.output_tokens,
+      total_tokens: (u.input_tokens || 0) + (u.output_tokens || 0),
+      ...(u.cache_read_input_tokens ? { prompt_tokens_details: { cached_tokens: u.cache_read_input_tokens } } : {}),
+    }
+  }
+
+  return out
+}
+
+export function toAnthropicChunk(chunk: CommonChunk): string {
+  if (!chunk.choices || !Array.isArray(chunk.choices) || chunk.choices.length === 0) {
+    return JSON.stringify({})
+  }
+
+  const choice = chunk.choices[0]
+  const delta = choice.delta
+  if (!delta) return JSON.stringify({})
+
+  const result: any = {}
+
+  if (delta.content) {
+    result.type = "content_block_delta"
+    result.index = 0
+    result.delta = { type: "text_delta", text: delta.content }
+  }
+
+  if (delta.tool_calls) {
+    for (const tc of delta.tool_calls) {
+      if (tc.function?.name) {
+        result.type = "content_block_start"
+        result.index = tc.index ?? 0
+        result.content_block = { type: "tool_use", id: tc.id, name: tc.function.name, input: {} }
+      } else if (tc.function?.arguments) {
+        result.type = "content_block_delta"
+        result.index = tc.index ?? 0
+        result.delta = { type: "input_json_delta", partial_json: tc.function.arguments }
+      }
+    }
+  }
+
+  if (choice.finish_reason) {
+    const stop_reason = (() => {
+      const r = choice.finish_reason
+      if (r === "stop") return "end_turn"
+      if (r === "tool_calls") return "tool_use"
+      if (r === "length") return "max_tokens"
+      if (r === "content_filter") return "content_filter"
+      return null
+    })()
+    result.type = "message_delta"
+    result.delta = { stop_reason, stop_sequence: null }
+  }
+
+  if (chunk.usage) {
+    const u = chunk.usage
+    result.usage = {
+      input_tokens: u.prompt_tokens,
+      output_tokens: u.completion_tokens,
+      cache_read_input_tokens: u.prompt_tokens_details?.cached_tokens,
+    }
+  }
+
+  return JSON.stringify(result)
+}

+ 541 - 0
packages/console/app/src/routes/zen/util/provider/openai-compatible.ts

@@ -0,0 +1,541 @@
+import { ProviderHelper, CommonRequest, CommonResponse, CommonChunk } from "./provider"
+
+type Usage = {
+  prompt_tokens?: number
+  completion_tokens?: number
+  total_tokens?: number
+  // used by moonshot
+  cached_tokens?: number
+  // used by xai
+  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 const oaCompatHelper = {
+  format: "oa-compat",
+  modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
+  modifyHeaders: (headers: Headers, apiKey: string) => {
+    headers.set("authorization", `Bearer ${apiKey}`)
+  },
+  modifyBody: (body: Record<string, any>) => {
+    return {
+      ...body,
+      ...(body.stream ? { stream_options: { include_usage: true } } : {}),
+    }
+  },
+  createUsageParser: () => {
+    let usage: Usage
+
+    return {
+      parse: (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
+      },
+      retrieve: () => usage,
+    }
+  },
+  normalizeUsage: (usage: Usage) => {
+    const inputTokens = usage.prompt_tokens ?? 0
+    const outputTokens = usage.completion_tokens ?? 0
+    const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
+    const cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
+    return {
+      inputTokens: inputTokens - (cacheReadTokens ?? 0),
+      outputTokens,
+      reasoningTokens,
+      cacheReadTokens,
+      cacheWrite5mTokens: undefined,
+      cacheWrite1hTokens: undefined,
+    }
+  },
+} satisfies ProviderHelper
+
+export function fromOaCompatibleRequest(body: any): CommonRequest {
+  if (!body || typeof body !== "object") return body
+
+  const msgsIn = Array.isArray(body.messages) ? body.messages : []
+  const msgsOut: any[] = []
+
+  for (const m of msgsIn) {
+    if (!m || !m.role) continue
+
+    if (m.role === "system") {
+      if (typeof m.content === "string" && m.content.length > 0) msgsOut.push({ role: "system", content: m.content })
+      continue
+    }
+
+    if (m.role === "user") {
+      if (typeof m.content === "string") {
+        msgsOut.push({ role: "user", content: m.content })
+      } else if (Array.isArray(m.content)) {
+        const parts: any[] = []
+        for (const p of m.content) {
+          if (!p || !p.type) continue
+          if (p.type === "text" && typeof p.text === "string") parts.push({ type: "text", text: p.text })
+          if (p.type === "image_url") parts.push({ type: "image_url", image_url: p.image_url })
+        }
+        if (parts.length === 1 && parts[0].type === "text") msgsOut.push({ role: "user", content: parts[0].text })
+        else if (parts.length > 0) msgsOut.push({ role: "user", content: parts })
+      }
+      continue
+    }
+
+    if (m.role === "assistant") {
+      const out: any = { role: "assistant" }
+      if (typeof m.content === "string") out.content = m.content
+      if (Array.isArray(m.tool_calls)) out.tool_calls = m.tool_calls
+      msgsOut.push(out)
+      continue
+    }
+
+    if (m.role === "tool") {
+      msgsOut.push({ role: "tool", tool_call_id: m.tool_call_id, content: m.content })
+      continue
+    }
+  }
+
+  return {
+    max_tokens: body.max_tokens,
+    temperature: body.temperature,
+    top_p: body.top_p,
+    stop: body.stop,
+    messages: msgsOut,
+    stream: !!body.stream,
+    tools: Array.isArray(body.tools) ? body.tools : undefined,
+    tool_choice: body.tool_choice,
+  }
+}
+
+export function toOaCompatibleRequest(body: CommonRequest) {
+  if (!body || typeof body !== "object") return body
+
+  const msgsIn = Array.isArray(body.messages) ? body.messages : []
+  const msgsOut: any[] = []
+
+  const toImg = (p: any) => {
+    if (!p || typeof p !== "object") return undefined
+    if (p.type === "image_url" && p.image_url) return { type: "image_url", image_url: p.image_url }
+    const s = (p as any).source
+    if (!s || typeof s !== "object") return undefined
+    if (s.type === "url" && typeof s.url === "string") return { type: "image_url", image_url: { url: s.url } }
+    if (s.type === "base64" && typeof s.media_type === "string" && typeof s.data === "string")
+      return { type: "image_url", image_url: { url: `data:${s.media_type};base64,${s.data}` } }
+    return undefined
+  }
+
+  for (const m of msgsIn) {
+    if (!m || !m.role) continue
+
+    if (m.role === "system") {
+      if (typeof m.content === "string" && m.content.length > 0) msgsOut.push({ role: "system", content: m.content })
+      continue
+    }
+
+    if (m.role === "user") {
+      if (typeof m.content === "string") {
+        msgsOut.push({ role: "user", content: m.content })
+        continue
+      }
+      if (Array.isArray(m.content)) {
+        const parts: any[] = []
+        for (const p of m.content) {
+          if (!p || !p.type) continue
+          if (p.type === "text" && typeof p.text === "string") parts.push({ type: "text", text: p.text })
+          const ip = toImg(p)
+          if (ip) parts.push(ip)
+        }
+        if (parts.length === 1 && parts[0].type === "text") msgsOut.push({ role: "user", content: parts[0].text })
+        else if (parts.length > 0) msgsOut.push({ role: "user", content: parts })
+      }
+      continue
+    }
+
+    if (m.role === "assistant") {
+      const out: any = { role: "assistant" }
+      if (typeof m.content === "string") out.content = m.content
+      if (Array.isArray(m.tool_calls)) out.tool_calls = m.tool_calls
+      msgsOut.push(out)
+      continue
+    }
+
+    if (m.role === "tool") {
+      msgsOut.push({ role: "tool", tool_call_id: m.tool_call_id, content: m.content })
+      continue
+    }
+  }
+
+  const tools = Array.isArray(body.tools)
+    ? body.tools.map((tool: any) => ({
+        type: "function",
+        function: {
+          name: tool.name,
+          description: tool.description,
+          parameters: tool.parameters,
+        },
+      }))
+    : undefined
+
+  return {
+    model: body.model,
+    max_tokens: body.max_tokens,
+    temperature: body.temperature,
+    top_p: body.top_p,
+    stop: body.stop,
+    messages: msgsOut,
+    stream: !!body.stream,
+    tools,
+    tool_choice: body.tool_choice,
+    response_format: (body as any).response_format,
+  }
+}
+
+export function fromOaCompatibleResponse(resp: any): CommonResponse {
+  if (!resp || typeof resp !== "object") return resp
+
+  if (!Array.isArray((resp as any).choices)) return resp
+
+  const choice = (resp as any).choices[0]
+  if (!choice) return resp
+
+  const message = choice.message
+  if (!message) return resp
+
+  const content: any[] = []
+
+  if (typeof message.content === "string" && message.content.length > 0) {
+    content.push({ type: "text", text: message.content })
+  }
+
+  if (Array.isArray(message.tool_calls)) {
+    for (const toolCall of message.tool_calls) {
+      if (toolCall.type === "function" && toolCall.function) {
+        let input
+        try {
+          input = JSON.parse(toolCall.function.arguments)
+        } catch {
+          input = toolCall.function.arguments
+        }
+        content.push({
+          type: "tool_use",
+          id: toolCall.id,
+          name: toolCall.function.name,
+          input,
+        })
+      }
+    }
+  }
+
+  const stopReason = (() => {
+    const reason = choice.finish_reason
+    if (reason === "stop") return "stop"
+    if (reason === "tool_calls") return "tool_calls"
+    if (reason === "length") return "length"
+    if (reason === "content_filter") return "content_filter"
+    return null
+  })()
+
+  const usage = (() => {
+    const u = (resp as any).usage
+    if (!u) return undefined
+    return {
+      prompt_tokens: u.prompt_tokens,
+      completion_tokens: u.completion_tokens,
+      total_tokens: u.total_tokens,
+      ...(u.prompt_tokens_details?.cached_tokens
+        ? { prompt_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } }
+        : {}),
+    }
+  })()
+
+  return {
+    id: (resp as any).id,
+    object: "chat.completion" as const,
+    created: Math.floor(Date.now() / 1000),
+    model: (resp as any).model,
+    choices: [
+      {
+        index: 0,
+        message: {
+          role: "assistant" as const,
+          ...(content.length > 0 && content.some((c) => c.type === "text")
+            ? {
+                content: content
+                  .filter((c) => c.type === "text")
+                  .map((c: any) => c.text)
+                  .join(""),
+              }
+            : {}),
+          ...(content.length > 0 && content.some((c) => c.type === "tool_use")
+            ? {
+                tool_calls: content
+                  .filter((c) => c.type === "tool_use")
+                  .map((c: any) => ({
+                    id: c.id,
+                    type: "function" as const,
+                    function: {
+                      name: c.name,
+                      arguments: typeof c.input === "string" ? c.input : JSON.stringify(c.input),
+                    },
+                  })),
+              }
+            : {}),
+        },
+        finish_reason: stopReason,
+      },
+    ],
+    ...(usage ? { usage } : {}),
+  }
+}
+
+export function toOaCompatibleResponse(resp: CommonResponse) {
+  if (!resp || typeof resp !== "object") return resp
+
+  if (Array.isArray((resp as any).choices)) return resp
+
+  const isAnthropic = typeof (resp as any).type === "string" && (resp as any).type === "message"
+  if (!isAnthropic) return resp
+
+  const idIn = (resp as any).id
+  const id =
+    typeof idIn === "string" ? idIn.replace(/^msg_/, "chatcmpl_") : `chatcmpl_${Math.random().toString(36).slice(2)}`
+  const model = (resp as any).model
+
+  const blocks: any[] = Array.isArray((resp as any).content) ? (resp as any).content : []
+  const text = blocks
+    .filter((b) => b && b.type === "text" && typeof b.text === "string")
+    .map((b) => b.text)
+    .join("")
+  const tcs = blocks
+    .filter((b) => b && b.type === "tool_use")
+    .map((b) => {
+      const name = (b as any).name
+      const args = (() => {
+        const inp = (b as any).input
+        if (typeof inp === "string") return inp
+        try {
+          return JSON.stringify(inp ?? {})
+        } catch {
+          return String(inp ?? "")
+        }
+      })()
+      const tid =
+        typeof (b as any).id === "string" && (b as any).id.length > 0
+          ? (b as any).id
+          : `toolu_${Math.random().toString(36).slice(2)}`
+      return { id: tid, type: "function" as const, function: { name, arguments: args } }
+    })
+
+  const finish = (r: string | null) => {
+    if (r === "end_turn") return "stop"
+    if (r === "tool_use") return "tool_calls"
+    if (r === "max_tokens") return "length"
+    if (r === "content_filter") return "content_filter"
+    return null
+  }
+
+  const u = (resp as any).usage
+  const usage = (() => {
+    if (!u) return undefined as any
+    const pt = typeof u.input_tokens === "number" ? u.input_tokens : undefined
+    const ct = typeof u.output_tokens === "number" ? u.output_tokens : undefined
+    const total = pt != null && ct != null ? pt + ct : undefined
+    const cached = typeof u.cache_read_input_tokens === "number" ? u.cache_read_input_tokens : undefined
+    const details = cached != null ? { cached_tokens: cached } : undefined
+    return {
+      prompt_tokens: pt,
+      completion_tokens: ct,
+      total_tokens: total,
+      ...(details ? { prompt_tokens_details: details } : {}),
+    }
+  })()
+
+  return {
+    id,
+    object: "chat.completion",
+    created: Math.floor(Date.now() / 1000),
+    model,
+    choices: [
+      {
+        index: 0,
+        message: {
+          role: "assistant",
+          ...(text && text.length > 0 ? { content: text } : {}),
+          ...(tcs.length > 0 ? { tool_calls: tcs } : {}),
+        },
+        finish_reason: finish((resp as any).stop_reason ?? null),
+      },
+    ],
+    ...(usage ? { usage } : {}),
+  }
+}
+
+export function fromOaCompatibleChunk(chunk: string): CommonChunk | string {
+  if (!chunk.startsWith("data: ")) return chunk
+
+  let json
+  try {
+    json = JSON.parse(chunk.slice(6))
+  } catch {
+    return chunk
+  }
+
+  if (!json.choices || !Array.isArray(json.choices) || json.choices.length === 0) {
+    return chunk
+  }
+
+  const choice = json.choices[0]
+  const delta = choice.delta
+
+  if (!delta) return chunk
+
+  const result: CommonChunk = {
+    id: json.id ?? "",
+    object: "chat.completion.chunk",
+    created: json.created ?? Math.floor(Date.now() / 1000),
+    model: json.model ?? "",
+    choices: [],
+  }
+
+  if (delta.content) {
+    result.choices.push({
+      index: choice.index ?? 0,
+      delta: { content: delta.content },
+      finish_reason: null,
+    })
+  }
+
+  if (delta.tool_calls) {
+    for (const toolCall of delta.tool_calls) {
+      result.choices.push({
+        index: choice.index ?? 0,
+        delta: {
+          tool_calls: [
+            {
+              index: toolCall.index ?? 0,
+              id: toolCall.id,
+              type: toolCall.type ?? "function",
+              function: toolCall.function,
+            },
+          ],
+        },
+        finish_reason: null,
+      })
+    }
+  }
+
+  if (choice.finish_reason) {
+    result.choices.push({
+      index: choice.index ?? 0,
+      delta: {},
+      finish_reason: choice.finish_reason,
+    })
+  }
+
+  if (json.usage) {
+    const usage = json.usage
+    result.usage = {
+      prompt_tokens: usage.prompt_tokens,
+      completion_tokens: usage.completion_tokens,
+      total_tokens: usage.total_tokens,
+      ...(usage.prompt_tokens_details?.cached_tokens
+        ? { prompt_tokens_details: { cached_tokens: usage.prompt_tokens_details.cached_tokens } }
+        : {}),
+    }
+  }
+
+  return result
+}
+
+export function toOaCompatibleChunk(chunk: CommonChunk): string {
+  const result: any = {
+    id: chunk.id,
+    object: "chat.completion.chunk",
+    created: chunk.created,
+    model: chunk.model,
+    choices: [],
+  }
+
+  if (!chunk.choices || chunk.choices.length === 0) {
+    return `data: ${JSON.stringify(result)}`
+  }
+
+  const choice = chunk.choices[0]
+  const delta = choice.delta
+
+  if (delta?.role) {
+    result.choices.push({
+      index: choice.index,
+      delta: { role: delta.role },
+      finish_reason: null,
+    })
+  }
+
+  if (delta?.content) {
+    result.choices.push({
+      index: choice.index,
+      delta: { content: delta.content },
+      finish_reason: null,
+    })
+  }
+
+  if (delta?.tool_calls) {
+    for (const tc of delta.tool_calls) {
+      result.choices.push({
+        index: choice.index,
+        delta: {
+          tool_calls: [
+            {
+              index: tc.index,
+              id: tc.id,
+              type: tc.type,
+              function: tc.function,
+            },
+          ],
+        },
+        finish_reason: null,
+      })
+    }
+  }
+
+  if (choice.finish_reason) {
+    result.choices.push({
+      index: choice.index,
+      delta: {},
+      finish_reason: choice.finish_reason,
+    })
+  }
+
+  if (chunk.usage) {
+    result.usage = {
+      prompt_tokens: chunk.usage.prompt_tokens,
+      completion_tokens: chunk.usage.completion_tokens,
+      total_tokens: chunk.usage.total_tokens,
+      ...(chunk.usage.prompt_tokens_details?.cached_tokens
+        ? {
+            prompt_tokens_details: { cached_tokens: chunk.usage.prompt_tokens_details.cached_tokens },
+          }
+        : {}),
+    }
+  }
+
+  return `data: ${JSON.stringify(result)}`
+}

+ 600 - 0
packages/console/app/src/routes/zen/util/provider/openai.ts

@@ -0,0 +1,600 @@
+import { ProviderHelper, CommonRequest, CommonResponse, CommonChunk } from "./provider"
+
+type Usage = {
+  input_tokens?: number
+  input_tokens_details?: {
+    cached_tokens?: number
+  }
+  output_tokens?: number
+  output_tokens_details?: {
+    reasoning_tokens?: number
+  }
+  total_tokens?: number
+}
+
+export const openaiHelper = {
+  format: "openai",
+  modifyUrl: (providerApi: string) => providerApi + "/responses",
+  modifyHeaders: (headers: Headers, apiKey: string) => {
+    headers.set("authorization", `Bearer ${apiKey}`)
+  },
+  modifyBody: (body: Record<string, any>) => {
+    return body
+  },
+  createUsageParser: () => {
+    let usage: Usage
+
+    return {
+      parse: (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
+      },
+      retrieve: () => 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,
+      cacheWrite5mTokens: undefined,
+      cacheWrite1hTokens: undefined,
+    }
+  },
+} satisfies ProviderHelper
+
+export function fromOpenaiRequest(body: any): CommonRequest {
+  if (!body || typeof body !== "object") return body
+
+  const toImg = (p: any) => {
+    if (!p || typeof p !== "object") return undefined
+    if ((p as any).type === "image_url" && (p as any).image_url)
+      return { type: "image_url", image_url: (p as any).image_url }
+    if ((p as any).type === "input_image" && (p as any).image_url)
+      return { type: "image_url", image_url: (p as any).image_url }
+    const s = (p as any).source
+    if (!s || typeof s !== "object") return undefined
+    if ((s as any).type === "url" && typeof (s as any).url === "string")
+      return { type: "image_url", image_url: { url: (s as any).url } }
+    if (
+      (s as any).type === "base64" &&
+      typeof (s as any).media_type === "string" &&
+      typeof (s as any).data === "string"
+    )
+      return { type: "image_url", image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` } }
+    return undefined
+  }
+
+  const msgs: any[] = []
+
+  const inMsgs = Array.isArray(body.input) ? body.input : Array.isArray(body.messages) ? body.messages : []
+
+  for (const m of inMsgs) {
+    if (!m) continue
+
+    // Responses API items without role:
+    if (!(m as any).role && (m as any).type) {
+      if ((m as any).type === "function_call") {
+        const name = (m as any).name
+        const a = (m as any).arguments
+        const args = typeof a === "string" ? a : JSON.stringify(a ?? {})
+        msgs.push({
+          role: "assistant",
+          tool_calls: [{ id: (m as any).id, type: "function", function: { name, arguments: args } }],
+        })
+      }
+      if ((m as any).type === "function_call_output") {
+        const id = (m as any).call_id
+        const out = (m as any).output
+        const content = typeof out === "string" ? out : JSON.stringify(out)
+        msgs.push({ role: "tool", tool_call_id: id, content })
+      }
+      continue
+    }
+
+    if ((m as any).role === "system" || (m as any).role === "developer") {
+      const c = (m as any).content
+      if (typeof c === "string" && c.length > 0) msgs.push({ role: "system", content: c })
+      if (Array.isArray(c)) {
+        const t = c.find((p: any) => p && typeof p.text === "string")
+        if (t && typeof t.text === "string" && t.text.length > 0) msgs.push({ role: "system", content: t.text })
+      }
+      continue
+    }
+
+    if ((m as any).role === "user") {
+      const c = (m as any).content
+      if (typeof c === "string") {
+        msgs.push({ role: "user", content: c })
+      } else if (Array.isArray(c)) {
+        const parts: any[] = []
+        for (const p of c) {
+          if (!p || !(p as any).type) continue
+          if (((p as any).type === "text" || (p as any).type === "input_text") && typeof (p as any).text === "string")
+            parts.push({ type: "text", text: (p as any).text })
+          const ip = toImg(p)
+          if (ip) parts.push(ip)
+          if ((p as any).type === "tool_result") {
+            const id = (p as any).tool_call_id
+            const content =
+              typeof (p as any).content === "string" ? (p as any).content : JSON.stringify((p as any).content)
+            msgs.push({ role: "tool", tool_call_id: id, content })
+          }
+        }
+        if (parts.length === 1 && parts[0].type === "text") msgs.push({ role: "user", content: parts[0].text })
+        else if (parts.length > 0) msgs.push({ role: "user", content: parts })
+      }
+      continue
+    }
+
+    if ((m as any).role === "assistant") {
+      const c = (m as any).content
+      const out: any = { role: "assistant" }
+      if (typeof c === "string" && c.length > 0) out.content = c
+      if (Array.isArray((m as any).tool_calls)) out.tool_calls = (m as any).tool_calls
+      msgs.push(out)
+      continue
+    }
+
+    if ((m as any).role === "tool") {
+      msgs.push({ role: "tool", tool_call_id: (m as any).tool_call_id, content: (m as any).content })
+      continue
+    }
+  }
+
+  const tcIn = body.tool_choice
+  const tc = (() => {
+    if (!tcIn) return undefined
+    if (tcIn === "auto") return "auto"
+    if (tcIn === "required") return "required"
+    if ((tcIn as any).type === "function" && (tcIn as any).function?.name)
+      return { type: "function" as const, function: { name: (tcIn as any).function.name } }
+    return undefined
+  })()
+
+  const stop = (() => {
+    const v = body.stop_sequences ?? body.stop
+    if (!v) return undefined
+    if (Array.isArray(v)) return v.length === 1 ? v[0] : v
+    if (typeof v === "string") return v
+    return undefined
+  })()
+
+  return {
+    max_tokens: body.max_output_tokens ?? body.max_tokens,
+    temperature: body.temperature,
+    top_p: body.top_p,
+    stop,
+    messages: msgs,
+    stream: !!body.stream,
+    tools: Array.isArray(body.tools) ? body.tools : undefined,
+    tool_choice: tc,
+  }
+}
+
+export function toOpenaiRequest(body: CommonRequest) {
+  if (!body || typeof body !== "object") return body
+
+  const msgsIn = Array.isArray(body.messages) ? body.messages : []
+  const input: any[] = []
+
+  const toPart = (p: any) => {
+    if (!p || typeof p !== "object") return undefined
+    if ((p as any).type === "text" && typeof (p as any).text === "string")
+      return { type: "input_text", text: (p as any).text }
+    if ((p as any).type === "image_url" && (p as any).image_url)
+      return { type: "input_image", image_url: (p as any).image_url }
+    const s = (p as any).source
+    if (!s || typeof s !== "object") return undefined
+    if ((s as any).type === "url" && typeof (s as any).url === "string")
+      return { type: "input_image", image_url: { url: (s as any).url } }
+    if (
+      (s as any).type === "base64" &&
+      typeof (s as any).media_type === "string" &&
+      typeof (s as any).data === "string"
+    )
+      return { type: "input_image", image_url: { url: `data:${(s as any).media_type};base64,${(s as any).data}` } }
+    return undefined
+  }
+
+  for (const m of msgsIn) {
+    if (!m || !(m as any).role) continue
+
+    if ((m as any).role === "system") {
+      const c = (m as any).content
+      if (typeof c === "string") input.push({ role: "system", content: c })
+      continue
+    }
+
+    if ((m as any).role === "user") {
+      const c = (m as any).content
+      if (typeof c === "string") {
+        input.push({ role: "user", content: [{ type: "input_text", text: c }] })
+      } else if (Array.isArray(c)) {
+        const parts: any[] = []
+        for (const p of c) {
+          const op = toPart(p)
+          if (op) parts.push(op)
+        }
+        if (parts.length > 0) input.push({ role: "user", content: parts })
+      }
+      continue
+    }
+
+    if ((m as any).role === "assistant") {
+      const c = (m as any).content
+      if (typeof c === "string" && c.length > 0) {
+        input.push({ role: "assistant", content: [{ type: "output_text", text: c }] })
+      }
+      if (Array.isArray((m as any).tool_calls)) {
+        for (const tc of (m as any).tool_calls) {
+          if ((tc as any).type === "function" && (tc as any).function) {
+            const name = (tc as any).function.name
+            const a = (tc as any).function.arguments
+            const args = typeof a === "string" ? a : JSON.stringify(a)
+            input.push({ type: "function_call", call_id: (tc as any).id, name, arguments: args })
+          }
+        }
+      }
+      continue
+    }
+
+    if ((m as any).role === "tool") {
+      const out = typeof (m as any).content === "string" ? (m as any).content : JSON.stringify((m as any).content)
+      input.push({ type: "function_call_output", call_id: (m as any).tool_call_id, output: out })
+      continue
+    }
+  }
+
+  const stop_sequences = (() => {
+    const v = body.stop
+    if (!v) return undefined
+    if (Array.isArray(v)) return v
+    if (typeof v === "string") return [v]
+    return undefined
+  })()
+
+  const tcIn = body.tool_choice
+  const tool_choice = (() => {
+    if (!tcIn) return undefined
+    if (tcIn === "auto") return "auto"
+    if (tcIn === "required") return "required"
+    if ((tcIn as any).type === "function" && (tcIn as any).function?.name)
+      return { type: "function", function: { name: (tcIn as any).function.name } }
+    return undefined
+  })()
+
+  const tools = (() => {
+    if (!Array.isArray(body.tools)) return undefined
+    return body.tools.map((tool: any) => {
+      if (tool.type === "function") {
+        return {
+          type: "function",
+          name: tool.function?.name,
+          description: tool.function?.description,
+          parameters: tool.function?.parameters,
+          strict: tool.function?.strict,
+        }
+      }
+      return tool
+    })
+  })()
+
+  return {
+    model: body.model,
+    input,
+    max_output_tokens: body.max_tokens,
+    top_p: body.top_p,
+    stop_sequences,
+    stream: !!body.stream,
+    tools,
+    tool_choice,
+    include: Array.isArray((body as any).include) ? (body as any).include : undefined,
+    truncation: (body as any).truncation,
+    metadata: (body as any).metadata,
+    store: (body as any).store,
+    user: (body as any).user,
+    text: { verbosity: "low" },
+    reasoning: { effort: "medium" },
+  }
+}
+
+export function fromOpenaiResponse(resp: any): CommonResponse {
+  if (!resp || typeof resp !== "object") return resp
+  if (Array.isArray((resp as any).choices)) return resp
+
+  const r = (resp as any).response ?? resp
+  if (!r || typeof r !== "object") return resp
+
+  const idIn = (r as any).id
+  const id =
+    typeof idIn === "string" ? idIn.replace(/^resp_/, "chatcmpl_") : `chatcmpl_${Math.random().toString(36).slice(2)}`
+  const model = (r as any).model ?? (resp as any).model
+
+  const out = Array.isArray((r as any).output) ? (r as any).output : []
+  const text = out
+    .filter((o: any) => o && o.type === "message" && Array.isArray((o as any).content))
+    .flatMap((o: any) => (o as any).content)
+    .filter((p: any) => p && p.type === "output_text" && typeof p.text === "string")
+    .map((p: any) => p.text)
+    .join("")
+
+  const tcs = out
+    .filter((o: any) => o && o.type === "function_call")
+    .map((o: any) => {
+      const name = (o as any).name
+      const a = (o as any).arguments
+      const args = typeof a === "string" ? a : JSON.stringify(a ?? {})
+      const tid =
+        typeof (o as any).id === "string" && (o as any).id.length > 0
+          ? (o as any).id
+          : `toolu_${Math.random().toString(36).slice(2)}`
+      return { id: tid, type: "function" as const, function: { name, arguments: args } }
+    })
+
+  const finish = (r: string | null) => {
+    if (r === "stop") return "stop"
+    if (r === "tool_call" || r === "tool_calls") return "tool_calls"
+    if (r === "length" || r === "max_output_tokens") return "length"
+    if (r === "content_filter") return "content_filter"
+    return null
+  }
+
+  const u = (r as any).usage ?? (resp as any).usage
+  const usage = (() => {
+    if (!u) return undefined as any
+    const pt = typeof (u as any).input_tokens === "number" ? (u as any).input_tokens : undefined
+    const ct = typeof (u as any).output_tokens === "number" ? (u as any).output_tokens : undefined
+    const total = pt != null && ct != null ? pt + ct : undefined
+    const cached = (u as any).input_tokens_details?.cached_tokens
+    const details = typeof cached === "number" ? { cached_tokens: cached } : undefined
+    return {
+      prompt_tokens: pt,
+      completion_tokens: ct,
+      total_tokens: total,
+      ...(details ? { prompt_tokens_details: details } : {}),
+    }
+  })()
+
+  return {
+    id,
+    object: "chat.completion",
+    created: Math.floor(Date.now() / 1000),
+    model,
+    choices: [
+      {
+        index: 0,
+        message: {
+          role: "assistant",
+          ...(text && text.length > 0 ? { content: text } : {}),
+          ...(tcs.length > 0 ? { tool_calls: tcs } : {}),
+        },
+        finish_reason: finish((r as any).stop_reason ?? null),
+      },
+    ],
+    ...(usage ? { usage } : {}),
+  }
+}
+
+export function toOpenaiResponse(resp: CommonResponse) {
+  if (!resp || typeof resp !== "object") return resp
+  if (!Array.isArray((resp as any).choices)) return resp
+
+  const choice = (resp as any).choices[0]
+  if (!choice) return resp
+
+  const msg = choice.message
+  if (!msg) return resp
+
+  const outputItems: any[] = []
+
+  if (typeof msg.content === "string" && msg.content.length > 0) {
+    outputItems.push({
+      id: `msg_${Math.random().toString(36).slice(2)}`,
+      type: "message",
+      status: "completed",
+      role: "assistant",
+      content: [{ type: "output_text", text: msg.content, annotations: [], logprobs: [] }],
+    })
+  }
+
+  if (Array.isArray(msg.tool_calls)) {
+    for (const tc of msg.tool_calls) {
+      if ((tc as any).type === "function" && (tc as any).function) {
+        outputItems.push({
+          id: (tc as any).id,
+          type: "function_call",
+          name: (tc as any).function.name,
+          call_id: (tc as any).id,
+          arguments: (tc as any).function.arguments,
+        })
+      }
+    }
+  }
+
+  const stop_reason = (() => {
+    const r = choice.finish_reason
+    if (r === "stop") return "stop"
+    if (r === "tool_calls") return "tool_call"
+    if (r === "length") return "max_output_tokens"
+    if (r === "content_filter") return "content_filter"
+    return null
+  })()
+
+  const usage = (() => {
+    const u = (resp as any).usage
+    if (!u) return undefined
+    return {
+      input_tokens: u.prompt_tokens,
+      output_tokens: u.completion_tokens,
+      total_tokens: u.total_tokens,
+      ...(u.prompt_tokens_details?.cached_tokens
+        ? { input_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } }
+        : {}),
+    }
+  })()
+
+  return {
+    id: (resp as any).id?.replace(/^chatcmpl_/, "resp_") ?? `resp_${Math.random().toString(36).slice(2)}`,
+    object: "response",
+    model: (resp as any).model,
+    output: outputItems,
+    stop_reason,
+    usage,
+  }
+}
+
+export function fromOpenaiChunk(chunk: string): CommonChunk | string {
+  const lines = chunk.split("\n")
+  const ev = lines[0]
+  const dl = lines[1]
+  if (!ev || !dl || !dl.startsWith("data: ")) return chunk
+
+  let json: any
+  try {
+    json = JSON.parse(dl.slice(6))
+  } catch {
+    return chunk
+  }
+
+  const respObj = json.response ?? {}
+
+  const out: CommonChunk = {
+    id: respObj.id ?? json.id ?? "",
+    object: "chat.completion.chunk",
+    created: Math.floor(Date.now() / 1000),
+    model: respObj.model ?? json.model ?? "",
+    choices: [],
+  }
+
+  const e = ev.replace("event: ", "").trim()
+
+  if (e === "response.output_text.delta") {
+    const d = (json as any).delta ?? (json as any).text ?? (json as any).output_text_delta
+    if (typeof d === "string" && d.length > 0)
+      out.choices.push({ index: 0, delta: { content: d }, finish_reason: null })
+  }
+
+  if (e === "response.output_item.added" && (json as any).item?.type === "function_call") {
+    const name = (json as any).item?.name
+    const id = (json as any).item?.id
+    if (typeof name === "string" && name.length > 0) {
+      out.choices.push({
+        index: 0,
+        delta: { tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: "" } }] },
+        finish_reason: null,
+      })
+    }
+  }
+
+  if (e === "response.function_call_arguments.delta") {
+    const a = (json as any).delta ?? (json as any).arguments_delta
+    if (typeof a === "string" && a.length > 0) {
+      out.choices.push({
+        index: 0,
+        delta: { tool_calls: [{ index: 0, function: { arguments: a } }] },
+        finish_reason: null,
+      })
+    }
+  }
+
+  if (e === "response.completed") {
+    const fr = (() => {
+      const sr = (respObj as any).stop_reason ?? (json as any).stop_reason
+      if (sr === "stop") return "stop"
+      if (sr === "tool_call" || sr === "tool_calls") return "tool_calls"
+      if (sr === "length" || sr === "max_output_tokens") return "length"
+      if (sr === "content_filter") return "content_filter"
+      return null
+    })()
+    out.choices.push({ index: 0, delta: {}, finish_reason: fr })
+
+    const u = (respObj as any).usage ?? (json as any).response?.usage
+    if (u) {
+      out.usage = {
+        prompt_tokens: u.input_tokens,
+        completion_tokens: u.output_tokens,
+        total_tokens: (u.input_tokens || 0) + (u.output_tokens || 0),
+        ...(u.input_tokens_details?.cached_tokens
+          ? { prompt_tokens_details: { cached_tokens: u.input_tokens_details.cached_tokens } }
+          : {}),
+      }
+    }
+  }
+
+  return out
+}
+
+export function toOpenaiChunk(chunk: CommonChunk): string {
+  if (!chunk.choices || !Array.isArray(chunk.choices) || chunk.choices.length === 0) {
+    return ""
+  }
+
+  const choice = chunk.choices[0]
+  const d = choice.delta
+  if (!d) return ""
+
+  const id = chunk.id
+  const model = chunk.model
+
+  if (d.content) {
+    const data = { id, type: "response.output_text.delta", delta: d.content, response: { id, model } }
+    return `event: response.output_text.delta\ndata: ${JSON.stringify(data)}`
+  }
+
+  if (d.tool_calls) {
+    for (const tc of d.tool_calls) {
+      if (tc.function?.name) {
+        const data = {
+          type: "response.output_item.added",
+          output_index: 0,
+          item: { id: tc.id, type: "function_call", name: tc.function.name, call_id: tc.id, arguments: "" },
+        }
+        return `event: response.output_item.added\ndata: ${JSON.stringify(data)}`
+      }
+      if (tc.function?.arguments) {
+        const data = {
+          type: "response.function_call_arguments.delta",
+          output_index: 0,
+          delta: tc.function.arguments,
+        }
+        return `event: response.function_call_arguments.delta\ndata: ${JSON.stringify(data)}`
+      }
+    }
+  }
+
+  if (choice.finish_reason) {
+    const u = chunk.usage
+    const usage = u
+      ? {
+          input_tokens: u.prompt_tokens,
+          output_tokens: u.completion_tokens,
+          total_tokens: u.total_tokens,
+          ...(u.prompt_tokens_details?.cached_tokens
+            ? { input_tokens_details: { cached_tokens: u.prompt_tokens_details.cached_tokens } }
+            : {}),
+        }
+      : undefined
+
+    const data: any = { id, type: "response.completed", response: { id, model, ...(usage ? { usage } : {}) } }
+    return `event: response.completed\ndata: ${JSON.stringify(data)}`
+  }
+
+  return ""
+}

+ 207 - 0
packages/console/app/src/routes/zen/util/provider/provider.ts

@@ -0,0 +1,207 @@
+import { Format } from "../format"
+
+import {
+  fromAnthropicChunk,
+  fromAnthropicRequest,
+  fromAnthropicResponse,
+  toAnthropicChunk,
+  toAnthropicRequest,
+  toAnthropicResponse,
+} from "./anthropic"
+import {
+  fromOpenaiChunk,
+  fromOpenaiRequest,
+  fromOpenaiResponse,
+  toOpenaiChunk,
+  toOpenaiRequest,
+  toOpenaiResponse,
+} from "./openai"
+import {
+  fromOaCompatibleChunk,
+  fromOaCompatibleRequest,
+  fromOaCompatibleResponse,
+  toOaCompatibleChunk,
+  toOaCompatibleRequest,
+  toOaCompatibleResponse,
+} from "./openai-compatible"
+
+export type ProviderHelper = {
+  format: Format
+  modifyUrl: (providerApi: string) => string
+  modifyHeaders: (headers: Headers, apiKey: string) => void
+  modifyBody: (body: Record<string, any>) => Record<string, any>
+  createUsageParser: () => {
+    parse: (chunk: string) => void
+    retrieve: () => any
+  }
+  normalizeUsage: (usage: any) => {
+    inputTokens: number
+    outputTokens: number
+    reasoningTokens?: number
+    cacheReadTokens?: number
+    cacheWrite5mTokens?: number
+    cacheWrite1hTokens?: number
+  }
+}
+
+export interface CommonMessage {
+  role: "system" | "user" | "assistant" | "tool"
+  content?: string | Array<CommonContentPart>
+  tool_call_id?: string
+  tool_calls?: CommonToolCall[]
+}
+
+export interface CommonContentPart {
+  type: "text" | "image_url"
+  text?: string
+  image_url?: { url: string }
+}
+
+export interface CommonToolCall {
+  id: string
+  type: "function"
+  function: {
+    name: string
+    arguments: string
+  }
+}
+
+export interface CommonTool {
+  type: "function"
+  function: {
+    name: string
+    description?: string
+    parameters?: Record<string, any>
+  }
+}
+
+export interface CommonUsage {
+  input_tokens?: number
+  output_tokens?: number
+  total_tokens?: number
+  prompt_tokens?: number
+  completion_tokens?: number
+  cache_read_input_tokens?: number
+  cache_creation?: {
+    ephemeral_5m_input_tokens?: number
+    ephemeral_1h_input_tokens?: number
+  }
+  input_tokens_details?: {
+    cached_tokens?: number
+  }
+  output_tokens_details?: {
+    reasoning_tokens?: number
+  }
+}
+
+export interface CommonRequest {
+  model?: string
+  max_tokens?: number
+  temperature?: number
+  top_p?: number
+  stop?: string | string[]
+  messages: CommonMessage[]
+  stream?: boolean
+  tools?: CommonTool[]
+  tool_choice?: "auto" | "required" | { type: "function"; function: { name: string } }
+}
+
+export interface CommonResponse {
+  id: string
+  object: "chat.completion"
+  created: number
+  model: string
+  choices: Array<{
+    index: number
+    message: {
+      role: "assistant"
+      content?: string
+      tool_calls?: CommonToolCall[]
+    }
+    finish_reason: "stop" | "tool_calls" | "length" | "content_filter" | null
+  }>
+  usage?: {
+    prompt_tokens?: number
+    completion_tokens?: number
+    total_tokens?: number
+    prompt_tokens_details?: { cached_tokens?: number }
+  }
+}
+
+export interface CommonChunk {
+  id: string
+  object: "chat.completion.chunk"
+  created: number
+  model: string
+  choices: Array<{
+    index: number
+    delta: {
+      role?: "assistant"
+      content?: string
+      tool_calls?: Array<{
+        index: number
+        id?: string
+        type?: "function"
+        function?: {
+          name?: string
+          arguments?: string
+        }
+      }>
+    }
+    finish_reason: "stop" | "tool_calls" | "length" | "content_filter" | null
+  }>
+  usage?: {
+    prompt_tokens?: number
+    completion_tokens?: number
+    total_tokens?: number
+    prompt_tokens_details?: { cached_tokens?: number }
+  }
+}
+
+export function createBodyConverter(from: Format, to: Format) {
+  return (body: any): any => {
+    if (from === to) return body
+
+    let raw: CommonRequest
+    if (from === "anthropic") raw = fromAnthropicRequest(body)
+    else if (from === "openai") raw = fromOpenaiRequest(body)
+    else raw = fromOaCompatibleRequest(body)
+
+    if (to === "anthropic") return toAnthropicRequest(raw)
+    if (to === "openai") return toOpenaiRequest(raw)
+    if (to === "oa-compat") return toOaCompatibleRequest(raw)
+  }
+}
+
+export function createStreamPartConverter(from: Format, to: Format) {
+  return (part: any): any => {
+    if (from === to) return part
+
+    let raw: CommonChunk | string
+    if (from === "anthropic") raw = fromAnthropicChunk(part)
+    else if (from === "openai") raw = fromOpenaiChunk(part)
+    else raw = fromOaCompatibleChunk(part)
+
+    // If result is a string (error case), pass it through
+    if (typeof raw === "string") return raw
+
+    if (to === "anthropic") return toAnthropicChunk(raw)
+    if (to === "openai") return toOpenaiChunk(raw)
+    if (to === "oa-compat") return toOaCompatibleChunk(raw)
+  }
+}
+
+export function createResponseConverter(from: Format, to: Format) {
+  return (response: any): any => {
+    if (from === to) return response
+
+    let raw: CommonResponse
+    if (from === "anthropic") raw = fromAnthropicResponse(response)
+    else if (from === "openai") raw = fromOpenaiResponse(response)
+    else raw = fromOaCompatibleResponse(response)
+
+    if (to === "anthropic") return toAnthropicResponse(raw)
+    if (to === "openai") return toOpenaiResponse(raw)
+    if (to === "oa-compat") return toOaCompatibleResponse(raw)
+  }
+}

+ 2 - 56
packages/console/app/src/routes/zen/v1/chat/completions.ts

@@ -1,63 +1,9 @@
 import type { APIEvent } from "@solidjs/start/server"
-import { handler } from "~/routes/zen/handler"
-
-type Usage = {
-  prompt_tokens?: number
-  completion_tokens?: number
-  total_tokens?: number
-  // used by moonshot
-  cached_tokens?: number
-  // used by xai
-  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
-  }
-}
+import { handler } from "~/routes/zen/util/handler"
 
 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}`)
-    },
+    format: "oa-compat",
     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) => {
-      const inputTokens = usage.prompt_tokens ?? 0
-      const outputTokens = usage.completion_tokens ?? 0
-      const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
-      const cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
-      return {
-        inputTokens: inputTokens - (cacheReadTokens ?? 0),
-        outputTokens: outputTokens - (reasoningTokens ?? 0),
-        reasoningTokens,
-        cacheReadTokens,
-      }
-    },
   })
 }

+ 2 - 57
packages/console/app/src/routes/zen/v1/messages.ts

@@ -1,64 +1,9 @@
 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
-  }
-}
+import { handler } from "~/routes/zen/util/handler"
 
 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),
+    format: "anthropic",
     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))
-      } catch (e) {
-        return
-      }
-
-      // ie. { type: "message_start"; message: { usage: Usage } }
-      // ie. { type: "message_delta"; usage: Usage }
-      const usageUpdate = json.usage ?? json.message?.usage
-      if (!usageUpdate) return
-      usage = {
-        ...usage,
-        ...usageUpdate,
-        cache_creation: {
-          ...usage?.cache_creation,
-          ...usageUpdate.cache_creation,
-        },
-        server_tool_use: {
-          ...usage?.server_tool_use,
-          ...usageUpdate.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,
-    }),
   })
 }

+ 60 - 0
packages/console/app/src/routes/zen/v1/models.ts

@@ -0,0 +1,60 @@
+import type { APIEvent } from "@solidjs/start/server"
+import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
+import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
+import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
+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) {
+  return new Response(null, {
+    status: 200,
+    headers: {
+      "Access-Control-Allow-Origin": "*",
+      "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
+      "Access-Control-Allow-Headers": "Content-Type, Authorization",
+    },
+  })
+}
+
+export async function GET(input: APIEvent) {
+  const zenData = ZenData.list()
+  const disabledModels = await authenticate()
+
+  return new Response(
+    JSON.stringify({
+      object: "list",
+      data: Object.entries(zenData.models)
+        .filter(([id]) => !disabledModels.includes(id))
+        .map(([id, model]) => ({
+          id: `opencode/${id}`,
+          object: "model",
+          created: Math.floor(Date.now() / 1000),
+          owned_by: "opencode",
+        })),
+    }),
+    {
+      headers: {
+        "Content-Type": "application/json",
+      },
+    },
+  )
+
+  async function authenticate() {
+    const apiKey = input.request.headers.get("authorization")?.split(" ")[1]
+    if (!apiKey) return []
+
+    const disabledModels = await Database.use((tx) =>
+      tx
+        .select({
+          model: ModelTable.model,
+        })
+        .from(KeyTable)
+        .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, KeyTable.workspaceID))
+        .leftJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), isNull(ModelTable.timeDeleted)))
+        .where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
+        .then((rows) => rows.map((row) => row.model)),
+    )
+
+    return disabledModels
+  }
+}

+ 2 - 45
packages/console/app/src/routes/zen/v1/responses.ts

@@ -1,52 +1,9 @@
 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
-}
+import { handler } from "~/routes/zen/util/handler"
 
 export function POST(input: APIEvent) {
-  let usage: Usage
   return handler(input, {
-    setAuthHeader: (headers: Headers, apiKey: string) => {
-      headers.set("authorization", `Bearer ${apiKey}`)
-    },
+    format: "openai",
     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,
-      }
-    },
   })
 }