From 7283bfa480b163da7ea66250f4782747293afdfd Mon Sep 17 00:00:00 2001 From: Frank Date: Tue, 18 Nov 2025 14:28:27 -0500 Subject: [PATCH] zen: gemini --- .../app/src/routes/zen/util/handler.ts | 17 +++-- .../src/routes/zen/util/provider/anthropic.ts | 1 + .../src/routes/zen/util/provider/google.ts | 74 +++++++++++++++++++ .../zen/util/provider/openai-compatible.ts | 1 + .../src/routes/zen/util/provider/openai.ts | 1 + .../src/routes/zen/util/provider/provider.ts | 3 +- .../app/src/routes/zen/v1/chat/completions.ts | 2 + .../console/app/src/routes/zen/v1/messages.ts | 2 + .../app/src/routes/zen/v1/models/[model].ts | 13 ++++ .../app/src/routes/zen/v1/responses.ts | 2 + packages/console/core/src/model.ts | 2 +- 11 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 packages/console/app/src/routes/zen/util/provider/google.ts create mode 100644 packages/console/app/src/routes/zen/v1/models/[model].ts diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 70df7d7cd..3453a6d38 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -15,6 +15,7 @@ import { logger } from "./logger" import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error" import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider" import { anthropicHelper } from "./provider/anthropic" +import { googleHelper } from "./provider/google" import { openaiHelper } from "./provider/openai" import { oaCompatHelper } from "./provider/openai-compatible" import { createRateLimiter } from "./rateLimiter" @@ -30,6 +31,8 @@ export async function handler( opts: { format: ZenData.Format parseApiKey: (headers: Headers) => string | undefined + parseModel: (url: string, body: any) => string + parseIsStream: (url: string, body: any) => boolean }, ) { type AuthInfo = Awaited> @@ -43,15 +46,18 @@ export async function handler( ] try { + const url = input.request.url const body = await input.request.json() const ip = input.request.headers.get("x-real-ip") ?? "" + const model = opts.parseModel(url, body) + const isStream = opts.parseIsStream(url, body) logger.metric({ - is_tream: !!body.stream, + is_tream: isStream, session: input.request.headers.get("x-opencode-session"), request: input.request.headers.get("x-opencode-request"), }) const zenData = ZenData.list() - const modelInfo = validateModel(zenData, body.model) + const modelInfo = validateModel(zenData, model) const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip) await rateLimiter?.check() @@ -64,7 +70,7 @@ export async function handler( logger.metric({ provider: providerInfo.id }) const startTimestamp = Date.now() - const reqUrl = providerInfo.modifyUrl(providerInfo.api) + const reqUrl = providerInfo.modifyUrl(providerInfo.api, providerInfo.model, isStream) const reqBody = JSON.stringify( providerInfo.modifyBody({ ...createBodyConverter(opts.format, providerInfo.format)(body), @@ -114,7 +120,7 @@ export async function handler( logger.debug("STATUS: " + res.status + " " + res.statusText) // Handle non-streaming response - if (!body.stream) { + if (!isStream) { const responseConverter = createResponseConverter(providerInfo.format, opts.format) const json = await res.json() const body = JSON.stringify(responseConverter(json)) @@ -169,7 +175,7 @@ export async function handler( responseLength += value.length buffer += decoder.decode(value, { stream: true }) - const parts = buffer.split("\n\n") + const parts = buffer.split(providerInfo.streamSeparator) buffer = parts.pop() ?? "" for (let part of parts) { @@ -283,6 +289,7 @@ export async function handler( ...(() => { const format = zenData.providers[provider.id].format if (format === "anthropic") return anthropicHelper + if (format === "google") return googleHelper if (format === "openai") return openaiHelper return oaCompatHelper })(), diff --git a/packages/console/app/src/routes/zen/util/provider/anthropic.ts b/packages/console/app/src/routes/zen/util/provider/anthropic.ts index d8d1cd741..887a6e4b5 100644 --- a/packages/console/app/src/routes/zen/util/provider/anthropic.ts +++ b/packages/console/app/src/routes/zen/util/provider/anthropic.ts @@ -30,6 +30,7 @@ export const anthropicHelper = { service_tier: "standard_only", } }, + streamSeparator: "\n\n", createUsageParser: () => { let usage: Usage diff --git a/packages/console/app/src/routes/zen/util/provider/google.ts b/packages/console/app/src/routes/zen/util/provider/google.ts new file mode 100644 index 000000000..afde42096 --- /dev/null +++ b/packages/console/app/src/routes/zen/util/provider/google.ts @@ -0,0 +1,74 @@ +import { ProviderHelper } from "./provider" + +/* +{ + promptTokenCount: 11453, + candidatesTokenCount: 71, + totalTokenCount: 11625, + cachedContentTokenCount: 8100, + promptTokensDetails: [ + {modality: "TEXT",tokenCount: 11453} + ], + cacheTokensDetails: [ + {modality: "TEXT",tokenCount: 8100} + ], + thoughtsTokenCount: 101 +} +*/ + +type Usage = { + promptTokenCount?: number + candidatesTokenCount?: number + totalTokenCount?: number + cachedContentTokenCount?: number + promptTokensDetails?: { modality: string; tokenCount: number }[] + cacheTokensDetails?: { modality: string; tokenCount: number }[] + thoughtsTokenCount?: number +} + +export const googleHelper = { + format: "google", + modifyUrl: (providerApi: string, model?: string, isStream?: boolean) => + `${providerApi}/models/${model}:${isStream ? "streamGenerateContent?alt=sse" : "generateContent"}`, + modifyHeaders: (headers: Headers, body: Record, apiKey: string) => { + headers.set("x-goog-api-key", apiKey) + }, + modifyBody: (body: Record) => { + return body + }, + streamSeparator: "\r\n\r\n", + createUsageParser: () => { + let usage: Usage + + return { + parse: (chunk: string) => { + if (!chunk.startsWith("data: ")) return + + let json + try { + json = JSON.parse(chunk.slice(6)) as { usageMetadata?: Usage } + } catch (e) { + return + } + + if (!json.usageMetadata) return + usage = json.usageMetadata + }, + retrieve: () => usage, + } + }, + normalizeUsage: (usage: Usage) => { + const inputTokens = usage.promptTokenCount ?? 0 + const outputTokens = usage.candidatesTokenCount ?? 0 + const reasoningTokens = usage.thoughtsTokenCount ?? 0 + const cacheReadTokens = usage.cachedContentTokenCount ?? 0 + return { + inputTokens: inputTokens - cacheReadTokens, + outputTokens, + reasoningTokens, + cacheReadTokens, + cacheWrite5mTokens: undefined, + cacheWrite1hTokens: undefined, + } + }, +} satisfies ProviderHelper diff --git a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts index 8a9170ef1..5771ed4fa 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai-compatible.ts @@ -33,6 +33,7 @@ export const oaCompatHelper = { ...(body.stream ? { stream_options: { include_usage: true } } : {}), } }, + streamSeparator: "\n\n", createUsageParser: () => { let usage: Usage diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts index e79e83579..dff6e13fb 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai.ts @@ -21,6 +21,7 @@ export const openaiHelper = { modifyBody: (body: Record) => { return body }, + streamSeparator: "\n\n", createUsageParser: () => { let usage: Usage diff --git a/packages/console/app/src/routes/zen/util/provider/provider.ts b/packages/console/app/src/routes/zen/util/provider/provider.ts index d0f123968..8366f3a63 100644 --- a/packages/console/app/src/routes/zen/util/provider/provider.ts +++ b/packages/console/app/src/routes/zen/util/provider/provider.ts @@ -26,9 +26,10 @@ import { export type ProviderHelper = { format: ZenData.Format - modifyUrl: (providerApi: string) => string + modifyUrl: (providerApi: string, model?: string, isStream?: boolean) => string modifyHeaders: (headers: Headers, body: Record, apiKey: string) => void modifyBody: (body: Record) => Record + streamSeparator: string createUsageParser: () => { parse: (chunk: string) => void retrieve: () => any 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 44326e79e..655459129 100644 --- a/packages/console/app/src/routes/zen/v1/chat/completions.ts +++ b/packages/console/app/src/routes/zen/v1/chat/completions.ts @@ -5,5 +5,7 @@ export function POST(input: APIEvent) { return handler(input, { format: "oa-compat", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], + parseModel: (url: string, body: any) => body.model, + parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/v1/messages.ts b/packages/console/app/src/routes/zen/v1/messages.ts index 4478b6444..54d223f95 100644 --- a/packages/console/app/src/routes/zen/v1/messages.ts +++ b/packages/console/app/src/routes/zen/v1/messages.ts @@ -5,5 +5,7 @@ export function POST(input: APIEvent) { return handler(input, { format: "anthropic", parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined, + parseModel: (url: string, body: any) => body.model, + parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/app/src/routes/zen/v1/models/[model].ts b/packages/console/app/src/routes/zen/v1/models/[model].ts new file mode 100644 index 000000000..b20378e37 --- /dev/null +++ b/packages/console/app/src/routes/zen/v1/models/[model].ts @@ -0,0 +1,13 @@ +import type { APIEvent } from "@solidjs/start/server" +import { handler } from "~/routes/zen/util/handler" + +export function POST(input: APIEvent) { + return handler(input, { + format: "google", + parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined, + parseModel: (url: string, body: any) => url.split("/").pop()?.split(":")?.[0] ?? "", + parseIsStream: (url: string, body: any) => + // ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse' + url.split("/").pop()?.split(":")?.[1]?.startsWith("streamGenerateContent") ?? false, + }) +} diff --git a/packages/console/app/src/routes/zen/v1/responses.ts b/packages/console/app/src/routes/zen/v1/responses.ts index eadc5bc8e..a82a667cc 100644 --- a/packages/console/app/src/routes/zen/v1/responses.ts +++ b/packages/console/app/src/routes/zen/v1/responses.ts @@ -5,5 +5,7 @@ export function POST(input: APIEvent) { return handler(input, { format: "openai", parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1], + parseModel: (url: string, body: any) => body.model, + parseIsStream: (url: string, body: any) => !!body.stream, }) } diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 222bdd0f8..bff999e61 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -8,7 +8,7 @@ import { Actor } from "./actor" import { Resource } from "@opencode-ai/console-resource" export namespace ZenData { - const FormatSchema = z.enum(["anthropic", "openai", "oa-compat"]) + const FormatSchema = z.enum(["anthropic", "google", "openai", "oa-compat"]) export type Format = z.infer const ModelCostSchema = z.object({