Используем Google Gemini в любом OpenAI-клиенте (например, в OpenCode)
Привет! Если вы когда-нибудь пытались подключить Google Gemini к инструменту, который умеет работать только с OpenAI-совместимыми API, то знаете эту боль. Gemini говорит на своём protobuf-диалекте, а клиент ждёт классические chat/completions. Я покажу, как за 15 минут написать Cloudflare Worker, который делает обратную конвертацию: принимает OpenAI-формат, перекладывает его в Google-формат, отправляет в Gemini API и конвертирует ответ обратно.
Зачем это нужно
Google Gemini API — отличная вещь: модели быстро работают, бесплатный лимит щедрый, а качество на уровне топовых решений. Но есть нюанс: Gemini не поддерживает OpenAI-формат напрямую. Многие утилиты, от opencode до кастомных ботов, умеют работать только с эндпоинтами вида /v1beta/openai/chat/completions.
Решение — поднять прослойку на Cloudflare Workers. Бесплатно (100 000 запросов в день), быстро (edge-сеть), и код помещается в один файл.
Что умеет прокси
Принимает
chat/completionsв OpenAI-формате, отдаёт в OpenAI-форматеПоддерживает streaming (SSE-чанки)
Конвертирует tool calls туда и обратно
Сохраняет
_thoughtSignature(важно для Gemini при function calling)Очищает JSON Schema от полей, которые Gemini не переваривает (
$schema,const,exclusiveMinimumи т.д.)Проксирует запрос списка моделей (
/v1beta/openai/models)Защищён Bearer-токеном
Как это работает
Клиент (OpenAI format) → Cloudflare Worker → Google Gemini API
← ←
Worker сидит на двух маршрутах:
GET /v1beta/openai/models— прозрачно проксируется в Google API (список доступных моделей)POST /v1beta/openai/chat/completions— входящее тело конвертируется из OpenAI-формата в Google-формат, отправляется в Gemini, ответ конвертируется обратно
Пошаговая сборка
0. Регистрация в Cloudflare
Важно: В некоторых регионах доступ к сервисам Cloudflare или API Google Gemini может быть ограничен. Если у вас возникают ошибки при подключении или деплое, используйте VPN.
Зарегистрируйтесь на dash.cloudflare.com.
Установите Wrangler CLI (официальный инструмент разработки для Workers).
Авторизуйтесь в консоли:
npx wrangler login
1. Создаём проект
npx wrangler init gemini-proxy
cd gemini-proxy
Выберите TypeScript, когда спросит.
2. Пишем код (src/index.ts)
Полный листинг. Мы добавили базовую валидацию, обработку ошибок, поддержку прерывания запросов и логирование:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (!env.GEMINI_API_KEY) {
return new Response("Missing GEMINI_API_KEY", { status: 500 })
}
if (!env.PROXY_SECRET) {
return new Response("Missing PROXY_SECRET", { status: 500 })
}
const url = new URL(request.url)
const path = url.pathname
const authHeader = request.headers.get("Authorization")
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null
if (token !== env.PROXY_SECRET) {
return new Response("Forbidden", { status: 403 })
}
if (path === "/v1beta/openai/models") {
return proxyToOpenAI(request, env)
}
if (path === "/v1beta/openai/chat/completions") {
let body: any
try {
body = await request.json()
} catch (e) {
return new Response("Invalid JSON", { status: 400 })
}
return callGoogleNative(body, env, request.signal)
}
return proxyToOpenAI(request, env)
},
} satisfies ExportedHandler<Env>
async function callGoogleNative(body: any, env: Env, signal: AbortSignal): Promise<Response> {
const isStream = body.stream === true
const modelName = body.model.replace("models/", "")
const googleBody = toGoogleFormat(body, modelName)
const googleUrl = isStream
? `https://generativelanguage.googleapis.com/v1beta/models/\({modelName}:streamGenerateContent?alt=sse&key=\){env.GEMINI_API_KEY}`
: `https://generativelanguage.googleapis.com/v1beta/models/\({modelName}:generateContent?key=\){env.GEMINI_API_KEY}`
const resp = await fetch(googleUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(googleBody),
signal,
})
if (!resp.ok) {
const err = await resp.text()
console.error(`Gemini API Error: \({resp.status} \){err}`)
return new Response(err, { status: resp.status })
}
if (!isStream) {
const data = await resp.json()
return new Response(JSON.stringify(toOpenAIResponse(data, body.model)), {
headers: { "Content-Type": "application/json" },
})
}
const { readable, writable } = new TransformStream()
const writer = writable.getWriter()
const encoder = new TextEncoder()
;(async () => {
const reader = resp.body?.getReader()
if (!reader) { writer.close(); return }
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || ""
for (const line of lines) {
if (!line.startsWith("data: ")) continue
const json = line.slice(6)
if (json === "[DONE]") {
await writer.write(encoder.encode("data: [DONE]\n\n"))
continue
}
try {
const chunk = JSON.parse(json)
const converted = toOpenAIChunk(chunk, body.model)
if (converted) {
await writer.write(encoder.encode("data: " + JSON.stringify(converted) + "\n\n"))
}
} catch { /* skip */ }
}
}
await writer.write(encoder.encode("data: [DONE]\n\n"))
await writer.close()
})()
return new Response(readable, {
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
})
}
async function proxyToOpenAI(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)
url.hostname = "generativelanguage.googleapis.com"
url.protocol = "https:"
const headers = new Headers(request.headers)
headers.set("Authorization", `Bearer ${env.GEMINI_API_KEY}`)
headers.delete("host")
headers.delete("cf-connecting-ip")
headers.delete("cf-ray")
return fetch(url.toString(), { method: request.method, headers, body: request.body, redirect: "follow" })
}
// ─── helpers ──────────────────────────────────────────────────
function hasTools(body: any): boolean {
return body?.tools?.length > 0 || body?.tool_choice !== undefined
}
function cleanParams(params: any): any {
if (!params || typeof params !== "object") return params
if (Array.isArray(params)) return params.map(cleanParams)
const cleaned: any = {}
for (const [key, val] of Object.entries(params)) {
if (["$schema", "exclusiveMinimum", "exclusiveMaximum", "const", "examples"].includes(key)) continue
cleaned[key] = val !== null && typeof val === "object" ? cleanParams(val) : val
}
return cleaned
}
function toGoogleFormat(body: any, modelName: string): any {
const result: any = {}
const systemMsg = body.messages?.find((m: any) => m.role === "system")
const otherMsgs = body.messages?.filter((m: any) => m.role !== "system") || []
if (systemMsg) {
result.systemInstruction = { parts: [{ text: typeof systemMsg.content === "string" ? systemMsg.content : "" }] }
}
result.contents = convertMessages(otherMsgs, modelName)
const cfg: any = {}
if (body.temperature !== undefined) cfg.temperature = body.temperature
if (body.max_tokens !== undefined) cfg.maxOutputTokens = body.max_tokens
if (body.top_p !== undefined) cfg.topP = body.top_p
if (Object.keys(cfg).length) result.generationConfig = cfg
if (body.tools?.length) {
result.tools = body.tools.map((t: any) => ({
functionDeclarations: t.functions?.map((f: any) => ({
name: f.name,
description: f.description,
parameters: cleanParams(f.parameters),
})) || (t.type === "function" ? [{
name: t.function.name,
description: t.function.description || "",
parameters: cleanParams(t.function.parameters),
}] : []),
})).filter((t: any) => t.functionDeclarations?.length)
}
if (body.tool_choice !== undefined) {
if (body.tool_choice === "auto") {
// default
} else if (body.tool_choice?.type === "function") {
result.tool_config = { function_calling_config: { mode: "ANY", allowed_function_names: [body.tool_choice.function?.name] } }
}
}
// Убираем служебные _id из parts
if (result.contents) {
result.contents = result.contents.map((c: any) => ({
...c,
parts: c.parts?.map((p: any) => {
if (p._id !== undefined) {
const { _id, ...rest } = p
return rest
}
return p
}),
}))
}
return result
}
function convertMessages(messages: any[], modelName: string): any[] {
const contents: any[] = []
for (const msg of messages) {
if (msg.role === "tool") {
const role = "function"
// Ищем имя функции по tool_call_id в предыдущем assistant-сообщении
let funcName = msg.name || ""
if (!funcName) {
for (let i = contents.length - 1; i >= 0; i--) {
if (contents[i].role === "model") {
const funcCall = contents[i].parts?.find((p: any) =>
p.functionCall && (p._id === msg.tool_call_id || p.functionCall.name === msg.tool_call_id)
)
if (funcCall?.functionCall) {
funcName = funcCall.functionCall.name
break
}
}
}
}
const parts = [{ functionResponse: { name: funcName || "unknown", response: { response: msg.content } } }]
if (contents.length && contents[contents.length - 1].role === role) {
contents[contents.length - 1].parts.push(...parts)
} else {
contents.push({ role, parts })
}
continue
}
const role = msg.role === "assistant" ? "model" : "user"
const parts = contentToParts(msg.content, msg.tool_calls, modelName)
if (!parts.length) continue
contents.push({ role, parts })
}
return contents
}
function contentToParts(content: any, toolCalls?: any[], modelName?: string): any[] {
if (toolCalls?.length) {
return toolCalls.map((tc: any) => {
const parsed = JSON.parse(tc.function?.arguments || "{}")
let thoughtSig = tc._thoughtSignature
if (!thoughtSig && parsed._thoughtSignature) {
thoughtSig = parsed._thoughtSignature
delete parsed._thoughtSignature
}
const part: any = {
_id: tc.id || "",
functionCall: {
name: tc.function?.name || "",
args: parsed,
},
}
if (thoughtSig) {
part.thoughtSignature = thoughtSig
} else if (modelName?.includes("gemini-3")) {
part.thoughtSignature = "skip_thought_signature_validator"
}
return part
})
}
if (typeof content === "string") return [{ text: content }]
if (!Array.isArray(content)) return [{ text: String(content || "") }]
return content.filter((c: any) => c.type === "text").map((c: any) => ({ text: c.text }))
}
function toOpenAIResponse(data: any, model: string): any {
const candidate = data?.candidates?.[0]
if (!candidate) {
return {
id: "chatcmpl-" + Date.now(),
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [{ index: 0, message: { role: "assistant", content: "" }, finish_reason: "stop" }],
}
}
const parts = candidate.content?.parts || []
const text = parts.map((p: any) => p.text || "").join("")
const funcCalls = parts.filter((p: any) => p.functionCall).map((p: any, i: number) => {
const args = JSON.parse(JSON.stringify(p.functionCall.args || {}))
const sig = p.thoughtSignature || p.functionCall?.thoughtSignature
const toolCall: any = {
id: "call_" + i,
type: "function",
function: {
name: p.functionCall.name,
arguments: JSON.stringify(args),
},
}
if (sig) {
args._thoughtSignature = sig
toolCall.function.arguments = JSON.stringify(args)
toolCall._thoughtSignature = sig
}
return toolCall
})
const msg: any = { role: "assistant" }
if (text) msg.content = text
if (funcCalls.length) msg.tool_calls = funcCalls
return {
id: "chatcmpl-" + Date.now(),
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model,
choices: [{
index: 0,
message: msg,
finish_reason: funcCalls.length ? "tool_calls" : (candidate.finishReason || "STOP").toLowerCase(),
}],
usage: data?.usageMetadata || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
}
}
function toOpenAIChunk(chunk: any, model: string): any | null {
const parts = chunk?.candidates?.[0]?.content?.parts || []
const candidate = chunk?.candidates?.[0]
const text = parts.map((p: any) => p.text || "").join("")
const funcCalls = parts.filter((p: any) => p.functionCall).map((p: any, i: number) => {
const args = JSON.parse(JSON.stringify(p.functionCall.args || {}))
const sig = p.thoughtSignature || p.functionCall?.thoughtSignature
const toolCall: any = {
id: "call_" + i,
type: "function",
function: {
name: p.functionCall.name,
arguments: JSON.stringify(args),
},
}
if (sig) {
args._thoughtSignature = sig
toolCall.function.arguments = JSON.stringify(args)
toolCall._thoughtSignature = sig
}
return toolCall
})
const finishReason = candidate?.finishReason
if (!text && !funcCalls.length && !finishReason) return null
const delta: any = {}
if (text) delta.content = text
if (funcCalls.length) delta.tool_calls = funcCalls
return {
id: "chatcmpl-" + Date.now(),
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model,
choices: [{
index: 0,
delta,
finish_reason: finishReason ? finishReason.toLowerCase() : null,
}],
}
}
interface Env {
GEMINI_API_KEY: string
PROXY_SECRET: string
}
3. Настраиваем wrangler.jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "gemini-proxy",
"main": "src/index.ts",
"compatibility_date": "2026-06-05",
"compatibility_flags": ["nodejs_compat"],
"vars": {
"PROXY_SECRET": "my-proxy-secret"
}
}
PROXY_SECRET — это токен, который клиент будет передавать в заголовке Authorization: Bearer .... Можете придумать любой.
4. Устанавливаем секреты
# API-ключ из Google AI Studio (https://aistudio.google.com)
npx wrangler secret put GEMINI_API_KEY
# Тот же PROXY_SECRET (если хотите спрятать от wrangler.jsonc)
npx wrangler secret put PROXY_SECRET
Секреты в Cloudflare имеют приоритет над vars из wrangler.jsonc. Если вы однажды выполнили secret put, то значение из vars будет игнорироваться.
5. Деплоим
npx wrangler deploy
После деплоя вы получите URL: https://gemini-proxy.ваш-поддомен.workers.dev.
Подключаем клиента
Указываете в настройках baseURL с путём /v1beta/openai:
https://gemini-proxy.ваш-поддомен.workers.dev/v1beta/openai
Пример для opencode
В ~/.config/opencode/opencode.json:
{
"provider": {
"cloudflare": {
"name": "Gemini via CF",
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "https://gemini-proxy.ваш-поддомен.workers.dev/v1beta/openai",
"apiKey": "my-proxy-secret"
},
"models": {
"gemini-3.1-flash-lite": {
"name": "Gemini 3.1 Flash Lite",
"options": {
"contextWindow": 1000000
}
}
}
}
}
}
Проверка через curl
Список доступных моделей:
curl -H "Authorization: Bearer my-proxy-secret" \
https://gemini-proxy.ваш-поддомен.workers.dev/v1beta/openai/models
Чат (без streaming):
curl -H "Authorization: Bearer my-proxy-secret" \
-H "Content-Type: application/json" \
-d '{"model":"gemini-3.1-flash-lite","messages":[{"role":"user","content":"Hello!"}]}' \
https://gemini-proxy.ваш-поддомен.workers.dev/v1beta/openai/chat/completions
Чат со streaming:
curl -N -H "Authorization: Bearer my-proxy-secret" \
-H "Content-Type: application/json" \
-d '{"model":"gemini-3.1-flash-lite","messages":[{"role":"user","content":"Hello!"}],"stream":true}' \
https://gemini-proxy.ваш-поддомен.workers.dev/v1beta/openai/chat/completions
Как это устроено внутри
Аутентификация
const authHeader = request.headers.get("Authorization")
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null
if (token !== env.PROXY_SECRET) {
return new Response("Forbidden", { status: 403 })
}
Все входящие запросы проверяются на Bearer-токен. Не совпало — 403. Просто и надёжно.
Конвертация сообщений
Основные соответствия OpenAI → Google:
| OpenAI | |
|---|---|
role: "system" |
systemInstruction.parts[].text |
role: "assistant" |
role: "model" |
role: "user" |
role: "user" |
role: "tool" |
role: "function" с functionResponse |
tool_calls |
parts[].functionCall |
tools[].function |
tools[].functionDeclarations |
tool_choice |
tool_config.function_calling_config |
Streaming
Google Gemini возвращает SSE с событиями data: {...}. Worker читает этот поток через getReader(), парсит строки, конвертирует каждый чанк в OpenAI-формат и пишет в выходной TransformStream. Клиент получает стандартные SSE-чанки data: {...}\n\n с [DONE] в конце.
_thoughtSignature
Некоторые Gemini модели (особенно при function calling) возвращают в ответе thoughtSignature. Это служебное поле обязательно нужно сохранить и вернуть в следующем запросе, иначе API упадёт с ошибкой. Код хранит его на двух уровнях:
В самом
tool_callкак_thoughtSignature— для обратной совместимостиВнутри
argumentsкак_thoughtSignature— на случай, если какая-то библиотека сериализует только arguments
// Сохранение Google → OpenAI
if (sig) {
args._thoughtSignature = sig
toolCall.function.arguments = JSON.stringify(args)
toolCall._thoughtSignature = sig
}
// Восстановление OpenAI → Google
let thoughtSig = tc._thoughtSignature
if (!thoughtSig && parsed._thoughtSignature) {
thoughtSig = parsed._thoughtSignature
delete parsed._thoughtSignature
}
Очистка JSON Schema
Gemini не поддерживает некоторые поля OpenAPI ($schema, const, exclusiveMinimum, exclusiveMaximum, examples). Если их не удалить, Gemini вернёт ошибку. Функция cleanParams() рекурсивно обходит схему и выбрасывает неподдерживаемые поля.
Бесплатный лимит Cloudflare
Cloudflare Workers даёт 100 000 запросов в день на бесплатном плане. Этого хватит на активное ежедневное использование. Если нужно больше — план $5/мес за 10 млн запросов.
Возможные проблемы
Error 1102 (CPU/Memory exceeded) — маловероятно для этого кода, он делает только лёгкую конвертацию JSON. Если возникло — проверьте, не передаёте ли гигантские сообщения.
Forbidden — неверный
PROXY_SECRETили забыли заголовокAuthorization.User location is not supported — ваш регион не поддерживает Gemini API. Убедитесь, что Cloudflare Worker запущен в регионе, где Gemini доступен (например, США или Европа).
thought_signature error — Gemini жалуется на отсутствие thoughtSignature. Этот код обрабатывает это корректно, так что если ошибка появилась — возможно, вы используете модифицированную версию.
Заключение
Cloudflare Worker получился лёгким, быстрым и бесплатным. Один файл, никаких зависимостей — только fetch и стандартные Web API. Прокси умеет всё, что нужно для повседневной работы: сообщения, системные промпты, tool calls, streaming.
Удачного вайбкодинга!
