feat: use Google Gemini as default vision backend, make provider configurable
- Plugin options: baseURL, apiKey, model, providerID, visionModels
- Default: Google Gemini OpenAI-compatible endpoint, gemini-2.5-flash
- Support {env:VAR} templates and GEMINI_API_KEY fallback
- Legacy fallback to ludmila-ai provider block for existing setups
- vision.md subagent now uses google/gemini-2.5-flash
This commit is contained in:
@@ -1,68 +1,98 @@
|
|||||||
# Opencode Vision Bridge Plugin
|
# Opencode Vision Bridge Plugin
|
||||||
|
|
||||||
Этот плагин позволяет Opencode работать с изображениями, даже если активная модель не поддерживает мультимодальность. Он перехватывает изображения, отправляет их в указанную vision-модель для анализа, а затем заменяет изображение текстовым описанием в контексте диалога.
|
Этот плагин позволяет Opencode работать с изображениями, даже если активная модель не поддерживает мультимодальность. Он перехватывает изображения, отправляет их в vision-модель для анализа, а затем заменяет изображение текстовым описанием в контексте диалога.
|
||||||
|
|
||||||
|
Распознавание выполняется через **Google Gemini API** (OpenAI-совместимый эндпоинт), модель по умолчанию — `gemini-2.5-flash`. Если активная модель сама умеет видеть изображения (помечена `"attachment": true` в конфиге), картинки передаются ей напрямую без распознавания.
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
- Доступ к API-эндпоинту Ludmila AI (https://ai.totmin.ru/v1).
|
- API-ключ Google Gemini. Получить бесплатно: [Google AI Studio](https://aistudio.google.com/apikey).
|
||||||
- Ключ API Ludmila AI.
|
- Сетевой доступ к `generativelanguage.googleapis.com`.
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
### 1. Добавьте плагин в `~/.config/opencode/opencode.json`
|
### 1. Добавьте плагин в `~/.config/opencode/opencode.json`
|
||||||
|
|
||||||
В секцию `plugin` добавьте следующую запись:
|
В секцию `plugin` добавьте запись (tuple-форма с опциями):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"plugin": [
|
"plugin": [
|
||||||
// ... другие плагины
|
// ... другие плагины
|
||||||
"git+https://git.totmin.ru/en2zmax/vision-bridge.git"
|
[
|
||||||
|
"git+https://git.totmin.ru/en2zmax/vision-bridge.git",
|
||||||
|
{
|
||||||
|
"baseURL": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||||
|
"apiKey": "{env:GEMINI_API_KEY}",
|
||||||
|
"model": "gemini-2.5-flash"
|
||||||
|
}
|
||||||
|
]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Настройте провайдер Ludmila AI
|
`apiKey` можно указать явной строкой, шаблоном `{env:ИМЯ_ПЕРЕМЕННОЙ}` — либо не указывать вовсе, тогда плагин возьмёт его из `GEMINI_API_KEY`.
|
||||||
|
|
||||||
В секцию `provider` вашего `~/.config/opencode/opencode.json` добавьте или обновите блок `ludmila-ai`:
|
### 2. Задайте API-ключ
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export GEMINI_API_KEY=ВАШ_КЛЮЧ
|
||||||
|
```
|
||||||
|
|
||||||
|
Добавьте это в `~/.bashrc` / `~/.zshrc`, чтобы ключ был доступен при каждом запуске.
|
||||||
|
|
||||||
|
### 3. (Опционально) Зарегистрируйте провайдера Google
|
||||||
|
|
||||||
|
Нужно только если вы хотите использовать модель `gemini-2.5-flash` как основную или для vision-сабагента:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"provider": {
|
"provider": {
|
||||||
"ludmila-ai": {
|
"google": {
|
||||||
"npm": "@ai-sdk/openai-compatible",
|
"npm": "@ai-sdk/google",
|
||||||
"name": "Ludmila AI",
|
"name": "Google",
|
||||||
"options": {
|
"options": {
|
||||||
"baseURL": "https://ai.totmin.ru/v1",
|
"apiKey": "{env:GEMINI_API_KEY}"
|
||||||
"apiKey": "<ЗАМЕНИТЕ_НА_ВАШ_API_КЛЮЧ>"
|
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"gemini/gemini-2.5-flash": {
|
"gemini-2.5-flash": {}
|
||||||
"attachment": true,
|
|
||||||
"limit": {
|
|
||||||
"context": 1048576,
|
|
||||||
"output": 65536
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
**ВАЖНО**: Замените `<ЗАМЕНИТЕ_НА_ВАШ_API_КЛЮЧ>` на ваш реальный API-ключ Ludmila AI.
|
|
||||||
|
|
||||||
### 3. Скопируйте файл субагента
|
### 4. Скопируйте файл субагента
|
||||||
|
|
||||||
Субагенты не загружаются из npm-пакетов, поэтому скопируйте файл `vision.md` в соответствующий каталог:
|
Субагенты не загружаются из npm-пакетов, поэтому скопируйте файл `vision.md` в соответствующий каталог:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp ~/totmin/opencode/plugins/vision-bridge/agents/vision.md ~/.config/opencode/agents/vision.md
|
cp agents/vision.md ~/.config/opencode/agents/vision.md
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Перезапустите Opencode
|
Субагент использует модель `google/gemini-2.5-flash`.
|
||||||
|
|
||||||
|
### 5. Перезапустите Opencode
|
||||||
|
|
||||||
Изменения в конфигурации применяются только после перезапуска Opencode.
|
Изменения в конфигурации применяются только после перезапуска Opencode.
|
||||||
|
|
||||||
|
## Подключение другого OpenAI-совместимого сервиса
|
||||||
|
|
||||||
|
Плагин универсален: подойдёт любой сервис с эндпоинтом `/chat/completions`. Достаточно поменять опции:
|
||||||
|
|
||||||
|
- **OpenAI**: `baseURL: "https://api.openai.com/v1"`, `model: "gpt-4o-mini"`, `apiKey: "{env:OPENAI_API_KEY}"`.
|
||||||
|
- **Любой OpenAI-совместимый прокси** — аналогично.
|
||||||
|
|
||||||
|
## Опции плагина
|
||||||
|
|
||||||
|
| Опция | По умолчанию | Назначение |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `baseURL` | `https://generativelanguage.googleapis.com/v1beta/openai` | Эндпоинт `/chat/completions` |
|
||||||
|
| `apiKey` | `process.env.GEMINI_API_KEY` | Ключ API (поддерживает `{env:VAR}`) |
|
||||||
|
| `model` | `gemini-2.5-flash` | Модель распознавания |
|
||||||
|
| `providerID` | `ludmila-ai` (legacy) | Читать настройки из блока `provider` конфига opencode |
|
||||||
|
| `visionModels` | модели с `attachment: true` | ID моделей, которые видят изображения нативно |
|
||||||
|
|
||||||
## Пример конфигурации (`examples/opencode.json`)
|
## Пример конфигурации (`examples/opencode.json`)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -70,31 +100,31 @@ cp ~/totmin/opencode/plugins/vision-bridge/agents/vision.md ~/.config/opencode/a
|
|||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": [
|
"plugin": [
|
||||||
"opencode-browser",
|
"opencode-browser",
|
||||||
"git+ssh://git@git.totmin.ru:en2zmax/vision-bridge.git"
|
[
|
||||||
|
"git+https://git.totmin.ru/en2zmax/vision-bridge.git",
|
||||||
|
{
|
||||||
|
"baseURL": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||||
|
"apiKey": "{env:GEMINI_API_KEY}",
|
||||||
|
"model": "gemini-2.5-flash"
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"provider": {
|
"provider": {
|
||||||
"ludmila-ai": {
|
"google": {
|
||||||
"npm": "@ai-sdk/openai-compatible",
|
"npm": "@ai-sdk/google",
|
||||||
"name": "Ludmila AI",
|
"name": "Google",
|
||||||
"options": {
|
"options": {
|
||||||
"baseURL": "https://ai.totmin.ru/v1",
|
"apiKey": "{env:GEMINI_API_KEY}"
|
||||||
"apiKey": "<ЗАМЕНИТЕ_НА_ВАШ_API_КЛЮЧ>"
|
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"gemini/gemini-2.5-flash": {
|
"gemini-2.5-flash": {}
|
||||||
"attachment": true,
|
|
||||||
"limit": {
|
|
||||||
"context": 1048576,
|
|
||||||
"output": 65536
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
"vision": {
|
"vision": {
|
||||||
"mode": "subagent",
|
"mode": "subagent",
|
||||||
"model": "ludmila-ai/gemini/gemini-2.5-flash",
|
"model": "google/gemini-2.5-flash",
|
||||||
"description": "Анализ изображений и скриншотов (OCR текста или разбор UI)",
|
"description": "Анализ изображений и скриншотов (OCR текста или разбор UI)",
|
||||||
"permission": {
|
"permission": {
|
||||||
"read": "allow",
|
"read": "allow",
|
||||||
@@ -106,3 +136,4 @@ cp ~/totmin/opencode/plugins/vision-bridge/agents/vision.md ~/.config/opencode/a
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
description: Анализ изображений и скриншотов (OCR текста или разбор UI)
|
description: Анализ изображений и скриншотов (OCR текста или разбор UI)
|
||||||
mode: subagent
|
mode: subagent
|
||||||
model: ludmila-ai/gemini/gemini-2.5-flash
|
model: google/gemini-2.5-flash
|
||||||
temperature: 0.1
|
temperature: 0.1
|
||||||
permission:
|
permission:
|
||||||
read: allow
|
read: allow
|
||||||
|
|||||||
+14
-14
@@ -2,31 +2,31 @@
|
|||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"plugin": [
|
"plugin": [
|
||||||
"opencode-browser",
|
"opencode-browser",
|
||||||
"git+https://git.totmin.ru/en2zmax/vision-bridge.git"
|
[
|
||||||
|
"git+https://git.totmin.ru/en2zmax/vision-bridge.git",
|
||||||
|
{
|
||||||
|
"baseURL": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||||
|
"apiKey": "{env:GEMINI_API_KEY}",
|
||||||
|
"model": "gemini-2.5-flash"
|
||||||
|
}
|
||||||
|
]
|
||||||
],
|
],
|
||||||
"provider": {
|
"provider": {
|
||||||
"ludmila-ai": {
|
"google": {
|
||||||
"npm": "@ai-sdk/openai-compatible",
|
"npm": "@ai-sdk/google",
|
||||||
"name": "Ludmila AI",
|
"name": "Google",
|
||||||
"options": {
|
"options": {
|
||||||
"baseURL": "https://ai.totmin.ru/v1",
|
"apiKey": "{env:GEMINI_API_KEY}"
|
||||||
"apiKey": "<ЗАМЕНИТЕ_НА_ВАШ_API_КЛЮЧ>"
|
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"gemini/gemini-2.5-flash": {
|
"gemini-2.5-flash": {}
|
||||||
"attachment": true,
|
|
||||||
"limit": {
|
|
||||||
"context": 1048576,
|
|
||||||
"output": 65536
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"agent": {
|
"agent": {
|
||||||
"vision": {
|
"vision": {
|
||||||
"mode": "subagent",
|
"mode": "subagent",
|
||||||
"model": "ludmila-ai/gemini/gemini-2.5-flash",
|
"model": "google/gemini-2.5-flash",
|
||||||
"description": "Анализ изображений и скриншотов (OCR текста или разбор UI)",
|
"description": "Анализ изображений и скриншотов (OCR текста или разбор UI)",
|
||||||
"permission": {
|
"permission": {
|
||||||
"read": "allow",
|
"read": "allow",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vision-bridge",
|
"name": "vision-bridge",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Opencode plugin: bridges images to a vision model when the active model cannot see images.",
|
"description": "Opencode plugin: bridges images to a vision model when the active model cannot see images.",
|
||||||
"main": "src/vision-bridge.ts",
|
"main": "src/vision-bridge.ts",
|
||||||
|
|||||||
+67
-20
@@ -39,6 +39,19 @@ interface ProviderInfo {
|
|||||||
|
|
||||||
const IMAGE_MIME_RE = /^image\//
|
const IMAGE_MIME_RE = /^image\//
|
||||||
|
|
||||||
|
// Default recognition backend: Google Gemini (OpenAI-compatible endpoint).
|
||||||
|
// Override via plugin options: { baseURL, apiKey, model } or a config provider
|
||||||
|
// block (providerID). Legacy fallback: the "ludmila-ai" provider block.
|
||||||
|
const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai"
|
||||||
|
const DEFAULT_MODEL = "gemini-2.5-flash"
|
||||||
|
const DEFAULT_PROVIDER_ID = "ludmila-ai"
|
||||||
|
const ENV_TEMPLATE_RE = /\{env:([^}]+)\}/g
|
||||||
|
|
||||||
|
function resolveTemplates(value: string | undefined): string | undefined {
|
||||||
|
if (typeof value !== "string") return value
|
||||||
|
return value.replace(ENV_TEMPLATE_RE, (_m, name: string) => process.env[name] ?? "")
|
||||||
|
}
|
||||||
|
|
||||||
function isVisionModel(modelID: string | undefined, visionModels: Set<string>): boolean {
|
function isVisionModel(modelID: string | undefined, visionModels: Set<string>): boolean {
|
||||||
if (!modelID) return false
|
if (!modelID) return false
|
||||||
// Normalize: strip provider prefix if present, and also handle "provider/model" form
|
// Normalize: strip provider prefix if present, and also handle "provider/model" form
|
||||||
@@ -102,7 +115,7 @@ async function recognizeImage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VisionBridge: Plugin = async () => {
|
export const VisionBridge: Plugin = async (_input: any, options: any = {}) => {
|
||||||
let config: any = null
|
let config: any = null
|
||||||
let providerInfo: ProviderInfo | null = null
|
let providerInfo: ProviderInfo | null = null
|
||||||
let visionModels = new Set<string>()
|
let visionModels = new Set<string>()
|
||||||
@@ -114,29 +127,62 @@ export const VisionBridge: Plugin = async () => {
|
|||||||
// Track which sessions are running a vision-capable model.
|
// Track which sessions are running a vision-capable model.
|
||||||
const visionSessions = new Map<string, boolean>()
|
const visionSessions = new Map<string, boolean>()
|
||||||
|
|
||||||
const providerID = "ludmila-ai"
|
// Resolve the recognition backend. Priority:
|
||||||
|
// 1. plugin options: { baseURL, apiKey, model } — Google Gemini defaults.
|
||||||
function loadProvider(configAny: any) {
|
// 2. options.providerID → read that provider block from opencode config.
|
||||||
const p = configAny?.provider?.[providerID]
|
// 3. legacy: the "ludmila-ai" provider block (keeps existing setups working).
|
||||||
const baseURL = p?.options?.baseURL
|
function resolveProvider() {
|
||||||
const apiKey = p?.options?.apiKey
|
const optKey = resolveTemplates(options.apiKey) ?? process.env.GEMINI_API_KEY
|
||||||
if (!baseURL || !apiKey) {
|
if (optKey) {
|
||||||
providerInfo = null
|
providerInfo = {
|
||||||
|
baseURL: (resolveTemplates(options.baseURL) || DEFAULT_BASE_URL).replace(/\/$/, ""),
|
||||||
|
apiKey: optKey,
|
||||||
|
visionModel: resolveTemplates(options.model) || DEFAULT_MODEL,
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
providerInfo = { baseURL: baseURL.replace(/\/$/, ""), apiKey, visionModel: "" }
|
|
||||||
const models = p?.models ?? {}
|
const pid = options.providerID || DEFAULT_PROVIDER_ID
|
||||||
|
const p = config?.provider?.[pid]
|
||||||
|
const pBase = p?.options?.baseURL
|
||||||
|
const pKey = p?.options?.apiKey
|
||||||
|
if (pBase && pKey) {
|
||||||
|
let pmodel = resolveTemplates(options.model) || ""
|
||||||
|
if (!pmodel) {
|
||||||
|
for (const [id, m] of Object.entries<any>(p?.models ?? {})) {
|
||||||
|
if (m?.attachment === true) {
|
||||||
|
pmodel = typeof m?.id === "string" && m.id ? m.id : id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
providerInfo = {
|
||||||
|
baseURL: String(pBase).replace(/\/$/, ""),
|
||||||
|
apiKey: pKey,
|
||||||
|
visionModel: pmodel || DEFAULT_MODEL,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
providerInfo = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect model IDs that see images natively (attachment: true in config),
|
||||||
|
// so their sessions pass images through untouched. Also accepts an explicit
|
||||||
|
// list via options.visionModels.
|
||||||
|
function collectVisionModels(configAny: any) {
|
||||||
visionModels = new Set<string>()
|
visionModels = new Set<string>()
|
||||||
let visionApiName = ""
|
for (const [pid, p] of Object.entries<any>(configAny?.provider ?? {})) {
|
||||||
for (const [id, m] of Object.entries<any>(models)) {
|
for (const [id, m] of Object.entries<any>(p?.models ?? {})) {
|
||||||
if (m?.attachment === true) {
|
if (m?.attachment === true) {
|
||||||
visionModels.add(id)
|
visionModels.add(id)
|
||||||
if (!visionApiName) visionApiName = typeof m?.id === "string" && m.id ? m.id : id
|
visionModels.add(pid + "/" + id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The vision model used for recognition = the model flagged attachment:true.
|
if (Array.isArray(options.visionModels)) {
|
||||||
// Change it in the config by moving the `attachment: true` flag.
|
for (const m of options.visionModels) visionModels.add(m)
|
||||||
providerInfo.visionModel = visionApiName
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashImage(dataUrl: string): string {
|
function hashImage(dataUrl: string): string {
|
||||||
@@ -192,7 +238,8 @@ export const VisionBridge: Plugin = async () => {
|
|||||||
const hooks: Hooks = {
|
const hooks: Hooks = {
|
||||||
config: async (cfg: any) => {
|
config: async (cfg: any) => {
|
||||||
config = cfg
|
config = cfg
|
||||||
loadProvider(cfg)
|
collectVisionModels(cfg)
|
||||||
|
resolveProvider()
|
||||||
},
|
},
|
||||||
|
|
||||||
"chat.message": async (input: any, output: any) => {
|
"chat.message": async (input: any, output: any) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user