From 71abca9571b74830908bf5d2aff0c9864b1c5191 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 27 Oct 2025 17:00:49 -0400 Subject: [PATCH] wip: zen --- .../console/app/src/routes/zen/util/error.ts | 5 + .../console/app/src/routes/zen/util/format.ts | 1 + .../app/src/routes/zen/{ => util}/handler.ts | 115 ++-- .../console/app/src/routes/zen/util/logger.ts | 12 + .../src/routes/zen/util/provider/anthropic.ts | 618 ++++++++++++++++++ .../zen/util/provider/openai-compatible.ts | 541 +++++++++++++++ .../src/routes/zen/util/provider/openai.ts | 600 +++++++++++++++++ .../src/routes/zen/util/provider/provider.ts | 207 ++++++ .../app/src/routes/zen/v1/chat/completions.ts | 58 +- .../console/app/src/routes/zen/v1/messages.ts | 59 +- .../console/app/src/routes/zen/v1/models.ts | 60 ++ .../app/src/routes/zen/v1/responses.ts | 47 +- 12 files changed, 2108 insertions(+), 215 deletions(-) create mode 100644 packages/console/app/src/routes/zen/util/error.ts create mode 100644 packages/console/app/src/routes/zen/util/format.ts rename packages/console/app/src/routes/zen/{ => util}/handler.ts (86%) create mode 100644 packages/console/app/src/routes/zen/util/logger.ts create mode 100644 packages/console/app/src/routes/zen/util/provider/anthropic.ts create mode 100644 packages/console/app/src/routes/zen/util/provider/openai-compatible.ts create mode 100644 packages/console/app/src/routes/zen/util/provider/openai.ts create mode 100644 packages/console/app/src/routes/zen/util/provider/provider.ts create mode 100644 packages/console/app/src/routes/zen/v1/models.ts diff --git a/packages/console/app/src/routes/zen/util/error.ts b/packages/console/app/src/routes/zen/util/error.ts new file mode 100644 index 00000000..dfc7e9fc --- /dev/null +++ b/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 {} diff --git a/packages/console/app/src/routes/zen/util/format.ts b/packages/console/app/src/routes/zen/util/format.ts new file mode 100644 index 00000000..53a07496 --- /dev/null +++ b/packages/console/app/src/routes/zen/util/format.ts @@ -0,0 +1 @@ +export type Format = "anthropic" | "openai" | "oa-compat" diff --git a/packages/console/app/src/routes/zen/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts similarity index 86% rename from packages/console/app/src/routes/zen/handler.ts rename to packages/console/app/src/routes/zen/util/handler.ts index 67b03ab0..7fbb518a 100644 --- a/packages/console/app/src/routes/zen/handler.ts +++ b/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> +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> - type Model = ZenData["models"][string] - const FREE_WORKSPACES = [ "wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank "wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench ] - const logger = { - metric: (values: Record) => { - 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>, modelInfo: ReturnType, - providerId: string, + providerInfo: Awaited>, 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, diff --git a/packages/console/app/src/routes/zen/util/logger.ts b/packages/console/app/src/routes/zen/util/logger.ts new file mode 100644 index 00000000..aef46ddd --- /dev/null +++ b/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) => { + console.log(`_metric:${JSON.stringify(values)}`) + }, + log: console.log, + debug: (message: string) => { + if (Resource.App.stage === "production") return + console.debug(message) + }, +} diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts new file mode 100644 index 00000000..64b040a5 --- /dev/null +++ b/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) => { + 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: \n" + "data: " + 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) +} diff --git a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts new file mode 100644 index 00000000..aae6bed5 --- /dev/null +++ b/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) => { + 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)}` +} diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts new file mode 100644 index 00000000..9781d821 --- /dev/null +++ b/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) => { + 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 "" +} diff --git a/packages/console/app/src/routes/zen/util/provider/provider.ts b/packages/console/app/src/routes/zen/util/provider/provider.ts new file mode 100644 index 00000000..5beb460e --- /dev/null +++ b/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) => Record + 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 + 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 + } +} + +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) + } +} diff --git a/packages/console/app/src/routes/zen/v1/chat/completions.ts b/packages/console/app/src/routes/zen/v1/chat/completions.ts index 33c16247..44326e79 100644 --- a/packages/console/app/src/routes/zen/v1/chat/completions.ts +++ b/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, - } - }, }) } diff --git a/packages/console/app/src/routes/zen/v1/messages.ts b/packages/console/app/src/routes/zen/v1/messages.ts index 4a7dda5f..4478b644 100644 --- a/packages/console/app/src/routes/zen/v1/messages.ts +++ b/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, - }), }) } diff --git a/packages/console/app/src/routes/zen/v1/models.ts b/packages/console/app/src/routes/zen/v1/models.ts new file mode 100644 index 00000000..ad5769bb --- /dev/null +++ b/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 + } +} diff --git a/packages/console/app/src/routes/zen/v1/responses.ts b/packages/console/app/src/routes/zen/v1/responses.ts index 486c129b..eadc5bc8 100644 --- a/packages/console/app/src/routes/zen/v1/responses.ts +++ b/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, - } - }, }) }