Привет! Если вы когда-нибудь пытались подключить 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" \
--
Чат (без 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!"}]}' \
--
Чат со 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}' \
--
Как это устроено внутри
Аутентификация
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 <---> Google
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.
Удачного вайбкодинга! 


