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

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

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

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


Вы здесь » kuban-forum.ru - Лучший форум для общения » 💻Компьютеры и программы » Используем 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

Пробежал глазом - мозг скрючился и завис, но
одна мысль пробилась - может Красное и Белое, может футбол?

0


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