У Вас отключён javascript.
В данном режиме, отображение ресурса
браузером не поддерживается

kuban-forum.ru - Лучший форум для общения

Информация о пользователе

Привет, Гость! Войдите или зарегистрируйтесь.


Вы здесь » kuban-forum.ru - Лучший форум для общения » 💻Электронная техника и IT » Используем Google Gemini в любом OpenAI-клиенте (например, в OpenCode)


Используем Google Gemini в любом OpenAI-клиенте (например, в OpenCode)

Сообщений 1 страница 2 из 2

1

Привет! Если вы когда-нибудь пытались подключить 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.

Удачного вайбкодинга! :)

Подпись автора

Как вставить видео на форум Слайдер для картинок AI бот Вика в Telegram

0

2

Это должна была быть статья для одного айтишного ресурса, но модератор её отклонил - дескать, не соответствует их требованиям к статьям. Ну и ладно:)
Здесь этот материал разместил как резервную копию :)

Кстати, "реакции" на этом форуме работают на тех же самых ресурсах, что и микросервисы у бота Vika. И на том же аккаунте Cloudflare Workers ещё работает прокси для моделей Gemini в программе OpenCode (я использую Gemini 3.1 Flash Lite).

Но для прокси для Gemini нужно включать VPN, так как Cloudflare заблокирован РКН. Почему бы тоже не пропускать через Яндекс? Да потому, что у Gemini, OpenCode и Yandex Cloud  - у всех трёх разные понимания правильности форматов ответа, заголовков, дополнительных полей... короч,  работает через Cloudflare  с VPN, ну и слава Богу.

Подпись автора

Функционал форума Книга жалоб Книга предложений Знак зодиака Как вставить видео на форум Форум"Грибные места" Слайдер для картинок Live-box с темами AI бот Вика в Telegram

0


Вы здесь » kuban-forum.ru - Лучший форум для общения » 💻Электронная техника и IT » Используем Google Gemini в любом OpenAI-клиенте (например, в OpenCode)