forked from templates/template-go-orm
Go-сервис-прокси между Codex CLI и Ollama. Добавляет Bearer-авторизацию, LLM-маршрутизатор (deepseek классифицирует запросы: code/doc/general), поддержку OpenAI Responses API для Codex CLI, стриминг SSE, кеш модели. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
278 lines
14 KiB
Markdown
278 lines
14 KiB
Markdown
# Архитектура Ollama Proxy
|
||
|
||
## 1. Общее описание
|
||
|
||
**Ollama Proxy** — Go-сервис, прозрачно встающий между Codex CLI и локальной Ollama. Для Codex CLI прокси выглядит как обычная Ollama (те же эндпоинты, тот же формат). Внутри прокси:
|
||
|
||
1. Проверяет авторизацию (токен из env)
|
||
2. Классифицирует запрос через Router LLM (gemma:1b)
|
||
3. Подменяет модель в запросе на подходящую
|
||
4. Проксирует запрос в реальную Ollama
|
||
5. Стримит ответ обратно в Codex CLI
|
||
|
||
Сервис **stateless** — нет БД, нет сессий, нет хранения истории. История сообщений хранится на стороне Codex CLI.
|
||
|
||
---
|
||
|
||
## 2. Архитектурная схема
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ CODEX CLI │
|
||
│ (отправляет запросы как к обычной Ollama) │
|
||
└────────────────────────────┬────────────────────────────────────────┘
|
||
│ HTTP model: "auto" или "qwen2.5-coder:1.5b"
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ OLLAMA PROXY (:11435) │
|
||
│ │
|
||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||
│ │ Token Auth Middleware │ │
|
||
│ │ Authorization: Bearer <AUTH_TOKEN> → 401 если неверный │ │
|
||
│ └───────────────────────┬──────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ ┌────────────────────────▼─────────────────────────────────────┐ │
|
||
│ │ Router LLM │ │
|
||
│ │ │ │
|
||
│ │ Если model == "auto" или пустой: │ │
|
||
│ │ → gemma:1b (синхронный вызов, stream=false) │ │
|
||
│ │ → промпт: "Classify: code / document / general" │ │
|
||
│ │ → ответ: одно слово │ │
|
||
│ │ │ │
|
||
│ │ Если model указана явно → пропустить, использовать как есть │ │
|
||
│ └────────────────────────┬─────────────────────────────────────┘ │
|
||
│ │ model подменён на целевую │
|
||
│ ┌────────────────────────▼─────────────────────────────────────┐ │
|
||
│ │ Proxy │ │
|
||
│ │ POST /api/chat → Ollama /api/chat │ │
|
||
│ │ Стриминг NDJSON построчно (bufio.Scanner + http.Flusher) │ │
|
||
│ └────────────────────────┬─────────────────────────────────────┘ │
|
||
└───────────────────────────┼─────────────────────────────────────────┘
|
||
│ HTTP model: "qwen2.5-coder:1.5b"
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────┐
|
||
│ OLLAMA (:11434) │
|
||
│ │
|
||
│ gemma:1b qwen2.5-coder:1.5b (другие модели) │
|
||
│ (router + docs) (код) │
|
||
└─────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Структура проекта
|
||
|
||
```
|
||
.
|
||
├── cmd/server/main.go # Точка входа: загрузка конфига, запуск HTTP-сервера
|
||
├── internal/
|
||
│ ├── config/
|
||
│ │ └── config.go # Парсинг env-переменных (caarlos0/env)
|
||
│ ├── model/
|
||
│ │ └── ollama.go # Go-типы Ollama API: ChatRequest, ChatResponse,
|
||
│ │ # GenerateRequest, GenerateResponse, TagsResponse, Message
|
||
│ ├── handler/
|
||
│ │ ├── middleware.go # Проверка Bearer-токена (простое сравнение строк)
|
||
│ │ └── proxy.go # HTTP-хендлеры: HandleChat, HandleGenerate, HandleTags
|
||
│ ├── service/
|
||
│ │ ├── ollama_client.go # HTTP-клиент к Ollama:
|
||
│ │ │ # ProxyChat — стриминг /api/chat
|
||
│ │ │ # ProxyGenerate — стриминг /api/generate
|
||
│ │ │ # GetTags — список моделей
|
||
│ │ │ # Complete — синхронный вызов (для роутера)
|
||
│ │ └── router.go # LLM-маршрутизатор:
|
||
│ │ # Route(ctx, model, messages) → целевая модель
|
||
├── router/
|
||
│ └── router.go # chi-роутер, подключение middleware и хендлеров
|
||
├── go.mod / go.sum # Зависимости
|
||
├── Makefile # build / run / test
|
||
├── .env # Локальные переменные (не в git)
|
||
├── .gitignore
|
||
├── .gitattributes # LF line endings
|
||
├── VERSION # Семантическая версия
|
||
└── docs/
|
||
└── architecture.md # Этот файл
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Поток обработки запроса
|
||
|
||
### Случай 1: `model = "auto"` (первый запрос в сессии)
|
||
|
||
```
|
||
1. Codex CLI → POST /api/chat {"model":"auto", "messages":[...]}
|
||
|
||
2. Middleware: проверить Authorization: Bearer <token>
|
||
→ если неверный: 401 Unauthorized
|
||
|
||
3. Handler: декодировать ChatRequest из JSON
|
||
|
||
4. Router.Route(ctx, "auto", messages):
|
||
a. Взять последнее user-сообщение
|
||
b. POST /api/chat к Ollama с model=gemma:1b, stream=false
|
||
Промпт: "Classify the following user request into exactly one category:
|
||
code, document, general.
|
||
Reply with ONLY the category name, nothing else.
|
||
User request: {текст}"
|
||
c. Получить ответ: "code"
|
||
d. Вернуть CODE_MODEL = "qwen2.5-coder:1.5b"
|
||
|
||
5. Подменить req.Model = "qwen2.5-coder:1.5b"
|
||
|
||
6. OllamaClient.ProxyChat(ctx, w, req):
|
||
a. POST /api/chat к Ollama с model=qwen2.5-coder:1.5b
|
||
b. Читать ответ построчно (bufio.Scanner)
|
||
c. Каждую строку NDJSON сразу писать в w + Flush()
|
||
|
||
7. Codex CLI получает стриминг, видит model="qwen2.5-coder:1.5b" в ответе
|
||
→ запоминает модель для последующих запросов
|
||
```
|
||
|
||
### Случай 2: `model = "qwen2.5-coder:1.5b"` (последующие запросы)
|
||
|
||
```
|
||
1. Codex CLI → POST /api/chat {"model":"qwen2.5-coder:1.5b", "messages":[...]}
|
||
|
||
2. Middleware: проверить токен
|
||
|
||
3. Router.Route(ctx, "qwen2.5-coder:1.5b", messages):
|
||
→ модель указана явно, не "auto" → вернуть как есть
|
||
|
||
4. OllamaClient.ProxyChat(ctx, w, req):
|
||
→ прямой прокси к Ollama, Router LLM не вызывается
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Ollama API — поддерживаемые эндпоинты
|
||
|
||
| Метод | URL | Описание |
|
||
|-------|-----|----------|
|
||
| GET | `/health` | Проверка работоспособности (без авторизации) |
|
||
| POST | `/api/chat` | Чат. Streaming NDJSON |
|
||
| POST | `/api/generate` | Генерация текста. Streaming NDJSON |
|
||
| GET | `/api/tags` | Список моделей из реальной Ollama |
|
||
|
||
### Формат запроса `/api/chat`
|
||
|
||
```json
|
||
{
|
||
"model": "auto",
|
||
"messages": [
|
||
{"role": "user", "content": "напиши функцию на Go"}
|
||
],
|
||
"stream": true
|
||
}
|
||
```
|
||
|
||
### Формат ответа `/api/chat` (NDJSON, одна строка = один чанк)
|
||
|
||
```json
|
||
{"model":"qwen2.5-coder:1.5b","created_at":"2025-01-01T12:00:00Z","message":{"role":"assistant","content":"func"},"done":false}
|
||
{"model":"qwen2.5-coder:1.5b","created_at":"2025-01-01T12:00:01Z","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","total_duration":1234567890,"eval_count":42}
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Авторизация
|
||
|
||
Простая проверка Bearer-токена:
|
||
|
||
```
|
||
Authorization: Bearer <AUTH_TOKEN>
|
||
```
|
||
|
||
- Токен задаётся в `AUTH_TOKEN` (.env)
|
||
- Сравнение строк — никакого JWT, bcrypt, БД
|
||
- Неверный или отсутствующий токен → `401 Unauthorized`
|
||
- `/health` — без авторизации
|
||
|
||
**Это временная заглушка.** В следующем этапе будет полноценная авторизация с JWT и БД.
|
||
|
||
---
|
||
|
||
## 7. Router LLM — детали реализации
|
||
|
||
**Промпт классификатора:**
|
||
```
|
||
Classify the following user request into exactly one category: code, document, general.
|
||
Reply with ONLY the category name, nothing else.
|
||
|
||
User request: {последнее user-сообщение}
|
||
```
|
||
|
||
**Вызов:** синхронный (`stream: false`), короткий timeout (5–10 сек).
|
||
|
||
**Парсинг ответа:**
|
||
- Взять ответ, привести к нижнему регистру, убрать пробелы
|
||
- Если содержит "code" → CODE_MODEL
|
||
- Если содержит "document" → DOC_MODEL
|
||
- Иначе → GENERAL_MODEL (fallback)
|
||
|
||
**Маппинг категорий → модели:**
|
||
|
||
| Категория | Тестовая модель | Production модель |
|
||
|-----------|----------------|------------------|
|
||
| code | qwen2.5-coder:1.5b | qwen2.5-coder:32b |
|
||
| document | gemma:1b | deepseek-r1:70b |
|
||
| general | gemma:1b | llama3.3:70b |
|
||
|
||
---
|
||
|
||
## 8. Streaming-прокси — детали реализации
|
||
|
||
Ключевые требования:
|
||
- `http.Flusher` — сбрасывать буфер после каждой строки
|
||
- `bufio.Scanner` с буфером 1MB (для длинных строк с кодом)
|
||
- `http.Client` без глобального таймаута (используется контекст запроса)
|
||
- Отмена через `r.Context()` — если Codex CLI отключился, upstream-запрос к Ollama отменяется
|
||
|
||
```go
|
||
// Псевдокод
|
||
scanner := bufio.NewScanner(resp.Body)
|
||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
|
||
|
||
flusher := w.(http.Flusher)
|
||
for scanner.Scan() {
|
||
w.Write(scanner.Bytes())
|
||
w.Write([]byte("\n"))
|
||
flusher.Flush()
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Переменные окружения
|
||
|
||
| Переменная | Описание | По умолчанию |
|
||
|-----------|----------|-------------|
|
||
| `PROXY_PORT` | Порт прокси | 11435 |
|
||
| `AUTH_TOKEN` | Токен авторизации | — (обязательна) |
|
||
| `OLLAMA_URL` | URL реальной Ollama | http://localhost:11434 |
|
||
| `ROUTER_MODEL` | Модель-классификатор | gemma:1b |
|
||
| `CODE_MODEL` | Модель для кода | qwen2.5-coder:1.5b |
|
||
| `DOC_MODEL` | Модель для документов | gemma:1b |
|
||
| `GENERAL_MODEL` | Общая модель | gemma:1b |
|
||
|
||
---
|
||
|
||
## 10. Технологический стек
|
||
|
||
| Компонент | Технология |
|
||
|-----------|-----------|
|
||
| Язык | Go |
|
||
| HTTP-фреймворк | chi/v5 |
|
||
| Конфигурация | caarlos0/env/v11 |
|
||
| Streaming | bufio.Scanner + http.Flusher |
|
||
| LLM-инфраструктура | Ollama (локально) |
|
||
|
||
---
|
||
|
||
## 11. Следующие этапы (после MVP)
|
||
|
||
- **Авторизация**: полноценный JWT + PostgreSQL + регистрация/логин
|
||
- **История чатов**: хранение сообщений в БД (User → Chat → Message)
|
||
- **Production-модели**: qwen2.5-coder:32b, deepseek-r1:70b
|
||
- **Codex CLI**: тестирование сквозного потока с реальным клиентом
|